Repository: kingyue737/vitify-admin Branch: main Commit: 1bf6404a108d Files: 101 Total size: 120.4 KB Directory structure: gitextract_1fv644ps/ ├── .editorconfig ├── .gitattributes ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ └── settings.json ├── LICENSE ├── README.md ├── cypress/ │ └── e2e/ │ └── example.spec.ts ├── cypress.config.ts ├── eslint.config.js ├── index.html ├── netlify.toml ├── package.json ├── patches/ │ └── vite-plugin-vue-layouts@0.8.0.patch ├── prettier.config.js ├── public/ │ └── mockServiceWorker.js ├── src/ │ ├── App.vue │ ├── api/ │ │ └── users.ts │ ├── assets/ │ │ └── styles/ │ │ ├── _overrides.scss │ │ ├── _scrollbar.scss │ │ ├── _utils.scss │ │ ├── index.scss │ │ ├── variables.scss │ │ └── vuetify-variables.scss │ ├── auto-imports.d.ts │ ├── components/ │ │ ├── DialogConfirm.vue │ │ ├── StatsCard.vue │ │ ├── VHeadCard.vue │ │ ├── demo-charts/ │ │ │ ├── ChartBar.vue │ │ │ ├── ChartLine.vue │ │ │ ├── ChartPie.vue │ │ │ └── ChartRadar.vue │ │ └── layout/ │ │ ├── AppBar.vue │ │ ├── AppBreadcrumbs.vue │ │ ├── AppDrawer.vue │ │ ├── AppDrawerItem.vue │ │ ├── AppFooter.vue │ │ ├── AppMessage.vue │ │ ├── AppMessageItem.vue │ │ ├── AppView.vue │ │ ├── ButtonFullScreen.vue │ │ ├── ButtonLocale.vue │ │ ├── ButtonSettings.vue │ │ ├── ButtonUser.vue │ │ └── RouterWrapper.vue │ ├── components.d.ts │ ├── composables/ │ │ └── useVuetify.ts │ ├── env.d.ts │ ├── layouts/ │ │ ├── default.vue │ │ └── empty.vue │ ├── locales/ │ │ ├── en.json │ │ └── zh.json │ ├── main.ts │ ├── mocks/ │ │ └── index.ts │ ├── pages/ │ │ ├── [...all].vue │ │ ├── __tests__/ │ │ │ └── login.spec.ts │ │ ├── dashboard.vue │ │ ├── homepage.vue │ │ ├── index.vue │ │ ├── login.vue │ │ ├── nested/ │ │ │ ├── menu1.vue │ │ │ ├── menu2/ │ │ │ │ ├── menu2-1.vue │ │ │ │ └── menu2-2.vue │ │ │ └── menu2.vue │ │ ├── nested.vue │ │ ├── reset-password.vue │ │ ├── user-manage/ │ │ │ ├── [id].vue │ │ │ └── index.vue │ │ └── user-manage.vue │ ├── plugins/ │ │ ├── README.md │ │ ├── components.ts │ │ ├── echarts.ts │ │ ├── i18n.ts │ │ ├── pinia.ts │ │ ├── portal-vue.ts │ │ ├── router.ts │ │ └── vuetify.ts │ ├── route-meta.d.ts │ ├── shims.d.ts │ ├── stores/ │ │ ├── __tests__/ │ │ │ └── message.spec.ts │ │ ├── app.ts │ │ ├── message.ts │ │ └── user.ts │ └── utils/ │ ├── date.ts │ ├── permission.ts │ ├── request.ts │ ├── string.ts │ └── types.ts ├── test/ │ ├── helpers.ts │ └── vitest.setup.ts ├── tsconfig.app.json ├── tsconfig.cypress.json ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.vitest.json ├── vite.config.preview.ts └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true max_line_length = 80 ================================================ FILE: .gitattributes ================================================ # Enforce Unix newlines * text=auto eol=lf public/mockServiceWorker.js linguist-vendored=true ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: - main pull_request: branches: - main jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20.x cache: pnpm - name: Install run: pnpm install - name: Lint run: pnpm run lint typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20.x cache: pnpm - name: Install run: pnpm install - name: Typecheck run: pnpm run typecheck test: runs-on: ${{ matrix.os }} strategy: matrix: node-version: [20.x] os: [ubuntu-latest] fail-fast: false steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: pnpm install - run: pnpm run test:unit test-e2e: runs-on: ubuntu-latest strategy: matrix: node-version: [20.x] os: [ubuntu-latest] fail-fast: false steps: - uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - name: Cypress uses: cypress-io/github-action@v6 with: install-command: pnpm install build: pnpm run build start: pnpm run preview record: true command-prefix: '--' env: # pass the Dashboard record key as an environment variable CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} # pass GitHub token to allow accurately detecting a build vs a re-run build GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # pass the project ID from the secrets through environment variable CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }} ================================================ FILE: .gitignore ================================================ .DS_Store node_modules /dist *.local *.log .idea /.vite-inspect /coverage /public/docs /cypress/videos/* ================================================ FILE: .npmrc ================================================ auto-install-peers=true ================================================ FILE: .prettierignore ================================================ pnpm-lock.yaml src/auto-imports.d.ts public/mockServiceWorker.js ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "vue.volar", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "editorconfig.editorconfig", "lokalise.i18n-ally", "lukas-tr.materialdesignicons-intellisense" ] } ================================================ FILE: .vscode/launch.json ================================================ { // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "Debug Current Vitest File", "autoAttachChildProcesses": true, "skipFiles": ["/**", "**/node_modules/**"], "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", "args": ["run", "${relativeFile}"], "smartStep": true, "console": "integratedTerminal" } ] } ================================================ FILE: .vscode/settings.json ================================================ { "i18n-ally.localesPaths": ["src/locales"], "i18n-ally.keystyle": "nested", "i18n-ally.enabledFrameworks": ["vue-sfc", "vue"], "json.schemas": [ { "fileMatch": ["/.prettierrc"], "url": "https://json.schemastore.org/prettierrc.json" } ], "editor.defaultFormatter": "esbenp.prettier-vscode" } ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2022-present Yue JIN & NuStar Nuclear Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

Vitify - Opinionated Vuetify Admin Starter Template

Vitify Admin

vue vuetify license

Vite + Vuetify, Opinionated Admin Starter Template

Live Demo

Documentation

