feat(deploy): production deployment setup and fixes
- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma binaryTargets for openssl 3.0 - nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal cron, http->https redirect - prisma: add origin_goods.delisted migration, sync missing schema (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz - api: CORS wildcard reflection, helmet CORP cross-origin, price backfill in persistProductDetail, categoryIcon ancestor fallback, mediaByColor per-color gallery in public goods detail - admin: /admin base path (vite + router) - import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
+24
-24
@@ -1,24 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
Vendored
+3
-3
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
|
||||
+13
-13
@@ -1,13 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>inkreach-official-admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>inkreach-official-admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+32
-32
@@ -1,32 +1,32 @@
|
||||
{
|
||||
"name": "@inkreach/admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"axios": "^1.18.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"element-plus": "^2.14.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.3",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"sass": "^1.101.0",
|
||||
"typescript": "~6.0.2",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.0.12",
|
||||
"vue-tsc": "^3.2.8"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "@inkreach/admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"axios": "^1.18.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"element-plus": "^2.14.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.3",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"sass": "^1.101.0",
|
||||
"typescript": "~6.0.2",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.0.12",
|
||||
"vue-tsc": "^3.2.8"
|
||||
}
|
||||
}
|
||||
|
||||
+24
-24
@@ -1,24 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
@@ -1,32 +1,32 @@
|
||||
<svg preserveAspectRatio="xMidYMid meet" width="97" height="56" viewBox="0 0 97 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame" clip-path="url(#clip0_0_30)">
|
||||
<g id="Group">
|
||||
<path id="Vector" d="M16.318 51.2332V53.8323C17.2443 53.6294 18.0909 53.4188 18.8525 53.1979L19.0969 54.0481C17.9416 54.3665 16.7117 54.6542 15.4072 54.9085L15.0341 54.1354C15.2683 54.0044 15.3866 53.8169 15.3866 53.5678V47.6478C16.632 47.4526 17.7486 47.1957 18.7341 46.8747L19.3105 47.617C18.4614 47.8918 17.463 48.1383 16.3155 48.3592V50.342H19.0171V51.2306H16.318V51.2332ZM21.4666 54.6131L21.2119 53.7142L22.0842 53.7347C22.3389 53.7347 22.465 53.5935 22.465 53.3135V48.2513H20.5378V55.9307H19.5986V47.373H23.4067V53.5087C23.4067 54.2458 23.0671 54.6131 22.3878 54.6131H21.4666Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_2" d="M34.4941 52.1013C34.4606 52.2965 34.4221 52.4788 34.3757 52.6483H38.0474V53.5267H34.8183C35.4847 54.2895 36.7275 54.8057 38.5466 55.0805L38.0577 55.9589C35.9633 55.548 34.5893 54.8032 33.9358 53.7219C33.7325 54.0737 33.4855 54.3794 33.1922 54.6414C32.5463 55.209 31.4142 55.6584 29.7958 55.9897L29.415 55.0805C30.8173 54.834 31.8233 54.477 32.4305 54.0147C32.6209 53.858 32.783 53.6962 32.9194 53.5267H29.8755V52.6483H33.398C33.4572 52.4583 33.5086 52.2451 33.5549 52.0037L34.4941 52.1013ZM29.9733 47.8224H32.078C31.9468 47.5219 31.8079 47.2548 31.6561 47.0211L32.6338 46.8644C32.7522 47.111 32.8808 47.432 33.0249 47.8224H34.9135C35.0627 47.4962 35.1785 47.1675 35.2557 46.8362L36.254 46.962C36.1691 47.2548 36.0508 47.5425 35.9015 47.8224H37.9368V48.6623H34.4272V49.4045H37.4222V50.2058H34.4272V50.9584H38.2636V51.8188H29.6491V50.9584H33.4855V50.2058H30.5188V49.4045H33.4855V48.6623H29.9707V47.8224H29.9733Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_3" d="M50.8993 55.756C50.5597 55.756 49.9525 55.7483 49.0776 55.7355C48.419 55.7278 47.876 55.6738 47.4541 55.5685C47.0372 55.4504 46.677 55.2244 46.3786 54.8853C46.2422 54.7235 46.111 54.6414 45.9875 54.6414C45.779 54.6414 45.4188 55.0934 44.9119 56L44.2275 55.3656C44.7241 54.5592 45.1692 54.0506 45.5681 53.8426V51.097H44.2378V50.1981H46.4609V53.9094C46.5123 53.9479 46.5612 53.9967 46.6076 54.0558C46.8623 54.3152 47.1093 54.5078 47.3512 54.6311C47.6522 54.7672 48.1076 54.8468 48.7226 54.8648C49.4919 54.8776 50.1763 54.8853 50.7784 54.8853L52.1395 54.8751C52.5898 54.8545 52.9449 54.8365 53.2073 54.816L52.9835 55.7534H50.8993V55.756ZM45.0174 47.0006C45.6838 47.5091 46.2473 48.0125 46.7105 48.5159L46.0261 49.199C45.635 48.7111 45.0869 48.182 44.3819 47.617L45.0174 47.0006ZM47.2096 49.0039H49.2938C49.3452 48.5338 49.3761 48.0561 49.3813 47.5682V46.9132H50.3101V47.3627C50.3101 47.9354 50.2818 48.4825 50.2226 49.0039H52.5718V49.9028H50.3899C50.7167 51.1073 51.5426 52.3221 52.8651 53.5472L52.2193 54.2304C51.0511 53.0644 50.2689 51.9189 49.8701 50.7914C49.4199 52.3221 48.6274 53.4676 47.4927 54.2304L46.924 53.4676C48.0664 52.7382 48.8101 51.5491 49.1548 49.9028H47.2071V49.0039H47.2096Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_4" d="M63.6535 46.8953C64.8808 48.1589 66.2908 49.1528 67.8809 49.8745L67.4203 50.7041C66.9186 50.4627 66.4606 50.2135 66.0489 49.9516V50.8299H63.7873V52.2965H66.5378V53.1954H63.7873V54.739H67.4281V55.6481H59.215V54.739H62.8455V53.1954H60.0847V52.2965H62.8455V50.8299H60.5839V49.9516C60.167 50.2058 59.6962 50.4627 59.1738 50.7246L58.7236 49.913C60.4269 49.0989 61.8446 48.0921 62.9819 46.8953H63.6535ZM66.0309 49.9413C65.012 49.3172 64.1089 48.5801 63.319 47.7325C62.5882 48.5339 61.6851 49.271 60.607 49.9413H66.0309Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_5" d="M73.6859 50.4319H74.7229V48.3618H73.5779V47.4911H76.7401V48.3618H75.6646V50.4319H76.5368V51.3102H75.6646V53.031C76.0505 52.8615 76.4082 52.6946 76.7401 52.5328V53.4317C75.8061 53.894 74.7769 54.2972 73.6473 54.6439L73.4132 53.7245C73.9355 53.6063 74.3729 53.4933 74.7254 53.3829V51.3128H73.6885V50.4319H73.6859ZM76.8276 48.5159H78.9812V46.9235H79.8715V48.5159H81.2017C80.9341 48.2102 80.5893 47.8815 80.1648 47.5296L80.6922 47.0211C81.2017 47.4064 81.616 47.7762 81.935 48.1358L81.5645 48.5159H82.1126V49.4045H79.8715V50.2752C80.0027 50.6861 80.1519 51.0688 80.3217 51.4284C80.7077 50.9789 81.0628 50.4832 81.3895 49.9439L82.0842 50.4627C81.634 51.1664 81.1811 51.7494 80.7231 52.2117C81.2069 53.0464 81.7986 53.7758 82.5036 54.3999L81.8861 55.1319C81.037 54.2972 80.3655 53.3212 79.8689 52.2014V54.9085C79.8689 55.5274 79.5498 55.8356 78.9092 55.8356H77.8851L77.6895 54.9573C78.0549 55.0035 78.3585 55.0266 78.6004 55.0266C78.8551 55.0266 78.9812 54.9136 78.9812 54.685V52.6714C78.3817 53.5113 77.687 54.2381 76.8971 54.8494L76.4082 54.0481C77.3679 53.4163 78.2247 52.543 78.9812 51.4309V49.4097H76.8276V48.5159ZM77.512 50.0209C77.9546 50.6733 78.2942 51.228 78.5309 51.6929L77.8851 52.1424C77.6098 51.6338 77.2496 51.0765 76.7993 50.4729L77.512 50.0209Z" fill="var(--fill-0, #FF6902)"/>
|
||||
</g>
|
||||
<path id="Vector_6" d="M11.1801 50.889H0.165039V51.4386H11.1801V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_7" d="M97 50.889H85.7508V51.5671H97V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<g id="Group_2">
|
||||
<path id="Vector_8" d="M64.2864 27.2064C55.1033 35.3686 44.6466 38.6072 40.9337 34.4414C39.4748 32.8054 39.2741 30.273 40.1207 27.304C37.5091 32.1016 36.9481 36.4293 39.0863 38.8281C42.8017 42.994 53.2585 39.7527 62.439 31.5931C68.0173 26.6362 71.8408 21.0039 73.1504 16.4117C71.2232 19.9508 68.1845 23.7443 64.2864 27.2064Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_9" d="M3.07218 12.536H0.810499C0.362795 12.536 0 12.8982 0 13.3451V15.6026C0 16.0495 0.362795 16.4117 0.810499 16.4117H3.07218C3.51988 16.4117 3.88268 16.0495 3.88268 15.6026V13.3451C3.88268 12.8982 3.51988 12.536 3.07218 12.536Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_10" d="M3.07218 17.6804H0.810499C0.362795 17.6804 0 18.0451 0 18.492V30.1702C0 30.6171 0.362795 30.9793 0.810499 30.9793H3.07218C3.51988 30.9793 3.88268 30.6171 3.88268 30.1702V18.492C3.88268 18.0451 3.51988 17.6804 3.07218 17.6804Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_11" d="M11.6889 17.6907C10.9711 17.701 10.2841 17.8397 9.64853 18.0811C9.50701 17.8422 9.24714 17.6804 8.94867 17.6804H6.5146C6.0669 17.6804 5.7041 18.0426 5.7041 18.4895V23.847V23.9806V30.2113C5.7041 30.6582 6.0669 31.0204 6.5146 31.0204H8.94867C9.39637 31.0204 9.75917 30.6582 9.75917 30.2113V23.9831V23.7571C9.75917 22.6193 10.7086 21.6973 11.8587 21.741C12.9523 21.7821 13.8039 22.7144 13.8039 23.8085V30.2447C13.8039 30.6916 14.1667 31.0538 14.6144 31.0538H17.0485C17.4962 31.0538 17.859 30.6916 17.859 30.2447V23.7571C17.8616 20.3797 15.0853 17.6393 11.6889 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_12" d="M90.8296 17.6907C90.1606 17.701 89.5199 17.8217 88.9204 18.0349V10.6303C88.9204 10.1835 88.5576 9.82132 88.1099 9.82132H85.6758C85.2281 9.82132 84.8653 10.1835 84.8653 10.6303V22.907C84.8653 22.9969 84.873 23.0842 84.8833 23.169C84.8576 23.3924 84.8447 23.6184 84.8447 23.847V30.2113C84.8447 30.6582 85.2075 31.0204 85.6552 31.0204H88.0893C88.537 31.0204 88.8998 30.6582 88.8998 30.2113V23.7571C88.8998 22.6193 89.8492 21.6973 90.9994 21.741C92.0929 21.7821 92.9446 22.7144 92.9446 23.8085V30.2447C92.9446 30.6916 93.3074 31.0538 93.7551 31.0538H96.1891C96.6368 31.0538 96.9996 30.6916 96.9996 30.2447V23.7571C96.9996 20.3797 94.2234 17.6393 90.8296 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_13" d="M31.0412 17.7909H28.6072C28.1595 17.7909 27.7967 18.153 27.7967 18.5999V19.4089C27.7967 20.4748 26.9656 21.3506 25.9184 21.4276C25.8386 21.4251 25.7563 21.4251 25.6765 21.4251C25.6559 21.4251 25.6379 21.4276 25.6173 21.4276C25.0898 21.3891 24.5238 21.1477 23.9886 20.6314C23.8316 20.4799 23.7416 20.2693 23.7416 20.051V10.6303C23.7416 10.1835 23.3788 9.82132 22.9311 9.82132H20.497C20.0493 9.82132 19.6865 10.1835 19.6865 10.6303V18.5999V21.985V27.5865V30.3269C19.6865 30.7738 20.0493 31.1359 20.497 31.1359H22.9311C23.3788 31.1359 23.7416 30.7738 23.7416 30.3269V30.2653V27.494C23.7416 26.4307 24.5701 25.5549 25.6147 25.4779C25.7125 25.4805 25.8103 25.4805 25.9081 25.4779C26.9759 25.5524 27.7967 26.4693 27.7967 27.5454V30.3295C27.7967 30.7764 28.1595 31.1385 28.6072 31.1385H31.0412C31.4889 31.1385 31.8517 30.7764 31.8517 30.3295V27.4966C31.8517 25.9453 31.2651 24.5302 30.3028 23.454C31.2651 22.3805 31.8517 20.9628 31.8517 19.4089V18.5999C31.8517 18.153 31.4889 17.7909 31.0412 17.7909Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_14" d="M39.8324 21.7744C39.8761 21.7769 39.9173 21.7821 39.9585 21.7846C40.7355 20.6546 41.6052 19.5271 42.5598 18.4124C41.6927 17.9604 40.7046 17.7087 39.66 17.7267C38.9807 17.7369 38.3323 17.8628 37.7277 18.0785C37.6633 17.8628 37.4678 17.7061 37.2311 17.7061H34.5062C34.0585 17.7061 33.6957 18.0682 33.6957 18.5151V23.4181C33.6855 23.5722 33.6777 23.7263 33.6777 23.883V30.2473C33.6777 30.4759 33.7729 30.6839 33.9273 30.8303C34.074 30.9818 34.2798 31.0769 34.5088 31.0769H36.9429C37.3906 31.0769 37.7534 30.7147 37.7534 30.2678V23.5285C37.8846 22.514 38.7723 21.7358 39.8324 21.7744Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_15" d="M41.5516 25.4805C41.7291 26.5746 42.1948 27.6199 42.8844 28.4854H42.8818C45.3493 31.6239 50.2304 31.891 53.4003 29.1943C53.5701 29.0504 53.5881 28.7936 53.444 28.6241L51.5709 26.4256C51.432 26.2612 51.1875 26.2381 51.0151 26.3665C49.6257 27.4016 48.1925 27.643 46.7362 26.9008L51.3316 25.0079L54.6611 23.6338C54.8695 23.5491 54.9673 23.3102 54.8798 23.1048C54.5299 22.2675 54.0847 21.1246 53.6087 20.5056C52.3685 18.7617 50.3461 17.6907 48.1488 17.6933H48.1514C44.0628 17.6624 40.8466 21.4662 41.5516 25.4805ZM45.3262 23.9215C45.4909 21.7384 48.262 20.5698 49.9164 22.0312C48.738 22.5166 46.5021 23.4386 45.3262 23.9241V23.9215Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_16" d="M77.6686 21.6125C78.5975 21.2299 79.6396 21.3352 80.4604 21.8668C80.7768 22.0723 81.1988 22.0209 81.4664 21.7538L82.9433 20.2796C83.2932 19.9303 83.2495 19.355 82.8533 19.0571C81.7006 18.1864 80.2854 17.7087 78.8213 17.7112C75.7929 17.7112 73.1401 19.7428 72.3579 22.663C71.5732 25.5832 72.852 28.6652 75.4764 30.1779C77.8256 31.5315 80.7305 31.3234 82.8507 29.7285C83.2469 29.4306 83.2932 28.8553 82.9433 28.5034L81.4664 27.0292C81.1988 26.7621 80.7794 26.7107 80.4604 26.9162C79.6396 27.4478 78.5975 27.5531 77.6686 27.1704C76.5417 26.7056 75.8084 25.6089 75.8084 24.3915C75.8084 23.1767 76.5417 22.0774 77.6686 21.6125Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_17" d="M69.361 19.617V18.1299C69.361 17.9064 69.1783 17.7241 68.9545 17.7241H65.9132C65.6893 17.7241 65.5067 17.9064 65.5067 18.1299V18.3225C64.5907 17.8936 63.564 17.665 62.4808 17.6933C58.9481 17.7909 56.0663 20.67 55.9788 24.1989C55.9145 26.7107 57.2421 28.9169 59.2465 30.1086C60.2757 29.3278 61.2921 28.488 62.2904 27.5942C62.4345 27.4632 62.5786 27.3322 62.7227 27.2012C62.7047 27.2012 62.6892 27.2038 62.6712 27.2038C60.9987 27.2038 59.6582 25.7527 59.8512 24.0448C59.9953 22.7452 61.0451 21.6973 62.347 21.5535C64.0581 21.3634 65.5118 22.699 65.5118 24.3684C65.5118 24.3889 65.5118 24.4095 65.5092 24.4326C66.9681 22.8556 68.2649 21.235 69.361 19.617Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_18" d="M65.5089 30.3166V30.9536H68.9567C69.1806 30.9536 69.3633 30.7712 69.3633 30.5478V26.102C68.0356 27.6173 67.0836 28.9066 65.5089 30.3166Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_19" d="M48.9692 12.2458C57.8229 3.65217 68.4906 0.272244 72.7979 4.69492C74.6762 6.62374 75.0338 9.71602 74.1101 13.304C76.2791 8.46524 76.467 4.15814 74.159 1.78756C70.037 -2.44249 59.5185 1.09411 50.6674 9.6852C47.765 12.5027 45.4004 15.5153 43.6816 18.4381C45.1431 16.3372 46.9185 14.2389 48.9692 12.2458Z" fill="var(--fill-0, #FF6800)"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_30">
|
||||
<rect width="97" height="56" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg preserveAspectRatio="xMidYMid meet" width="97" height="56" viewBox="0 0 97 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame" clip-path="url(#clip0_0_30)">
|
||||
<g id="Group">
|
||||
<path id="Vector" d="M16.318 51.2332V53.8323C17.2443 53.6294 18.0909 53.4188 18.8525 53.1979L19.0969 54.0481C17.9416 54.3665 16.7117 54.6542 15.4072 54.9085L15.0341 54.1354C15.2683 54.0044 15.3866 53.8169 15.3866 53.5678V47.6478C16.632 47.4526 17.7486 47.1957 18.7341 46.8747L19.3105 47.617C18.4614 47.8918 17.463 48.1383 16.3155 48.3592V50.342H19.0171V51.2306H16.318V51.2332ZM21.4666 54.6131L21.2119 53.7142L22.0842 53.7347C22.3389 53.7347 22.465 53.5935 22.465 53.3135V48.2513H20.5378V55.9307H19.5986V47.373H23.4067V53.5087C23.4067 54.2458 23.0671 54.6131 22.3878 54.6131H21.4666Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_2" d="M34.4941 52.1013C34.4606 52.2965 34.4221 52.4788 34.3757 52.6483H38.0474V53.5267H34.8183C35.4847 54.2895 36.7275 54.8057 38.5466 55.0805L38.0577 55.9589C35.9633 55.548 34.5893 54.8032 33.9358 53.7219C33.7325 54.0737 33.4855 54.3794 33.1922 54.6414C32.5463 55.209 31.4142 55.6584 29.7958 55.9897L29.415 55.0805C30.8173 54.834 31.8233 54.477 32.4305 54.0147C32.6209 53.858 32.783 53.6962 32.9194 53.5267H29.8755V52.6483H33.398C33.4572 52.4583 33.5086 52.2451 33.5549 52.0037L34.4941 52.1013ZM29.9733 47.8224H32.078C31.9468 47.5219 31.8079 47.2548 31.6561 47.0211L32.6338 46.8644C32.7522 47.111 32.8808 47.432 33.0249 47.8224H34.9135C35.0627 47.4962 35.1785 47.1675 35.2557 46.8362L36.254 46.962C36.1691 47.2548 36.0508 47.5425 35.9015 47.8224H37.9368V48.6623H34.4272V49.4045H37.4222V50.2058H34.4272V50.9584H38.2636V51.8188H29.6491V50.9584H33.4855V50.2058H30.5188V49.4045H33.4855V48.6623H29.9707V47.8224H29.9733Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_3" d="M50.8993 55.756C50.5597 55.756 49.9525 55.7483 49.0776 55.7355C48.419 55.7278 47.876 55.6738 47.4541 55.5685C47.0372 55.4504 46.677 55.2244 46.3786 54.8853C46.2422 54.7235 46.111 54.6414 45.9875 54.6414C45.779 54.6414 45.4188 55.0934 44.9119 56L44.2275 55.3656C44.7241 54.5592 45.1692 54.0506 45.5681 53.8426V51.097H44.2378V50.1981H46.4609V53.9094C46.5123 53.9479 46.5612 53.9967 46.6076 54.0558C46.8623 54.3152 47.1093 54.5078 47.3512 54.6311C47.6522 54.7672 48.1076 54.8468 48.7226 54.8648C49.4919 54.8776 50.1763 54.8853 50.7784 54.8853L52.1395 54.8751C52.5898 54.8545 52.9449 54.8365 53.2073 54.816L52.9835 55.7534H50.8993V55.756ZM45.0174 47.0006C45.6838 47.5091 46.2473 48.0125 46.7105 48.5159L46.0261 49.199C45.635 48.7111 45.0869 48.182 44.3819 47.617L45.0174 47.0006ZM47.2096 49.0039H49.2938C49.3452 48.5338 49.3761 48.0561 49.3813 47.5682V46.9132H50.3101V47.3627C50.3101 47.9354 50.2818 48.4825 50.2226 49.0039H52.5718V49.9028H50.3899C50.7167 51.1073 51.5426 52.3221 52.8651 53.5472L52.2193 54.2304C51.0511 53.0644 50.2689 51.9189 49.8701 50.7914C49.4199 52.3221 48.6274 53.4676 47.4927 54.2304L46.924 53.4676C48.0664 52.7382 48.8101 51.5491 49.1548 49.9028H47.2071V49.0039H47.2096Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_4" d="M63.6535 46.8953C64.8808 48.1589 66.2908 49.1528 67.8809 49.8745L67.4203 50.7041C66.9186 50.4627 66.4606 50.2135 66.0489 49.9516V50.8299H63.7873V52.2965H66.5378V53.1954H63.7873V54.739H67.4281V55.6481H59.215V54.739H62.8455V53.1954H60.0847V52.2965H62.8455V50.8299H60.5839V49.9516C60.167 50.2058 59.6962 50.4627 59.1738 50.7246L58.7236 49.913C60.4269 49.0989 61.8446 48.0921 62.9819 46.8953H63.6535ZM66.0309 49.9413C65.012 49.3172 64.1089 48.5801 63.319 47.7325C62.5882 48.5339 61.6851 49.271 60.607 49.9413H66.0309Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_5" d="M73.6859 50.4319H74.7229V48.3618H73.5779V47.4911H76.7401V48.3618H75.6646V50.4319H76.5368V51.3102H75.6646V53.031C76.0505 52.8615 76.4082 52.6946 76.7401 52.5328V53.4317C75.8061 53.894 74.7769 54.2972 73.6473 54.6439L73.4132 53.7245C73.9355 53.6063 74.3729 53.4933 74.7254 53.3829V51.3128H73.6885V50.4319H73.6859ZM76.8276 48.5159H78.9812V46.9235H79.8715V48.5159H81.2017C80.9341 48.2102 80.5893 47.8815 80.1648 47.5296L80.6922 47.0211C81.2017 47.4064 81.616 47.7762 81.935 48.1358L81.5645 48.5159H82.1126V49.4045H79.8715V50.2752C80.0027 50.6861 80.1519 51.0688 80.3217 51.4284C80.7077 50.9789 81.0628 50.4832 81.3895 49.9439L82.0842 50.4627C81.634 51.1664 81.1811 51.7494 80.7231 52.2117C81.2069 53.0464 81.7986 53.7758 82.5036 54.3999L81.8861 55.1319C81.037 54.2972 80.3655 53.3212 79.8689 52.2014V54.9085C79.8689 55.5274 79.5498 55.8356 78.9092 55.8356H77.8851L77.6895 54.9573C78.0549 55.0035 78.3585 55.0266 78.6004 55.0266C78.8551 55.0266 78.9812 54.9136 78.9812 54.685V52.6714C78.3817 53.5113 77.687 54.2381 76.8971 54.8494L76.4082 54.0481C77.3679 53.4163 78.2247 52.543 78.9812 51.4309V49.4097H76.8276V48.5159ZM77.512 50.0209C77.9546 50.6733 78.2942 51.228 78.5309 51.6929L77.8851 52.1424C77.6098 51.6338 77.2496 51.0765 76.7993 50.4729L77.512 50.0209Z" fill="var(--fill-0, #FF6902)"/>
|
||||
</g>
|
||||
<path id="Vector_6" d="M11.1801 50.889H0.165039V51.4386H11.1801V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_7" d="M97 50.889H85.7508V51.5671H97V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<g id="Group_2">
|
||||
<path id="Vector_8" d="M64.2864 27.2064C55.1033 35.3686 44.6466 38.6072 40.9337 34.4414C39.4748 32.8054 39.2741 30.273 40.1207 27.304C37.5091 32.1016 36.9481 36.4293 39.0863 38.8281C42.8017 42.994 53.2585 39.7527 62.439 31.5931C68.0173 26.6362 71.8408 21.0039 73.1504 16.4117C71.2232 19.9508 68.1845 23.7443 64.2864 27.2064Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_9" d="M3.07218 12.536H0.810499C0.362795 12.536 0 12.8982 0 13.3451V15.6026C0 16.0495 0.362795 16.4117 0.810499 16.4117H3.07218C3.51988 16.4117 3.88268 16.0495 3.88268 15.6026V13.3451C3.88268 12.8982 3.51988 12.536 3.07218 12.536Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_10" d="M3.07218 17.6804H0.810499C0.362795 17.6804 0 18.0451 0 18.492V30.1702C0 30.6171 0.362795 30.9793 0.810499 30.9793H3.07218C3.51988 30.9793 3.88268 30.6171 3.88268 30.1702V18.492C3.88268 18.0451 3.51988 17.6804 3.07218 17.6804Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_11" d="M11.6889 17.6907C10.9711 17.701 10.2841 17.8397 9.64853 18.0811C9.50701 17.8422 9.24714 17.6804 8.94867 17.6804H6.5146C6.0669 17.6804 5.7041 18.0426 5.7041 18.4895V23.847V23.9806V30.2113C5.7041 30.6582 6.0669 31.0204 6.5146 31.0204H8.94867C9.39637 31.0204 9.75917 30.6582 9.75917 30.2113V23.9831V23.7571C9.75917 22.6193 10.7086 21.6973 11.8587 21.741C12.9523 21.7821 13.8039 22.7144 13.8039 23.8085V30.2447C13.8039 30.6916 14.1667 31.0538 14.6144 31.0538H17.0485C17.4962 31.0538 17.859 30.6916 17.859 30.2447V23.7571C17.8616 20.3797 15.0853 17.6393 11.6889 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_12" d="M90.8296 17.6907C90.1606 17.701 89.5199 17.8217 88.9204 18.0349V10.6303C88.9204 10.1835 88.5576 9.82132 88.1099 9.82132H85.6758C85.2281 9.82132 84.8653 10.1835 84.8653 10.6303V22.907C84.8653 22.9969 84.873 23.0842 84.8833 23.169C84.8576 23.3924 84.8447 23.6184 84.8447 23.847V30.2113C84.8447 30.6582 85.2075 31.0204 85.6552 31.0204H88.0893C88.537 31.0204 88.8998 30.6582 88.8998 30.2113V23.7571C88.8998 22.6193 89.8492 21.6973 90.9994 21.741C92.0929 21.7821 92.9446 22.7144 92.9446 23.8085V30.2447C92.9446 30.6916 93.3074 31.0538 93.7551 31.0538H96.1891C96.6368 31.0538 96.9996 30.6916 96.9996 30.2447V23.7571C96.9996 20.3797 94.2234 17.6393 90.8296 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_13" d="M31.0412 17.7909H28.6072C28.1595 17.7909 27.7967 18.153 27.7967 18.5999V19.4089C27.7967 20.4748 26.9656 21.3506 25.9184 21.4276C25.8386 21.4251 25.7563 21.4251 25.6765 21.4251C25.6559 21.4251 25.6379 21.4276 25.6173 21.4276C25.0898 21.3891 24.5238 21.1477 23.9886 20.6314C23.8316 20.4799 23.7416 20.2693 23.7416 20.051V10.6303C23.7416 10.1835 23.3788 9.82132 22.9311 9.82132H20.497C20.0493 9.82132 19.6865 10.1835 19.6865 10.6303V18.5999V21.985V27.5865V30.3269C19.6865 30.7738 20.0493 31.1359 20.497 31.1359H22.9311C23.3788 31.1359 23.7416 30.7738 23.7416 30.3269V30.2653V27.494C23.7416 26.4307 24.5701 25.5549 25.6147 25.4779C25.7125 25.4805 25.8103 25.4805 25.9081 25.4779C26.9759 25.5524 27.7967 26.4693 27.7967 27.5454V30.3295C27.7967 30.7764 28.1595 31.1385 28.6072 31.1385H31.0412C31.4889 31.1385 31.8517 30.7764 31.8517 30.3295V27.4966C31.8517 25.9453 31.2651 24.5302 30.3028 23.454C31.2651 22.3805 31.8517 20.9628 31.8517 19.4089V18.5999C31.8517 18.153 31.4889 17.7909 31.0412 17.7909Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_14" d="M39.8324 21.7744C39.8761 21.7769 39.9173 21.7821 39.9585 21.7846C40.7355 20.6546 41.6052 19.5271 42.5598 18.4124C41.6927 17.9604 40.7046 17.7087 39.66 17.7267C38.9807 17.7369 38.3323 17.8628 37.7277 18.0785C37.6633 17.8628 37.4678 17.7061 37.2311 17.7061H34.5062C34.0585 17.7061 33.6957 18.0682 33.6957 18.5151V23.4181C33.6855 23.5722 33.6777 23.7263 33.6777 23.883V30.2473C33.6777 30.4759 33.7729 30.6839 33.9273 30.8303C34.074 30.9818 34.2798 31.0769 34.5088 31.0769H36.9429C37.3906 31.0769 37.7534 30.7147 37.7534 30.2678V23.5285C37.8846 22.514 38.7723 21.7358 39.8324 21.7744Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_15" d="M41.5516 25.4805C41.7291 26.5746 42.1948 27.6199 42.8844 28.4854H42.8818C45.3493 31.6239 50.2304 31.891 53.4003 29.1943C53.5701 29.0504 53.5881 28.7936 53.444 28.6241L51.5709 26.4256C51.432 26.2612 51.1875 26.2381 51.0151 26.3665C49.6257 27.4016 48.1925 27.643 46.7362 26.9008L51.3316 25.0079L54.6611 23.6338C54.8695 23.5491 54.9673 23.3102 54.8798 23.1048C54.5299 22.2675 54.0847 21.1246 53.6087 20.5056C52.3685 18.7617 50.3461 17.6907 48.1488 17.6933H48.1514C44.0628 17.6624 40.8466 21.4662 41.5516 25.4805ZM45.3262 23.9215C45.4909 21.7384 48.262 20.5698 49.9164 22.0312C48.738 22.5166 46.5021 23.4386 45.3262 23.9241V23.9215Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_16" d="M77.6686 21.6125C78.5975 21.2299 79.6396 21.3352 80.4604 21.8668C80.7768 22.0723 81.1988 22.0209 81.4664 21.7538L82.9433 20.2796C83.2932 19.9303 83.2495 19.355 82.8533 19.0571C81.7006 18.1864 80.2854 17.7087 78.8213 17.7112C75.7929 17.7112 73.1401 19.7428 72.3579 22.663C71.5732 25.5832 72.852 28.6652 75.4764 30.1779C77.8256 31.5315 80.7305 31.3234 82.8507 29.7285C83.2469 29.4306 83.2932 28.8553 82.9433 28.5034L81.4664 27.0292C81.1988 26.7621 80.7794 26.7107 80.4604 26.9162C79.6396 27.4478 78.5975 27.5531 77.6686 27.1704C76.5417 26.7056 75.8084 25.6089 75.8084 24.3915C75.8084 23.1767 76.5417 22.0774 77.6686 21.6125Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_17" d="M69.361 19.617V18.1299C69.361 17.9064 69.1783 17.7241 68.9545 17.7241H65.9132C65.6893 17.7241 65.5067 17.9064 65.5067 18.1299V18.3225C64.5907 17.8936 63.564 17.665 62.4808 17.6933C58.9481 17.7909 56.0663 20.67 55.9788 24.1989C55.9145 26.7107 57.2421 28.9169 59.2465 30.1086C60.2757 29.3278 61.2921 28.488 62.2904 27.5942C62.4345 27.4632 62.5786 27.3322 62.7227 27.2012C62.7047 27.2012 62.6892 27.2038 62.6712 27.2038C60.9987 27.2038 59.6582 25.7527 59.8512 24.0448C59.9953 22.7452 61.0451 21.6973 62.347 21.5535C64.0581 21.3634 65.5118 22.699 65.5118 24.3684C65.5118 24.3889 65.5118 24.4095 65.5092 24.4326C66.9681 22.8556 68.2649 21.235 69.361 19.617Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_18" d="M65.5089 30.3166V30.9536H68.9567C69.1806 30.9536 69.3633 30.7712 69.3633 30.5478V26.102C68.0356 27.6173 67.0836 28.9066 65.5089 30.3166Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_19" d="M48.9692 12.2458C57.8229 3.65217 68.4906 0.272244 72.7979 4.69492C74.6762 6.62374 75.0338 9.71602 74.1101 13.304C76.2791 8.46524 76.467 4.15814 74.159 1.78756C70.037 -2.44249 59.5185 1.09411 50.6674 9.6852C47.765 12.5027 45.4004 15.5153 43.6816 18.4381C45.1431 16.3372 46.9185 14.2389 48.9692 12.2458Z" fill="var(--fill-0, #FF6800)"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_30">
|
||||
<rect width="97" height="56" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
+14
-14
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
// Root component renders router-managed views.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
// Root component renders router-managed views.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
+18
-18
@@ -1,19 +1,19 @@
|
||||
import request from './request'
|
||||
import type { LoginRequest, LoginResponse, User } from '@/types'
|
||||
|
||||
export const authApi = {
|
||||
// Login
|
||||
login: (data: LoginRequest) => {
|
||||
return request.post<any, LoginResponse>('/auth/login', data)
|
||||
},
|
||||
|
||||
// Get current user
|
||||
getCurrentUser: () => {
|
||||
return request.get<any, User>('/auth/me')
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: () => {
|
||||
return request.post('/auth/logout')
|
||||
},
|
||||
import request from './request'
|
||||
import type { LoginRequest, LoginResponse, User } from '@/types'
|
||||
|
||||
export const authApi = {
|
||||
// Login
|
||||
login: (data: LoginRequest) => {
|
||||
return request.post<any, LoginResponse>('/auth/login', data)
|
||||
},
|
||||
|
||||
// Get current user
|
||||
getCurrentUser: () => {
|
||||
return request.get<any, User>('/auth/me')
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: () => {
|
||||
return request.post('/auth/logout')
|
||||
},
|
||||
}
|
||||
@@ -1,41 +1,41 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
Category,
|
||||
CategoryTree,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
CategoryFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const categoriesApi = {
|
||||
// Get categories list
|
||||
getCategoriesList: (params: CategoryFilter) => {
|
||||
return request.get<any, PaginatedResult<Category>>('/categories', { params })
|
||||
},
|
||||
|
||||
// Get category tree
|
||||
getCategoryTree: () => {
|
||||
return request.get<any, CategoryTree[]>('/categories')
|
||||
},
|
||||
|
||||
// Get category by id
|
||||
getCategoryById: (id: string) => {
|
||||
return request.get<any, Category>(`/categories/${id}`)
|
||||
},
|
||||
|
||||
// Create category
|
||||
createCategory: (data: CreateCategoryRequest) => {
|
||||
return request.post<any, Category>('/categories', data)
|
||||
},
|
||||
|
||||
// Update category
|
||||
updateCategory: (id: string, data: UpdateCategoryRequest) => {
|
||||
return request.patch<any, Category>(`/categories/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete category
|
||||
deleteCategory: (id: string) => {
|
||||
return request.delete(`/categories/${id}`)
|
||||
},
|
||||
import request from './request'
|
||||
import type {
|
||||
Category,
|
||||
CategoryTree,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
CategoryFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const categoriesApi = {
|
||||
// Get categories list
|
||||
getCategoriesList: (params: CategoryFilter) => {
|
||||
return request.get<any, PaginatedResult<Category>>('/categories', { params })
|
||||
},
|
||||
|
||||
// Get category tree
|
||||
getCategoryTree: () => {
|
||||
return request.get<any, CategoryTree[]>('/categories')
|
||||
},
|
||||
|
||||
// Get category by id
|
||||
getCategoryById: (id: string) => {
|
||||
return request.get<any, Category>(`/categories/${id}`)
|
||||
},
|
||||
|
||||
// Create category
|
||||
createCategory: (data: CreateCategoryRequest) => {
|
||||
return request.post<any, Category>('/categories', data)
|
||||
},
|
||||
|
||||
// Update category
|
||||
updateCategory: (id: string, data: UpdateCategoryRequest) => {
|
||||
return request.patch<any, Category>(`/categories/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete category
|
||||
deleteCategory: (id: string) => {
|
||||
return request.delete(`/categories/${id}`)
|
||||
},
|
||||
}
|
||||
@@ -1,35 +1,35 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
Country,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest,
|
||||
CountryFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const countriesApi = {
|
||||
// Get countries list
|
||||
getCountriesList: (params: CountryFilter) => {
|
||||
return request.get<any, PaginatedResult<Country>>('/countries', { params })
|
||||
},
|
||||
|
||||
// Get country by id
|
||||
getCountryById: (id: string) => {
|
||||
return request.get<any, Country>(`/countries/${id}`)
|
||||
},
|
||||
|
||||
// Create country
|
||||
createCountry: (data: CreateCountryRequest) => {
|
||||
return request.post<any, Country>('/countries', data)
|
||||
},
|
||||
|
||||
// Update country
|
||||
updateCountry: (id: string, data: UpdateCountryRequest) => {
|
||||
return request.patch<any, Country>(`/countries/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete country
|
||||
deleteCountry: (id: string) => {
|
||||
return request.delete(`/countries/${id}`)
|
||||
},
|
||||
import request from './request'
|
||||
import type {
|
||||
Country,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest,
|
||||
CountryFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const countriesApi = {
|
||||
// Get countries list
|
||||
getCountriesList: (params: CountryFilter) => {
|
||||
return request.get<any, PaginatedResult<Country>>('/countries', { params })
|
||||
},
|
||||
|
||||
// Get country by id
|
||||
getCountryById: (id: string) => {
|
||||
return request.get<any, Country>(`/countries/${id}`)
|
||||
},
|
||||
|
||||
// Create country
|
||||
createCountry: (data: CreateCountryRequest) => {
|
||||
return request.post<any, Country>('/countries', data)
|
||||
},
|
||||
|
||||
// Update country
|
||||
updateCountry: (id: string, data: UpdateCountryRequest) => {
|
||||
return request.patch<any, Country>(`/countries/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete country
|
||||
deleteCountry: (id: string) => {
|
||||
return request.delete(`/countries/${id}`)
|
||||
},
|
||||
}
|
||||
+39
-39
@@ -1,29 +1,29 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
import request from './request'
|
||||
import type {
|
||||
Good,
|
||||
GoodDetail,
|
||||
CreateGoodRequest,
|
||||
UpdateGoodRequest,
|
||||
BatchCreateGoodsRequest,
|
||||
BatchPriorityRequest,
|
||||
CreateGoodRequest,
|
||||
UpdateGoodRequest,
|
||||
BatchCreateGoodsRequest,
|
||||
BatchPriorityRequest,
|
||||
GoodsFilter,
|
||||
PaginatedResult,
|
||||
CreateCustomGoodRequest,
|
||||
UpdateCustomGoodContentRequest,
|
||||
} from '@/types'
|
||||
|
||||
export const goodsApi = {
|
||||
// Get goods list
|
||||
getGoodsList: (params: GoodsFilter) => {
|
||||
return request.get<any, PaginatedResult<Good>>('/goods', { params })
|
||||
},
|
||||
|
||||
// Get good by id
|
||||
getGoodById: (id: string) => {
|
||||
} from '@/types'
|
||||
|
||||
export const goodsApi = {
|
||||
// Get goods list
|
||||
getGoodsList: (params: GoodsFilter) => {
|
||||
return request.get<any, PaginatedResult<Good>>('/goods', { params })
|
||||
},
|
||||
|
||||
// Get good by id
|
||||
getGoodById: (id: string) => {
|
||||
return request.get<any, GoodDetail>(`/goods/${id}`)
|
||||
},
|
||||
|
||||
// Create good
|
||||
},
|
||||
|
||||
// Create good
|
||||
createGood: (data: CreateGoodRequest) => {
|
||||
return request.post<any, Good>('/goods', data)
|
||||
},
|
||||
@@ -35,24 +35,24 @@ export const goodsApi = {
|
||||
updateCustomGoodContent: (id: string, data: UpdateCustomGoodContentRequest) => {
|
||||
return request.patch<any, GoodDetail>(`/goods/${id}/custom-content`, data)
|
||||
},
|
||||
|
||||
// Update good
|
||||
updateGood: (id: string, data: UpdateGoodRequest) => {
|
||||
return request.patch<any, Good>(`/goods/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete good
|
||||
deleteGood: (id: string) => {
|
||||
return request.delete(`/goods/${id}`)
|
||||
},
|
||||
|
||||
// Batch create goods
|
||||
batchCreateGoods: (data: BatchCreateGoodsRequest) => {
|
||||
return request.post<any, Good[]>('/goods/batch', data)
|
||||
},
|
||||
|
||||
// Batch update priority
|
||||
batchUpdatePriority: (data: BatchPriorityRequest) => {
|
||||
return request.patch<any, { count: number }>('/goods/batch-priority', data)
|
||||
},
|
||||
|
||||
// Update good
|
||||
updateGood: (id: string, data: UpdateGoodRequest) => {
|
||||
return request.patch<any, Good>(`/goods/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete good
|
||||
deleteGood: (id: string) => {
|
||||
return request.delete(`/goods/${id}`)
|
||||
},
|
||||
|
||||
// Batch create goods
|
||||
batchCreateGoods: (data: BatchCreateGoodsRequest) => {
|
||||
return request.post<any, Good[]>('/goods/batch', data)
|
||||
},
|
||||
|
||||
// Batch update priority
|
||||
batchUpdatePriority: (data: BatchPriorityRequest) => {
|
||||
return request.patch<any, { count: number }>('/goods/batch-priority', data)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import request from './request'
|
||||
import type { OriginGood, OriginGoodsTreeResponse, PaginatedResult } from '@/types'
|
||||
|
||||
export const originGoodsApi = {
|
||||
getTree: () => {
|
||||
return request.get<any, OriginGoodsTreeResponse>('/origin-goods/tree')
|
||||
},
|
||||
|
||||
getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => {
|
||||
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
|
||||
},
|
||||
}
|
||||
import request from './request'
|
||||
import type { OriginGood, OriginGoodsTreeResponse, PaginatedResult } from '@/types'
|
||||
|
||||
export const originGoodsApi = {
|
||||
getTree: () => {
|
||||
return request.get<any, OriginGoodsTreeResponse>('/origin-goods/tree')
|
||||
},
|
||||
|
||||
getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => {
|
||||
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
Position,
|
||||
CreatePositionRequest,
|
||||
UpdatePositionRequest,
|
||||
PositionFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const positionsApi = {
|
||||
// Get positions list
|
||||
getPositionsList: (params: PositionFilter) => {
|
||||
return request.get<any, PaginatedResult<Position>>('/positions', { params })
|
||||
},
|
||||
|
||||
// Get position by id
|
||||
getPositionById: (id: string) => {
|
||||
return request.get<any, Position>(`/positions/${id}`)
|
||||
},
|
||||
|
||||
// Create position
|
||||
createPosition: (data: CreatePositionRequest) => {
|
||||
return request.post<any, Position>('/positions', data)
|
||||
},
|
||||
|
||||
// Update position
|
||||
updatePosition: (id: string, data: UpdatePositionRequest) => {
|
||||
return request.patch<any, Position>(`/positions/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete position
|
||||
deletePosition: (id: string) => {
|
||||
return request.delete(`/positions/${id}`)
|
||||
},
|
||||
import request from './request'
|
||||
import type {
|
||||
Position,
|
||||
CreatePositionRequest,
|
||||
UpdatePositionRequest,
|
||||
PositionFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const positionsApi = {
|
||||
// Get positions list
|
||||
getPositionsList: (params: PositionFilter) => {
|
||||
return request.get<any, PaginatedResult<Position>>('/positions', { params })
|
||||
},
|
||||
|
||||
// Get position by id
|
||||
getPositionById: (id: string) => {
|
||||
return request.get<any, Position>(`/positions/${id}`)
|
||||
},
|
||||
|
||||
// Create position
|
||||
createPosition: (data: CreatePositionRequest) => {
|
||||
return request.post<any, Position>('/positions', data)
|
||||
},
|
||||
|
||||
// Update position
|
||||
updatePosition: (id: string, data: UpdatePositionRequest) => {
|
||||
return request.patch<any, Position>(`/positions/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete position
|
||||
deletePosition: (id: string) => {
|
||||
return request.delete(`/positions/${id}`)
|
||||
},
|
||||
}
|
||||
@@ -1,81 +1,81 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE || '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Session tokens live in HttpOnly cookies — send them along.
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// Response interceptor
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// Backend wraps everything in { data, success } — unwrap to data
|
||||
const body = response.data
|
||||
if (body && typeof body === 'object' && 'success' in body && 'data' in body) {
|
||||
return body.data
|
||||
}
|
||||
return body
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
// Access token expired: try the refresh cookie once, then retry the
|
||||
// original request. A second 401 (refresh failed) logs the user out.
|
||||
const config = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
config &&
|
||||
!config._retried &&
|
||||
!config.url?.includes('/auth/login') &&
|
||||
!config.url?.includes('/auth/refresh')
|
||||
) {
|
||||
config._retried = true
|
||||
try {
|
||||
await axios.post(
|
||||
`${import.meta.env.VITE_API_BASE || '/api'}/auth/refresh`,
|
||||
{},
|
||||
{ withCredentials: true },
|
||||
)
|
||||
return request.request(config)
|
||||
} catch {
|
||||
// fall through to the 401 handling below
|
||||
}
|
||||
}
|
||||
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
ElMessage.error('Unauthorized, please login')
|
||||
router.push('/login')
|
||||
break
|
||||
case 403:
|
||||
ElMessage.error('Forbidden')
|
||||
break
|
||||
case 404:
|
||||
ElMessage.error('Resource not found')
|
||||
break
|
||||
case 500:
|
||||
ElMessage.error('Server error')
|
||||
break
|
||||
default:
|
||||
const errorMessage = (data as any)?.message || 'Request failed'
|
||||
ElMessage.error(errorMessage)
|
||||
}
|
||||
} else if (error.request) {
|
||||
ElMessage.error('Network error')
|
||||
} else {
|
||||
ElMessage.error('Request failed')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE || '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Session tokens live in HttpOnly cookies — send them along.
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// Response interceptor
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// Backend wraps everything in { data, success } — unwrap to data
|
||||
const body = response.data
|
||||
if (body && typeof body === 'object' && 'success' in body && 'data' in body) {
|
||||
return body.data
|
||||
}
|
||||
return body
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
// Access token expired: try the refresh cookie once, then retry the
|
||||
// original request. A second 401 (refresh failed) logs the user out.
|
||||
const config = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
config &&
|
||||
!config._retried &&
|
||||
!config.url?.includes('/auth/login') &&
|
||||
!config.url?.includes('/auth/refresh')
|
||||
) {
|
||||
config._retried = true
|
||||
try {
|
||||
await axios.post(
|
||||
`${import.meta.env.VITE_API_BASE || '/api'}/auth/refresh`,
|
||||
{},
|
||||
{ withCredentials: true },
|
||||
)
|
||||
return request.request(config)
|
||||
} catch {
|
||||
// fall through to the 401 handling below
|
||||
}
|
||||
}
|
||||
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
ElMessage.error('Unauthorized, please login')
|
||||
router.push('/login')
|
||||
break
|
||||
case 403:
|
||||
ElMessage.error('Forbidden')
|
||||
break
|
||||
case 404:
|
||||
ElMessage.error('Resource not found')
|
||||
break
|
||||
case 500:
|
||||
ElMessage.error('Server error')
|
||||
break
|
||||
default:
|
||||
const errorMessage = (data as any)?.message || 'Request failed'
|
||||
ElMessage.error(errorMessage)
|
||||
}
|
||||
} else if (error.request) {
|
||||
ElMessage.error('Network error')
|
||||
} else {
|
||||
ElMessage.error('Request failed')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
|
||||
+16
-16
@@ -1,13 +1,13 @@
|
||||
import request from './request'
|
||||
import type { SyncLog } from '@/types'
|
||||
|
||||
export const syncApi = {
|
||||
syncProducts: () => {
|
||||
return request.post<any, { message: string }>('/sync/products')
|
||||
},
|
||||
|
||||
import request from './request'
|
||||
import type { SyncLog } from '@/types'
|
||||
|
||||
export const syncApi = {
|
||||
syncProducts: () => {
|
||||
return request.post<any, { message: string }>('/sync/products')
|
||||
},
|
||||
|
||||
syncCategories: () => {
|
||||
return request.post<any, { message: string }>('/sync/categories')
|
||||
return request.post<any, { message: string }>('/sync/categories')
|
||||
},
|
||||
|
||||
syncProductDetails: () => {
|
||||
@@ -19,10 +19,10 @@ export const syncApi = {
|
||||
`/sync/products/${goodId}/detail`,
|
||||
)
|
||||
},
|
||||
|
||||
getSyncStatus: (limit?: number) => {
|
||||
return request.get<any, SyncLog[]>('/sync/status', {
|
||||
params: limit ? { limit } : undefined,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
getSyncStatus: (limit?: number) => {
|
||||
return request.get<any, SyncLog[]>('/sync/status', {
|
||||
params: limit ? { limit } : undefined,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
TagGroup,
|
||||
CreateTagGroupRequest,
|
||||
UpdateTagGroupRequest,
|
||||
ReorderTagGroupsRequest,
|
||||
} from '@/types'
|
||||
|
||||
export const tagGroupsApi = {
|
||||
getTagGroupsList: () => {
|
||||
return request.get<any, TagGroup[]>('/tag-groups')
|
||||
},
|
||||
|
||||
getTagGroupById: (id: string) => {
|
||||
return request.get<any, TagGroup>(`/tag-groups/${id}`)
|
||||
},
|
||||
|
||||
createTagGroup: (data: CreateTagGroupRequest) => {
|
||||
return request.post<any, TagGroup>('/tag-groups', data)
|
||||
},
|
||||
|
||||
updateTagGroup: (id: string, data: UpdateTagGroupRequest) => {
|
||||
return request.patch<any, TagGroup>(`/tag-groups/${id}`, data)
|
||||
},
|
||||
|
||||
deleteTagGroup: (id: string) => {
|
||||
return request.delete(`/tag-groups/${id}`)
|
||||
},
|
||||
|
||||
reorderTagGroups: (data: ReorderTagGroupsRequest) => {
|
||||
return request.patch('/tag-groups/sort', data)
|
||||
},
|
||||
}
|
||||
import request from './request'
|
||||
import type {
|
||||
TagGroup,
|
||||
CreateTagGroupRequest,
|
||||
UpdateTagGroupRequest,
|
||||
ReorderTagGroupsRequest,
|
||||
} from '@/types'
|
||||
|
||||
export const tagGroupsApi = {
|
||||
getTagGroupsList: () => {
|
||||
return request.get<any, TagGroup[]>('/tag-groups')
|
||||
},
|
||||
|
||||
getTagGroupById: (id: string) => {
|
||||
return request.get<any, TagGroup>(`/tag-groups/${id}`)
|
||||
},
|
||||
|
||||
createTagGroup: (data: CreateTagGroupRequest) => {
|
||||
return request.post<any, TagGroup>('/tag-groups', data)
|
||||
},
|
||||
|
||||
updateTagGroup: (id: string, data: UpdateTagGroupRequest) => {
|
||||
return request.patch<any, TagGroup>(`/tag-groups/${id}`, data)
|
||||
},
|
||||
|
||||
deleteTagGroup: (id: string) => {
|
||||
return request.delete(`/tag-groups/${id}`)
|
||||
},
|
||||
|
||||
reorderTagGroups: (data: ReorderTagGroupsRequest) => {
|
||||
return request.patch('/tag-groups/sort', data)
|
||||
},
|
||||
}
|
||||
|
||||
+40
-40
@@ -1,41 +1,41 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
Tag,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
TagFilter,
|
||||
ReorderTagsRequest,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const tagsApi = {
|
||||
// Get tags list
|
||||
getTagsList: (params: TagFilter) => {
|
||||
return request.get<any, PaginatedResult<Tag>>('/tags', { params })
|
||||
},
|
||||
|
||||
// Get tag by id
|
||||
getTagById: (id: string) => {
|
||||
return request.get<any, Tag>(`/tags/${id}`)
|
||||
},
|
||||
|
||||
// Create tag
|
||||
createTag: (data: CreateTagRequest) => {
|
||||
return request.post<any, Tag>('/tags', data)
|
||||
},
|
||||
|
||||
// Update tag
|
||||
updateTag: (id: string, data: UpdateTagRequest) => {
|
||||
return request.patch<any, Tag>(`/tags/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete tag
|
||||
deleteTag: (id: string) => {
|
||||
return request.delete(`/tags/${id}`)
|
||||
},
|
||||
|
||||
// Reorder tags (and/or move across groups)
|
||||
reorderTags: (data: ReorderTagsRequest) => {
|
||||
return request.patch('/tags/sort', data)
|
||||
},
|
||||
import request from './request'
|
||||
import type {
|
||||
Tag,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
TagFilter,
|
||||
ReorderTagsRequest,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const tagsApi = {
|
||||
// Get tags list
|
||||
getTagsList: (params: TagFilter) => {
|
||||
return request.get<any, PaginatedResult<Tag>>('/tags', { params })
|
||||
},
|
||||
|
||||
// Get tag by id
|
||||
getTagById: (id: string) => {
|
||||
return request.get<any, Tag>(`/tags/${id}`)
|
||||
},
|
||||
|
||||
// Create tag
|
||||
createTag: (data: CreateTagRequest) => {
|
||||
return request.post<any, Tag>('/tags', data)
|
||||
},
|
||||
|
||||
// Update tag
|
||||
updateTag: (id: string, data: UpdateTagRequest) => {
|
||||
return request.patch<any, Tag>(`/tags/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete tag
|
||||
deleteTag: (id: string) => {
|
||||
return request.delete(`/tags/${id}`)
|
||||
},
|
||||
|
||||
// Reorder tags (and/or move across groups)
|
||||
reorderTags: (data: ReorderTagsRequest) => {
|
||||
return request.patch('/tags/sort', data)
|
||||
},
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import request from './request'
|
||||
|
||||
export interface UploadResult {
|
||||
url: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export const uploadApi = {
|
||||
uploadImage: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post<any, UploadResult>('/upload/image', formData)
|
||||
},
|
||||
}
|
||||
import request from './request'
|
||||
|
||||
export interface UploadResult {
|
||||
url: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export const uploadApi = {
|
||||
uploadImage: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post<any, UploadResult>('/upload/image', formData)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,154 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Loading } from '@element-plus/icons-vue'
|
||||
import type { UploadProps } from 'element-plus'
|
||||
import { uploadApi } from '@/api/upload'
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
width?: number
|
||||
height?: number
|
||||
shape?: 'square' | 'circle'
|
||||
label?: string
|
||||
}>(), {
|
||||
width: 80,
|
||||
height: 80,
|
||||
shape: 'square',
|
||||
label: '点击上传',
|
||||
})
|
||||
|
||||
const uploading = ref(false)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const customUpload: UploadProps['httpRequest'] = async (options) => {
|
||||
const file = options.file as File
|
||||
uploading.value = true
|
||||
try {
|
||||
const result = await uploadApi.uploadImage(file)
|
||||
model.value = result.url
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
model.value = ''
|
||||
}
|
||||
|
||||
function onImgError() {
|
||||
console.warn('Image failed to load:', model.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="img-upload">
|
||||
<el-upload
|
||||
class="img-uploader"
|
||||
:show-file-list="false"
|
||||
:http-request="customUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<div v-if="model" class="img-preview-wrap" :style="{ width: width + 'px', height: height + 'px', borderRadius: shape === 'circle' ? '50%' : '8px' }">
|
||||
<img :src="model" class="img-preview-img" @click.stop="previewVisible = true" @error="onImgError" />
|
||||
<div class="img-overlay">
|
||||
<span @click.stop="previewVisible = true">预览</span>
|
||||
<span @click.stop="removeImage">删除</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="img-placeholder" :style="{ width: width + 'px', height: height + 'px', borderRadius: shape === 'circle' ? '50%' : '8px' }">
|
||||
<el-icon v-if="!uploading" :size="20"><Plus /></el-icon>
|
||||
<el-icon v-else :size="20" class="is-loading"><Loading /></el-icon>
|
||||
<span class="img-placeholder-text">{{ uploading ? '上传中' : label }}</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<el-image-viewer v-if="previewVisible" :url-list="[model]" @close="previewVisible = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.img-upload {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.img-uploader {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.img-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
background: #fafafa;
|
||||
color: #a8abb2;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.img-placeholder:hover {
|
||||
border-color: var(--brand-color, #ff6800);
|
||||
color: var(--brand-color, #ff6800);
|
||||
}
|
||||
|
||||
.img-placeholder-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.img-preview-wrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.img-preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.img-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.img-preview-wrap:hover .img-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.img-overlay span {
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.img-overlay span:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.is-loading {
|
||||
animation: rotating 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Loading } from '@element-plus/icons-vue'
|
||||
import type { UploadProps } from 'element-plus'
|
||||
import { uploadApi } from '@/api/upload'
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
width?: number
|
||||
height?: number
|
||||
shape?: 'square' | 'circle'
|
||||
label?: string
|
||||
}>(), {
|
||||
width: 80,
|
||||
height: 80,
|
||||
shape: 'square',
|
||||
label: '点击上传',
|
||||
})
|
||||
|
||||
const uploading = ref(false)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const customUpload: UploadProps['httpRequest'] = async (options) => {
|
||||
const file = options.file as File
|
||||
uploading.value = true
|
||||
try {
|
||||
const result = await uploadApi.uploadImage(file)
|
||||
model.value = result.url
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
model.value = ''
|
||||
}
|
||||
|
||||
function onImgError() {
|
||||
console.warn('Image failed to load:', model.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="img-upload">
|
||||
<el-upload
|
||||
class="img-uploader"
|
||||
:show-file-list="false"
|
||||
:http-request="customUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<div v-if="model" class="img-preview-wrap" :style="{ width: width + 'px', height: height + 'px', borderRadius: shape === 'circle' ? '50%' : '8px' }">
|
||||
<img :src="model" class="img-preview-img" @click.stop="previewVisible = true" @error="onImgError" />
|
||||
<div class="img-overlay">
|
||||
<span @click.stop="previewVisible = true">预览</span>
|
||||
<span @click.stop="removeImage">删除</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="img-placeholder" :style="{ width: width + 'px', height: height + 'px', borderRadius: shape === 'circle' ? '50%' : '8px' }">
|
||||
<el-icon v-if="!uploading" :size="20"><Plus /></el-icon>
|
||||
<el-icon v-else :size="20" class="is-loading"><Loading /></el-icon>
|
||||
<span class="img-placeholder-text">{{ uploading ? '上传中' : label }}</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<el-image-viewer v-if="previewVisible" :url-list="[model]" @close="previewVisible = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.img-upload {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.img-uploader {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.img-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
background: #fafafa;
|
||||
color: #a8abb2;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.img-placeholder:hover {
|
||||
border-color: var(--brand-color, #ff6800);
|
||||
color: var(--brand-color, #ff6800);
|
||||
}
|
||||
|
||||
.img-placeholder-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.img-preview-wrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.img-preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.img-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.img-preview-wrap:hover .img-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.img-overlay span {
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.img-overlay span:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.is-loading {
|
||||
animation: rotating 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,274 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Goods,
|
||||
Expand,
|
||||
Fold,
|
||||
ArrowDown,
|
||||
User,
|
||||
SwitchButton,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
interface MenuItem {
|
||||
index: string
|
||||
title: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const menuItems = ref<MenuItem[]>([
|
||||
{ index: '/goods', title: '商品管理', icon: 'Goods' },
|
||||
])
|
||||
|
||||
const iconMap: Record<string, unknown> = {
|
||||
Goods,
|
||||
}
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
const collapsed = computed(() => appStore.sidebarCollapsed)
|
||||
const username = computed(() => authStore.user?.username || 'Admin')
|
||||
|
||||
const breadcrumb = computed(() => {
|
||||
const meta = route.meta as { title?: string } | undefined
|
||||
return meta?.title || '商品管理'
|
||||
})
|
||||
|
||||
function toggleSidebar() {
|
||||
appStore.toggleSidebar()
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定退出登录吗?', '退出登录', {
|
||||
confirmButtonText: '退出登录',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function handleCommand(command: string) {
|
||||
if (command === 'logout') {
|
||||
handleLogout()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="layout-root">
|
||||
<!-- Sidebar -->
|
||||
<el-aside :width="collapsed ? '64px' : '240px'" class="layout-aside">
|
||||
<div class="brand" :class="{ collapsed }">
|
||||
<img src="/logo-unified.svg" class="brand-logo" alt="InkReach" />
|
||||
<span v-if="!collapsed" class="brand-text">印美达官网后台</span>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="collapsed"
|
||||
:collapse-transition="false"
|
||||
background-color="#1f1f24"
|
||||
text-color="#d4d4d8"
|
||||
active-text-color="#ff6800"
|
||||
router
|
||||
>
|
||||
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
|
||||
<el-icon>
|
||||
<component :is="iconMap[item.icon]" />
|
||||
</el-icon>
|
||||
<template #title>{{ item.title }}</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<!-- Header -->
|
||||
<el-header class="layout-header" height="56px">
|
||||
<div class="header-left">
|
||||
<el-button
|
||||
text
|
||||
class="collapse-btn"
|
||||
:title="collapsed ? '展开侧边栏' : '折叠侧边栏'"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<el-icon :size="20">
|
||||
<component :is="collapsed ? Expand : Fold" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-breadcrumb separator="/" class="breadcrumb">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>{{ breadcrumb }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<span class="user-trigger">
|
||||
<el-icon><User /></el-icon>
|
||||
<span class="user-name">{{ username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>退出登录</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- Main content -->
|
||||
<el-main class="layout-main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-root {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.layout-aside {
|
||||
background: #1f1f24;
|
||||
transition: width 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 56px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #2b2b33;
|
||||
background: #16161a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand.collapsed {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 24px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
color: #e4e4e7;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu-item.is-active) {
|
||||
background: rgba(255, 104, 0, 0.12) !important;
|
||||
border-right: 3px solid var(--brand-color);
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu-item:hover) {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.user-trigger:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
background: #f5f7fa;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* View transition */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Goods,
|
||||
Expand,
|
||||
Fold,
|
||||
ArrowDown,
|
||||
User,
|
||||
SwitchButton,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
interface MenuItem {
|
||||
index: string
|
||||
title: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const menuItems = ref<MenuItem[]>([
|
||||
{ index: '/goods', title: '商品管理', icon: 'Goods' },
|
||||
])
|
||||
|
||||
const iconMap: Record<string, unknown> = {
|
||||
Goods,
|
||||
}
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
const collapsed = computed(() => appStore.sidebarCollapsed)
|
||||
const username = computed(() => authStore.user?.username || 'Admin')
|
||||
|
||||
const breadcrumb = computed(() => {
|
||||
const meta = route.meta as { title?: string } | undefined
|
||||
return meta?.title || '商品管理'
|
||||
})
|
||||
|
||||
function toggleSidebar() {
|
||||
appStore.toggleSidebar()
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定退出登录吗?', '退出登录', {
|
||||
confirmButtonText: '退出登录',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function handleCommand(command: string) {
|
||||
if (command === 'logout') {
|
||||
handleLogout()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="layout-root">
|
||||
<!-- Sidebar -->
|
||||
<el-aside :width="collapsed ? '64px' : '240px'" class="layout-aside">
|
||||
<div class="brand" :class="{ collapsed }">
|
||||
<img src="/logo-unified.svg" class="brand-logo" alt="InkReach" />
|
||||
<span v-if="!collapsed" class="brand-text">印美达官网后台</span>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="collapsed"
|
||||
:collapse-transition="false"
|
||||
background-color="#1f1f24"
|
||||
text-color="#d4d4d8"
|
||||
active-text-color="#ff6800"
|
||||
router
|
||||
>
|
||||
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
|
||||
<el-icon>
|
||||
<component :is="iconMap[item.icon]" />
|
||||
</el-icon>
|
||||
<template #title>{{ item.title }}</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<!-- Header -->
|
||||
<el-header class="layout-header" height="56px">
|
||||
<div class="header-left">
|
||||
<el-button
|
||||
text
|
||||
class="collapse-btn"
|
||||
:title="collapsed ? '展开侧边栏' : '折叠侧边栏'"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<el-icon :size="20">
|
||||
<component :is="collapsed ? Expand : Fold" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-breadcrumb separator="/" class="breadcrumb">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>{{ breadcrumb }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<span class="user-trigger">
|
||||
<el-icon><User /></el-icon>
|
||||
<span class="user-name">{{ username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>退出登录</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- Main content -->
|
||||
<el-main class="layout-main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-root {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.layout-aside {
|
||||
background: #1f1f24;
|
||||
transition: width 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 56px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #2b2b33;
|
||||
background: #16161a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand.collapsed {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 24px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
color: #e4e4e7;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu-item.is-active) {
|
||||
background: rgba(255, 104, 0, 0.12) !important;
|
||||
border-right: 3px solid var(--brand-color);
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu-item:hover) {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.user-trigger:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
background: #f5f7fa;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* View transition */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
+15
-15
@@ -1,15 +1,15 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/LoginView.vue'),
|
||||
meta: { title: '登录', public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/DefaultLayout.vue'),
|
||||
redirect: '/goods',
|
||||
children: [
|
||||
{
|
||||
path: 'goods',
|
||||
name: 'Goods',
|
||||
component: () => import('@/views/product-management/ProductManagementView.vue'),
|
||||
meta: { title: '商品管理' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/goods',
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const authStore = useAuthStore()
|
||||
const isPublic = to.meta?.public === true
|
||||
|
||||
if (!authStore.isLoggedIn && !isPublic) {
|
||||
return { path: '/login', replace: true }
|
||||
}
|
||||
|
||||
if (authStore.isLoggedIn && to.path === '/login') {
|
||||
return { path: '/', replace: true }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = (to.meta?.title as string) || 'Inkreach 官网后台'
|
||||
document.title = `${title} | Inkreach 官网后台`
|
||||
})
|
||||
|
||||
export default router
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/LoginView.vue'),
|
||||
meta: { title: '登录', public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/DefaultLayout.vue'),
|
||||
redirect: '/goods',
|
||||
children: [
|
||||
{
|
||||
path: 'goods',
|
||||
name: 'Goods',
|
||||
component: () => import('@/views/product-management/ProductManagementView.vue'),
|
||||
meta: { title: '商品管理' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/goods',
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/admin/'),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const authStore = useAuthStore()
|
||||
const isPublic = to.meta?.public === true
|
||||
|
||||
if (!authStore.isLoggedIn && !isPublic) {
|
||||
return { path: '/login', replace: true }
|
||||
}
|
||||
|
||||
if (authStore.isLoggedIn && to.path === '/login') {
|
||||
return { path: '/', replace: true }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = (to.meta?.title as string) || 'Inkreach 官网后台'
|
||||
document.title = `${title} | Inkreach 官网后台`
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const SIDEBAR_KEY = 'app:sidebarCollapsed'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const sidebarCollapsed = ref<boolean>(
|
||||
localStorage.getItem(SIDEBAR_KEY) === 'true'
|
||||
)
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
localStorage.setItem(SIDEBAR_KEY, String(sidebarCollapsed.value))
|
||||
}
|
||||
|
||||
function setSidebarCollapsed(value: boolean) {
|
||||
sidebarCollapsed.value = value
|
||||
localStorage.setItem(SIDEBAR_KEY, String(value))
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
setSidebarCollapsed,
|
||||
}
|
||||
})
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const SIDEBAR_KEY = 'app:sidebarCollapsed'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const sidebarCollapsed = ref<boolean>(
|
||||
localStorage.getItem(SIDEBAR_KEY) === 'true'
|
||||
)
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
localStorage.setItem(SIDEBAR_KEY, String(sidebarCollapsed.value))
|
||||
}
|
||||
|
||||
function setSidebarCollapsed(value: boolean) {
|
||||
sidebarCollapsed.value = value
|
||||
localStorage.setItem(SIDEBAR_KEY, String(value))
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
setSidebarCollapsed,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { LoginRequest, User } from '@/types'
|
||||
import { authApi } from '@/api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// The session lives in HttpOnly cookies set by the API; nothing
|
||||
// security-relevant is stored client-side. `user` is just UI state,
|
||||
// restored from the server via /auth/me on app start.
|
||||
const user = ref<User | null>(null)
|
||||
const sessionChecked = ref(false)
|
||||
|
||||
// Tokens moved to HttpOnly cookies; clean up any stale values from the
|
||||
// previous localStorage-based session.
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
|
||||
// Restore the session once per app start. The router guard awaits this
|
||||
// so a page refresh on a protected route does not bounce to /login.
|
||||
async function ensureSessionChecked() {
|
||||
if (sessionChecked.value) return
|
||||
sessionChecked.value = true
|
||||
try {
|
||||
user.value = await authApi.getCurrentUser()
|
||||
} catch {
|
||||
user.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function setUser(newUser: User | null) {
|
||||
user.value = newUser
|
||||
}
|
||||
|
||||
async function login(payload: LoginRequest) {
|
||||
const res = await authApi.login(payload) as any
|
||||
const userData: User | null = res.user ?? res.data?.user ?? null
|
||||
sessionChecked.value = true
|
||||
setUser(userData)
|
||||
return res
|
||||
}
|
||||
|
||||
async function fetchCurrentUser() {
|
||||
const current = await authApi.getCurrentUser()
|
||||
setUser(current)
|
||||
return current
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch {
|
||||
// Ignore network errors during logout
|
||||
}
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
isLoggedIn,
|
||||
ensureSessionChecked,
|
||||
login,
|
||||
fetchCurrentUser,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { LoginRequest, User } from '@/types'
|
||||
import { authApi } from '@/api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// The session lives in HttpOnly cookies set by the API; nothing
|
||||
// security-relevant is stored client-side. `user` is just UI state,
|
||||
// restored from the server via /auth/me on app start.
|
||||
const user = ref<User | null>(null)
|
||||
const sessionChecked = ref(false)
|
||||
|
||||
// Tokens moved to HttpOnly cookies; clean up any stale values from the
|
||||
// previous localStorage-based session.
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
|
||||
// Restore the session once per app start. The router guard awaits this
|
||||
// so a page refresh on a protected route does not bounce to /login.
|
||||
async function ensureSessionChecked() {
|
||||
if (sessionChecked.value) return
|
||||
sessionChecked.value = true
|
||||
try {
|
||||
user.value = await authApi.getCurrentUser()
|
||||
} catch {
|
||||
user.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function setUser(newUser: User | null) {
|
||||
user.value = newUser
|
||||
}
|
||||
|
||||
async function login(payload: LoginRequest) {
|
||||
const res = await authApi.login(payload) as any
|
||||
const userData: User | null = res.user ?? res.data?.user ?? null
|
||||
sessionChecked.value = true
|
||||
setUser(userData)
|
||||
return res
|
||||
}
|
||||
|
||||
async function fetchCurrentUser() {
|
||||
const current = await authApi.getCurrentUser()
|
||||
setUser(current)
|
||||
return current
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch {
|
||||
// Ignore network errors during logout
|
||||
}
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
isLoggedIn,
|
||||
ensureSessionChecked,
|
||||
login,
|
||||
fetchCurrentUser,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
|
||||
+100
-100
@@ -1,100 +1,100 @@
|
||||
:root {
|
||||
--brand-color: #ff6800;
|
||||
--brand-color-hover: #ff7d1f;
|
||||
--brand-color-active: #e65a00;
|
||||
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-width-collapsed: 64px;
|
||||
--header-height: 56px;
|
||||
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Arial,
|
||||
'PingFang SC',
|
||||
'Hiragino Sans GB',
|
||||
'Microsoft YaHei',
|
||||
sans-serif;
|
||||
color: #1f2937;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Element Plus brand color overrides */
|
||||
:root {
|
||||
--el-color-primary: var(--brand-color);
|
||||
--el-color-primary-light-3: #ff8f3d;
|
||||
--el-color-primary-light-5: #ffb073;
|
||||
--el-color-primary-light-7: #ffd1a8;
|
||||
--el-color-primary-light-8: #ffe0c2;
|
||||
--el-color-primary-light-9: #fff0e0;
|
||||
--el-color-primary-dark-2: #e65a00;
|
||||
}
|
||||
|
||||
/* Brand-colored buttons */
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--brand-color);
|
||||
--el-button-border-color: var(--brand-color);
|
||||
--el-button-hover-bg-color: var(--brand-color-hover);
|
||||
--el-button-hover-border-color: var(--brand-color-hover);
|
||||
--el-button-active-bg-color: var(--brand-color-active);
|
||||
--el-button-active-border-color: var(--brand-color-active);
|
||||
}
|
||||
|
||||
/* Page wrapper helper */
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Compact filter bar */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-bar .el-input,
|
||||
.filter-bar .el-select,
|
||||
.filter-bar .el-cascader {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
/* Action cell padding */
|
||||
.table-actions .el-button + .el-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Brand-colored dialog titles */
|
||||
.brand-title {
|
||||
color: var(--brand-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
:root {
|
||||
--brand-color: #ff6800;
|
||||
--brand-color-hover: #ff7d1f;
|
||||
--brand-color-active: #e65a00;
|
||||
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-width-collapsed: 64px;
|
||||
--header-height: 56px;
|
||||
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Arial,
|
||||
'PingFang SC',
|
||||
'Hiragino Sans GB',
|
||||
'Microsoft YaHei',
|
||||
sans-serif;
|
||||
color: #1f2937;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Element Plus brand color overrides */
|
||||
:root {
|
||||
--el-color-primary: var(--brand-color);
|
||||
--el-color-primary-light-3: #ff8f3d;
|
||||
--el-color-primary-light-5: #ffb073;
|
||||
--el-color-primary-light-7: #ffd1a8;
|
||||
--el-color-primary-light-8: #ffe0c2;
|
||||
--el-color-primary-light-9: #fff0e0;
|
||||
--el-color-primary-dark-2: #e65a00;
|
||||
}
|
||||
|
||||
/* Brand-colored buttons */
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--brand-color);
|
||||
--el-button-border-color: var(--brand-color);
|
||||
--el-button-hover-bg-color: var(--brand-color-hover);
|
||||
--el-button-hover-border-color: var(--brand-color-hover);
|
||||
--el-button-active-bg-color: var(--brand-color-active);
|
||||
--el-button-active-border-color: var(--brand-color-active);
|
||||
}
|
||||
|
||||
/* Page wrapper helper */
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Compact filter bar */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-bar .el-input,
|
||||
.filter-bar .el-select,
|
||||
.filter-bar .el-cascader {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
/* Action cell padding */
|
||||
.table-actions .el-button + .el-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Brand-colored dialog titles */
|
||||
.brand-title {
|
||||
color: var(--brand-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
+306
-306
@@ -1,47 +1,47 @@
|
||||
// Common types
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
// Good types
|
||||
// Common types
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
// Good types
|
||||
export interface Good {
|
||||
id: string
|
||||
goodName: string
|
||||
goodImage: string | null
|
||||
goodPriority: number
|
||||
originGoodId: string
|
||||
originGood?: OriginGood
|
||||
countryId: string
|
||||
country?: Country
|
||||
categoryId: string
|
||||
category?: Category
|
||||
tagId?: string
|
||||
tag?: Tag
|
||||
tags: Tag[]
|
||||
positionId?: string
|
||||
position?: Position
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
id: string
|
||||
goodName: string
|
||||
goodImage: string | null
|
||||
goodPriority: number
|
||||
originGoodId: string
|
||||
originGood?: OriginGood
|
||||
countryId: string
|
||||
country?: Country
|
||||
categoryId: string
|
||||
category?: Category
|
||||
tagId?: string
|
||||
tag?: Tag
|
||||
tags: Tag[]
|
||||
positionId?: string
|
||||
position?: Position
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface OriginGoodDetail {
|
||||
@@ -115,15 +115,15 @@ export interface GoodDetail extends Good {
|
||||
originDetail: OriginGoodDetail | null
|
||||
variants: OriginGoodVariant[]
|
||||
}
|
||||
|
||||
|
||||
export interface CreateGoodRequest {
|
||||
goodName: string
|
||||
goodImage?: string
|
||||
originGoodId: number
|
||||
countryId: number
|
||||
categoryId: number
|
||||
tagIds?: number[]
|
||||
positionId?: number
|
||||
goodName: string
|
||||
goodImage?: string
|
||||
originGoodId: number
|
||||
countryId: number
|
||||
categoryId: number
|
||||
tagIds?: number[]
|
||||
positionId?: number
|
||||
goodPriority?: number
|
||||
}
|
||||
|
||||
@@ -147,199 +147,199 @@ export interface UpdateCustomGoodContentRequest {
|
||||
detail?: CustomGoodDetailRequest
|
||||
variants?: CustomGoodVariantRequest[]
|
||||
}
|
||||
|
||||
export interface UpdateGoodRequest {
|
||||
goodName?: string
|
||||
goodImage?: string | null
|
||||
originGoodId?: number
|
||||
countryId?: number
|
||||
categoryId?: number
|
||||
tagIds?: number[]
|
||||
positionId?: number | null
|
||||
goodPriority?: number
|
||||
}
|
||||
|
||||
export interface BatchCreateItem {
|
||||
originGoodId: number
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export interface BatchCreateGoodsRequest {
|
||||
items: BatchCreateItem[]
|
||||
countryId: number
|
||||
categoryId: number
|
||||
tagIds?: number[]
|
||||
positionId?: number
|
||||
defaultPriority?: number
|
||||
}
|
||||
|
||||
export interface UpdatePriorityRequest {
|
||||
goodIds: string[]
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface BatchPriorityItem {
|
||||
id: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface BatchPriorityRequest {
|
||||
items: BatchPriorityItem[]
|
||||
}
|
||||
|
||||
// Country types
|
||||
export interface Country {
|
||||
id: string
|
||||
countryName: string
|
||||
countryIcon?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateCountryRequest {
|
||||
countryName: string
|
||||
countryIcon?: string
|
||||
}
|
||||
|
||||
export interface UpdateCountryRequest {
|
||||
countryName?: string
|
||||
countryIcon?: string | null
|
||||
}
|
||||
|
||||
// Category types
|
||||
export interface Category {
|
||||
id: string
|
||||
categoryName: string
|
||||
categoryIcon?: string | null
|
||||
sdsCategoryId?: string | null
|
||||
parentCategoryId?: string | null
|
||||
parent?: Category
|
||||
children?: Category[]
|
||||
_count?: {
|
||||
children: number
|
||||
}
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateCategoryRequest {
|
||||
categoryName: string
|
||||
categoryIcon?: string
|
||||
parentCategoryId?: number
|
||||
}
|
||||
|
||||
export interface UpdateCategoryRequest {
|
||||
categoryName?: string
|
||||
categoryIcon?: string | null
|
||||
parentCategoryId?: number | null
|
||||
}
|
||||
|
||||
export interface CategoryTree extends Category {
|
||||
children?: CategoryTree[]
|
||||
}
|
||||
|
||||
// Tag types
|
||||
export interface Tag {
|
||||
id: string
|
||||
tagName: string
|
||||
tagColor?: string | null
|
||||
tagFontColor?: string | null
|
||||
timing?: string | null
|
||||
tagGroupId?: string | null
|
||||
sortOrder?: number
|
||||
tagGroup?: TagGroup | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateTagRequest {
|
||||
tagName: string
|
||||
tagColor?: string
|
||||
tagFontColor?: string
|
||||
timing?: string
|
||||
tagGroupId?: number
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface UpdateTagRequest {
|
||||
tagName?: string
|
||||
tagColor?: string | null
|
||||
tagFontColor?: string | null
|
||||
timing?: string | null
|
||||
tagGroupId?: number | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
// Tag Group types
|
||||
export interface TagGroup {
|
||||
id: string
|
||||
groupName: string
|
||||
groupIcon?: string | null
|
||||
groupColor?: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
_count?: { tags: number }
|
||||
}
|
||||
|
||||
export interface CreateTagGroupRequest {
|
||||
groupName: string
|
||||
groupIcon?: string
|
||||
groupColor?: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface UpdateTagGroupRequest {
|
||||
groupName?: string
|
||||
groupIcon?: string | null
|
||||
groupColor?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface ReorderTagGroupsRequest {
|
||||
items: Array<{ id: number; sortOrder: number }>
|
||||
}
|
||||
|
||||
export interface ReorderTagsRequest {
|
||||
items: Array<{ id: number; tagGroupId?: number | null; sortOrder: number }>
|
||||
}
|
||||
|
||||
// Position types
|
||||
export interface Position {
|
||||
id: string
|
||||
indexVal: number
|
||||
countryId?: string
|
||||
country?: Country
|
||||
categoryId?: string
|
||||
category?: Category
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreatePositionRequest {
|
||||
indexVal: number
|
||||
countryId?: number
|
||||
categoryId?: number
|
||||
}
|
||||
|
||||
export interface UpdatePositionRequest {
|
||||
indexVal?: number
|
||||
countryId?: number | null
|
||||
categoryId?: number | null
|
||||
}
|
||||
|
||||
// Origin Good types
|
||||
|
||||
export interface UpdateGoodRequest {
|
||||
goodName?: string
|
||||
goodImage?: string | null
|
||||
originGoodId?: number
|
||||
countryId?: number
|
||||
categoryId?: number
|
||||
tagIds?: number[]
|
||||
positionId?: number | null
|
||||
goodPriority?: number
|
||||
}
|
||||
|
||||
export interface BatchCreateItem {
|
||||
originGoodId: number
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export interface BatchCreateGoodsRequest {
|
||||
items: BatchCreateItem[]
|
||||
countryId: number
|
||||
categoryId: number
|
||||
tagIds?: number[]
|
||||
positionId?: number
|
||||
defaultPriority?: number
|
||||
}
|
||||
|
||||
export interface UpdatePriorityRequest {
|
||||
goodIds: string[]
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface BatchPriorityItem {
|
||||
id: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface BatchPriorityRequest {
|
||||
items: BatchPriorityItem[]
|
||||
}
|
||||
|
||||
// Country types
|
||||
export interface Country {
|
||||
id: string
|
||||
countryName: string
|
||||
countryIcon?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateCountryRequest {
|
||||
countryName: string
|
||||
countryIcon?: string
|
||||
}
|
||||
|
||||
export interface UpdateCountryRequest {
|
||||
countryName?: string
|
||||
countryIcon?: string | null
|
||||
}
|
||||
|
||||
// Category types
|
||||
export interface Category {
|
||||
id: string
|
||||
categoryName: string
|
||||
categoryIcon?: string | null
|
||||
sdsCategoryId?: string | null
|
||||
parentCategoryId?: string | null
|
||||
parent?: Category
|
||||
children?: Category[]
|
||||
_count?: {
|
||||
children: number
|
||||
}
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateCategoryRequest {
|
||||
categoryName: string
|
||||
categoryIcon?: string
|
||||
parentCategoryId?: number
|
||||
}
|
||||
|
||||
export interface UpdateCategoryRequest {
|
||||
categoryName?: string
|
||||
categoryIcon?: string | null
|
||||
parentCategoryId?: number | null
|
||||
}
|
||||
|
||||
export interface CategoryTree extends Category {
|
||||
children?: CategoryTree[]
|
||||
}
|
||||
|
||||
// Tag types
|
||||
export interface Tag {
|
||||
id: string
|
||||
tagName: string
|
||||
tagColor?: string | null
|
||||
tagFontColor?: string | null
|
||||
timing?: string | null
|
||||
tagGroupId?: string | null
|
||||
sortOrder?: number
|
||||
tagGroup?: TagGroup | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateTagRequest {
|
||||
tagName: string
|
||||
tagColor?: string
|
||||
tagFontColor?: string
|
||||
timing?: string
|
||||
tagGroupId?: number
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface UpdateTagRequest {
|
||||
tagName?: string
|
||||
tagColor?: string | null
|
||||
tagFontColor?: string | null
|
||||
timing?: string | null
|
||||
tagGroupId?: number | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
// Tag Group types
|
||||
export interface TagGroup {
|
||||
id: string
|
||||
groupName: string
|
||||
groupIcon?: string | null
|
||||
groupColor?: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
_count?: { tags: number }
|
||||
}
|
||||
|
||||
export interface CreateTagGroupRequest {
|
||||
groupName: string
|
||||
groupIcon?: string
|
||||
groupColor?: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface UpdateTagGroupRequest {
|
||||
groupName?: string
|
||||
groupIcon?: string | null
|
||||
groupColor?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface ReorderTagGroupsRequest {
|
||||
items: Array<{ id: number; sortOrder: number }>
|
||||
}
|
||||
|
||||
export interface ReorderTagsRequest {
|
||||
items: Array<{ id: number; tagGroupId?: number | null; sortOrder: number }>
|
||||
}
|
||||
|
||||
// Position types
|
||||
export interface Position {
|
||||
id: string
|
||||
indexVal: number
|
||||
countryId?: string
|
||||
country?: Country
|
||||
categoryId?: string
|
||||
category?: Category
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreatePositionRequest {
|
||||
indexVal: number
|
||||
countryId?: number
|
||||
categoryId?: number
|
||||
}
|
||||
|
||||
export interface UpdatePositionRequest {
|
||||
indexVal?: number
|
||||
countryId?: number | null
|
||||
categoryId?: number | null
|
||||
}
|
||||
|
||||
// Origin Good types
|
||||
export interface OriginGood {
|
||||
id: string
|
||||
goodName: string | null
|
||||
goodImage: string | null
|
||||
goodPrice: string | null
|
||||
id: string
|
||||
goodName: string | null
|
||||
goodImage: string | null
|
||||
goodPrice: string | null
|
||||
sdsGoodId: string
|
||||
source: 'SDS' | 'CUSTOM'
|
||||
isCustom: boolean
|
||||
sdsCategoryId: string | null
|
||||
delisted?: boolean
|
||||
createdAt: string
|
||||
sdsCategoryId: string | null
|
||||
delisted?: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
hasDetail?: boolean
|
||||
detailSyncedAt?: string | null
|
||||
@@ -347,43 +347,43 @@ export interface OriginGood {
|
||||
sizeRowCount?: number
|
||||
packageRowCount?: number
|
||||
productCode?: string | null
|
||||
}
|
||||
|
||||
// Origin Goods Tree types
|
||||
}
|
||||
|
||||
// Origin Goods Tree types
|
||||
export interface OriginGoodsTreeNode {
|
||||
id: string
|
||||
goodName: string
|
||||
goodImage: string | null
|
||||
goodPrice: string | null
|
||||
sdsGoodId: string
|
||||
delisted: boolean
|
||||
configuredCount: number
|
||||
configuredCountries: string[]
|
||||
id: string
|
||||
goodName: string
|
||||
goodImage: string | null
|
||||
goodPrice: string | null
|
||||
sdsGoodId: string
|
||||
delisted: boolean
|
||||
configuredCount: number
|
||||
configuredCountries: string[]
|
||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]
|
||||
hasDetail: boolean
|
||||
detailSyncedAt: string | null
|
||||
variantCount: number
|
||||
sizeRowCount: number
|
||||
packageRowCount: number
|
||||
}
|
||||
|
||||
export interface OriginGoodsTreeCategoryNode {
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
sdsCategoryId: string | null
|
||||
configuredCount: number
|
||||
totalCount: number
|
||||
children: OriginGoodsTreeCategoryNode[]
|
||||
originGoods: OriginGoodsTreeNode[]
|
||||
}
|
||||
|
||||
export interface OriginGoodsTreeResponse {
|
||||
tree: OriginGoodsTreeCategoryNode[]
|
||||
totalOriginGoods: number
|
||||
configuredCount: number
|
||||
}
|
||||
|
||||
// Sync types
|
||||
}
|
||||
|
||||
export interface OriginGoodsTreeCategoryNode {
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
sdsCategoryId: string | null
|
||||
configuredCount: number
|
||||
totalCount: number
|
||||
children: OriginGoodsTreeCategoryNode[]
|
||||
originGoods: OriginGoodsTreeNode[]
|
||||
}
|
||||
|
||||
export interface OriginGoodsTreeResponse {
|
||||
tree: OriginGoodsTreeCategoryNode[]
|
||||
totalOriginGoods: number
|
||||
configuredCount: number
|
||||
}
|
||||
|
||||
// Sync types
|
||||
export interface SyncLog {
|
||||
id: string
|
||||
type: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS'
|
||||
@@ -392,39 +392,39 @@ export interface SyncLog {
|
||||
startedAt: string
|
||||
finishedAt: string | null
|
||||
}
|
||||
|
||||
// Filter types
|
||||
export interface GoodsFilter {
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
tagId?: string
|
||||
keyword?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface CategoryFilter {
|
||||
parentCategoryId?: string
|
||||
categoryName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface CountryFilter {
|
||||
countryName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface TagFilter {
|
||||
tagName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface PositionFilter {
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
// Filter types
|
||||
export interface GoodsFilter {
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
tagId?: string
|
||||
keyword?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface CategoryFilter {
|
||||
parentCategoryId?: string
|
||||
categoryName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface CountryFilter {
|
||||
countryName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface TagFilter {
|
||||
tagName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface PositionFilter {
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
@@ -1,246 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Category,
|
||||
CategoryTree,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
} from '@/types'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
|
||||
const loading = ref(false)
|
||||
const tree = ref<CategoryTree[]>([])
|
||||
|
||||
async function fetchTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
tree.value = await categoriesApi.getCategoryTree()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Cascader ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
const cascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.categoryName,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
cascaderOptions.value = buildCascader(tree.value)
|
||||
}
|
||||
|
||||
// ---------- Dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
||||
categoryName: '',
|
||||
categoryIcon: '',
|
||||
parentCategoryId: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
categoryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, categoryName: '', categoryIcon: '', parentCategoryId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function openEditDialog(c: Category) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: c.id,
|
||||
categoryName: c.categoryName,
|
||||
categoryIcon: c.categoryIcon || '',
|
||||
parentCategoryId: c.parentCategoryId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCategoryRequest = {
|
||||
categoryName: dialogForm.categoryName,
|
||||
categoryIcon: dialogForm.categoryIcon || undefined,
|
||||
parentCategoryId: dialogForm.parentCategoryId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await categoriesApi.createCategory(payload)
|
||||
ElMessage.success('分类创建成功')
|
||||
} else {
|
||||
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
|
||||
ElMessage.success('分类更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchTree()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(c: Category) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${c.categoryName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await categoriesApi.deleteCategory(c.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchTree()
|
||||
}
|
||||
|
||||
onMounted(fetchTree)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增分类</span>
|
||||
</el-button>
|
||||
<el-button @click="fetchTree">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>刷新</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tree"
|
||||
border
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
>
|
||||
<el-table-column prop="categoryName" label="名称" min-width="220" />
|
||||
<el-table-column label="图标" width="100">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
<el-image
|
||||
v-if="row.categoryIcon"
|
||||
:src="row.categoryIcon"
|
||||
:preview-src-list="[row.categoryIcon]"
|
||||
fit="cover"
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="父级">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
{{ row.parent?.categoryName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="子级数" width="100">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无分类" />
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增分类' : '编辑分类'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="dialogRef"
|
||||
:model="dialogForm"
|
||||
:rules="dialogRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="名称" prop="categoryName">
|
||||
<el-input v-model="dialogForm.categoryName" placeholder="请输入分类名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="父级分类">
|
||||
<el-cascader
|
||||
v-model="dialogForm.parentCategoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="顶级(可选)"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图标 URL">
|
||||
<el-input v-model="dialogForm.categoryIcon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Category,
|
||||
CategoryTree,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
} from '@/types'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
|
||||
const loading = ref(false)
|
||||
const tree = ref<CategoryTree[]>([])
|
||||
|
||||
async function fetchTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
tree.value = await categoriesApi.getCategoryTree()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Cascader ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
const cascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.categoryName,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
cascaderOptions.value = buildCascader(tree.value)
|
||||
}
|
||||
|
||||
// ---------- Dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
||||
categoryName: '',
|
||||
categoryIcon: '',
|
||||
parentCategoryId: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
categoryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, categoryName: '', categoryIcon: '', parentCategoryId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function openEditDialog(c: Category) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: c.id,
|
||||
categoryName: c.categoryName,
|
||||
categoryIcon: c.categoryIcon || '',
|
||||
parentCategoryId: c.parentCategoryId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCategoryRequest = {
|
||||
categoryName: dialogForm.categoryName,
|
||||
categoryIcon: dialogForm.categoryIcon || undefined,
|
||||
parentCategoryId: dialogForm.parentCategoryId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await categoriesApi.createCategory(payload)
|
||||
ElMessage.success('分类创建成功')
|
||||
} else {
|
||||
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
|
||||
ElMessage.success('分类更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchTree()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(c: Category) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${c.categoryName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await categoriesApi.deleteCategory(c.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchTree()
|
||||
}
|
||||
|
||||
onMounted(fetchTree)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增分类</span>
|
||||
</el-button>
|
||||
<el-button @click="fetchTree">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>刷新</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tree"
|
||||
border
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
>
|
||||
<el-table-column prop="categoryName" label="名称" min-width="220" />
|
||||
<el-table-column label="图标" width="100">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
<el-image
|
||||
v-if="row.categoryIcon"
|
||||
:src="row.categoryIcon"
|
||||
:preview-src-list="[row.categoryIcon]"
|
||||
fit="cover"
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="父级">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
{{ row.parent?.categoryName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="子级数" width="100">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无分类" />
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增分类' : '编辑分类'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="dialogRef"
|
||||
:model="dialogForm"
|
||||
:rules="dialogRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="名称" prop="categoryName">
|
||||
<el-input v-model="dialogForm.categoryName" placeholder="请输入分类名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="父级分类">
|
||||
<el-cascader
|
||||
v-model="dialogForm.parentCategoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="顶级(可选)"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图标 URL">
|
||||
<el-input v-model="dialogForm.categoryIcon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,231 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Country,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest,
|
||||
CountryFilter,
|
||||
} from '@/types'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Country[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const filter = reactive<Required<CountryFilter>>({
|
||||
countryName: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await countriesApi.getCountriesList(filter)
|
||||
const data = await countriesApi.getCountriesList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.countryName = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
|
||||
countryName: '',
|
||||
countryIcon: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
countryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, countryName: '', countryIcon: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(c: Country) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, { id: c.id, countryName: c.countryName, countryIcon: c.countryIcon || '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCountryRequest = {
|
||||
countryName: dialogForm.countryName,
|
||||
countryIcon: dialogForm.countryIcon || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await countriesApi.createCountry(payload)
|
||||
ElMessage.success('国家创建成功')
|
||||
} else {
|
||||
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
|
||||
ElMessage.success('国家更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(c: Country) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${c.countryName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await countriesApi.deleteCountry(c.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.countryName"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增国家</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="图标" width="80">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
<el-image
|
||||
v-if="row.countryIcon"
|
||||
:src="row.countryIcon"
|
||||
:preview-src-list="[row.countryIcon]"
|
||||
fit="cover"
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="countryName" label="名称" min-width="200" />
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
{{ new Date(row.createdAt).toLocaleString() }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无国家" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
v-model:current-page="filter.page"
|
||||
v-model:page-size="filter.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增国家' : '编辑国家'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="countryName">
|
||||
<el-input v-model="dialogForm.countryName" placeholder="请输入国家名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标 URL">
|
||||
<el-input v-model="dialogForm.countryIcon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Country,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest,
|
||||
CountryFilter,
|
||||
} from '@/types'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Country[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const filter = reactive<Required<CountryFilter>>({
|
||||
countryName: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await countriesApi.getCountriesList(filter)
|
||||
const data = await countriesApi.getCountriesList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.countryName = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
|
||||
countryName: '',
|
||||
countryIcon: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
countryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, countryName: '', countryIcon: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(c: Country) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, { id: c.id, countryName: c.countryName, countryIcon: c.countryIcon || '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCountryRequest = {
|
||||
countryName: dialogForm.countryName,
|
||||
countryIcon: dialogForm.countryIcon || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await countriesApi.createCountry(payload)
|
||||
ElMessage.success('国家创建成功')
|
||||
} else {
|
||||
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
|
||||
ElMessage.success('国家更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(c: Country) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${c.countryName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await countriesApi.deleteCountry(c.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.countryName"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增国家</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="图标" width="80">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
<el-image
|
||||
v-if="row.countryIcon"
|
||||
:src="row.countryIcon"
|
||||
:preview-src-list="[row.countryIcon]"
|
||||
fit="cover"
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="countryName" label="名称" min-width="200" />
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
{{ new Date(row.createdAt).toLocaleString() }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无国家" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
v-model:current-page="filter.page"
|
||||
v-model:page-size="filter.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增国家' : '编辑国家'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="countryName">
|
||||
<el-input v-model="dialogForm.countryName" placeholder="请输入国家名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标 URL">
|
||||
<el-input v-model="dialogForm.countryIcon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
+1985
-1985
File diff suppressed because it is too large
Load Diff
@@ -1,181 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 2, max: 64, message: '长度 2-64', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 4, max: 64, message: '长度 4-64', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login({ username: form.username, password: form.password })
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
// Error toast is shown by axios response interceptor
|
||||
console.error('Login failed', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-bg" />
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<div class="brand-logo">Inkreach</div>
|
||||
<div class="brand-subtitle">官网后台</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
label-position="top"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
autocomplete="username"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="handleSubmit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="login-button"
|
||||
:loading="loading"
|
||||
native-type="submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<span>© {{ new Date().getFullYear() }} Inkreach</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(255, 104, 0, 0.18), transparent 60%),
|
||||
radial-gradient(circle at 80% 80%, rgba(255, 141, 31, 0.15), transparent 60%),
|
||||
linear-gradient(135deg, #1f1f24 0%, #2b2b33 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 40px 36px 28px;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--brand-color);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 2, max: 64, message: '长度 2-64', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 4, max: 64, message: '长度 4-64', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login({ username: form.username, password: form.password })
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
// Error toast is shown by axios response interceptor
|
||||
console.error('Login failed', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-bg" />
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<div class="brand-logo">Inkreach</div>
|
||||
<div class="brand-subtitle">官网后台</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
label-position="top"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
autocomplete="username"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="handleSubmit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="login-button"
|
||||
:loading="loading"
|
||||
native-type="submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<span>© {{ new Date().getFullYear() }} Inkreach</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(255, 104, 0, 0.18), transparent 60%),
|
||||
radial-gradient(circle at 80% 80%, rgba(255, 141, 31, 0.15), transparent 60%),
|
||||
linear-gradient(135deg, #1f1f24 0%, #2b2b33 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 40px 36px 28px;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--brand-color);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,317 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Position,
|
||||
CreatePositionRequest,
|
||||
UpdatePositionRequest,
|
||||
PositionFilter,
|
||||
Country,
|
||||
Category,
|
||||
CategoryTree,
|
||||
} from '@/types'
|
||||
import { positionsApi } from '@/api/positions'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Position[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const countries = ref<Country[]>([])
|
||||
const categoriesTree = ref<CategoryTree[]>([])
|
||||
|
||||
const filter = reactive<Required<PositionFilter>>({
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function loadLookups() {
|
||||
const [c, ct] = await Promise.all([
|
||||
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
||||
categoriesApi.getCategoryTree(),
|
||||
])
|
||||
countries.value = c.items
|
||||
categoriesTree.value = ct
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await positionsApi.getPositionsList(filter)
|
||||
const data = await positionsApi.getPositionsList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.countryId = ''
|
||||
filter.categoryId = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// ---------- Cascader ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
const cascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.categoryName,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
||||
indexVal: 0,
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
indexVal: [{ required: true, message: '排序值是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
cascaderOptions.value = buildCascader(categoriesTree.value)
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, indexVal: 0, countryId: '', categoryId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(p: Position) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: p.id,
|
||||
indexVal: p.indexVal,
|
||||
countryId: p.countryId || '',
|
||||
categoryId: p.categoryId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreatePositionRequest = {
|
||||
indexVal: Number(dialogForm.indexVal),
|
||||
countryId: dialogForm.countryId || undefined,
|
||||
categoryId: dialogForm.categoryId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await positionsApi.createPosition(payload)
|
||||
ElMessage.success('位置创建成功')
|
||||
} else {
|
||||
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
|
||||
ElMessage.success('位置更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(p: Position) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除位置 #${p.indexVal} 吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await positionsApi.deletePosition(p.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function getCategoryName(p: Position): string {
|
||||
if (!p.category) return p.categoryId || '-'
|
||||
return p.category.parent ? `${p.category.parent.categoryName} / ${p.category.categoryName}` : p.category.categoryName
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLookups()
|
||||
await fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-select
|
||||
v-model="filter.countryId"
|
||||
placeholder="请选择国家"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.countryName"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-cascader
|
||||
v-model="filter.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
|
||||
placeholder="请选择分类"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
/>
|
||||
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增位置</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
|
||||
<el-table-column label="国家" min-width="160">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
{{ row.country?.countryName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" min-width="200">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
{{ getCategoryName(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无位置" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
v-model:current-page="filter.page"
|
||||
v-model:page-size="filter.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增位置' : '编辑位置'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="排序值" prop="indexVal">
|
||||
<el-input-number v-model="dialogForm.indexVal" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="国家">
|
||||
<el-select v-model="dialogForm.countryId" placeholder="可选" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.countryName"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-cascader
|
||||
v-model="dialogForm.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="可选"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Position,
|
||||
CreatePositionRequest,
|
||||
UpdatePositionRequest,
|
||||
PositionFilter,
|
||||
Country,
|
||||
Category,
|
||||
CategoryTree,
|
||||
} from '@/types'
|
||||
import { positionsApi } from '@/api/positions'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Position[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const countries = ref<Country[]>([])
|
||||
const categoriesTree = ref<CategoryTree[]>([])
|
||||
|
||||
const filter = reactive<Required<PositionFilter>>({
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function loadLookups() {
|
||||
const [c, ct] = await Promise.all([
|
||||
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
||||
categoriesApi.getCategoryTree(),
|
||||
])
|
||||
countries.value = c.items
|
||||
categoriesTree.value = ct
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await positionsApi.getPositionsList(filter)
|
||||
const data = await positionsApi.getPositionsList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.countryId = ''
|
||||
filter.categoryId = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// ---------- Cascader ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
const cascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.categoryName,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
||||
indexVal: 0,
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
indexVal: [{ required: true, message: '排序值是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
cascaderOptions.value = buildCascader(categoriesTree.value)
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, indexVal: 0, countryId: '', categoryId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(p: Position) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: p.id,
|
||||
indexVal: p.indexVal,
|
||||
countryId: p.countryId || '',
|
||||
categoryId: p.categoryId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreatePositionRequest = {
|
||||
indexVal: Number(dialogForm.indexVal),
|
||||
countryId: dialogForm.countryId || undefined,
|
||||
categoryId: dialogForm.categoryId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await positionsApi.createPosition(payload)
|
||||
ElMessage.success('位置创建成功')
|
||||
} else {
|
||||
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
|
||||
ElMessage.success('位置更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(p: Position) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除位置 #${p.indexVal} 吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await positionsApi.deletePosition(p.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function getCategoryName(p: Position): string {
|
||||
if (!p.category) return p.categoryId || '-'
|
||||
return p.category.parent ? `${p.category.parent.categoryName} / ${p.category.categoryName}` : p.category.categoryName
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLookups()
|
||||
await fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-select
|
||||
v-model="filter.countryId"
|
||||
placeholder="请选择国家"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.countryName"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-cascader
|
||||
v-model="filter.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
|
||||
placeholder="请选择分类"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
/>
|
||||
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增位置</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
|
||||
<el-table-column label="国家" min-width="160">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
{{ row.country?.countryName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" min-width="200">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
{{ getCategoryName(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无位置" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
v-model:current-page="filter.page"
|
||||
v-model:page-size="filter.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增位置' : '编辑位置'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="排序值" prop="indexVal">
|
||||
<el-input-number v-model="dialogForm.indexVal" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="国家">
|
||||
<el-select v-model="dialogForm.countryId" placeholder="可选" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.countryName"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-cascader
|
||||
v-model="dialogForm.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="可选"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { markRaw, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Goods, Refresh } from '@element-plus/icons-vue'
|
||||
import GoodsView from '@/views/goods/GoodsView.vue'
|
||||
import SyncView from '@/views/sync/SyncView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tabs = [
|
||||
{ name: 'goods', label: '商品配置', icon: markRaw(Goods), comp: markRaw(GoodsView) },
|
||||
{ name: 'sync', label: '数据同步', icon: markRaw(Refresh), comp: markRaw(SyncView) },
|
||||
]
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'goods')
|
||||
|
||||
watch(activeTab, (val) => {
|
||||
router.replace({ query: { ...route.query, tab: val } })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pm-wrapper">
|
||||
<el-tabs v-model="activeTab" class="pm-tabs" type="card">
|
||||
<el-tab-pane
|
||||
v-for="t in tabs"
|
||||
:key="t.name"
|
||||
:name="t.name"
|
||||
>
|
||||
<template #label>
|
||||
<span class="pm-tab-label">
|
||||
<el-icon class="pm-tab-icon"><component :is="t.icon" /></el-icon>
|
||||
{{ t.label }}
|
||||
</span>
|
||||
</template>
|
||||
<component :is="t.comp" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pm-wrapper { height: 100%; display: flex; flex-direction: column; }
|
||||
.pm-tabs { height: 100%; display: flex; flex-direction: column; }
|
||||
.pm-tabs :deep(.el-tabs__header) { margin-bottom: 0; flex-shrink: 0; }
|
||||
.pm-tabs :deep(.el-tabs__content) { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.pm-tabs :deep(.el-tab-pane) { height: 100%; }
|
||||
.pm-tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.pm-tab-icon { font-size: 14px; }
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { markRaw, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Goods, Refresh } from '@element-plus/icons-vue'
|
||||
import GoodsView from '@/views/goods/GoodsView.vue'
|
||||
import SyncView from '@/views/sync/SyncView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tabs = [
|
||||
{ name: 'goods', label: '商品配置', icon: markRaw(Goods), comp: markRaw(GoodsView) },
|
||||
{ name: 'sync', label: '数据同步', icon: markRaw(Refresh), comp: markRaw(SyncView) },
|
||||
]
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'goods')
|
||||
|
||||
watch(activeTab, (val) => {
|
||||
router.replace({ query: { ...route.query, tab: val } })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pm-wrapper">
|
||||
<el-tabs v-model="activeTab" class="pm-tabs" type="card">
|
||||
<el-tab-pane
|
||||
v-for="t in tabs"
|
||||
:key="t.name"
|
||||
:name="t.name"
|
||||
>
|
||||
<template #label>
|
||||
<span class="pm-tab-label">
|
||||
<el-icon class="pm-tab-icon"><component :is="t.icon" /></el-icon>
|
||||
{{ t.label }}
|
||||
</span>
|
||||
</template>
|
||||
<component :is="t.comp" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pm-wrapper { height: 100%; display: flex; flex-direction: column; }
|
||||
.pm-tabs { height: 100%; display: flex; flex-direction: column; }
|
||||
.pm-tabs :deep(.el-tabs__header) { margin-bottom: 0; flex-shrink: 0; }
|
||||
.pm-tabs :deep(.el-tabs__content) { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.pm-tabs :deep(.el-tab-pane) { height: 100%; }
|
||||
.pm-tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.pm-tab-icon { font-size: 14px; }
|
||||
</style>
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Box, Clock, CircleCheck, CircleClose, Loading } from '@element-plus/icons-vue'
|
||||
import type { SyncLog } from '@/types'
|
||||
import { syncApi } from '@/api/sync'
|
||||
|
||||
const logs = ref<SyncLog[]>([])
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Box, Clock, CircleCheck, CircleClose, Loading } from '@element-plus/icons-vue'
|
||||
import type { SyncLog } from '@/types'
|
||||
import { syncApi } from '@/api/sync'
|
||||
|
||||
const logs = ref<SyncLog[]>([])
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
type SyncType = 'PRODUCTS' | 'CATEGORIES' | 'PRODUCT_DETAILS'
|
||||
const currentType = ref<SyncType>('PRODUCTS')
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refreshLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await syncApi.getSyncStatus(50) as any
|
||||
logs.value = Array.isArray(data) ? data : []
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch sync status', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refreshLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await syncApi.getSyncStatus(50) as any
|
||||
logs.value = Array.isArray(data) ? data : []
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch sync status', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function syncTypeLabel(type: SyncType): string {
|
||||
if (type === 'CATEGORIES') return '分类'
|
||||
if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
|
||||
@@ -33,33 +33,33 @@ function syncTypeLabel(type: SyncType): string {
|
||||
}
|
||||
|
||||
async function pollUntilDone(type: SyncType) {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const data = await syncApi.getSyncStatus(5) as any
|
||||
const latest = Array.isArray(data) ? data : []
|
||||
if (latest.length > 0) logs.value = [...latest, ...logs.value.slice(latest.length)]
|
||||
const top = latest.find((l: SyncLog) => l.type === type)
|
||||
if (top && top.status !== 'RUNNING') {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
syncing.value = false
|
||||
if (top.status === 'SUCCESS') {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const data = await syncApi.getSyncStatus(5) as any
|
||||
const latest = Array.isArray(data) ? data : []
|
||||
if (latest.length > 0) logs.value = [...latest, ...logs.value.slice(latest.length)]
|
||||
const top = latest.find((l: SyncLog) => l.type === type)
|
||||
if (top && top.status !== 'RUNNING') {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
syncing.value = false
|
||||
if (top.status === 'SUCCESS') {
|
||||
ElMessage.success(`${syncTypeLabel(type)}同步完成`)
|
||||
} else {
|
||||
} else {
|
||||
ElMessage.error(`${syncTypeLabel(type)}同步失败`)
|
||||
}
|
||||
await refreshLogs()
|
||||
}
|
||||
} catch { /* ignore poll errors */ }
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
async function handleSyncProducts() {
|
||||
await doSync('PRODUCTS')
|
||||
}
|
||||
|
||||
}
|
||||
await refreshLogs()
|
||||
}
|
||||
} catch { /* ignore poll errors */ }
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
async function handleSyncProducts() {
|
||||
await doSync('PRODUCTS')
|
||||
}
|
||||
|
||||
async function handleSyncCategories() {
|
||||
await doSync('CATEGORIES')
|
||||
await doSync('CATEGORIES')
|
||||
}
|
||||
|
||||
async function handleSyncProductDetails() {
|
||||
@@ -68,94 +68,94 @@ async function handleSyncProductDetails() {
|
||||
|
||||
async function doSync(type: SyncType) {
|
||||
const label = syncTypeLabel(type)
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
|
||||
'确认',
|
||||
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch { return }
|
||||
|
||||
syncing.value = true
|
||||
currentType.value = type
|
||||
try {
|
||||
'确认',
|
||||
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch { return }
|
||||
|
||||
syncing.value = true
|
||||
currentType.value = type
|
||||
try {
|
||||
if (type === 'PRODUCTS') {
|
||||
await syncApi.syncProducts()
|
||||
} else if (type === 'PRODUCT_DETAILS') {
|
||||
await syncApi.syncProductDetails()
|
||||
} else {
|
||||
await syncApi.syncCategories()
|
||||
}
|
||||
ElMessage.info(`${label}同步已开始`)
|
||||
pollUntilDone(type)
|
||||
} catch {
|
||||
ElMessage.error(`${label}同步启动失败`)
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(s?: string): string {
|
||||
if (!s) return '-'
|
||||
const d = new Date(s)
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDuration(start?: string, end?: string): string | null {
|
||||
if (!start || !end) return null
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
const stats = computed(() => {
|
||||
} else {
|
||||
await syncApi.syncCategories()
|
||||
}
|
||||
ElMessage.info(`${label}同步已开始`)
|
||||
pollUntilDone(type)
|
||||
} catch {
|
||||
ElMessage.error(`${label}同步启动失败`)
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(s?: string): string {
|
||||
if (!s) return '-'
|
||||
const d = new Date(s)
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDuration(start?: string, end?: string): string | null {
|
||||
if (!start || !end) return null
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = logs.value.length
|
||||
const success = logs.value.filter(l => l.status === 'SUCCESS').length
|
||||
const failed = logs.value.filter(l => l.status === 'FAILED').length
|
||||
const lastLog = logs.value[0]
|
||||
return { total, success, failed, lastLog }
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refreshLogs()
|
||||
timer = setInterval(refreshLogs, 60_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sync-page" v-loading="loading">
|
||||
<!-- Action Card -->
|
||||
<div class="sync-action-card">
|
||||
<div class="sync-action-info">
|
||||
<div class="sync-action-icon">
|
||||
<el-icon :size="28"><Box /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="sync-action-title">数据同步</h2>
|
||||
return { total, success, failed, lastLog }
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refreshLogs()
|
||||
timer = setInterval(refreshLogs, 60_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sync-page" v-loading="loading">
|
||||
<!-- Action Card -->
|
||||
<div class="sync-action-card">
|
||||
<div class="sync-action-info">
|
||||
<div class="sync-action-icon">
|
||||
<el-icon :size="28"><Box /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="sync-action-title">数据同步</h2>
|
||||
<p class="sync-action-desc">分类和产品每小时自动同步;全部 SDS 原产品详情每天 03:30 自动同步,也可手动执行</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sync-action-buttons">
|
||||
<el-button
|
||||
size="large"
|
||||
:loading="syncing && currentType === 'CATEGORIES'"
|
||||
:disabled="syncing"
|
||||
@click="handleSyncCategories"
|
||||
>
|
||||
同步分类
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
</div>
|
||||
</div>
|
||||
<div class="sync-action-buttons">
|
||||
<el-button
|
||||
size="large"
|
||||
:loading="syncing && currentType === 'CATEGORIES'"
|
||||
:disabled="syncing"
|
||||
@click="handleSyncCategories"
|
||||
>
|
||||
同步分类
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="syncing && currentType === 'PRODUCTS'"
|
||||
:disabled="syncing"
|
||||
:icon="Refresh"
|
||||
@click="handleSyncProducts"
|
||||
>
|
||||
:disabled="syncing"
|
||||
:icon="Refresh"
|
||||
@click="handleSyncProducts"
|
||||
>
|
||||
同步产品
|
||||
</el-button>
|
||||
<el-button
|
||||
@@ -168,307 +168,307 @@ onUnmounted(() => {
|
||||
>
|
||||
同步全部原产品详情
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="sync-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ stats.total }}</span>
|
||||
<span class="stat-label">总同步次数</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-success">{{ stats.success }}</span>
|
||||
<span class="stat-label">成功</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-failed">{{ stats.failed }}</span>
|
||||
<span class="stat-label">失败</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="sync-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ stats.total }}</span>
|
||||
<span class="stat-label">总同步次数</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-success">{{ stats.success }}</span>
|
||||
<span class="stat-label">成功</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-failed">{{ stats.failed }}</span>
|
||||
<span class="stat-label">失败</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-time">{{ stats.lastLog ? formatTime(stats.lastLog.startedAt) : '-' }}</span>
|
||||
<span class="stat-label">最近同步</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Timeline -->
|
||||
<div class="sync-logs">
|
||||
<div class="sync-logs-head">
|
||||
<h3 class="sync-logs-title">同步日志</h3>
|
||||
<el-button text :icon="Refresh" @click="refreshLogs">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length === 0 && !loading" class="sync-empty">
|
||||
<el-empty description="暂无同步记录" :image-size="80" />
|
||||
</div>
|
||||
|
||||
<div v-else class="sync-timeline">
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="timeline-item"
|
||||
>
|
||||
<div class="timeline-dot" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||
<el-icon :size="12">
|
||||
<Loading v-if="log.status === 'RUNNING'" />
|
||||
<CircleCheck v-else-if="log.status === 'SUCCESS'" />
|
||||
<CircleClose v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<span class="stat-label">最近同步</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Timeline -->
|
||||
<div class="sync-logs">
|
||||
<div class="sync-logs-head">
|
||||
<h3 class="sync-logs-title">同步日志</h3>
|
||||
<el-button text :icon="Refresh" @click="refreshLogs">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length === 0 && !loading" class="sync-empty">
|
||||
<el-empty description="暂无同步记录" :image-size="80" />
|
||||
</div>
|
||||
|
||||
<div v-else class="sync-timeline">
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="timeline-item"
|
||||
>
|
||||
<div class="timeline-dot" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||
<el-icon :size="12">
|
||||
<Loading v-if="log.status === 'RUNNING'" />
|
||||
<CircleCheck v-else-if="log.status === 'SUCCESS'" />
|
||||
<CircleClose v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<span class="timeline-type">{{ syncTypeLabel(log.type) }}</span>
|
||||
<span class="timeline-status" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
|
||||
</span>
|
||||
<span class="timeline-status" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
|
||||
</span>
|
||||
<span v-if="formatDuration(log.startedAt, log.finishedAt || undefined)" class="timeline-duration">
|
||||
<el-icon :size="11"><Clock /></el-icon>
|
||||
<el-icon :size="11"><Clock /></el-icon>
|
||||
{{ formatDuration(log.startedAt, log.finishedAt || undefined) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="log.message" class="timeline-message">{{ log.message }}</p>
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="log.message" class="timeline-message">{{ log.message }}</p>
|
||||
<span class="timeline-time">{{ formatTime(log.startedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sync-page {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Action Card */
|
||||
.sync-action-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sync-action-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sync-action-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #fff2e8, #ffe0c2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--brand-color, #ff6800);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-action-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sync-action-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.sync-action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.sync-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 16px 28px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-value.stat-success { color: #67c23a; }
|
||||
.stat-value.stat-failed { color: #f56c6c; }
|
||||
.stat-value.stat-time { font-size: 14px; font-weight: 600; color: #606266; }
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: #ebeef5;
|
||||
}
|
||||
|
||||
/* Logs */
|
||||
.sync-logs {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sync-logs-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.sync-logs-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sync-empty {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* Timeline */
|
||||
.sync-timeline {
|
||||
padding: 16px 20px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-item:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 22px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.timeline-dot.is-success {
|
||||
background: #f0f9eb;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.timeline-dot.is-failed {
|
||||
background: #fef0f0;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.timeline-dot.is-running {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline-status.is-success { color: #67c23a; }
|
||||
.timeline-status.is-failed { color: #f56c6c; }
|
||||
.timeline-status.is-running { color: #409eff; }
|
||||
|
||||
.timeline-type {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
background: #f5f7fa;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.timeline-duration {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
background: #f5f7fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.timeline-message {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sync-page {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Action Card */
|
||||
.sync-action-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sync-action-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sync-action-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #fff2e8, #ffe0c2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--brand-color, #ff6800);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-action-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sync-action-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.sync-action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.sync-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 16px 28px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-value.stat-success { color: #67c23a; }
|
||||
.stat-value.stat-failed { color: #f56c6c; }
|
||||
.stat-value.stat-time { font-size: 14px; font-weight: 600; color: #606266; }
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: #ebeef5;
|
||||
}
|
||||
|
||||
/* Logs */
|
||||
.sync-logs {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sync-logs-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.sync-logs-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sync-empty {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* Timeline */
|
||||
.sync-timeline {
|
||||
padding: 16px 20px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-item:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 22px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.timeline-dot.is-success {
|
||||
background: #f0f9eb;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.timeline-dot.is-failed {
|
||||
background: #fef0f0;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.timeline-dot.is-running {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline-status.is-success { color: #67c23a; }
|
||||
.timeline-status.is-failed { color: #f56c6c; }
|
||||
.timeline-status.is-running { color: #409eff; }
|
||||
|
||||
.timeline-type {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
background: #f5f7fa;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.timeline-duration {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
background: #f5f7fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.timeline-message {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,324 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Tag,
|
||||
TagGroup,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
TagFilter,
|
||||
} from '@/types'
|
||||
import { tagsApi } from '@/api/tags'
|
||||
import { tagGroupsApi } from '@/api/tag-groups'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Tag[]>([])
|
||||
const total = ref(0)
|
||||
const allGroups = ref<TagGroup[]>([])
|
||||
|
||||
const filter = reactive<Required<TagFilter>>({
|
||||
tagName: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await tagsApi.getTagsList(filter)
|
||||
const data = await tagsApi.getTagsList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
allGroups.value = await tagGroupsApi.getTagGroupsList()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.tagName = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: number | null }>({
|
||||
id: undefined,
|
||||
tagName: '',
|
||||
tagColor: '#ff6800',
|
||||
tagFontColor: '#ffffff',
|
||||
timing: '',
|
||||
tagGroupId: null,
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
tagName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff', timing: '', tagGroupId: null })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(t: Tag) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: t.id,
|
||||
tagName: t.tagName,
|
||||
tagColor: t.tagColor || '#ff6800',
|
||||
tagFontColor: t.tagFontColor || '#ffffff',
|
||||
timing: t.timing || '',
|
||||
tagGroupId: t.tagGroupId ? Number(t.tagGroupId) : null,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateTagRequest = {
|
||||
tagName: dialogForm.tagName,
|
||||
tagColor: dialogForm.tagColor || undefined,
|
||||
tagFontColor: dialogForm.tagFontColor || undefined,
|
||||
timing: dialogForm.timing || undefined,
|
||||
tagGroupId: dialogForm.tagGroupId ?? undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await tagsApi.createTag(payload)
|
||||
ElMessage.success('标签创建成功')
|
||||
} else {
|
||||
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
|
||||
ElMessage.success('标签更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(t: Tag) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${t.tagName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await tagsApi.deleteTag(t.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.tagName"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增标签</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="预览" width="120">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<span
|
||||
v-if="row.tagColor"
|
||||
class="tag-preview"
|
||||
:style="{ background: row.tagColor, color: row.tagFontColor || '#fff' }"
|
||||
>{{ row.tagName }}</span>
|
||||
<el-tag v-else effect="plain">{{ row.tagName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tagName" label="名称" min-width="200" />
|
||||
<el-table-column label="背景色" width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.tagColor || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="字体色" width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属分组" min-width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
|
||||
<span v-else style="color: #999;">未分组</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timing" label="定时" min-width="120" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无标签" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
v-model:current-page="filter.page"
|
||||
v-model:page-size="filter.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增标签' : '编辑标签'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="tagName">
|
||||
<el-input v-model="dialogForm.tagName" placeholder="请输入标签名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="背景色">
|
||||
<el-color-picker v-model="dialogForm.tagColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="字体色">
|
||||
<el-color-picker v-model="dialogForm.tagFontColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagFontColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属分组">
|
||||
<el-select v-model="dialogForm.tagGroupId" clearable placeholder="未分组" style="width: 100%">
|
||||
<el-option
|
||||
v-for="g in allGroups"
|
||||
:key="g.id"
|
||||
:label="g.groupName"
|
||||
:value="Number(g.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="定时">
|
||||
<el-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.color-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.color-readout {
|
||||
margin-left: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.tag-preview {
|
||||
display: inline-block;
|
||||
padding: 2px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Tag,
|
||||
TagGroup,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
TagFilter,
|
||||
} from '@/types'
|
||||
import { tagsApi } from '@/api/tags'
|
||||
import { tagGroupsApi } from '@/api/tag-groups'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Tag[]>([])
|
||||
const total = ref(0)
|
||||
const allGroups = ref<TagGroup[]>([])
|
||||
|
||||
const filter = reactive<Required<TagFilter>>({
|
||||
tagName: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await tagsApi.getTagsList(filter)
|
||||
const data = await tagsApi.getTagsList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
allGroups.value = await tagGroupsApi.getTagGroupsList()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.tagName = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: number | null }>({
|
||||
id: undefined,
|
||||
tagName: '',
|
||||
tagColor: '#ff6800',
|
||||
tagFontColor: '#ffffff',
|
||||
timing: '',
|
||||
tagGroupId: null,
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
tagName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff', timing: '', tagGroupId: null })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(t: Tag) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: t.id,
|
||||
tagName: t.tagName,
|
||||
tagColor: t.tagColor || '#ff6800',
|
||||
tagFontColor: t.tagFontColor || '#ffffff',
|
||||
timing: t.timing || '',
|
||||
tagGroupId: t.tagGroupId ? Number(t.tagGroupId) : null,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateTagRequest = {
|
||||
tagName: dialogForm.tagName,
|
||||
tagColor: dialogForm.tagColor || undefined,
|
||||
tagFontColor: dialogForm.tagFontColor || undefined,
|
||||
timing: dialogForm.timing || undefined,
|
||||
tagGroupId: dialogForm.tagGroupId ?? undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await tagsApi.createTag(payload)
|
||||
ElMessage.success('标签创建成功')
|
||||
} else {
|
||||
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
|
||||
ElMessage.success('标签更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(t: Tag) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${t.tagName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await tagsApi.deleteTag(t.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.tagName"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增标签</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="预览" width="120">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<span
|
||||
v-if="row.tagColor"
|
||||
class="tag-preview"
|
||||
:style="{ background: row.tagColor, color: row.tagFontColor || '#fff' }"
|
||||
>{{ row.tagName }}</span>
|
||||
<el-tag v-else effect="plain">{{ row.tagName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tagName" label="名称" min-width="200" />
|
||||
<el-table-column label="背景色" width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.tagColor || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="字体色" width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属分组" min-width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
|
||||
<span v-else style="color: #999;">未分组</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timing" label="定时" min-width="120" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无标签" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
v-model:current-page="filter.page"
|
||||
v-model:page-size="filter.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增标签' : '编辑标签'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="tagName">
|
||||
<el-input v-model="dialogForm.tagName" placeholder="请输入标签名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="背景色">
|
||||
<el-color-picker v-model="dialogForm.tagColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="字体色">
|
||||
<el-color-picker v-model="dialogForm.tagFontColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagFontColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属分组">
|
||||
<el-select v-model="dialogForm.tagGroupId" clearable placeholder="未分组" style="width: 100%">
|
||||
<el-option
|
||||
v-for="g in allGroups"
|
||||
:key="g.id"
|
||||
:label="g.groupName"
|
||||
:value="Number(g.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="定时">
|
||||
<el-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.color-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.color-readout {
|
||||
margin-left: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.tag-preview {
|
||||
display: inline-block;
|
||||
padding: 2px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client", "node"],
|
||||
|
||||
/* Path aliases */
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"src/auto-imports.d.ts",
|
||||
"src/components.d.ts"
|
||||
]
|
||||
}
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client", "node"],
|
||||
|
||||
/* Path aliases */
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"src/auto-imports.d.ts",
|
||||
"src/components.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
+47
-46
@@ -1,46 +1,47 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
imports: ['vue', 'vue-router', 'pinia'],
|
||||
dts: 'src/auto-imports.d.ts',
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: 'src/components.d.ts',
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/assets': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
base: '/admin/',
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
imports: ['vue', 'vue-router', 'pinia'],
|
||||
dts: 'src/auto-imports.d.ts',
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: 'src/components.d.ts',
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/assets': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user