This commit is contained in:
2026-03-25 14:57:39 +08:00
commit 47c9f98f8f
51 changed files with 7738 additions and 0 deletions

8
.editorconfig Normal file
View 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
View File

@@ -0,0 +1 @@
* text=auto eol=lf

39
.gitignore vendored Normal file
View 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
View 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
View File

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

9
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}

48
README.md Normal file
View 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.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

0
env/.env vendored Normal file
View File

1
env/.env.development vendored Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE=http://localhost:5162

1
env/.env.production vendored Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE=https://api.stopshopping.bjbj.me

26
eslint.config.ts Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View 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
View 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
View 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
View 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 }

View 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
View 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 })
}

View File

@@ -0,0 +1,8 @@
import type { ApiResponseType } from './ApiResponseType';
export interface AccessToken {
token: string;
expiresIn: number;
};
export type AccessTokenResponse = ApiResponseType<AccessToken>;

View File

@@ -0,0 +1,8 @@
import type { ApiResponseType } from './ApiResponseType';
export interface AntiForgeryToken {
headerName: string;
token: string;
};
export type AntiForgeryTokenResponse = ApiResponseType<AntiForgeryToken>;

View File

@@ -0,0 +1,7 @@
export interface ApiResponseType<TData> {
isSucced: boolean;
message: string | null;
data: TData | null;
}
export type ApiResponseMessage = ApiResponseType<null>;

View 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
View File

@@ -0,0 +1,6 @@
export const UploadScences = {
Avatar: 'Avatar',
Product: 'Product',
Category: 'Category',
} as const
export type UploadScencesType = (typeof UploadScences)[keyof typeof UploadScences]

View File

@@ -0,0 +1,6 @@
export interface PagedResult<TData> {
pageCount: number;
pageSize: number;
pageIndex: number;
data: Array<TData>;
}

View File

@@ -0,0 +1,4 @@
export interface PagedSearchType {
pageIndex: number;
pageSize: number;
};

14
src/api/models/SignIn.ts Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
@import 'tailwindcss';
@plugin "daisyui";

View 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>> }

View 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>

View 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>

View File

@@ -0,0 +1,4 @@
export * from './TreeItem'
import TreeView from './TreeView.vue'
export { TreeView }

1
src/components/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './Tree'

11
src/main.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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>

View 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>

View 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>

View 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
View 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
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

28
tsconfig.node.json Normal file
View 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
View 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)),
},
},
})