## Variants - [vitify-nuxt](https://github.com/kingyue737/vitify-nuxt) - with Nuxt 3, the best DX 🔥🔥🔥 - [vitify-next](https://github.com/kingyue737/vitify-next) - Lightweight Vue 3 version of this template - [vitify-electron](https://github.com/kingyue737/vitify-electron) - Vuetify 3 + Electron starter ## Features - 🦾 Full [TypeScript Support and intellisense](https://github.com/vuetifyjs/vuetify/issues/14798#issuecomment-1139788615) for [Vuetify 2](https://vuetifyjs.com/) components, powered by [Volar](https://github.com/johnsoncodehk/volar/tree/master/extensions/vscode-vue-language-features) - 🖖 [Vue 2.7](https://github.com/vuejs/vue) - Composition API and ` ================================================ FILE: netlify.toml ================================================ [[redirects]] from = "/*" to = "/index.html" status = 200 [[headers]] for = "/manifest.webmanifest" [headers.values] Content-Type = "application/manifest+json" ================================================ FILE: package.json ================================================ { "private": true, "type": "module", "packageManager": "pnpm@9.11.0", "scripts": { "dev": "vite --open --host", "build": "vite build", "preview": "vite preview --port 5050 --host --config vite.config.preview.ts", "test:e2e": "start-server-and-test preview http://127.0.0.1:5050/ 'cypress open'", "test:e2e:ci": "start-server-and-test preview http://127.0.0.1:5050/ 'cypress run'", "test:unit": "vitest", "coverage": "vitest run --coverage", "typecheck": "vue-tsc --build --force", "lint": "eslint . --fix", "format": "prettier . --write" }, "dependencies": { "@mdi/js": "^7.4.47", "@vueuse/core": "^11.1.0", "axios": "^1.7.7", "echarts": "^5.5.1", "pinia": "^2.2.2", "portal-vue": "^2.1.7", "vue": "^2.7.16", "vue-echarts": "^7.0.3", "vue-i18n": "^8.28.2", "vue-i18n-bridge": "^9.14.1", "vue-router": "^3.6.5", "vuetify": "^2.7.2" }, "devDependencies": { "@eslint/compat": "^1.1.1", "@intlify/core-base": "^9.14.1", "@intlify/unplugin-vue-i18n": "^2.0.0", "@kingyue/vite-plugin-vue2-svg": "^0.6.0", "@pinia/testing": "^0.1.5", "@testing-library/vue": "^5.9.0", "@types/jsdom": "^21.1.7", "@types/node": "^20.16.10", "@vitejs/plugin-legacy": "^5.4.2", "@vitejs/plugin-vue2": "^2.3.1", "@vue/test-utils": "^1.3.6", "browserslist": "^4.24.0", "browserslist-to-esbuild": "^2.1.1", "cypress": "^13.15.0", "eslint": "^9.11.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-cypress": "^3.5.0", "eslint-plugin-vue": "^9.28.0", "flush-promises": "^1.0.2", "jsdom": "^25.0.1", "msw": "^2.4.9", "postcss-preset-env": "^10.0.5", "prettier": "^3.3.3", "rollup-plugin-regexp": "^5.0.1", "sass": "~1.32.13", "start-server-and-test": "^2.0.8", "terser": "^5.34.1", "typescript": "^5.6.2", "typescript-eslint": "^8.7.0", "unplugin-auto-import": "^0.18.3", "unplugin-vue-components": "^0.27.4", "vite": "^5.4.8", "vite-plugin-inspect": "^0.8.7", "vite-plugin-pages": "^0.32.3", "vite-plugin-vue-layouts": "^0.8.0", "vitest": "^2.1.1", "vue-template-compiler": "^2.7.16", "vue-tsc": "^2.1.6", "vuetify2-component-types": "^2.7.2" }, "browserslist": [ "> 1.3%", "last 2 versions", "not dead", "not op_mini all", "not ie>0" ], "msw": { "workerDirectory": "public" }, "pnpm": { "peerDependencyRules": { "allowedVersions": { "vite-plugin-vue-layouts>vite": "5" } }, "allowedDeprecatedVersions": { "vue": "2" }, "patchedDependencies": { "vite-plugin-vue-layouts@0.8.0": "patches/vite-plugin-vue-layouts@0.8.0.patch" } } } ================================================ FILE: patches/vite-plugin-vue-layouts@0.8.0.patch ================================================ diff --git a/package.json b/package.json index de999023f87c55dee2e57e2583e1e96b6f95095f..3df13b998cd92bc3a2cfd1767147c4b042edc6a9 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,15 @@ "client.d.ts" ], "exports": { - ".": { - "require": "./dist/index.js", - "import": "./dist/index.mjs" + "./client": { + "types": "./client.d.ts" }, - "./*": "./*" + "./*": "./*", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } }, "scripts": { "dev": "npm run build -- --watch", @@ -59,3 +63,4 @@ "vue-router": "^4.0.13" } } + ================================================ FILE: prettier.config.js ================================================ /** @type {import("prettier").Config} */ export default { semi: false, singleQuote: true, } ================================================ FILE: public/mockServiceWorker.js ================================================ /* eslint-disable */ /* tslint:disable */ /** * Mock Service Worker. * @see https://github.com/mswjs/msw * - Please do NOT modify this file. * - Please do NOT serve this file on production. */ const PACKAGE_VERSION = '2.4.7' const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() self.addEventListener('install', function () { self.skipWaiting() }) self.addEventListener('activate', function (event) { event.waitUntil(self.clients.claim()) }) self.addEventListener('message', async function (event) { const clientId = event.source.id if (!clientId || !self.clients) { return } const client = await self.clients.get(clientId) if (!client) { return } const allClients = await self.clients.matchAll({ type: 'window', }) switch (event.data) { case 'KEEPALIVE_REQUEST': { sendToClient(client, { type: 'KEEPALIVE_RESPONSE', }) break } case 'INTEGRITY_CHECK_REQUEST': { sendToClient(client, { type: 'INTEGRITY_CHECK_RESPONSE', payload: { packageVersion: PACKAGE_VERSION, checksum: INTEGRITY_CHECKSUM, }, }) break } case 'MOCK_ACTIVATE': { activeClientIds.add(clientId) sendToClient(client, { type: 'MOCKING_ENABLED', payload: true, }) break } case 'MOCK_DEACTIVATE': { activeClientIds.delete(clientId) break } case 'CLIENT_CLOSED': { activeClientIds.delete(clientId) const remainingClients = allClients.filter((client) => { return client.id !== clientId }) // Unregister itself when there are no more clients if (remainingClients.length === 0) { self.registration.unregister() } break } } }) self.addEventListener('fetch', function (event) { const { request } = event // Bypass navigation requests. if (request.mode === 'navigate') { return } // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { return } // Bypass all requests when there are no active clients. // Prevents the self-unregistered worked from handling requests // after it's been deleted (still remains active until the next reload). if (activeClientIds.size === 0) { return } // Generate unique request ID. const requestId = crypto.randomUUID() event.respondWith(handleRequest(event, requestId)) }) async function handleRequest(event, requestId) { const client = await resolveMainClient(event) const response = await getResponse(event, client, requestId) // Send back the response clone for the "response:*" life-cycle events. // Ensure MSW is active and ready to handle the message, otherwise // this message will pend indefinitely. if (client && activeClientIds.has(client.id)) { ;(async function () { const responseClone = response.clone() sendToClient( client, { type: 'RESPONSE', payload: { requestId, isMockedResponse: IS_MOCKED_RESPONSE in response, type: responseClone.type, status: responseClone.status, statusText: responseClone.statusText, body: responseClone.body, headers: Object.fromEntries(responseClone.headers.entries()), }, }, [responseClone.body], ) })() } return response } // Resolve the main client for the given event. // Client that issues a request doesn't necessarily equal the client // that registered the worker. It's with the latter the worker should // communicate with during the response resolving phase. async function resolveMainClient(event) { const client = await self.clients.get(event.clientId) if (client?.frameType === 'top-level') { return client } const allClients = await self.clients.matchAll({ type: 'window', }) return allClients .filter((client) => { // Get only those clients that are currently visible. return client.visibilityState === 'visible' }) .find((client) => { // Find the client ID that's recorded in the // set of clients that have registered the worker. return activeClientIds.has(client.id) }) } async function getResponse(event, client, requestId) { const { request } = event // Clone the request because it might've been already used // (i.e. its body has been read and sent to the client). const requestClone = request.clone() function passthrough() { const headers = Object.fromEntries(requestClone.headers.entries()) // Remove internal MSW request header so the passthrough request // complies with any potential CORS preflight checks on the server. // Some servers forbid unknown request headers. delete headers['x-msw-intention'] return fetch(requestClone, { headers }) } // Bypass mocking when the client is not active. if (!client) { return passthrough() } // Bypass initial page load requests (i.e. static assets). // The absence of the immediate/parent client in the map of the active clients // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet // and is not ready to handle requests. if (!activeClientIds.has(client.id)) { return passthrough() } // Notify the client that a request has been intercepted. const requestBuffer = await request.arrayBuffer() const clientMessage = await sendToClient( client, { type: 'REQUEST', payload: { id: requestId, url: request.url, mode: request.mode, method: request.method, headers: Object.fromEntries(request.headers.entries()), cache: request.cache, credentials: request.credentials, destination: request.destination, integrity: request.integrity, redirect: request.redirect, referrer: request.referrer, referrerPolicy: request.referrerPolicy, body: requestBuffer, keepalive: request.keepalive, }, }, [requestBuffer], ) switch (clientMessage.type) { case 'MOCK_RESPONSE': { return respondWithMock(clientMessage.data) } case 'PASSTHROUGH': { return passthrough() } } return passthrough() } function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { const channel = new MessageChannel() channel.port1.onmessage = (event) => { if (event.data && event.data.error) { return reject(event.data.error) } resolve(event.data) } client.postMessage( message, [channel.port2].concat(transferrables.filter(Boolean)), ) }) } async function respondWithMock(response) { // Setting response status code to 0 is a no-op. // However, when responding with a "Response.error()", the produced Response // instance will have status code set to 0. Since it's not possible to create // a Response instance with status code 0, handle that use-case separately. if (response.status === 0) { return Response.error() } const mockedResponse = new Response(response.body, response) Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { value: true, enumerable: true, }) return mockedResponse } ================================================ FILE: src/App.vue ================================================ ================================================ FILE: src/api/users.ts ================================================ import service from '@/utils/request' export type Role = 'superuser' | 'admin' | 'staff' export type Group = { id?: number name: Role permissions: number[] } export interface IUserData { id: number username: string name?: string email?: string groups: number[] joinDate: string } export type Token = { accessToken: string refreshToken: string // tokenType: string // expiresAt: number // issuedAt: number // refreshTokenExpiresAt: number // refreshTokenIssuedAt: number } export const getUsers = () => service.get('/users') export const getUser = (userId: number) => service.get(`/users/${userId}`) export const createUser = (user: IUserData) => service.post('/users', user) export const updateUser = (user: Partial) => service.patch(`/users/${user.id}`, user) export const deleteUser = (userId: number) => service.delete(`/users/${userId}`) export const getToken = (username: string, password: string) => service.post( '/auth/access-token', new URLSearchParams({ username, password }), ) export const refreshToken = (refreshToken: string) => service.post('/auth/refresh-token', { refreshToken }) export const resetPassword = (newPassword: string, oldPassword: string) => service.post(`/users/reset-password`, { newPassword, oldPassword, }) export const getGroup = (id: number) => service.get(`/groups/${id}`) export const getGroups = () => service.get(`/groups`) ================================================ FILE: src/assets/styles/_overrides.scss ================================================ @import './variables.scss'; .v-main__wrap { > .container--fluid { padding-left: 20px; padding-right: 20px; } > .d-contents, > .d-fake > .d-fake, > .d-fake { > .container--fluid { padding-left: 20px; padding-right: 20px; } } } .v-dialog { > .v-card { > .v-card__title { font-size: 18px; text-align: center; width: 100%; padding: 24px 24px 0; } > .v-card__text { padding-top: 24px; } > .v-card__actions { padding-left: 24px; padding-right: 24px; } } } .theme--light.v-text-field > .v-input__control > .v-input__slot:before { border-color: #d2d2d2; } .theme--light .v-main { background-color: #f2f5f8; } .v-data-table > .v-data-table__wrapper tbody { tr:first-child:hover td:first-child { border-top-left-radius: 0px; } tr:first-child:hover td:last-child { border-top-right-radius: 0px; } } .v-text-field.v-text-field--solo:not(.v-text-field--solo-flat) > .v-input__control > .v-input__slot { box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.14); } .v-select:not(.v-select--is-multiple) { .v-chip { cursor: pointer; &:hover::before { opacity: 0; } } } .v-card { &.heading-margin { margin-top: #{$card-heading-margin}; &.fill-height { height: calc(100% - #{$card-heading-margin}); } } &.v-sheet:not(.v-sheet--outlined, .v-card--flat, [class*=' elevation-']) { box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.14) !important; } } .v-application .d-contents { display: contents !important; } html { overflow-y: overlay; } .v-head-card__content { > .echarts { margin-top: -30px; height: calc(100% + 30px); min-height: 50px; } .v-data-table:not(.v-data-table--dense) { > .v-data-table__wrapper { max-height: calc( 100vh - 170px - (#{$app-bar-height} + #{$footer-height}) ); } } } .v-data-table__wrapper { overflow: overlay; } .v-data-table--fixed-header { > .v-data-table__wrapper { overflow-y: overlay; } } .v-menu__content { overflow-y: overlay; } .v-data-table--dense { .v-skeleton-loader__table-cell { height: 32px; width: 54px; } } .v-data-footer { padding-left: 0; } .v-application--is-ltr .v-data-table--fixed-header .v-data-footer { margin-right: 0; } .v-app-bar { .v-breadcrumbs { flex-wrap: nowrap; padding-left: 2px; padding-right: 2px; li { white-space: nowrap; transition-duration: 0.6s !important; &:nth-child(even) { padding: 0 0px; } &::before { float: left; padding: 0 12px; color: rgba(122, 122, 122, 0.5); content: '/'; } } } } .v-icon__component { fill: currentColor; } .v-form { display: contents; } .v-icon svg { height: 1em; width: auto; } html.dark { color-scheme: dark; } ================================================ FILE: src/assets/styles/_scrollbar.scss ================================================ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-thumb { background-color: rgba(149, 149, 149, 0.4); background-clip: content-box; min-height: 28px; border: 2px solid transparent; } ::-webkit-scrollbar-thumb:hover { background-color: rgba(149, 149, 149, 0.4); border: 1px solid transparent; border-radius: 4px; } ================================================ FILE: src/assets/styles/_utils.scss ================================================ .svg-up { transform: rotate(0deg); } .svg-right { transform: rotate(90deg); } .svg-down { transform: rotate(180deg); } .svg-left { transform: rotate(-90deg); } ================================================ FILE: src/assets/styles/index.scss ================================================ @import 'scrollbar'; @import 'utils'; @import 'overrides'; ================================================ FILE: src/assets/styles/variables.scss ================================================ $footer-height: 30px; $app-bar-height: 60px; $card-heading-margin: 10px; ================================================ FILE: src/assets/styles/vuetify-variables.scss ================================================ $grid-breakpoints: ( 'xs': 0, 'sm': 600px, 'md': 960px, 'lg': 1280px - 0px, 'xl': 1920px - 0px, ); ================================================ FILE: src/auto-imports.d.ts ================================================ /* eslint-disable */ /* prettier-ignore */ // @ts-nocheck // noinspection JSUnusedGlobalSymbols // Generated by unplugin-auto-import // biome-ignore lint: disable export {} declare global { const EffectScope: typeof import('vue')['EffectScope'] const Message: typeof import('./stores/message')['Message'] const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] const computed: typeof import('vue')['computed'] const createApp: typeof import('vue')['createApp'] const createPinia: typeof import('pinia')['createPinia'] const customRef: typeof import('vue')['customRef'] const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] const defineComponent: typeof import('vue')['defineComponent'] const defineStore: typeof import('pinia')['defineStore'] const effectScope: typeof import('vue')['effectScope'] const getActivePinia: typeof import('pinia')['getActivePinia'] const getCurrentInstance: typeof import('vue')['getCurrentInstance'] const getCurrentScope: typeof import('vue')['getCurrentScope'] const h: typeof import('vue')['h'] const inject: typeof import('vue')['inject'] const isProxy: typeof import('vue')['isProxy'] const isReactive: typeof import('vue')['isReactive'] const isReadonly: typeof import('vue')['isReadonly'] const isRef: typeof import('vue')['isRef'] const mapActions: typeof import('pinia')['mapActions'] const mapGetters: typeof import('pinia')['mapGetters'] const mapState: typeof import('pinia')['mapState'] const mapStores: typeof import('pinia')['mapStores'] const mapWritableState: typeof import('pinia')['mapWritableState'] const markRaw: typeof import('vue')['markRaw'] const nextTick: typeof import('vue')['nextTick'] const onActivated: typeof import('vue')['onActivated'] const onBeforeMount: typeof import('vue')['onBeforeMount'] const onBeforeRouteLeave: typeof import('vue-router/composables')['onBeforeRouteLeave'] const onBeforeRouteUpdate: typeof import('vue-router/composables')['onBeforeRouteUpdate'] const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] const onDeactivated: typeof import('vue')['onDeactivated'] const onErrorCaptured: typeof import('vue')['onErrorCaptured'] const onMounted: typeof import('vue')['onMounted'] const onRenderTracked: typeof import('vue')['onRenderTracked'] const onRenderTriggered: typeof import('vue')['onRenderTriggered'] const onScopeDispose: typeof import('vue')['onScopeDispose'] const onServerPrefetch: typeof import('vue')['onServerPrefetch'] const onUnmounted: typeof import('vue')['onUnmounted'] const onUpdated: typeof import('vue')['onUpdated'] const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] const provide: typeof import('vue')['provide'] const reactive: typeof import('vue')['reactive'] const readonly: typeof import('vue')['readonly'] const ref: typeof import('vue')['ref'] const resolveComponent: typeof import('vue')['resolveComponent'] const setActivePinia: typeof import('pinia')['setActivePinia'] const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] const shallowReactive: typeof import('vue')['shallowReactive'] const shallowReadonly: typeof import('vue')['shallowReadonly'] const shallowRef: typeof import('vue')['shallowRef'] const storeToRefs: typeof import('pinia')['storeToRefs'] const toRaw: typeof import('vue')['toRaw'] const toRef: typeof import('vue')['toRef'] const toRefs: typeof import('vue')['toRefs'] const toValue: typeof import('vue')['toValue'] const triggerRef: typeof import('vue')['triggerRef'] const unref: typeof import('vue')['unref'] const useAppStore: typeof import('./stores/app')['useAppStore'] const useAttrs: typeof import('vue')['useAttrs'] const useCssModule: typeof import('vue')['useCssModule'] const useCssVars: typeof import('vue')['useCssVars'] const useI18n: typeof import('vue-i18n-bridge')['useI18n'] const useId: typeof import('vue')['useId'] const useLink: typeof import('vue-router/composables')['useLink'] const useMessageStore: typeof import('./stores/message')['useMessageStore'] const useModel: typeof import('vue')['useModel'] const useRoute: typeof import('vue-router/composables')['useRoute'] const useRouter: typeof import('vue-router/composables')['useRouter'] const useSlots: typeof import('vue')['useSlots'] const useTemplateRef: typeof import('vue')['useTemplateRef'] const useUserStore: typeof import('./stores/user')['useUserStore'] const watch: typeof import('vue')['watch'] const watchEffect: typeof import('vue')['watchEffect'] const watchPostEffect: typeof import('vue')['watchPostEffect'] const watchSyncEffect: typeof import('vue')['watchSyncEffect'] } // for type re-export declare global { // @ts-ignore export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue' import('vue') } ================================================ FILE: src/components/DialogConfirm.vue ================================================ ================================================ FILE: src/components/StatsCard.vue ================================================ ================================================ FILE: src/components/VHeadCard.vue ================================================ ================================================ FILE: src/components/demo-charts/ChartBar.vue ================================================ ================================================ FILE: src/components/demo-charts/ChartLine.vue ================================================ ================================================ FILE: src/components/demo-charts/ChartPie.vue ================================================ ================================================ FILE: src/components/demo-charts/ChartRadar.vue ================================================ ================================================ FILE: src/components/layout/AppBar.vue ================================================ ================================================ FILE: src/components/layout/AppBreadcrumbs.vue ================================================ ================================================ FILE: src/components/layout/AppDrawer.vue ================================================ ================================================ FILE: src/components/layout/AppDrawerItem.vue ================================================ ================================================ FILE: src/components/layout/AppFooter.vue ================================================ ================================================ FILE: src/components/layout/AppMessage.vue ================================================ zh: noNew: 没有新的通知 clearAll: 清除所有通知 en: noNew: No New Notifications clearAll: Clear All Notifications ================================================ FILE: src/components/layout/AppMessageItem.vue ================================================ ================================================ FILE: src/components/layout/AppView.vue ================================================ ================================================ FILE: src/components/layout/ButtonFullScreen.vue ================================================ ================================================ FILE: src/components/layout/ButtonLocale.vue ================================================ ================================================ FILE: src/components/layout/ButtonSettings.vue ================================================ ================================================ FILE: src/components/layout/ButtonUser.vue ================================================ ================================================ FILE: src/components/layout/RouterWrapper.vue ================================================ ================================================ FILE: src/components.d.ts ================================================ import type { DefineComponent } from 'vue' declare module 'vue' { export interface GlobalComponents { VChart: (typeof import('vue-echarts'))['default'] VHeadCard: (typeof import('@/components/VHeadCard.vue'))['default'] RouterView: (typeof import('vue-router'))['RouterView'] RouterLink: (typeof import('vue-router'))['RouterLink'] Portal: DefineComponent<{ disabled?: boolean name?: string order?: number slim?: boolean slotProps?: any tag?: string to: string }> PortalTarget: DefineComponent<{ multiple?: boolean name: string slim?: boolean slotProps?: any tag?: string transition?: boolean | string | object }> } } export {} ================================================ FILE: src/composables/useVuetify.ts ================================================ import type { VuetifyParsedTheme } from 'vuetify/types/services/theme' export function useVuetify() { const instance = getCurrentInstance() if (!instance) { throw new Error(`useVuetify should be called in setup().`) } return instance.proxy.$vuetify } export function useParsedTheme() { // parsedTheme is only for internal usage and not typed in vuetify return (useVuetify().theme as any).parsedTheme as VuetifyParsedTheme } ================================================ FILE: src/env.d.ts ================================================ /// /// /// /// interface ImportMetaEnv { readonly VITE_API_URL: string readonly VITE_MOCK: string } interface ImportMeta { readonly env: ImportMetaEnv } ================================================ FILE: src/layouts/default.vue ================================================ ================================================ FILE: src/layouts/empty.vue ================================================ ================================================ FILE: src/locales/en.json ================================================ { "homepage": "Homepage", "fullscreen": "Fullscreen", "userManagement": "User Management", "userDetail": "User Detail", "dashboard": "Dashboard", "username": "Username", "name": "Name", "user": "User", "password": "Password", "resetPassword": "Reset Password", "language": "Language", "login": "Login", "logout": "Logout", "email": "Email", "group": "Group", "joinDate": "Joining Date", "actions": "Actions", "edit": "Edit", "delete": "Delete", "deleted": "Deleted", "submit": "Submit", "listOf": "{0} List", "userLogin": "@:user @:login", "pleaseEnter": "Please enter {0}", "lengthOf": "{0} length", "form": { "LTE": "{input} must be less than or equal to {limit}", "LT": "{input} must be less than {limit}", "GTE": "{input} must be greater than or equal to {limit}", "GT": "{input} must be greater than {limit}" }, "darkMode": "Dark Mode", "drawerBackground": "Drawer Background", "image": "Image", "interfaceSettings": "Interface Settings", "notification": "Notifications", "confirm": "Confirm", "cancel": "Cancel", "themeColor": "Theme Color", "documentation": "Documentation", "nestedRoutes": "Nested Routes" } ================================================ FILE: src/locales/zh.json ================================================ { "homepage": "主页", "fullscreen": "全屏", "userManagement": "用户管理", "userDetail": "用户详情", "dashboard": "仪表板", "username": "用户名", "user": "用户", "group": "用户组", "actions": "动作", "joinDate": "注册时间", "submit": "提交", "edit": "编辑", "delete": "删除", "deleted": "已删除", "name": "姓名", "password": "密码", "resetPassword": "重置密码", "language": "语言", "login": "登录", "logout": "注销", "email": "邮箱", "listOf": "{0}列表", "userLogin": "@:user@:login", "pleaseEnter": "请填写{0}", "lengthOf": "{0}长度", "form": { "LTE": "{input}必须小于等于{limit}", "LT": "{input}必须小于{limit}", "GTE": "{input}必须大于等于{limit}", "GT": "{input}必须大于{limit}" }, "image": "图片", "drawerBackground": "侧边栏背景", "notification": "通知", "darkMode": "暗模式", "interfaceSettings": "界面设置", "themeColor": "主题色", "confirm": "确认", "cancel": "取消", "hide": "隐藏", "documentation": "文档", "nestedRoutes": "嵌套路由" } ================================================ FILE: src/main.ts ================================================ import Vue from 'vue' import App from './App.vue' import '@/assets/styles/index.scss' import { filename } from './utils/string' import type { InstallPlugin } from './utils/types' Vue.config.productionTip = false if (import.meta.env.VITE_MOCK) { ;(await import('./mocks')).worker.start({ onUnhandledRequest: 'bypass', }) } const app = new Vue({ ...Object.fromEntries( Object.entries( import.meta.glob<{ install: InstallPlugin }>('./plugins/*.ts', { eager: true, }), ) .map(([k, v]) => [filename(k), v.install?.(Vue)] as [string, any]) .filter((entry) => entry[1]), ), render: (h) => h(App), }) app.$mount('#app') ================================================ FILE: src/mocks/index.ts ================================================ import { setupWorker } from 'msw/browser' import { http } from 'msw' const baseURL = import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.hostname}:9529/api/v1` const url = (path: string) => { return baseURL + path } const groups = [ { id: 1, name: 'admin' }, { id: 2, name: 'staff' }, ] const users = [ { id: 1, groups: [1], username: 'kingyue737', name: 'Yue JIN', email: 'yuejin13@fudan.edu.cn', joinDate: '2021-11-08T07:35:09.709Z', }, { id: 2, groups: [1], username: 'lodgepole', name: 'Ada Zhang', email: '', joinDate: '2021-04-08T23:45:09.709Z', }, { id: 3, groups: [2], username: 'fuant', name: 'Antony Fu', joinDate: '2022-07-08T21:32:00.709Z', }, { id: 4, groups: [2], name: 'Ivan You', username: 'xiaoyouyou', joinDate: '2022-07-08T12:35:09.709Z', }, { id: 5, groups: [2], name: 'Johnny Leider', username: 'johnnyleider', joinDate: '2022-01-21T12:35:09.709Z', }, ] export const worker = setupWorker( http.get(url('/users/:id'), async ({ request }) => { return Response.json({ id: 99, groups: [1] }) }), http.get(url('/users'), async ({ request }) => { return Response.json(users) }), http.delete(url('/users/:id'), ({ params }) => { users.splice(users.map((x) => x.id).indexOf(Number(params.id)), 1) return new Response(null, { status: 204 }) }), http.post(url('/auth/access-token'), async () => { return Response.json({ accessToken: 'admin', refreshToken: 'admin', }) }), http.get(url('/groups'), async () => { return Response.json(groups) }), http.get(url('/groups/:id'), async ({ params }) => { return Response.json(groups.find((g) => g.id === Number(params.id))) }), ) ================================================ FILE: src/pages/[...all].vue ================================================ ================================================ FILE: src/pages/__tests__/login.spec.ts ================================================ import LoginPage from '../login.vue' import { fireEvent } from '@testing-library/vue' import { renderWithVuetify } from '@/../test/helpers' describe('login page', () => { it('login correctly', async () => { const { getByText, getByLabelText } = renderWithVuetify(LoginPage) getByText('User Login') const userInput = getByLabelText('Username') await fireEvent.update(userInput, 'admin') const passwordInput = getByLabelText('Password') await fireEvent.update(passwordInput, 'admin123') const button = getByText('Login') await fireEvent.click(button) const store = useUserStore() expect(store.login).toBeCalledWith('admin', 'admin123') }) }) ================================================ FILE: src/pages/dashboard.vue ================================================ { "meta": { "title": "dashboard", "icon": "mdi-monitor-dashboard", "drawerIndex": 1 } } ================================================ FILE: src/pages/homepage.vue ================================================ { "meta": { "title": "homepage", "icon": "mdi-home", "drawerIndex": 0 } } en: description: Opinionated Admin Starter Template inputLabel: What's your name? warnMessage: How dare you refuse me, {0}! zh: description: 固执己见的后台项目模板 inputLabel: 你的名字? warnMessage: 你胆敢拒绝我, {0}! ================================================ FILE: src/pages/index.vue ================================================ { "redirect": "homepage" } ================================================ FILE: src/pages/login.vue ================================================ meta: layout: empty ================================================ FILE: src/pages/nested/menu1.vue ================================================ { "meta": { "title": "Menu 1", "icon": "mdi-animation" } } ================================================ FILE: src/pages/nested/menu2/menu2-1.vue ================================================ { "meta": { "title": "Menu 2-1", "icon": "mdi-animation" } } ================================================ FILE: src/pages/nested/menu2/menu2-2.vue ================================================ { "meta": { "title": "Menu 2-2", "icon": "mdi-animation" } } ================================================ FILE: src/pages/nested/menu2.vue ================================================ { "meta": { "title": "Menu 2", "icon": "mdi-view-list" } } ================================================ FILE: src/pages/nested.vue ================================================ { "meta": { "title": "nestedRoutes", "icon": "mdi-view-list", "breadcrumb": "disabled" } } ================================================ FILE: src/pages/reset-password.vue ================================================ { "en": { "passwordUpdated": "Password Updated", "currentPassword": "Current Password", "confirmPassword": "Confirm Password", "newPassword": "New Password", "notEqualErr": "Must be the same as New Password" }, "zh": { "passwordUpdated": "密码已更新", "currentPassword": "当前密码", "confirmPassword": "确认密码", "newPassword": "新密码", "notEqualErr": "与新密码不匹配,请重新输入" } } ================================================ FILE: src/pages/user-manage/[id].vue ================================================ { "meta": { "title": "userDetail", "breadcrumb": "disabled" } } ================================================ FILE: src/pages/user-manage/index.vue ================================================ { "meta": { "icon": "XXX" } } { "en": { "confirmMsg": "Are you sure to delete this user?" }, "zh": { "confirmMsg": "你确定要删除此用户吗?" } } ================================================ FILE: src/pages/user-manage.vue ================================================ { "meta": { "title": "userManagement", "icon": "mdi-account-group", "roles": ["admin"], "drawerGroup": "admin", "dataCy": "userManage" } } ================================================ FILE: src/plugins/README.md ================================================ ## Plugins A custom user plugin system. Place a `.ts` file with the following template, it will be installed automatically. ```ts import type { InstallPlugin } from '@/utils/types' export const install: InstallPlugin = (vue) => { // do something } ``` ================================================ FILE: src/plugins/components.ts ================================================ import VChart from 'vue-echarts' import VHeadCard from '@/components/VHeadCard.vue' import type { InstallPlugin } from '@/utils/types' export const install: InstallPlugin = (vue) => { vue.component('VHeadCard', VHeadCard) vue.component('VChart', VChart) } ================================================ FILE: src/plugins/echarts.ts ================================================ import * as echarts from 'echarts/core' import { LineChart, type LineSeriesOption, BarChart, type BarSeriesOption, EffectScatterChart, type EffectScatterSeriesOption, ScatterChart, type ScatterSeriesOption, PieChart, type PieSeriesOption, RadarChart, type RadarSeriesOption, } from 'echarts/charts' import { CanvasRenderer } from 'echarts/renderers' import { DataZoomComponent, type DataZoomComponentOption, LegendComponent, type LegendComponentOption, TooltipComponent, type TooltipComponentOption, ToolboxComponent, type ToolboxComponentOption, GridComponent, type GridComponentOption, TitleComponent, type TitleComponentOption, MarkPointComponent, type MarkPointComponentOption, DatasetComponent, type DatasetComponentOption, VisualMapComponent, type VisualMapComponentOption, } from 'echarts/components' echarts.use([ LineChart, BarChart, PieChart, RadarChart, EffectScatterChart, ScatterChart, CanvasRenderer, DataZoomComponent, LegendComponent, TooltipComponent, ToolboxComponent, GridComponent, TitleComponent, MarkPointComponent, DatasetComponent, VisualMapComponent, ]) export type ECOption = echarts.ComposeOption< | LineSeriesOption | BarSeriesOption | PieSeriesOption | RadarSeriesOption | EffectScatterSeriesOption | ScatterSeriesOption | DataZoomComponentOption | LegendComponentOption | TooltipComponentOption | ToolboxComponentOption | GridComponentOption | TitleComponentOption | MarkPointComponentOption | DatasetComponentOption | VisualMapComponentOption > export default echarts ================================================ FILE: src/plugins/i18n.ts ================================================ import VueI18n from 'vue-i18n' import { castToVueI18n, createI18n } from 'vue-i18n-bridge' import en from '@/locales/en.json' import zh from '@/locales/zh.json' import type { InstallPlugin } from '@/utils/types' export const install: InstallPlugin = (vue) => { vue.use(VueI18n, { bridge: true }) const i18n = castToVueI18n( createI18n( { legacy: false, locale: 'en', messages: { zh, en }, missingWarn: false, fallbackWarn: false, }, VueI18n, ), ) vue.use(i18n) return i18n } ================================================ FILE: src/plugins/pinia.ts ================================================ import { createPinia, PiniaVuePlugin } from 'pinia' import type { InstallPlugin } from '@/utils/types' export const install: InstallPlugin = (vue) => { vue.use(PiniaVuePlugin) return createPinia() } ================================================ FILE: src/plugins/portal-vue.ts ================================================ /* Replace this component by Vue 3 built-in Teleport in the future */ import PortalVue from 'portal-vue' import type { InstallPlugin } from '@/utils/types' export const install: InstallPlugin = (vue) => { vue.use(PortalVue) } ================================================ FILE: src/plugins/router.ts ================================================ import Router from 'vue-router' import { setupLayouts } from 'virtual:generated-layouts' import generatedRoutes from '~pages' import { isPermitted } from '@/utils/permission' import type { InstallPlugin } from '@/utils/types' export const routes = setupLayouts(generatedRoutes) export const install: InstallPlugin = (vue) => { vue.use(Router) const router = new Router({ mode: 'history', routes, }) router.beforeEach(async (to, from, next) => { const userStore = useUserStore() // Determine whether the user has logged in if (userStore.token) { if (to.path === '/login') { // If is logged in, redirect to the home page next({ path: '/' }) } else { // Check whether the user has obtained his permission roles if (userStore.roles.length === 0) { try { await userStore.getUserInfo() } catch (e) { // Remove token and redirect to login page userStore.logOut() Message.error(e) next('/login') return } } if (!to.meta?.roles || isPermitted(to.meta.roles)) { next() return } // Redirect to 404 error page if not permitted next({ name: 'all' }) } } else { if (to.path === '/login') { next() } else { next('/login') } } }) return router } ================================================ FILE: src/plugins/vuetify.ts ================================================ import Vuetify from 'vuetify/lib' import type { VuetifyParsedTheme } from 'vuetify/types/services/theme' import { Ripple, Resize, Scroll } from 'vuetify/lib/directives' import { useDark } from '@vueuse/core' import en from 'vuetify/lib/locale/en' import zh from 'vuetify/lib/locale/zh-Hans' import type { InstallPlugin } from '@/utils/types' import { filename } from '@/utils/string' import type { Component } from 'vue' const svgIcons = Object.fromEntries( Object.entries( import.meta.glob('@/assets/icons/*.svg', { eager: true, import: 'default', }), ).map(([k, v]) => [filename(k), { component: v }]), ) const theme = { primary: localStorage.getItem('theme-primary') || '#3f51b5', secondary: '#03A9F4', accent: '#9C27b0', info: '#00CAE3', } export const install: InstallPlugin = (vue) => { vue.use(Vuetify, { directives: { Ripple, Resize, Scroll, }, }) return new Vuetify({ lang: { locales: { zh, en }, current: 'en', }, theme: { dark: useDark().value, themes: { dark: theme, light: theme, }, options: { themeCache: { // https://vuetifyjs.com/features/theme/#section-30ad30e330c330b730e5 get: (key: VuetifyParsedTheme) => { return localStorage.getItem(`parsed-theme-${key.primary.base}`) }, set: (key: VuetifyParsedTheme, value: string) => { localStorage.setItem(`parsed-theme-${key.primary.base}`, value) }, }, customProperties: true, }, }, icons: { iconfont: 'mdiSvg', values: { ...svgIcons, }, }, breakpoint: { thresholds: { xs: 600, sm: 960, md: 1280, lg: 1920, }, mobileBreakpoint: 'sm', scrollBarWidth: 0, }, }) } ================================================ FILE: src/route-meta.d.ts ================================================ export {} import 'vue-router' import type { RouteConfig } from 'vue-router' import type { Role } from '@/api/users' declare module 'vue-router' { interface RouteMeta { /** Drawer item icon */ icon?: string /** Groups will be separated by divider line in drawer */ drawerGroup?: 'admin' | 'PUC' /** Determine the order of item in drawer */ drawerIndex?: number /** Drawer item and breadcrumb text */ title?: string /** Subtitle in drawer item */ subtitle?: string /** Authorized user groups */ roles?: Role[] /** For cypress location */ dataCy?: string /** Hide this route in drawer if truthy */ hidden?: boolean /** Default is enabled */ breadcrumb?: 'hidden' | 'disabled' } type RouteRecordRaw = RouteConfig // shim plugins for vue-router v4 } ================================================ FILE: src/shims.d.ts ================================================ declare module 'vuetify/lib/locale/*' { import type { VuetifyLocale } from 'vuetify/types/services/lang' const locale: VuetifyLocale export default locale } ================================================ FILE: src/stores/__tests__/message.spec.ts ================================================ describe('Message Store', () => { beforeEach(() => { // creates a fresh pinia and make it active so it's automatically picked // up by any useStore() call without having to pass it to it: // `useStore(pinia)` setActivePinia(createPinia()) }) it('Add and delete message', () => { const store = useMessageStore() store.addMessage('A message!') expect(store.messageCount).toBe(1) store.delMessage(0) expect(store.messages.length).toBe(0) }) it('Message utils', () => { const store = useMessageStore() Message.error('Error message') const message = store.messages.at(-1) expect(message!.text).toBe('Error message') expect(message!.type).toBe('error') }) }) export {} ================================================ FILE: src/stores/app.ts ================================================ export const useAppStore = defineStore('app', { state: () => { return { drawer: true, drawerImage: '占位', drawerImageShow: true, } }, }) ================================================ FILE: src/stores/message.ts ================================================ interface Message { show: boolean type: 'info' | 'error' | 'success' | 'warning' text: string time: Date id: number } export const useMessageStore = defineStore('message', { state: () => { const messages: Message[] = [] return { messages, messageCount: 0, } }, actions: { addMessage(text: string, type: Message['type'] = 'info') { this.messages.push({ id: this.messageCount++, text: text, type: type, time: new Date(), show: true, }) }, delMessage(id: number) { const index = this.messages.findIndex((m) => m.id === id) if (index !== -1) { this.messages.splice(index, 1) } }, }, }) export const Message = { info: (text: string) => useMessageStore().addMessage(text, 'info'), success: (text: string) => useMessageStore().addMessage(text, 'success'), warning: (text: string) => useMessageStore().addMessage(text, 'warning'), error: (val: any) => { let text = '' if (typeof val === 'string') { text = val } else if (val instanceof Error) { text = val.message } else { text = JSON.stringify(val) } useMessageStore().addMessage(text, 'error') }, } ================================================ FILE: src/stores/user.ts ================================================ import { getToken, getUser, refreshToken, getGroup, type Role, } from '@/api/users' export const useUserStore = defineStore('user', { state: () => { const roles: Role[] = [] return { name: localStorage.getItem('username') || '', id: parseInt(localStorage.getItem('id') || '-1'), token: localStorage.getItem('access') || '', roles, } }, actions: { async login(username: string, password: string) { const { data } = await getToken(username, password) Object.entries({ access: data.accessToken, refresh: data.refreshToken, username, }).map(([k, v]: [string, string]) => localStorage.setItem(k, v)) this.token = data.accessToken this.name = username await this.getUserInfo() useMessageStore().$reset() }, async getUserInfo() { const { data: user } = await getUser(this.id) const { groups } = user // roles must be a non-empty array if (!groups || groups.length <= 0) { throw Error('getUserInfo: roles must be a non-null array!') } const roles: Role[] = [] for (const id of groups) { roles.push((await getGroup(id)).data.name) } this.roles = roles localStorage.setItem('id', user.id.toString()) this.id = user.id }, logOut() { ;['access', 'refresh', 'username'].forEach((k) => localStorage.removeItem(k), ) this.$reset() }, async refreshToken() { const response = await refreshToken(localStorage.getItem('refresh')!) localStorage.setItem('access', response.data.accessToken) localStorage.setItem('fresh', response.data.refreshToken) this.token = response.data.accessToken return response }, }, }) ================================================ FILE: src/utils/date.ts ================================================ const utcOffset = new Date().getTimezoneOffset() * 60000 function localeISOString(d?: Date | string | number): string { return new Date((d ? new Date(d) : new Date()).getTime() - utcOffset) .toISOString() .slice(0, -1) } export function localeISODateString(d?: Date | string | number): string { return localeISOString(d).slice(0, 10) } /** * @param t1 - minuend * @param t2 - subtrahend * @return t1 - t2 */ export function deltaTime(t1: string | Date, t2: string | Date) { return new Date(t1).getTime() - new Date(t2).getTime() } export function formatTime(time: string | Date) { return new Date(time).toLocaleString('zh-CN', { hour12: false }) } ================================================ FILE: src/utils/permission.ts ================================================ import type { Role } from '@/api/users' export function isPermitted( allowedRoles: Role[], roles: Role[] = useUserStore().roles, ): boolean { if (roles.some((role) => allowedRoles.includes(role))) { return true } else { for (const role of roles) { for (const allowRole of allowedRoles) { if (isSubGroup(allowRole, role)) { return true } } } } return false } /** * So far, we don't have two roles having intersection but no one is subset * of the other. All these roles form a chain like: A ∈ B ∈ C ∈ D. For example, * admin role has all authority of staff, staff have all authority of guest. * * If one day there are two roles A and B in a chain in this situation: A, B both have * authority of action 1, A has authority of action 2 but B, B is accessible to * action 3 but A, they would never form a chain, so the chain should be corrected. * * TODO: correct the chain if there is the situation above. * */ const rolesChain: { [k in Role]: number } = { superuser: 100, admin: 90, staff: 60, } export function isSubGroup(role1: Role, role2: Role): boolean { return rolesChain[role2] >= rolesChain[role1] } ================================================ FILE: src/utils/request.ts ================================================ import axios, { type AxiosError } from 'axios' const service = axios.create({ baseURL: import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.hostname}:9529/api/v1`, timeout: 5000, }) const errHandler = async (error: AxiosError) => { const response = error.response const userStore = useUserStore() if (response) { switch (response.status) { case 401: // TODO: refresh token according to your backend // if (userStore.token) { // return userStore.refreshToken().then((resp) => { // return service(error.response!.config) // }) // } break } if (!response.headers['content-type']?.includes('text/html')) { throw response.data } } throw error } // Request interceptors service.interceptors.request.use( async (config) => { // Add X-Access-Token header to every request, you can add other custom headers here const token = useUserStore().token if (token) { config.headers!.Authorization = `Bearer ${token}` } return config }, // Add Error Handler Below ) // Response interceptors service.interceptors.response.use((response) => { return response }, errHandler) export default service ================================================ FILE: src/utils/string.ts ================================================ export function filename(path: string) { return path .split(/(\\|\/)/g) .pop()! .replace(/\.[^/.]+$/, '') } ================================================ FILE: src/utils/types.ts ================================================ import type Vue from 'vue' export type VForm = typeof Vue & { validate: () => boolean resetValidation: () => boolean reset: () => void } export type InstallPlugin = (vue: typeof Vue) => any ================================================ FILE: test/helpers.ts ================================================ import { createLocalVue, mount, shallowMount } from '@vue/test-utils' import Vuetify from 'vuetify/lib' import { PiniaVuePlugin } from 'pinia' import { createTestingPinia } from '@pinia/testing' import { render } from '@testing-library/vue' import Router from 'vue-router' import Vue from 'vue' import { install as installI18n } from '@/plugins/i18n' import { install as installComponents } from '@/plugins/components' export function mountComposable(composable: () => T) { let result: T | undefined const app = new Vue({ pinia: createTestingPinia(), setup() { result = composable() return () => {} }, }) Vue.use(PiniaVuePlugin) app.$mount(document.createElement('div')) return { composable: result!, vm: app, } } export function createWrapper( component: Parameters[0], options: Parameters[1] = {}, shallow = false, ) { const localVue = createLocalVue() installComponents(localVue) const i18n = installI18n(localVue) const vuetify = new Vuetify() const mountOptions = { vuetify, localVue, i18n, ...options } if (!shallow) { return mount(component, mountOptions) } else { return shallowMount(component, mountOptions) } } export function renderWithVuetify( component: Parameters[0], options: Parameters[1] = {}, ) { const root = document.createElement('div') root.setAttribute('data-app', 'true') return render( component, { container: document.body.appendChild(root), vuetify: new Vuetify(), pinia: createTestingPinia(), router: new Router(), stubs: ['Portal'], ...options, }, (vue) => { installComponents(vue) const i18n = installI18n(vue) vue.use(PiniaVuePlugin) return { i18n } }, ) } ================================================ FILE: test/vitest.setup.ts ================================================ import Vue from 'vue' Vue.config.devtools = false Vue.config.productionTip = false // import vuetify after suppressing devtools warning const Vuetify = (await import('vuetify/lib')).default Vue.use(Vuetify) // mock window.matchMedia which not implemented by jsdom Object.defineProperty(window, 'matchMedia', { writable: true, value: vi.fn().mockImplementation((query) => ({ matches: false, media: query, onchange: null, addListener: vi.fn(), // deprecated removeListener: vi.fn(), // deprecated addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), }) ================================================ FILE: tsconfig.app.json ================================================ { "compilerOptions": { "baseUrl": "./", "target": "esnext", "useDefineForClassFields": true, "module": "esnext", "moduleResolution": "Bundler", "strict": true, "jsx": "preserve", "sourceMap": true, "resolveJsonModule": true, "esModuleInterop": true, "isolatedModules": true, "verbatimModuleSyntax": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "paths": { "@/*": ["src/*"] }, // No `ScriptHost` as dropping support for IE "lib": ["esnext", "DOM", "DOM.iterable"], "types": [], "skipLibCheck": true }, "vueCompilerOptions": { "target": 2.7 }, "include": ["src/**/*", "src/**/*.vue"], "exclude": ["src/**/__tests__/*"] } ================================================ FILE: tsconfig.cypress.json ================================================ { "extends": "./tsconfig.app.json", "compilerOptions": { "isolatedModules": false, "types": ["cypress"] }, "include": ["./cypress/**/*.ts"], "exclude": [] } ================================================ FILE: tsconfig.json ================================================ { "files": [], "references": [ { "path": "./tsconfig.node.json" }, { "path": "./tsconfig.vitest.json" }, { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.cypress.json" } ], "ts-node": { "transpileOnly": true, "compilerOptions": { "module": "ESNext", "lib": ["es2023"], "target": "es2022" } } } ================================================ FILE: tsconfig.node.json ================================================ { "extends": "./tsconfig.app.json", "include": ["vite.config.*"], "compilerOptions": { "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "types": ["node", "vitest"], "lib": [] } } ================================================ FILE: tsconfig.vitest.json ================================================ { "extends": "./tsconfig.app.json", "include": ["./src/**/*", "env.d.ts", "./test/*"], "exclude": [], "compilerOptions": { "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo", "types": ["node", "jsdom", "vitest/globals"] } } ================================================ FILE: vite.config.preview.ts ================================================ import { defineConfig } from 'vite' // The more plugins, the slower the startup of `vite preview` // this file is for instant preview with an empty config export default defineConfig({ preview: {}, }) ================================================ FILE: vite.config.ts ================================================ import path from 'path' import { defineConfig } from 'vite' import vue2 from '@vitejs/plugin-vue2' import legacy from '@vitejs/plugin-legacy' import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { createSvgPlugin } from '@kingyue/vite-plugin-vue2-svg' import Pages from 'vite-plugin-pages' import Layouts from 'vite-plugin-vue-layouts' import Inspect from 'vite-plugin-inspect' import VueI18n from '@intlify/unplugin-vue-i18n/vite' import browserslistToEsbuild from 'browserslist-to-esbuild' import postcssPresetEnv from 'postcss-preset-env' import regexpPlugin from 'rollup-plugin-regexp' import * as mdicons from '@mdi/js' import browserslist from 'browserslist' const mdi: Record = {} Object.keys(mdicons).forEach((key) => { const value = (mdicons as Record)[key] mdi[ key .replace(/([A-Z])/g, '-$1') .toLowerCase() .replace(/([0-9]+)/g, '-$1') ] = value }) // https://vitejs.dev/config/ export default defineConfig({ build: { target: browserslistToEsbuild() }, server: { port: 9527, }, plugins: [ regexpPlugin({ exclude: ['node_modules/**'], find: /\b(? { if (mdi[match]) { return mdi[match] } else { console.warn('[plugin-regexp] No matched svg icon for ' + match) return match } }, sourcemap: false, }), vue2(), Pages(), Layouts(), legacy({ modernPolyfills: true, renderLegacyChunks: false, modernTargets: browserslist.loadConfig({ path: path.resolve(__dirname), }), }), Components({ resolvers: [ { type: 'component', resolve: (name) => { const blackList = ['VChart', 'VHeadCard'] if (name.match(/^V[A-Z]/) && !blackList.includes(name)) return { name, from: 'vuetify/lib' } }, }, ], dirs: [], dts: false, types: [], }), AutoImport({ imports: [ 'vue', 'pinia', 'vue-router/composables', { 'vue-i18n-bridge': ['useI18n'] }, ], dts: 'src/auto-imports.d.ts', dirs: ['src/stores'], vueTemplate: false, }), createSvgPlugin({ svgoConfig: { plugins: [ 'cleanupEnableBackground', 'removeDoctype', 'removeMetadata', 'removeComments', 'removeXMLNS', 'removeXMLProcInst', 'sortDefsChildren', 'convertTransform', ], }, }), VueI18n({ runtimeOnly: false, compositionOnly: true, fullInstall: false, include: [path.resolve(__dirname, 'src/locales/**')], }), Inspect(), ], css: { devSourcemap: true, preprocessorMaxWorkers: true, // https://vitejs.dev/config/#css-preprocessoroptions preprocessorOptions: { sass: { additionalData: [ // vuetify variable overrides '@import "@/assets/styles/vuetify-variables.scss"', '', ].join('\n'), }, }, postcss: { plugins: [postcssPresetEnv({ stage: 3 })], }, }, resolve: { alias: { '@': path.resolve(__dirname, 'src'), 'vue-i18n-bridge': 'vue-i18n-bridge/dist/vue-i18n-bridge.runtime.esm-bundler.js', }, }, test: { globals: true, include: ['test/**/*.test.ts', 'src/**/__tests__/*'], environment: 'jsdom', setupFiles: ['./test/vitest.setup.ts'], onConsoleLog(log) { /* Suppress EOL warning from vue-i18n */ if (log.startsWith('vue-i18n-bridge v10')) return false }, }, })