✨
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