✨
This commit is contained in:
8
.editorconfig
Normal file
8
.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
end_of_line = lf
|
||||||
|
max_line_length = 100
|
||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
*.timestamp-*-*.mjs
|
||||||
10
.oxlintrc.json
Normal file
10
.oxlintrc.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
9
.vscode/extensions.json
vendored
Normal file
9
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"oxc.oxc-vscode",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
48
README.md
Normal file
48
README.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# stop-shopping-admin
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Recommended Browser Setup
|
||||||
|
|
||||||
|
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||||
|
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||||
|
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||||
|
- Firefox:
|
||||||
|
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||||
|
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lint with [ESLint](https://eslint.org/)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
1
env/.env.development
vendored
Normal file
1
env/.env.development
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE=http://localhost:5162
|
||||||
1
env/.env.production
vendored
Normal file
1
env/.env.production
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE=https://api.stopshopping.bjbj.me
|
||||||
26
eslint.config.ts
Normal file
26
eslint.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||||
|
import skipFormatting from 'eslint-config-prettier/flat'
|
||||||
|
|
||||||
|
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||||
|
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||||
|
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||||
|
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||||
|
|
||||||
|
export default defineConfigWithVueTs(
|
||||||
|
{
|
||||||
|
name: 'app/files-to-lint',
|
||||||
|
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||||
|
},
|
||||||
|
|
||||||
|
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||||
|
|
||||||
|
...pluginVue.configs['flat/essential'],
|
||||||
|
vueTsConfigs.recommended,
|
||||||
|
|
||||||
|
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||||
|
|
||||||
|
skipFormatting,
|
||||||
|
)
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/stopshopping.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>停止购物 - 管理平台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6123
package-lock.json
generated
Normal file
6123
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
package.json
Normal file
50
package.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "stop-shopping-admin",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build",
|
||||||
|
"lint": "run-s lint:*",
|
||||||
|
"lint:oxlint": "oxlint . --fix",
|
||||||
|
"lint:eslint": "eslint . --fix --cache",
|
||||||
|
"format": "prettier --write --experimental-cli src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/qs": "^6.15.0",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"qs": "^6.15.0",
|
||||||
|
"vue": "^3.5.29",
|
||||||
|
"vue-router": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
"@types/node": "^24.11.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"daisyui": "^5.5.19",
|
||||||
|
"eslint": "^10.0.2",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-oxlint": "~1.50.0",
|
||||||
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
|
"oxlint": "~1.50.0",
|
||||||
|
"prettier": "3.8.1",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-vue-devtools": "^8.0.6",
|
||||||
|
"vue-tsc": "^3.2.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
64
public/stopshopping.svg
Normal file
64
public/stopshopping.svg
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="64mm"
|
||||||
|
height="64mm"
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
||||||
|
sodipodi:docname="stopshopping.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:zoom="0.40921756"
|
||||||
|
inkscape:cx="333.56339"
|
||||||
|
inkscape:cy="208.93531"
|
||||||
|
inkscape:window-width="1440"
|
||||||
|
inkscape:window-height="754"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="30"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" /><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"><g
|
||||||
|
id="g1"
|
||||||
|
transform="matrix(0.04383615,0,0,0.04237764,9.4063452,10.472973)"><path
|
||||||
|
d="M 796.672,905.6256 H 234.1376 c -63.8976,0 -112.2304,-57.8048 -100.9664,-120.6784 l 75.3664,-419.9936 c 8.7552,-48.896 51.3024,-84.4288 100.9664,-84.4288 h 417.1776 c 50.1248,0 92.928,36.2496 101.1712,85.7088 l 69.9904,419.9936 c 10.4448,62.464 -37.7856,119.3984 -101.1712,119.3984 z"
|
||||||
|
fill="#ff5f5f"
|
||||||
|
p-id="3464"
|
||||||
|
data-spm-anchor-id="a313x.search_index.0.i4.44a63a815gtofp"
|
||||||
|
class="selected"
|
||||||
|
id="path1" /><path
|
||||||
|
d="M 912.9984,783.6672 843.008,363.6736 a 117.57568,117.57568 0 0 0 -116.3264,-98.56 h -30.5664 c -2.8672,-94.2592 -80.384,-170.0864 -175.36,-170.0864 -94.976,0 -172.3904,75.8272 -175.3088,170.0864 H 309.504 c -57.1392,0 -105.984,40.8576 -116.0704,97.0752 l -75.3664,419.9936 c -6.1952,34.4576 3.1744,69.632 25.6,96.512 22.4256,26.88 55.4496,42.24 90.4704,42.24 H 796.672 c 34.7136,0 67.5328,-15.2064 90.0096,-41.728 a 117.47328,117.47328 0 0 0 26.3168,-95.5392 z m -392.192,-657.92 c 78.0288,0 141.7728,62.0544 144.64,139.3664 h -289.28 c 2.8672,-77.312 66.6112,-139.3664 144.64,-139.3664 z M 863.232,859.392 a 86.9888,86.9888 0 0 1 -66.56,30.8224 H 234.1376 c -25.9072,0 -50.2784,-11.3664 -66.8672,-31.232 a 86.9888,86.9888 0 0 1 -18.944,-71.3728 L 223.6928,367.616 a 87.11168,87.11168 0 0 1 85.8624,-71.7824 h 35.84 v 91.904 c 0,8.4992 6.8608,15.36 15.36,15.36 8.4992,0 15.36,-6.8608 15.36,-15.36 v -91.904 H 665.6 v 91.904 c 0,8.4992 6.8608,15.36 15.36,15.36 8.4992,0 15.36,-6.8608 15.36,-15.36 v -91.904 h 30.4128 c 42.8032,0 79.0016,30.6688 86.016,72.8576 l 69.9904,419.9936 c 4.1984,25.344 -2.9184,51.1488 -19.5072,70.7072 z"
|
||||||
|
fill="#424242"
|
||||||
|
p-id="3465"
|
||||||
|
data-spm-anchor-id="a313x.search_index.0.i6.44a63a815gtofp"
|
||||||
|
class=""
|
||||||
|
id="path2" /><path
|
||||||
|
d="M 673.28,764.672 H 342.3232 c -8.4992,0 -15.36,-6.8608 -15.36,-15.36 0,-8.4992 6.8608,-15.36 15.36,-15.36 H 673.28 c 8.4992,0 15.36,6.8608 15.36,15.36 0,8.4992 -6.912,15.36 -15.36,15.36 z"
|
||||||
|
fill="#ffffff"
|
||||||
|
p-id="3466"
|
||||||
|
data-spm-anchor-id="a313x.search_index.0.i5.44a63a815gtofp"
|
||||||
|
class="selected"
|
||||||
|
id="path3" /></g><path
|
||||||
|
d="M 50.273894,47.688222 19.6381,11.331697 c 9.955892,-5.9721105 23.051885,-4.0160558 30.775796,5.152098 7.723985,9.164092 7.435966,22.404309 -0.139958,31.204427 M 13.586112,47.520283 C 5.862128,38.356126 6.1541459,25.111913 13.726114,16.311791 l 30.635735,36.356524 c -9.955888,5.97211 -23.051822,4.016056 -30.775807,-5.148032 M 11.378076,7.5316122 C -2.1338163,18.919842 -3.8578147,39.104195 7.5301189,52.620312 18.91811,66.136552 39.105971,67.856584 52.621873,56.468412 66.133827,45.084244 67.857813,24.895893 56.469891,11.379713 45.081896,-2.136525 24.89403,-3.8566214 11.378138,7.5316122"
|
||||||
|
p-id="17445"
|
||||||
|
data-spm-anchor-id="a313x.search_index.0.i15.44a63a815gtofp"
|
||||||
|
class="selected"
|
||||||
|
id="path1-8"
|
||||||
|
style="fill:#f70012;stroke-width:0.0625053;fill-opacity:0.67073172" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
9
src/App.vue
Normal file
9
src/App.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
153
src/api/AxiosHelper.ts
Normal file
153
src/api/AxiosHelper.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import qs from 'qs'
|
||||||
|
import Axios, { HttpStatusCode } from 'axios'
|
||||||
|
import type { AxiosInstance, AxiosResponse, CreateAxiosDefaults } from 'axios'
|
||||||
|
import type { AccessToken, AccessTokenResponse } from './models/AccessToken'
|
||||||
|
import { type AntiForgeryTokenResponse, type AntiForgeryToken } from './models/AntiForgeryToken'
|
||||||
|
import { type ProblemDetails } from './ProblemDetails'
|
||||||
|
|
||||||
|
class AxiosHelper {
|
||||||
|
private readonly REFRESH_TOKEN_URL: string = '/common/refreshtoken'
|
||||||
|
private constructor(config?: CreateAxiosDefaults) {
|
||||||
|
this.axios = Axios.create({
|
||||||
|
withCredentials: true,
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE,
|
||||||
|
paramsSerializer: (params) => {
|
||||||
|
return qs.stringify(params, {
|
||||||
|
indices: false,
|
||||||
|
arrayFormat: 'repeat',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
...config,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.axios.interceptors.request.use(async (config) => {
|
||||||
|
if (this.accessToken?.token) {
|
||||||
|
config.headers.Authorization = `Bearer ${this.accessToken?.token}`
|
||||||
|
}
|
||||||
|
if (this.csrfToken) config.headers[this.csrfToken.headerName] = this.csrfToken.token
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
this.axios.interceptors.response.use(null, async (err) => {
|
||||||
|
if (err.response?.status === HttpStatusCode.Unauthorized) {
|
||||||
|
if (err.config.url == this.REFRESH_TOKEN_URL) {
|
||||||
|
window.location.href = '/signin'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const succed = await this.refreshAccessToken()
|
||||||
|
if (succed) return await this.axios(err.config)
|
||||||
|
else window.location.href = '/signin'
|
||||||
|
} else if (err.response?.status === HttpStatusCode.BadRequest) {
|
||||||
|
const details = err.response.data as ProblemDetails
|
||||||
|
|
||||||
|
if (details.code == 1000) {
|
||||||
|
const succed = await this.refreshCsrfToken()
|
||||||
|
if (succed) return await this.axios(err.config)
|
||||||
|
else throw Error('网络错误')
|
||||||
|
} else if (details.code == 1001) {
|
||||||
|
throw Error(details.detail || '')
|
||||||
|
} else {
|
||||||
|
throw Error(details.title || '')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw Error('网络错误')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private accessToken: AccessToken | null = null
|
||||||
|
private csrfToken: AntiForgeryToken | null = null
|
||||||
|
|
||||||
|
private axios: AxiosInstance
|
||||||
|
|
||||||
|
private static instance: AxiosHelper
|
||||||
|
|
||||||
|
static getInstance(config?: CreateAxiosDefaults): AxiosHelper {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new AxiosHelper(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshCsrfToken(): Promise<boolean> {
|
||||||
|
const csrfResp = await AxiosHelper.getInstance().post<AntiForgeryTokenResponse>(
|
||||||
|
'/common/antiforgery-token',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (csrfResp.isSucced) {
|
||||||
|
this.csrfToken = csrfResp.data
|
||||||
|
} else {
|
||||||
|
throw Error(csrfResp.message || '服务器错误,请刷新重试')
|
||||||
|
}
|
||||||
|
return csrfResp.isSucced
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshAccessToken(): Promise<boolean> {
|
||||||
|
const tokenResp = await AxiosHelper.getInstance().post<AccessTokenResponse>(
|
||||||
|
this.REFRESH_TOKEN_URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tokenResp.isSucced) {
|
||||||
|
this.accessToken = tokenResp.data
|
||||||
|
this.autoRefreshToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenResp.isSucced
|
||||||
|
}
|
||||||
|
|
||||||
|
private autoRefreshToken() {
|
||||||
|
if (this.accessToken?.expiresIn) {
|
||||||
|
setTimeout(
|
||||||
|
async () => {
|
||||||
|
await this.refreshAccessToken()
|
||||||
|
},
|
||||||
|
(this.accessToken.expiresIn - 10) * 1000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleResult<T>(result: AxiosResponse<T, unknown, unknown>): T {
|
||||||
|
if (result.status === HttpStatusCode.Ok) {
|
||||||
|
return result.data
|
||||||
|
} else {
|
||||||
|
console.error(result)
|
||||||
|
throw new Error(`${result.status}:${result.statusText}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录成功后调用
|
||||||
|
* @param accessToken
|
||||||
|
*/
|
||||||
|
async initToken(accessToken: AccessToken) {
|
||||||
|
this.accessToken = accessToken
|
||||||
|
if (this.accessToken?.expiresIn) {
|
||||||
|
this.autoRefreshToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async initCsrfToken(csrfToken: AntiForgeryToken) {
|
||||||
|
this.csrfToken = csrfToken
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<TOut, TIn = unknown>(path: string, data?: TIn): Promise<TOut> {
|
||||||
|
const result = await AxiosHelper.getInstance().axios.get(path, {
|
||||||
|
params: data,
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.handleResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async post<TOut, TIn = unknown>(path: string, data?: TIn): Promise<TOut> {
|
||||||
|
const result = await AxiosHelper.getInstance().axios.post(path, data)
|
||||||
|
return this.handleResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async postFormData<TOut, TIn = unknown>(path: string, data?: TIn): Promise<TOut> {
|
||||||
|
const result = await AxiosHelper.getInstance().axios.postForm(path, data)
|
||||||
|
return this.handleResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AxiosHelper }
|
||||||
7
src/api/ProblemDetails.ts
Normal file
7
src/api/ProblemDetails.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface ProblemDetails {
|
||||||
|
code: number
|
||||||
|
detail: string | null
|
||||||
|
instance: string | null
|
||||||
|
status: number | null
|
||||||
|
title: string | null
|
||||||
|
}
|
||||||
53
src/api/index.ts
Normal file
53
src/api/index.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import * as M from './models'
|
||||||
|
import { AxiosHelper } from './AxiosHelper'
|
||||||
|
|
||||||
|
export { AxiosHelper }
|
||||||
|
|
||||||
|
//user
|
||||||
|
export const signIn = async (params: M.SignInParams): Promise<M.SignInResponse> => {
|
||||||
|
const signInResult = await AxiosHelper.getInstance().post<M.SignInResponse>(
|
||||||
|
'/admin/signin',
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
if (signInResult.isSucced && signInResult.data?.accessToken)
|
||||||
|
await AxiosHelper.getInstance().initToken(signInResult.data.accessToken)
|
||||||
|
return signInResult
|
||||||
|
}
|
||||||
|
|
||||||
|
//common
|
||||||
|
export const upload = async (params: M.UploadParams): Promise<M.UploadResponse> => {
|
||||||
|
return await AxiosHelper.getInstance().postFormData('/common/upload', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const csrfToken = async (): Promise<M.AntiForgeryTokenResponse> => {
|
||||||
|
const csrfResult = await AxiosHelper.getInstance().post<M.AntiForgeryTokenResponse>(
|
||||||
|
'/common/antiforgery-token',
|
||||||
|
)
|
||||||
|
if (csrfResult.isSucced && csrfResult.data)
|
||||||
|
await AxiosHelper.getInstance().initCsrfToken(csrfResult.data)
|
||||||
|
|
||||||
|
return csrfResult
|
||||||
|
}
|
||||||
|
|
||||||
|
export const refreshToken = async (): Promise<M.AccessTokenResponse> => {
|
||||||
|
return await AxiosHelper.getInstance().post('/common/refreshtoken')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const signOut = async (): Promise<M.ApiResponseMessage> => {
|
||||||
|
return await AxiosHelper.getInstance().post('/common/signout')
|
||||||
|
}
|
||||||
|
|
||||||
|
//category
|
||||||
|
export const getCategoryTree = async (): Promise<M.CategoryListResponse> => {
|
||||||
|
return await AxiosHelper.getInstance().get('/category/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editCategory = async (
|
||||||
|
params: M.EditCategoryParams,
|
||||||
|
): Promise<M.EditCategoryResponse> => {
|
||||||
|
return await AxiosHelper.getInstance().post('/category/edit', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteCategory = async (categoryId: number): Promise<M.ApiResponseMessage> => {
|
||||||
|
return await AxiosHelper.getInstance().post('/category/delete', { categoryId })
|
||||||
|
}
|
||||||
8
src/api/models/AccessToken.ts
Normal file
8
src/api/models/AccessToken.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ApiResponseType } from './ApiResponseType';
|
||||||
|
|
||||||
|
export interface AccessToken {
|
||||||
|
token: string;
|
||||||
|
expiresIn: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccessTokenResponse = ApiResponseType<AccessToken>;
|
||||||
8
src/api/models/AntiForgeryToken.ts
Normal file
8
src/api/models/AntiForgeryToken.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ApiResponseType } from './ApiResponseType';
|
||||||
|
|
||||||
|
export interface AntiForgeryToken {
|
||||||
|
headerName: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AntiForgeryTokenResponse = ApiResponseType<AntiForgeryToken>;
|
||||||
7
src/api/models/ApiResponseType.ts
Normal file
7
src/api/models/ApiResponseType.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface ApiResponseType<TData> {
|
||||||
|
isSucced: boolean;
|
||||||
|
message: string | null;
|
||||||
|
data: TData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiResponseMessage = ApiResponseType<null>;
|
||||||
21
src/api/models/Category.ts
Normal file
21
src/api/models/Category.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { type ApiResponseType } from './ApiResponseType'
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number
|
||||||
|
parentId: number
|
||||||
|
name: string
|
||||||
|
logoUrl: string
|
||||||
|
order: number
|
||||||
|
children: Array<Category>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CategoryListResponse = ApiResponseType<Array<Category>>
|
||||||
|
|
||||||
|
export interface EditCategoryParams {
|
||||||
|
id: number | null
|
||||||
|
parentId: number
|
||||||
|
name: string
|
||||||
|
logo: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditCategoryResponse = ApiResponseType<Category>
|
||||||
6
src/api/models/Enums.ts
Normal file
6
src/api/models/Enums.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const UploadScences = {
|
||||||
|
Avatar: 'Avatar',
|
||||||
|
Product: 'Product',
|
||||||
|
Category: 'Category',
|
||||||
|
} as const
|
||||||
|
export type UploadScencesType = (typeof UploadScences)[keyof typeof UploadScences]
|
||||||
6
src/api/models/PagedResult.ts
Normal file
6
src/api/models/PagedResult.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface PagedResult<TData> {
|
||||||
|
pageCount: number;
|
||||||
|
pageSize: number;
|
||||||
|
pageIndex: number;
|
||||||
|
data: Array<TData>;
|
||||||
|
}
|
||||||
4
src/api/models/PagedSearchType.ts
Normal file
4
src/api/models/PagedSearchType.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface PagedSearchType {
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
14
src/api/models/SignIn.ts
Normal file
14
src/api/models/SignIn.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ApiResponseType } from './ApiResponseType'
|
||||||
|
import type { AccessToken } from './AccessToken'
|
||||||
|
|
||||||
|
interface SignIn {
|
||||||
|
nickName: string
|
||||||
|
accessToken: AccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignInResponse = ApiResponseType<SignIn>
|
||||||
|
|
||||||
|
export interface SignInParams {
|
||||||
|
account: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
14
src/api/models/Upload.ts
Normal file
14
src/api/models/Upload.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ApiResponseType } from './ApiResponseType';
|
||||||
|
import type { UploadScencesType } from './Enums';
|
||||||
|
|
||||||
|
interface Upload {
|
||||||
|
newName: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadResponse = ApiResponseType<Upload>;
|
||||||
|
|
||||||
|
export interface UploadParams {
|
||||||
|
scences: UploadScencesType,
|
||||||
|
file: File
|
||||||
|
};
|
||||||
9
src/api/models/index.ts
Normal file
9
src/api/models/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export * from './AccessToken'
|
||||||
|
export * from './AntiForgeryToken'
|
||||||
|
export * from './ApiResponseType'
|
||||||
|
export * from './Enums'
|
||||||
|
export * from './PagedResult'
|
||||||
|
export * from './PagedSearchType'
|
||||||
|
export * from './SignIn'
|
||||||
|
export * from './Upload'
|
||||||
|
export * from './Category'
|
||||||
2
src/assets/main.css
Normal file
2
src/assets/main.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@plugin "daisyui";
|
||||||
8
src/components/Tree/TreeItem.ts
Normal file
8
src/components/Tree/TreeItem.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
interface TreeItemBase {
|
||||||
|
id: number
|
||||||
|
parentId: number
|
||||||
|
name: string
|
||||||
|
logoUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TreeItem<TExtra> = TreeItemBase & TExtra & { children: Array<TreeItem<TExtra>> }
|
||||||
82
src/components/Tree/TreeView.vue
Normal file
82
src/components/Tree/TreeView.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { type TreeItem } from './TreeItem'
|
||||||
|
import TreeViewItems from './TreeViewItems.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: Array<TreeItem<unknown>>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
addClick: [parent: TreeItem<unknown>]
|
||||||
|
deleteClick: [item: TreeItem<unknown>]
|
||||||
|
focusChanged: [item: TreeItem<unknown>]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const states = ref<{
|
||||||
|
checked: TreeItem<unknown> | null
|
||||||
|
setChecked: (item: TreeItem<unknown>) => void
|
||||||
|
}>({
|
||||||
|
checked: null,
|
||||||
|
setChecked(item: TreeItem<unknown>) {
|
||||||
|
this.checked = item
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function itemClick(item: TreeItem<unknown>) {
|
||||||
|
states.value.setChecked(item)
|
||||||
|
emits('focusChanged', item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addClick(parent: TreeItem<unknown>) {
|
||||||
|
emits('addClick', parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteClick(item: TreeItem<unknown>) {
|
||||||
|
emits('deleteClick', item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootClick() {
|
||||||
|
itemClick({
|
||||||
|
children: [],
|
||||||
|
id: 0,
|
||||||
|
name: '根',
|
||||||
|
parentId: 0,
|
||||||
|
logoUrl: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootAdd() {
|
||||||
|
const parent: TreeItem<unknown> = {
|
||||||
|
children: [],
|
||||||
|
id: 0,
|
||||||
|
name: '根',
|
||||||
|
parentId: 0,
|
||||||
|
logoUrl: '',
|
||||||
|
}
|
||||||
|
states.value.setChecked(parent)
|
||||||
|
addClick(parent)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul class="menu menu-md bg-base-200 rounded-box w-full">
|
||||||
|
<li>
|
||||||
|
<details open>
|
||||||
|
<summary :class="states.checked?.id == 0 ? 'menu-focus' : ''" @click="rootClick">
|
||||||
|
根
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-xs btn-circle" @click.stop="rootAdd">+</button>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
<tree-view-items
|
||||||
|
:checked="states.checked"
|
||||||
|
:items="props.items"
|
||||||
|
@focus-changed="itemClick"
|
||||||
|
@add-click="addClick"
|
||||||
|
@delete-click="deleteClick"
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
62
src/components/Tree/TreeViewItems.vue
Normal file
62
src/components/Tree/TreeViewItems.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { type TreeItem } from './TreeItem'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: Array<TreeItem<unknown>>
|
||||||
|
checked: TreeItem<unknown> | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
addClick: [parent: TreeItem<unknown>]
|
||||||
|
deleteClick: [item: TreeItem<unknown>]
|
||||||
|
focusChanged: [item: TreeItem<unknown>]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function itemClick(item: TreeItem<unknown>) {
|
||||||
|
emits('focusChanged', item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addClick(parent: TreeItem<unknown>) {
|
||||||
|
emits('addClick', parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteClick(item: TreeItem<unknown>) {
|
||||||
|
emits('deleteClick', item)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item of props.items" :key="item.id">
|
||||||
|
<details open v-if="item.children.length > 0" @click.stop="itemClick(item)">
|
||||||
|
<summary :class="props.checked?.id == item.id ? 'menu-focus' : ''">
|
||||||
|
<img :src="item.logoUrl" class="w-4" />
|
||||||
|
{{ item.name }}
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-xs btn-circle" @click.stop="addClick(item)">+</button>
|
||||||
|
<button class="btn btn-xs btn-circle" @click.stop="deleteClick(item)">-</button>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
<tree-view-items
|
||||||
|
:items="item.children"
|
||||||
|
:checked="props.checked"
|
||||||
|
@add-click="addClick"
|
||||||
|
@delete-click="deleteClick"
|
||||||
|
@focus-changed="itemClick"
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
<a
|
||||||
|
v-else
|
||||||
|
:class="props.checked?.id == item.id ? 'menu-focus' : ''"
|
||||||
|
@click.stop="itemClick(item)"
|
||||||
|
>
|
||||||
|
<img :src="item.logoUrl" class="w-4" />
|
||||||
|
{{ item.name }}
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-xs btn-circle" @click.stop="addClick(item)">+</button>
|
||||||
|
<button class="btn btn-xs btn-circle" @click.stop="deleteClick(item)">-</button>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
4
src/components/Tree/index.ts
Normal file
4
src/components/Tree/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './TreeItem'
|
||||||
|
|
||||||
|
import TreeView from './TreeView.vue'
|
||||||
|
export { TreeView }
|
||||||
1
src/components/index.ts
Normal file
1
src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './Tree'
|
||||||
11
src/main.ts
Normal file
11
src/main.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
34
src/router/index.ts
Normal file
34
src/router/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import LayoutView from '@/views/LayoutView.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: LayoutView,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'home',
|
||||||
|
component: () => import('../views/HomeView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'category',
|
||||||
|
name: 'category',
|
||||||
|
component: () => import('../views/CategoryView.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/signin',
|
||||||
|
name: 'signin',
|
||||||
|
// route level code-splitting
|
||||||
|
// this generates a separate chunk (About.[hash].js) for this route
|
||||||
|
// which is lazy-loaded when the route is visited.
|
||||||
|
component: () => import('../views/SignInView.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
106
src/store/appdata.ts
Normal file
106
src/store/appdata.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
const KEY = 'app_data'
|
||||||
|
|
||||||
|
interface UserData {
|
||||||
|
nickName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorData {
|
||||||
|
error: string | null
|
||||||
|
showError: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmData {
|
||||||
|
message: string
|
||||||
|
showConfirm: boolean
|
||||||
|
resolve: ((value: boolean) => void) | null
|
||||||
|
reject: ((value: boolean) => void) | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppData = UserData & ErrorData & ConfirmData
|
||||||
|
|
||||||
|
interface AppDataActions {
|
||||||
|
restore: () => UserData | null
|
||||||
|
store: (data: UserData) => void
|
||||||
|
showError: (err: string) => void
|
||||||
|
confirm: (message: string) => Promise<boolean>
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const store = reactive<{ data: AppData } & AppDataActions>({
|
||||||
|
data: {
|
||||||
|
nickName: '',
|
||||||
|
error: null,
|
||||||
|
showError: false,
|
||||||
|
message: '',
|
||||||
|
showConfirm: false,
|
||||||
|
resolve: null,
|
||||||
|
reject: null,
|
||||||
|
},
|
||||||
|
restore() {
|
||||||
|
const dataStored = localStorage.getItem(KEY)
|
||||||
|
if (dataStored) {
|
||||||
|
const userData = JSON.parse(dataStored)
|
||||||
|
this.data = {
|
||||||
|
...this.data,
|
||||||
|
...userData,
|
||||||
|
}
|
||||||
|
return userData
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
store(data) {
|
||||||
|
this.data = {
|
||||||
|
...this.data,
|
||||||
|
...data,
|
||||||
|
}
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(this.data))
|
||||||
|
},
|
||||||
|
showError(err) {
|
||||||
|
this.data = {
|
||||||
|
...this.data,
|
||||||
|
error: err,
|
||||||
|
showError: true,
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
this.data = {
|
||||||
|
...this.data,
|
||||||
|
error: null,
|
||||||
|
showError: false,
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
},
|
||||||
|
confirm(message) {
|
||||||
|
this.data = {
|
||||||
|
...this.data,
|
||||||
|
message,
|
||||||
|
showConfirm: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.data = {
|
||||||
|
...this.data,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onConfirm() {
|
||||||
|
this.data = {
|
||||||
|
...this.data,
|
||||||
|
showConfirm: false,
|
||||||
|
message: '',
|
||||||
|
}
|
||||||
|
if (this.data.resolve) this.data.resolve(true)
|
||||||
|
},
|
||||||
|
onCancel() {
|
||||||
|
this.data = {
|
||||||
|
...this.data,
|
||||||
|
showConfirm: false,
|
||||||
|
message: '',
|
||||||
|
}
|
||||||
|
if (this.data.reject) this.data.reject(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
224
src/views/CategoryView.vue
Normal file
224
src/views/CategoryView.vue
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { store } from '../store/appdata'
|
||||||
|
import { getCategoryTree, editCategory, deleteCategory, upload } from '../api'
|
||||||
|
import { type Category } from '../api/models'
|
||||||
|
import { TreeView, type TreeItem } from '../components'
|
||||||
|
|
||||||
|
type AddOrEdit = 'add' | 'edit'
|
||||||
|
type CurrentType = Omit<Category, 'order' | 'children'> & {
|
||||||
|
logo: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const states = ref<{
|
||||||
|
list: Category[]
|
||||||
|
current: CurrentType
|
||||||
|
addOrEdit: AddOrEdit
|
||||||
|
parentName: string
|
||||||
|
loading: boolean
|
||||||
|
}>({
|
||||||
|
list: [],
|
||||||
|
current: {
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
parentId: 0,
|
||||||
|
logo: '',
|
||||||
|
logoUrl: '',
|
||||||
|
},
|
||||||
|
addOrEdit: 'edit',
|
||||||
|
parentName: '',
|
||||||
|
loading: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const apiResult = await getCategoryTree()
|
||||||
|
if (apiResult.isSucced) {
|
||||||
|
const categories = apiResult.data
|
||||||
|
if (categories != null) states.value.list = categories
|
||||||
|
} else {
|
||||||
|
store.showError(apiResult.message || '未知错误')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
store.showError(err as string)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function treeChecked(item: TreeItem<unknown>) {
|
||||||
|
const category = item as Category
|
||||||
|
states.value.current = {
|
||||||
|
...category,
|
||||||
|
logo: '',
|
||||||
|
}
|
||||||
|
states.value.addOrEdit = 'edit'
|
||||||
|
}
|
||||||
|
|
||||||
|
function addClick(parent: TreeItem<unknown>) {
|
||||||
|
const category = parent as Category
|
||||||
|
states.value.addOrEdit = 'add'
|
||||||
|
states.value.current = {
|
||||||
|
id: 0,
|
||||||
|
logo: '',
|
||||||
|
logoUrl: '',
|
||||||
|
name: '',
|
||||||
|
parentId: category.id,
|
||||||
|
}
|
||||||
|
states.value.parentName = category.name
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteClick(item: TreeItem<unknown>) {
|
||||||
|
const category = item as Category
|
||||||
|
let confirmed = true
|
||||||
|
if (category.children.length) {
|
||||||
|
confirmed = await store.confirm('此分类下有下级分类,确认要删除吗?')
|
||||||
|
}
|
||||||
|
if (confirmed) {
|
||||||
|
try {
|
||||||
|
states.value.loading = true
|
||||||
|
const deleteResult = await deleteCategory(category.id)
|
||||||
|
if (deleteResult.isSucced) {
|
||||||
|
treeReplace(states.value.list, category, 'delete')
|
||||||
|
} else {
|
||||||
|
store.showError(deleteResult.message || '服务器错误')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
store.showError(err as string)
|
||||||
|
} finally {
|
||||||
|
states.value.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveClick() {
|
||||||
|
if (!states.value.current.name) return
|
||||||
|
try {
|
||||||
|
states.value.loading = true
|
||||||
|
const editResult = await editCategory({
|
||||||
|
...states.value.current,
|
||||||
|
})
|
||||||
|
if (editResult.isSucced) {
|
||||||
|
if (states.value.addOrEdit == 'add') treeAdd(states.value.list, editResult.data!)
|
||||||
|
else treeReplace(states.value.list, editResult.data!, 'replace')
|
||||||
|
} else {
|
||||||
|
store.showError(editResult.message || '服务器错误')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
store.showError(err as string)
|
||||||
|
} finally {
|
||||||
|
states.value.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function treeReplace(treeList: Category[], item: Category, operation: 'replace' | 'delete') {
|
||||||
|
if (treeList.length) {
|
||||||
|
for (let i = 0; i < treeList.length; i++) {
|
||||||
|
if (treeList[i]!.id == item.id) {
|
||||||
|
if (operation == 'replace') {
|
||||||
|
item.children = treeList[i]!.children
|
||||||
|
treeList.splice(i, 1, item)
|
||||||
|
} else {
|
||||||
|
treeList.splice(i, 1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
treeReplace(treeList[i]!.children, item, operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function treeAdd(treeList: Category[], item: Category) {
|
||||||
|
if (item.parentId == 0) {
|
||||||
|
treeList.push(item)
|
||||||
|
} else {
|
||||||
|
if (treeList.length) {
|
||||||
|
for (let i = 0; i < treeList.length; i++) {
|
||||||
|
if (treeList[i]!.id == item.parentId) {
|
||||||
|
treeList[i]!.children.push(item)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
treeAdd(treeList[i]!.children, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoSelected(e: Event) {
|
||||||
|
const el = e.target as HTMLInputElement
|
||||||
|
if (el.files && el.files.length) {
|
||||||
|
try {
|
||||||
|
states.value.loading = true
|
||||||
|
const uploadResult = await upload({
|
||||||
|
file: el.files[0]!,
|
||||||
|
scences: 'Category',
|
||||||
|
})
|
||||||
|
if (uploadResult.isSucced) {
|
||||||
|
states.value.current = {
|
||||||
|
...states.value.current,
|
||||||
|
logo: uploadResult.data?.newName || '',
|
||||||
|
logoUrl: uploadResult.data?.url || '',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
store.showError(uploadResult.message || '')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
store.showError(err as string)
|
||||||
|
} finally {
|
||||||
|
states.value.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row justify-between gap-5">
|
||||||
|
<div class="card flex-2 bg-base-200 w-full shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">分类列表</h2>
|
||||||
|
<tree-view
|
||||||
|
:items="states.list"
|
||||||
|
@focus-changed="treeChecked"
|
||||||
|
@add-click="addClick"
|
||||||
|
@delete-click="deleteClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card flex-1 bg-base-100 w-full shadow-sm">
|
||||||
|
<form class="fieldset p-4" v-if="!(states.addOrEdit == 'edit' && states.current.id == 0)">
|
||||||
|
<legend class="fieldset-legend">
|
||||||
|
{{ states.addOrEdit == 'add' ? '新增' : '修改' }}
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<label class="label" v-if="states.addOrEdit == 'add'">父级</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
class="input"
|
||||||
|
v-model="states.parentName"
|
||||||
|
v-if="states.addOrEdit == 'add'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="label">名称</label>
|
||||||
|
<input type="text" class="input validator" v-model="states.current.name" required />
|
||||||
|
|
||||||
|
<label class="label">Logo</label>
|
||||||
|
<img :src="states.current.logoUrl" />
|
||||||
|
<input
|
||||||
|
:disabled="states.loading"
|
||||||
|
type="file"
|
||||||
|
class="file-input"
|
||||||
|
accept=".jpg,.png,.gif,.jpeg"
|
||||||
|
@change="logoSelected"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button class="btn btn-info mt-4 w-1/2" :disabled="states.loading" @click="saveClick">
|
||||||
|
<span class="loading loading-spinner" v-if="states.loading"></span>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css" scoped></style>
|
||||||
163
src/views/HomeView.vue
Normal file
163
src/views/HomeView.vue
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import * as echarts from 'echarts/core'
|
||||||
|
import { BarChart, PieChart, LineChart } from 'echarts/charts'
|
||||||
|
import {
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
GridComponent,
|
||||||
|
DatasetComponent,
|
||||||
|
TransformComponent,
|
||||||
|
} from 'echarts/components'
|
||||||
|
import { LabelLayout, UniversalTransition } from 'echarts/features'
|
||||||
|
import { SVGRenderer } from 'echarts/renderers'
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
BarChart,
|
||||||
|
PieChart,
|
||||||
|
LineChart,
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
GridComponent,
|
||||||
|
DatasetComponent,
|
||||||
|
TransformComponent,
|
||||||
|
LabelLayout,
|
||||||
|
UniversalTransition,
|
||||||
|
SVGRenderer,
|
||||||
|
])
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const now = new Date()
|
||||||
|
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()
|
||||||
|
|
||||||
|
const barOrders = echarts.init(document.getElementById('bar_orders'))
|
||||||
|
barOrders.setOption({
|
||||||
|
title: {
|
||||||
|
text: 'TOP10用户',
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
axisLabel: {
|
||||||
|
interval: 0,
|
||||||
|
rotate: 30,
|
||||||
|
},
|
||||||
|
data: [
|
||||||
|
'用户a',
|
||||||
|
'用户b',
|
||||||
|
'用户c',
|
||||||
|
'用户d',
|
||||||
|
'用户e',
|
||||||
|
'用户f',
|
||||||
|
'用户g',
|
||||||
|
'用户h',
|
||||||
|
'用户i',
|
||||||
|
'用户j',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
yAxis: {},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '销量',
|
||||||
|
type: 'bar',
|
||||||
|
data: Array.from({ length: 10 }).map(() => Math.floor(Math.random() * 100) + 1),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const barTop10 = echarts.init(document.getElementById('bar_top10'))
|
||||||
|
barTop10.setOption({
|
||||||
|
title: {
|
||||||
|
text: 'TOP10商品',
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
axisLabel: {
|
||||||
|
interval: 0,
|
||||||
|
rotate: 30,
|
||||||
|
},
|
||||||
|
data: [
|
||||||
|
'T恤',
|
||||||
|
'iPhone手机',
|
||||||
|
'青岛啤酒',
|
||||||
|
'拖鞋',
|
||||||
|
'牙膏150ml',
|
||||||
|
'手持风扇',
|
||||||
|
'双肩背包',
|
||||||
|
'透气袜子',
|
||||||
|
'酱油',
|
||||||
|
'无碘海盐',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
yAxis: {},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '销量',
|
||||||
|
type: 'bar',
|
||||||
|
data: Array.from({ length: 10 }).map(() => Math.floor(Math.random() * 100) + 1),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const line = echarts.init(document.getElementById('line'))
|
||||||
|
line.setOption({
|
||||||
|
title: {
|
||||||
|
text: '月度成交量',
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
axisLabel: {
|
||||||
|
interval: 0,
|
||||||
|
},
|
||||||
|
data: Array.from({ length: daysInMonth }, (_, k) => k + 1).map((m) => `${m}`),
|
||||||
|
},
|
||||||
|
yAxis: {},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
data: Array.from({ length: daysInMonth }).map(() => Math.floor(Math.random() * 100) + 1),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const pie = echarts.init(document.getElementById('pie'))
|
||||||
|
pie.setOption({
|
||||||
|
title: {
|
||||||
|
text: '分类成交量',
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'pie',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
value: 100,
|
||||||
|
name: '食品',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 200,
|
||||||
|
name: '电器',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 300,
|
||||||
|
name: '服装',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full h-full flex flex-col">
|
||||||
|
<div class="h-10">
|
||||||
|
<select class="select">
|
||||||
|
<option disabled selected>请选择周期</option>
|
||||||
|
<option>周</option>
|
||||||
|
<option>月</option>
|
||||||
|
<option>年</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 grid grid-cols-2 grid-rows-2">
|
||||||
|
<div id="bar_orders" class="border-r border-b border-r-base-300 border-b-base-300"></div>
|
||||||
|
<div id="bar_top10" class="border-b border-b-base-300"></div>
|
||||||
|
<div id="line" class="border-r border-r-base-300"></div>
|
||||||
|
<div id="pie"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css" scoped></style>
|
||||||
96
src/views/LayoutView.vue
Normal file
96
src/views/LayoutView.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView, RouterLink } from 'vue-router'
|
||||||
|
import TogglerIcon from './icons/TogglerIcon.vue'
|
||||||
|
import HomeIcon from './icons/HomeIcon.vue'
|
||||||
|
import CategoryIcon from './icons/CategoryIcon.vue'
|
||||||
|
import ErrorIcon from './icons/ErrorIcon.vue'
|
||||||
|
import { store } from '../store/appdata'
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.restore()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition-opacity duration-300"
|
||||||
|
leave-active-class="transition-opacity duration-300"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div class="fixed z-50 top-0 w-1/2 left-1/4" v-if="store.data.showError">
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<error-icon />
|
||||||
|
<span>{{ store.data.error }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
<dialog class="modal" :open="store.data.showConfirm">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="text-lg font-bold">确认</h3>
|
||||||
|
<p class="py-4">{{ store.data.message }}</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn btn-info" @click="() => store.onConfirm()">确认</button>
|
||||||
|
<button class="btn" @click="() => store.onCancel()">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<div class="drawer lg:drawer-open">
|
||||||
|
<input id="toggler" type="checkbox" class="drawer-toggle" />
|
||||||
|
<div class="drawer-content flex flex-col">
|
||||||
|
<nav class="navbar w-full bg-base-300/30 backdrop-blur-sm shadow-md sticky z-10 top-0">
|
||||||
|
<label for="toggler" class="btn btn-square btn-ghost">
|
||||||
|
<toggler-icon />
|
||||||
|
</label>
|
||||||
|
<div class="px-4">停止购物管理平台</div>
|
||||||
|
<ul class="menu menu-vertical lg:menu-horizontal ml-auto">
|
||||||
|
<li>
|
||||||
|
<router-link :to="{ path: '/' }" class="flex flex-row">
|
||||||
|
<div class="avatar">
|
||||||
|
<div class="w-8 rounded-full bg-base-content"></div>
|
||||||
|
</div>
|
||||||
|
<span>{{ store.data.nickName }}</span>
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="p-4 flex-1">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-side is-drawer-close:overflow-visible">
|
||||||
|
<label for="toggler" class="drawer-overlay"></label>
|
||||||
|
<div
|
||||||
|
class="flex min-h-full items-start bg-cyan-500 text-white is-drawer-close:w-16 is-drawer-open:w-52"
|
||||||
|
>
|
||||||
|
<ul class="menu menu-lg w-full grow text-xl">
|
||||||
|
<li>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'home' }"
|
||||||
|
class="is-drawer-close:tooltip-right is-drawer-close:tooltip py-2"
|
||||||
|
data-tip="主页"
|
||||||
|
>
|
||||||
|
<home-icon />
|
||||||
|
<span class="is-drawer-close:hidden">主页</span></router-link
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'category' }"
|
||||||
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right py-2"
|
||||||
|
data-tip="商品分类"
|
||||||
|
>
|
||||||
|
<category-icon />
|
||||||
|
<span class="is-drawer-close:hidden">商品分类</span></router-link
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css" scoped></style>
|
||||||
85
src/views/SignInView.vue
Normal file
85
src/views/SignInView.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { signIn, csrfToken } from '../api'
|
||||||
|
import { store } from '../store/appdata'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const states = ref({
|
||||||
|
account: '',
|
||||||
|
password: '',
|
||||||
|
message: '',
|
||||||
|
failed: false,
|
||||||
|
loading: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!states.value.account) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!states.value.password) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
states.value.loading = true
|
||||||
|
try {
|
||||||
|
const signInResult = await signIn({ ...states.value })
|
||||||
|
if (!signInResult.isSucced) {
|
||||||
|
states.value.failed = true
|
||||||
|
states.value.message = signInResult.message || ''
|
||||||
|
} else {
|
||||||
|
const csrfResult = await csrfToken()
|
||||||
|
if (!csrfResult.isSucced) {
|
||||||
|
states.value.failed = true
|
||||||
|
states.value.message = csrfResult.message || ''
|
||||||
|
} else {
|
||||||
|
store.store({
|
||||||
|
nickName: signInResult.data?.nickName || '',
|
||||||
|
})
|
||||||
|
window.location.href = '/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
store.showError(err as string)
|
||||||
|
} finally {
|
||||||
|
states.value.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col justify-start items-center min-h-dvh">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div v-show="states.failed" role="alert" class="alert alert-error alert-soft w-md">
|
||||||
|
<span>{{ states.message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-2">
|
||||||
|
<fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4">
|
||||||
|
<legend class="fieldset-legend">登录</legend>
|
||||||
|
|
||||||
|
<label class="label">账号</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input validator"
|
||||||
|
required
|
||||||
|
v-model="states.account"
|
||||||
|
placeholder="请输入账号"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="label">密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="input validator"
|
||||||
|
required
|
||||||
|
v-model="states.password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button class="btn btn-info mt-4" :disabled="states.loading" @click="submit">
|
||||||
|
<span class="loading loading-spinner" v-if="states.loading"></span>
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
17
src/views/icons/CategoryIcon.vue
Normal file
17
src/views/icons/CategoryIcon.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
t="1772881582769"
|
||||||
|
class="fill-current"
|
||||||
|
viewBox="0 0 1024 1024"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
p-id="8747"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M896 682.666667a42.666667 42.666667 0 0 0-42.666667-42.666667h-85.333333v-85.333333a85.333333 85.333333 0 0 0-85.333333-85.333334h-128V384h85.333333a42.666667 42.666667 0 0 0 42.666667-42.666667V170.666667a42.666667 42.666667 0 0 0-42.666667-42.666667H384a42.666667 42.666667 0 0 0-42.666667 42.666667v170.666666a42.666667 42.666667 0 0 0 42.666667 42.666667h85.333333v85.333333H341.333333a85.333333 85.333333 0 0 0-85.333333 85.333334v85.333333H170.666667a42.666667 42.666667 0 0 0-42.666667 42.666667v170.666666a42.666667 42.666667 0 0 0 42.666667 42.666667h256a42.666667 42.666667 0 0 0 42.666666-42.666667v-170.666666a42.666667 42.666667 0 0 0-42.666666-42.666667H341.333333v-85.333333h341.333334v85.333333h-85.333334a42.666667 42.666667 0 0 0-42.666666 42.666667v170.666666a42.666667 42.666667 0 0 0 42.666666 42.666667h256a42.666667 42.666667 0 0 0 42.666667-42.666667z"
|
||||||
|
p-id="8748"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
15
src/views/icons/ErrorIcon.vue
Normal file
15
src/views/icons/ErrorIcon.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
17
src/views/icons/HomeIcon.vue
Normal file
17
src/views/icons/HomeIcon.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
t="1772881405478"
|
||||||
|
class="fill-current"
|
||||||
|
viewBox="0 0 1152 1024"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
p-id="7656"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M560.77004 296.52L192.03004 600.22V928a32 32 0 0 0 32 32l224.12-0.58a32 32 0 0 0 31.84-32V736a32 32 0 0 1 32-32h128a32 32 0 0 1 32 32v191.28a32 32 0 0 0 32 32.1L928.03004 960a32 32 0 0 0 32-32V600L591.37004 296.52a24.38 24.38 0 0 0-30.6 0zM1143.23004 502.94L976.03004 365.12V88.1a24 24 0 0 0-24-24h-112a24 24 0 0 0-24 24v145.22L636.97004 86a96 96 0 0 0-122 0L8.71004 502.94a24 24 0 0 0-3.2 33.8l51 62A24 24 0 0 0 90.33004 602l470.44-387.48a24.38 24.38 0 0 1 30.6 0L1061.83004 602a24 24 0 0 0 33.8-3.2l51-62a24 24 0 0 0-3.4-33.86z"
|
||||||
|
p-id="7657"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
17
src/views/icons/TogglerIcon.vue
Normal file
17
src/views/icons/TogglerIcon.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
t="1772875347931"
|
||||||
|
class="fill-current"
|
||||||
|
viewBox="0 0 1161 1024"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
p-id="5447"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M429.944919 409.690054h697.19149a11.65036 11.65036 0 0 0 11.60969-11.65036v-81.349175a11.65036 11.65036 0 0 0-11.60969-11.609691h-697.19149a11.65036 11.65036 0 0 0-11.608238 11.609691v81.349175c0 6.403994 5.205696 11.609691 11.567569 11.60969l0.040669 0.04067z m-11.608238 296.229401c0 6.444664 5.205696 11.65036 11.567569 11.650361h697.27428a11.65036 11.65036 0 0 0 11.609691-11.609691V624.61095a11.65036 11.65036 0 0 0-11.609691-11.60969H429.944919a11.65036 11.65036 0 0 0-11.608238 11.60969v81.349175-0.04067zM1150.315121 0.092959H11.670695a11.65036 11.65036 0 0 0-11.648908 11.609691v81.349174c0 6.403994 5.246366 11.609691 11.648908 11.609691h1138.644426a11.65036 11.65036 0 0 0 11.65036-11.609691V11.660528a11.65036 11.65036 0 0 0-11.65036-11.608239v0.04067z m0 917.978962H11.670695a11.65036 11.65036 0 0 0-11.648908 11.60969v81.349175c0 6.361872 5.246366 11.609691 11.648908 11.609691h1138.644426a11.65036 11.65036 0 0 0 11.65036-11.609691v-81.349175a11.65036 11.65036 0 0 0-11.65036-11.60969zM44.104624 700.343376L271.130508 521.528284a12.807989 12.807989 0 0 0 0-20.202576L44.104624 322.433634a12.89078 12.89078 0 0 0-20.905577 9.997436v357.746382c0 10.742559 12.353362 16.857509 20.905577 10.164471z"
|
||||||
|
p-id="5448"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
18
tsconfig.app.json
Normal file
18
tsconfig.app.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
|
// Path mapping for cleaner imports.
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
28
tsconfig.node.json
Normal file
28
tsconfig.node.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/node24/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"nightwatch.conf.*",
|
||||||
|
"playwright.config.*",
|
||||||
|
"eslint.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||||
|
// Bundler mode provides a smoother developer experience.
|
||||||
|
"module": "preserve",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
|
||||||
|
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
|
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
vite.config.ts
Normal file
18
vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), vue(), vueDevTools()],
|
||||||
|
envDir: path.resolve('.', 'env'),
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user