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_internals>/**", "**/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
================================================
<p align="center">
<img alt="Vitify - Opinionated Vuetify Admin Starter Template" src="public/favicon.svg" width=200px/>
</p>
<h1 align="center">Vitify Admin</h1>
<p align="center">
<a href="https://github.com/vuejs/vue">
<img src="https://img.shields.io/badge/vue-2.7.16-brightgreen.svg" alt="vue">
</a>
<a href="https://github.com/vuetifyjs/vuetify">
<img src="https://img.shields.io/badge/vuetify-2.7.2-blue.svg" alt="vuetify">
</a>
<a href="https://github.com/kingyue737/vitify-admin/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/mashape/apistatus.svg" alt="license">
</a>
</p>
<p align='center'>
<b>Vite</b> + <b>Vuetify</b>, Opinionated Admin Starter Template<br><br>
</p>
<p align='center'>
<a href="https://vitify-admin.netlify.app/">Live Demo<br><br></a>
<a href="https://kingyue737.github.io/vitify-docs/">Documentation<br><br></a>
</p>
## 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 `<script setup>`
- ⚡️ [Vite](https://github.com/vitejs/vite), [pnpm](https://pnpm.io/), [ESBuild](https://github.com/evanw/esbuild) - born with fastness
- 🗂️ [File based routing](./src/pages)
- 📑 [Layout system](./src/layouts)
- 🍍 [State Management via Pinia](https://pinia.vuejs.org/)
- 🌍 [I18n ready](./locales)
- 📥 [APIs auto importing](https://github.com/antfu/unplugin-auto-import) - use Composition API and others directly
- ☁️ Deploy on [Netlify](https://www.netlify.com/), zero-config
- 🧪 Unit/Component Testing with [Vitest](https://github.com/vitest-dev/vitest) + [Testing Library](https://github.com/testing-library/vue-testing-library), E2E Testing with [Cypress](https://cypress.io/) on [GitHub Actions](https://github.com/features/actions)
<br>
### Admin Starter Template
- 🪟 Layout with drawer, header, footer(status bar) and login page
- 🧭 Auto generated navigation drawer and breadcrumbs based on routes
- 🤡 Mock API in dev and testing with [Mock Service Worker](https://github.com/mswjs/msw)
- 🔔 Notification store
- 🧑💼 Route authority based on user role
- 📉 Data visualization with [vue-echarts](https://github.com/ecomfe/vue-echarts)
- 🔗 Communicate with backend with REST API powered by [axios](https://github.com/axios/axios)
- 🎨 Theme color customization and dark mode
- 📱 Responsive layout
## Pre-packed
### UI Frameworks
- [Vuetify 2](https://vuetifyjs.com/) - Material Design Framework
### Plugins
- [Vue Router](https://github.com/vuejs/vue-router)
- [`vite-plugin-pages`](https://github.com/hannoeru/vite-plugin-pages) - File system based routing
- [`vite-plugin-vue-layouts`](https://github.com/JohnCampionJr/vite-plugin-vue-layouts) - Layouts for pages
- [Pinia](https://pinia.esm.dev) - Intuitive, type safe, light and flexible Store for Vue using the Composition API
- [`unplugin-vue-components`](https://github.com/antfu/unplugin-vue-components) - Auto import Vuetify 2 components
- [`unplugin-auto-import`](https://github.com/antfu/unplugin-auto-import) - Directly use Vue Composition API and others without importing
- [PortalVue](https://github.com/linusborg/portal-vue) - Use [`<Teleport>`](https://vuejs.org/guide/built-ins/teleport.html) of Vue 3 in Vue 2
- [Vue I18n](https://github.com/intlify/vue-i18n-next) - Internationalization
- [`vue-i18n-bridge`](https://github.com/intlify/vue-i18n-next/tree/master/packages/vue-i18n-bridge#readme) - Backport Composition API and message format syntax to Vue 2
- [`unplugin-vue-i18n`](https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n) - Prebundle Vue I18n messages and support SFC i18n custom block
- [VueUse](https://github.com/antfu/vueuse) - Collection of useful composition APIs
- [Mock Service Worker](https://github.com/mswjs/msw) - Seamless REST/GraphQL API mocking library for browser and Node.js
- [`vite-plugin-vue2-svg`](https://github.com/pakholeung37/vite-plugin-vue2-svg) - Load SVG files as Vue components, and auto register as Vuetify `v-icon`s
### Compatibility
- [`@vitejs/plugin-legacy`](https://github.com/vitejs/vite/tree/main/packages/plugin-legacy) - Generate polyfills with `@babel/preset-env` in production bundle
- [`postcss-preset-env`](https://github.com/csstools/postcss-plugins/tree/main/plugin-packs/postcss-preset-env) - Convert modern CSS into what most browsers understand, determining polyfills based on `browserslist`
### Coding Style
- [Prettier](https://prettier.io/), single quotes, no semi
- [ESLint](https://eslint.org/) with Flat Config
### Dev tools
- [TypeScript](https://www.typescriptlang.org/)
- [Vitest](https://github.com/vitest-dev/vitest) - Unit testing powered by Vite
- [Cypress](https://cypress.io/) - E2E testing
- [pnpm](https://pnpm.js.org/) - Fast, disk space efficient package manager
- [Netlify](https://www.netlify.com/) - zero-config deployment
- [VS Code Extensions](./.vscode/extensions.json)
- [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) - TypeScript support inside Vue SFCs
- [i18n Ally](https://marketplace.visualstudio.com/items?itemName=lokalise.i18n-ally) - All in one i18n support
- [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) - Find and fix problems in your code
- [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) - Code formatter
- [EditorConfig for VS Code](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig)
- [Material Design Icons Intellisense](https://marketplace.visualstudio.com/items?itemName=lukas-tr.materialdesignicons-intellisense)
## Try it now!
> Vitify Admin requires Node >=16.6.0
### GitHub Template
[Create a repo from this template on GitHub](https://github.com/kingyue737/vitify-admin/generate).
### Clone to local
If you prefer to do it manually with the cleaner git history
```bash
npx degit kingyue737/vitify-admin my-vitify-app
cd my-vitify-app
pnpm i
```
> Vitify Admin requires [`pnpm patch`](https://pnpm.io/cli/patch) for bug fixing in dependencies before maintainers release them. If you are using `yarn`, you can use [`yarn patch`](https://yarnpkg.com/cli/patch). For `npm` users, [`patch-package`](https://github.com/ds300/patch-package) is required as `npm` has no built-in patching functionality.
## Checklist
When you use this template, try follow the checklist to update your info properly
- [ ] Change the author name in `LICENSE`
- [ ] Change the title in `index.html`, navigation drawer and login page
- [ ] Change the hostname in `vite.config.ts`
- [ ] Change the favicon in `public`
- [ ] Clean up the `README` and remove routes
- [ ] Change the copyright in navigation drawer and login page
- [ ] Change default locale of `vue-i18n`
- [ ] Change or remove [Cypress Cloud](https://cloud.cypress.io/) related ID in [ci.yml](https://github.com/kingyue737/vitify-admin/blob/main/.github/workflows/ci.yml)
And, enjoy :)
## Usage
### Development
Just run and visit http://localhost:9527
```bash
pnpm dev
```
### Build
To build the App, run
```bash
pnpm build
```
And you will see the generated file in `dist` that ready to be served.
### Type Check
```
pnpm typecheck
```
### Testing
```
pnpm test:unit
```
For E2E test, you need to build the project first
```
pnpm build
pnpm test:e2e
```
### Record on Cypress Cloud
Go to [Cypress Cloud](https://cloud.cypress.io/), create a new project and add its `projectId` as `${CYPRESS_PROJECT_ID}`, its `record key` as `$CYPRESS_RECORD_KEY` in your repositry secrets (https://github.com/your-name/project-name/settings/secrets/actions).
If you don't want to use Cypress Cloud, remove `record: true` and the entire `env` block from [`.github/workflows/ci.yml`](https://github.com/kingyue737/vitify-admin/blob/main/.github/workflows/ci.yml):
```yml
- name: Cypress
uses: cypress-io/github-action@v4
with:
install-command: echo
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 }}
```
### Deploy on Netlify
Go to [Netlify](https://app.netlify.com/start) and select your clone, `OK` along the way, and your App will be live in a minute.
### Documentation
The [documentation](https://kingyue737.github.io/vitify-docs/) of this template is powered by [VitePress](https://vitepress.vuejs.org/) and [DocSearch](https://docsearch.algolia.com/)
Repo: https://github.com/kingyue737/vitify-docs
### Acknowledgement
Inspired by [vitesse](https://github.com/antfu/vitesse) and [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin) 💖. Thanks for every developer for making frontend community better.
I made this starter template for promptly scaffolding admin projects of my company, along with some good practices I've learned during making these apps.
Currently, plenty of awesome Vue 2 librarys have not migrated to Vue 3 ecosystem, maybe never 😭. There is still a [long way to go](https://vuetifyjs.com/en/introduction/roadmap/#in-development) before Vuetify 3 includes all the features of Vuetify 2. So I struggle with bridging perfect DX of Vue 3 to my Vuetify 2 projects.
It's strongly opinionated, but hope it can help you to avoid detours.
Don't hesitate to open an issue or a discussion if you meet any problem.
================================================
FILE: cypress/e2e/example.spec.ts
================================================
describe('Example Test', () => {
it('login', () => {
cy.visit('/')
cy.url().should('eq', 'http://localhost:5050/login')
cy.contains('Vitify').should('exist')
cy.contains('label', 'Username')
.invoke('attr', 'for')
.then((id) => {
cy.get('#' + id)
})
.type('your-username')
cy.contains('label', 'Password')
.invoke('attr', 'for')
.then((id) => {
cy.get('#' + id)
})
.type('your-password{Enter}')
cy.url().should('eq', 'http://localhost:5050/homepage')
})
})
================================================
FILE: cypress.config.ts
================================================
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:5050',
viewportWidth: 1280,
viewportHeight: 900,
chromeWebSecurity: false,
specPattern: 'cypress/e2e/**/*.spec.*',
supportFile: false,
},
})
================================================
FILE: eslint.config.js
================================================
import { includeIgnoreFile } from '@eslint/compat'
import js from '@eslint/js'
import eslintPluginVue from 'eslint-plugin-vue'
import ts from 'typescript-eslint'
import eslintConfigPrettier from 'eslint-config-prettier'
import pluginCypress from 'eslint-plugin-cypress/flat'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const gitignorePath = path.resolve(__dirname, '.gitignore')
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...eslintPluginVue.configs['flat/vue2-recommended'],
{
files: ['*.vue', '**/*.vue'],
languageOptions: {
parserOptions: {
parser: '@typescript-eslint/parser',
},
},
},
pluginCypress.configs.recommended,
eslintConfigPrettier,
{
rules: {
'no-undef': 'off',
'vue/multi-word-component-names': 'off',
'vue/valid-v-slot': ['error', { allowModifiers: true }], // allow vuetify slot modifier
'vue/html-self-closing': ['error', { html: { void: 'any' } }], // not conflict with prettier
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
},
{ ignores: ['public/mockServiceWorker.js'] },
)
================================================
FILE: index.html
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Vitify Admin</title>
<link rel="icon" type="image/svg+xml" as="image" href="/favicon.svg" />
<link rel="short icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
================================================
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
================================================
<script setup lang="ts">
import { THEME_KEY, INIT_OPTIONS_KEY, UPDATE_OPTIONS_KEY } from 'vue-echarts'
import { useVuetify } from './composables/useVuetify'
const vuetify = useVuetify()
const { locale } = useI18n()
provide(
THEME_KEY,
computed(() => (vuetify?.theme.dark ? 'dark' : undefined)),
)
provide(
INIT_OPTIONS_KEY,
computed(() => ({ locale: locale.value.toUpperCase() })),
)
provide(UPDATE_OPTIONS_KEY, { notMerge: false })
</script>
<template>
<v-app>
<PortalTarget name="app" class="d-contents" />
<v-fade-transition mode="out-in">
<router-view />
</v-fade-transition>
</v-app>
</template>
================================================
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<IUserData[]>('/users')
export const getUser = (userId: number) =>
service.get<IUserData>(`/users/${userId}`)
export const createUser = (user: IUserData) => service.post('/users', user)
export const updateUser = (user: Partial<IUserData>) =>
service.patch(`/users/${user.id}`, user)
export const deleteUser = (userId: number) => service.delete(`/users/${userId}`)
export const getToken = (username: string, password: string) =>
service.post<Token>(
'/auth/access-token',
new URLSearchParams({ username, password }),
)
export const refreshToken = (refreshToken: string) =>
service.post<Token>('/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<Group>(`/groups/${id}`)
export const getGroups = () => service.get<Group[]>(`/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
================================================
<script lang="ts">
export default defineComponent({
setup() {
const { t } = useI18n()
return { t }
},
data: () => ({
dialog: false,
confirmed: false,
resolve: (confirmed: boolean) => {},
reject: (val: unknown) => {},
message: '',
}),
watch: {
dialog(value) {
if (value === false) {
this.resolve(this.confirmed)
}
},
},
methods: {
open(message: string) {
this.confirmed = false
this.dialog = true
this.message = message
return new Promise<boolean>((resolve, reject) => {
this.resolve = resolve
this.reject = reject
})
},
confirm() {
this.confirmed = true
this.dialog = false
},
cancel() {
this.confirmed = false
this.dialog = false
},
},
})
</script>
<template>
<v-dialog v-model="dialog" max-width="400px">
<v-card style="z-index: -1">
<v-card-title class="font-weight-bold d-flex justify-center">
<v-icon class="mr-2" color="warning">$warning</v-icon>
<span style="line-height: 24px">{{ message }}</span>
</v-card-title>
<v-card-actions>
<v-spacer />
<v-btn color="primary darken-1" text @click="cancel">{{
t('cancel')
}}</v-btn>
<v-btn color="primary darken-1" text @click="confirm">{{
t('confirm')
}}</v-btn>
<v-spacer />
</v-card-actions>
</v-card>
</v-dialog>
</template>
================================================
FILE: src/components/StatsCard.vue
================================================
<script setup lang="ts">
withDefaults(
defineProps<{
icon: string
iconClass?: string
color: string
title: string
value: number | null
unit?: string
formatter?: (v: number) => string
}>(),
{
iconClass: '',
value: null,
unit: '',
formatter: (v: number) => v.toString(),
},
)
</script>
<template>
<v-card class="stats-card" v-bind="$attrs" v-on="$listeners">
<v-icon class="stats-icon" :color="color" :class="iconClass">{{
icon
}}</v-icon>
<div class="card-title ml-auto text-right">
<span
class="card-title--name font-weight-bold text--darken-2"
:class="`${color}--text`"
v-text="title"
/>
<h3
class="font-weight-regular text--primary d-inline-block ml-2"
style="font-size: 18px"
>
{{ value != null ? formatter(value) : '' }}
<small v-if="unit">{{ unit }}</small>
</h3>
<v-divider />
</div>
<div
class="v-alert__border v-alert__border--top v-alert__border--has-color"
:class="color"
/>
<div
v-if="$slots.footer"
class="grey--text text-right stats-footer text-caption"
>
<slot name="footer" />
</div>
</v-card>
</template>
<style lang="scss" scoped>
.stats-card {
padding: 5px;
padding-top: 10px;
.card-title {
width: fit-content;
.card-title--name {
display: inline-block;
backdrop-filter: blur(3px);
}
}
.caption {
font-size: 12px;
letter-spacing: 0;
}
.stats-icon {
position: absolute;
opacity: 0.3;
:deep(svg) {
height: 35px;
}
}
.stats-footer {
:deep(span) {
display: inline-block;
font-size: 12px !important;
letter-spacing: 0 !important;
}
}
}
</style>
================================================
FILE: src/components/VHeadCard.vue
================================================
<script setup lang="ts">
defineProps({
color: {
type: String,
default: 'primary',
},
icon: {
type: String,
default: undefined,
},
text: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
iconClass: {
type: String,
default: '',
},
})
</script>
<template>
<v-card
v-bind="$attrs"
class="v-head-card pa-3 d-flex flex-column justify-space-between"
>
<div class="d-flex justify-start">
<v-sheet
:color="color"
:height="icon ? 72 : undefined"
elevation="3"
class="text-center v-head-card__heading mb-n5 pa-5"
dark
>
<slot v-if="$slots.heading" name="heading" />
<div
v-else-if="title && !icon"
class="text-h5 font-weight-bold"
v-text="title"
/>
<v-icon v-else-if="icon" size="32" :class="iconClass">
{{ icon }}
</v-icon>
<div v-if="text" class="text-h5 font-weight-thin" v-text="text" />
</v-sheet>
<div
v-if="icon && title"
class="ml-2 text-h5 font-weight-light v-head-card__title"
v-text="title"
/>
<div
v-if="$slots['after-heading']"
id="after-heading"
class="ml-auto d-flex align-center justify-space-between"
style="max-width: 80%"
>
<slot name="after-heading" />
</div>
</div>
<div
class="flex-grow-1 v-head-card__content fill-height"
style="position: relative; min-height: 0"
>
<slot />
</div>
<template v-if="$slots.actions">
<v-card-actions class="pb-0 px-0 flex-wrap">
<slot name="actions" />
</v-card-actions>
</template>
</v-card>
</template>
<style lang="scss" scoped>
@import '@/assets/styles/variables.scss';
.v-head-card {
margin-top: $card-heading-margin;
max-height: calc(100% - #{$card-heading-margin});
&__title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.fill-height {
height: calc(100% - #{$card-heading-margin});
}
&__heading {
position: relative;
top: -22px;
transition: 0.3s ease;
z-index: 3;
border-radius: 4px;
}
:deep(#after-heading) > * {
margin-left: 1em;
}
}
</style>
================================================
FILE: src/components/demo-charts/ChartBar.vue
================================================
<script setup lang="ts">
import type { ECOption } from '@/plugins/echarts'
const option: ECOption = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
grid: {
top: 10,
left: '2%',
right: '2%',
bottom: '3%',
containLabel: true,
},
xAxis: [
{
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
axisTick: {
alignWithLabel: true,
},
},
],
yAxis: [
{
type: 'value',
axisTick: {
show: false,
},
},
],
series: [
{
name: 'pageA',
type: 'bar',
stack: 'vistors',
barWidth: '60%',
data: [79, 52, 200, 334, 390, 330, 220],
},
{
name: 'pageB',
type: 'bar',
stack: 'vistors',
barWidth: '60%',
data: [80, 52, 200, 334, 390, 330, 220],
},
{
name: 'pageC',
type: 'bar',
stack: 'vistors',
barWidth: '60%',
data: [30, 52, 200, 334, 390, 330, 220],
},
],
}
</script>
<template>
<v-chart :option="option" autoresize />
</template>
================================================
FILE: src/components/demo-charts/ChartLine.vue
================================================
<script setup lang="ts">
import type { ECOption } from '@/plugins/echarts'
const data = [
['2022-06-05', 116],
['2022-06-06', 129],
['2022-06-07', 135],
['2022-06-08', 86],
['2022-06-09', 73],
['2022-06-10', 85],
['2022-06-11', 73],
['2022-06-12', 68],
['2022-06-13', 92],
['2022-06-14', 130],
['2022-06-15', 245],
['2022-06-16', 139],
['2022-06-17', 115],
['2022-06-18', 111],
['2022-06-19', 309],
['2022-06-20', 206],
['2022-06-21', 137],
['2022-06-22', 128],
['2022-06-23', 85],
['2022-06-24', 94],
['2022-06-25', 71],
['2022-06-26', 106],
['2022-06-27', 84],
['2022-06-28', 93],
['2022-06-29', 85],
['2022-06-30', 73],
['2022-07-01', 83],
['2022-07-02', 125],
['2022-07-03', 107],
['2022-07-04', 82],
['2022-07-05', 44],
['2022-07-06', 72],
['2022-07-07', 106],
['2022-07-08', 107],
['2022-07-09', 66],
['2022-07-10', 91],
['2022-07-11', 92],
['2022-07-12', 113],
['2022-07-13', 107],
['2022-07-14', 131],
['2022-07-15', 111],
['2022-07-16', 64],
['2022-07-17', 69],
['2022-07-18', 88],
['2022-07-19', 77],
['2022-07-20', 83],
['2022-07-21', 111],
['2022-07-22', 57],
['2022-07-23', 55],
['2022-07-24', 60],
]
const option: ECOption = {
backgroundColor: 'transparent',
dataset: { source: data },
visualMap: {
show: false,
type: 'continuous',
min: 0,
max: 400,
},
tooltip: {
trigger: 'axis',
},
grid: {
top: 10,
left: '2%',
right: '2%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'time',
},
yAxis: {
type: 'value',
},
series: [
{
name: 'value',
type: 'line',
showSymbol: false,
lineStyle: {
width: 4,
},
},
],
}
</script>
<template>
<v-chart :option="option" autoresize />
</template>
================================================
FILE: src/components/demo-charts/ChartPie.vue
================================================
<script setup lang="ts">
import type { ECOption } from '@/plugins/echarts'
const option: ECOption = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
},
legend: {
left: 'center',
bottom: '10',
data: ['Industries', 'Technology', 'Forex', 'Gold', 'Forecasts'],
},
series: [
{
name: 'WEEKLY WRITE ARTICLES',
type: 'pie',
roseType: 'radius',
radius: [15, 95],
center: ['50%', '38%'],
data: [
{ value: 320, name: 'Industries' },
{ value: 240, name: 'Technology' },
{ value: 149, name: 'Forex' },
{ value: 100, name: 'Gold' },
{ value: 59, name: 'Forecasts' },
],
animationEasing: 'cubicInOut',
},
],
}
</script>
<template>
<v-chart :option="option" autoresize />
</template>
================================================
FILE: src/components/demo-charts/ChartRadar.vue
================================================
<script setup lang="ts">
import type { ECOption } from '@/plugins/echarts'
const option: ECOption = {
backgroundColor: 'transparent',
radar: {
radius: '66%',
center: ['50%', '42%'],
splitNumber: 8,
splitArea: {
areaStyle: {
color: 'rgba(127,95,132,.3)',
opacity: 1,
shadowBlur: 45,
shadowColor: 'rgba(0,0,0,.5)',
shadowOffsetX: 0,
shadowOffsetY: 15,
},
},
indicator: [
{ name: 'Sales' },
{ name: 'Administration' },
{ name: 'Technology' },
{ name: 'Customer Support' },
{ name: 'Development' },
{ name: 'Marketing' },
],
},
legend: {
left: 'center',
bottom: '10',
data: ['Allocated Budget', 'Expected Spending', 'Actual Spending'],
},
series: [
{
type: 'radar',
symbolSize: 0,
areaStyle: {
shadowBlur: 13,
shadowColor: 'rgba(0,0,0,.2)',
shadowOffsetX: 0,
shadowOffsetY: 10,
opacity: 1,
},
data: [
{
value: [5000, 7000, 12000, 11000, 15000, 14000],
name: 'Allocated Budget',
},
{
value: [4000, 9000, 15000, 15000, 13000, 11000],
name: 'Expected Spending',
},
{
value: [5500, 5000, 12000, 15000, 8000, 6000],
name: 'Actual Spending',
},
],
},
],
}
</script>
<template>
<v-chart :option="option" autoresize />
</template>
================================================
FILE: src/components/layout/AppBar.vue
================================================
<script setup lang="ts">
import AppBreadcrumbs from './AppBreadcrumbs.vue'
import ButtonFullScreen from './ButtonFullScreen.vue'
import ButtonLocale from './ButtonLocale.vue'
import ButtonUser from './ButtonUser.vue'
const { drawer } = storeToRefs(useAppStore())
</script>
<template>
<v-app-bar app color="transparent" elevate-on-scroll height="60px">
<v-btn elevation="1" fab small style="z-index: 1" @click="drawer = !drawer">
<v-icon>
{{ drawer ? 'mdi-backburger' : 'mdi-menu-open' }}
</v-icon>
</v-btn>
<AppBreadcrumbs />
<v-spacer />
<v-btn
text
min-width="0"
href="https://github.com/kingyue737/vitify-admin"
target="_blank"
>
<v-icon>mdi-github</v-icon>
</v-btn>
<PortalTarget name="app-bar" class="d-contents" />
<ButtonFullScreen />
<ButtonLocale />
<ButtonUser />
</v-app-bar>
</template>
<style lang="scss" scoped>
.v-app-bar {
backdrop-filter: blur(10px);
.v-btn:not(.v-btn--text):not(.v-btn--outlined):focus:before {
opacity: 0;
}
}
@supports not (backdrop-filter: blur(10px)) {
.theme--light .v-app-bar {
background-color: rgba(242, 245, 248, 0.8) !important;
}
.theme--dark .v-app-bar {
background-color: rgba(18, 18, 18, 0.8) !important;
}
}
</style>
================================================
FILE: src/components/layout/AppBreadcrumbs.vue
================================================
<script setup lang="ts">
const route = useRoute()
const { t } = useI18n()
const items = computed(() => {
return route!.matched
.slice(1)
.filter(
(item) =>
item.meta && item.meta.title && !(item.meta?.breadcrumb === 'hidden'),
)
.map((route) => ({
text: t(route.meta.title!),
disabled: route.meta?.breadcrumb === 'disabled' || false,
to: route.path,
}))
})
</script>
<template>
<v-breadcrumbs class="ml-n6 d-none d-sm-block">
<v-slide-x-reverse-transition class="v-breadcrumbs" leave-absolute group>
<v-breadcrumbs-item
v-for="item in items"
:key="item.text.toString()"
:to="item.to"
exact
:disabled="item.disabled"
>
{{ item.text }}
</v-breadcrumbs-item>
</v-slide-x-reverse-transition>
</v-breadcrumbs>
</template>
================================================
FILE: src/components/layout/AppDrawer.vue
================================================
<script setup lang="ts">
import { useVuetify } from '@/composables/useVuetify'
import AppDrawerItem from './AppDrawerItem.vue'
import generatedRoutes from '~pages'
import { isPermitted } from '@/utils/permission'
const appStore = useAppStore()
const {
drawer: drawerStored,
drawerImage,
drawerImageShow,
} = storeToRefs(appStore)
const vuetify = useVuetify()
const drawer = computed({
get() {
return drawerStored.value || !vuetify.breakpoint.mobile
},
set(val: boolean) {
drawerStored.value = val
},
})
const mini = computed(() => !drawerStored.value && !vuetify.breakpoint.mobile)
const gradient = computed(() =>
vuetify.theme.dark
? 'to bottom, rgba(0, 0, 0, .7), rgba(0, 0, 0, .7)'
: 'to bottom, rgba(255, 255, 255, 1), rgba(255, 255, 255, .7)',
)
const groupedRoutes = computed(() =>
Object.values(
generatedRoutes.reduce<Record<string, typeof generatedRoutes>>(
(r, v, i, a, k = v.meta?.drawerGroup || 'PUC') => (
(r[k] || (r[k] = [])).push(v), r
),
{},
),
)
.map((rs) =>
rs
.filter(
(r) => r.meta?.icon && (!r.meta?.roles || isPermitted(r.meta.roles)),
)
.sort(
(a, b) => (a.meta?.drawerIndex ?? 99) - (b.meta?.drawerIndex ?? 98),
),
)
.reverse(),
)
nextTick(() => {
drawerStored.value =
vuetify.breakpoint.lgAndUp && vuetify.breakpoint.width !== 1280
})
</script>
<template>
<v-navigation-drawer
id="app-navigation-drawer"
v-model="drawer"
:expand-on-hover="mini"
:src="drawerImageShow ? drawerImage : ''"
:mini-variant="mini"
app
>
<template #img="props">
<v-img v-show="drawerImageShow" :gradient="gradient" v-bind="props" />
</template>
<template #prepend>
<v-list nav>
<v-list-item class="pa-1">
<v-list-item-icon class="mr-5 my-auto">
<v-icon x-large class="logo-icon" color="primary">$vitify</v-icon>
</v-list-item-icon>
<v-list-item-content class="title-content pa-0">
<v-list-item-title>
Vitify <span class="primary--text">Admin</span>
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
<v-divider />
</template>
<v-list expand dense nav>
<template v-for="(routesInGroup, i) in groupedRoutes">
<v-divider
v-if="routesInGroup.length && i !== 0"
:key="`item-divider-${i}`"
class="mb-1"
/>
<AppDrawerItem
v-for="(route, j) in routesInGroup"
:key="`item-${i}-${j}`"
:data-cy="`drawer-${route.meta ? route.meta.dataCy : ''}`"
:item="route"
/>
</template>
</v-list>
<v-spacer />
<template #append>
<v-list-item
id="drawer-footer"
class="px-0 d-flex flex-column justify-center"
>
<div />
<div class="caption pt-6 pt-md-0 text-center">
© Copyright 2022
<a
href="https://github.com/kingyue737"
class="font-weight-bold"
target="_blank"
>Yue JIN</a
>
<span> & </span>
<a
href="https://www.nustarnuclear.com/"
class="font-weight-bold"
target="_blank"
>NuStar</a
>
</div>
</v-list-item>
</template>
</v-navigation-drawer>
</template>
<style lang="scss">
#app-navigation-drawer {
&.v-navigation-drawer--open-on-hover:not(.v-navigation-drawer--mini-variant) {
box-shadow: 0px 0px 6px 2px rgba(100, 100, 100, 0.6);
}
.v-navigation-drawer__content {
overflow-y: hidden;
&:hover {
overflow-y: overlay;
}
}
.v-list-group__header.v-list-item--active:before {
opacity: 0.24;
}
&.v-navigation-drawer--mini-variant {
.sub-bar-item {
padding-left: 0px !important;
}
.logo-icon {
height: 32px !important;
width: 32px !important;
}
}
.title-content {
.v-list-item__title {
line-height: 1.3;
font-size: 24px;
font-weight: bold;
}
}
#drawer-footer {
min-height: 30px;
div {
white-space: nowrap;
}
&::after {
min-height: 0;
}
}
}
</style>
================================================
FILE: src/components/layout/AppDrawerItem.vue
================================================
<script lang="ts">
import type { RouteConfig } from 'vue-router'
import type { PropType } from 'vue'
import type {} from '@intlify/core-base'
export default defineComponent({
name: 'AppDrawerItem',
props: {
level: {
type: Number,
default: 0,
},
item: {
type: Object as PropType<RouteConfig>,
required: true,
},
},
setup() {
return { t: useI18n().t }
},
computed: {
isItem() {
return !this.item.children || this.visibleChildrenNum <= 1
},
isItemInChild() {
return this.isItem && this.visibleChildrenNum === 1
},
indexItem() {
if (this.item.children) {
return this.item.children[0]
} else {
return this.item
}
},
icon() {
return this.item.meta?.icon || ''
},
title() {
return this.t(this.item.meta?.title || '')
},
subtitle() {
return this.item.meta?.subtitle || ''
},
visibleChildren() {
return this.item.children
?.filter((child) => child.meta?.icon)
.sort(
(a, b) => (a.meta?.drawerIndex ?? 99) - (b.meta?.drawerIndex ?? 98),
)
},
visibleChildrenNum() {
return this.visibleChildren?.length || 0
},
group() {
return (
this.item.path ||
this.item.name ||
this.item.children?.find((v) => !v.path)?.name
)
},
},
})
</script>
<template>
<div v-if="!item.meta || item.meta.icon" :class="level && 'sub-bar-item'">
<v-list-item
v-if="isItem"
:to="{ name: item.name || visibleChildren?.[0].name }"
active-class="primary white--text"
class="mb-1"
>
<v-list-item-icon>
<v-icon>{{ icon }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ title }}</v-list-item-title>
<v-list-item-subtitle>{{ subtitle }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-group v-else :prepend-icon="icon" :group="group">
<template #activator>
<v-list-item-title>{{ title }}</v-list-item-title>
</template>
<AppDrawerItem
v-for="child in visibleChildren"
:key="child.name"
:item="child"
:level="level + 1"
/>
</v-list-group>
</div>
</template>
<style lang="scss" scoped>
.sub-bar-item {
padding-left: 12px;
transition: padding 0.2s;
}
</style>
================================================
FILE: src/components/layout/AppFooter.vue
================================================
<script setup lang="ts">
import ButtonSettings from './ButtonSettings.vue'
import AppMessage from './AppMessage.vue'
import { useNow } from '@vueuse/core'
const { t } = useI18n()
const now = useNow()
</script>
<template>
<v-footer
id="app-footer"
app
inset
padless
height="30"
class="font-weight-light"
>
<v-icon small class="ml-3 mr-1"> mdi-clock-outline </v-icon>
<span>
{{ now.toLocaleString() }}
</span>
<v-spacer />
<v-tooltip top>
<template #activator="{ on }">
<v-btn
text
tile
small
width="40"
href="https://kingyue737.github.io/vitify-docs/"
target="_blank"
v-on="on"
>
<v-icon>mdi-book-outline</v-icon>
<v-icon size="12">mdi-open-in-new</v-icon>
</v-btn>
</template>
<span>{{ t('documentation') }}</span>
</v-tooltip>
<AppMessage />
<ButtonSettings />
<div class="ml-2" />
</v-footer>
</template>
<style lang="scss" scoped>
#app-footer {
user-select: none;
span {
font-size: 14px;
}
> :deep(.v-btn),
> :deep(.d-contents) > .v-btn {
height: 100% !important;
min-width: 0 !important;
width: 30px;
font-size: 14px;
font-weight: 300;
.v-icon svg {
height: 18px;
}
}
}
.theme--light.v-footer {
background-color: #d4dee8;
}
</style>
================================================
FILE: src/components/layout/AppMessage.vue
================================================
<script setup lang="ts">
import AppMessageItem from './AppMessageItem.vue'
import { formatTime } from '@/utils/date'
const { t } = useI18n()
const messageStore = useMessageStore()
const { messages } = storeToRefs(messageStore)
const messagesShown = computed(() =>
messages.value.filter((message) => message.show).reverse(),
)
const showAll = ref(false)
const timeout = ref(5000)
function deleteMessage(id: number) {
messageStore.delMessage(id)
}
function emptyMessages() {
messageStore.$reset()
}
function toggleAll() {
showAll.value = !showAll.value
messages.value.forEach((m) => {
m.show = showAll.value
})
if (showAll.value) {
timeout.value = -1
} else {
timeout.value = 5000
}
}
</script>
<template>
<div class="d-contents">
<v-tooltip top>
<template #activator="{ on }">
<v-btn text tile small @click="toggleAll" v-on="on">
<v-icon>{{
messages.length ? 'mdi-bell-badge-outline' : 'mdi-bell-outline'
}}</v-icon>
</v-btn>
</template>
<span>{{ t('notification') }}</span>
</v-tooltip>
<Portal to="app">
<v-card
elevation="6"
width="400"
class="d-flex flex-column message-card"
:class="{ 'message-card--open': showAll }"
>
<v-toolbar flat dense>
<v-toolbar-title class="font-weight-light text-body-1">{{
t(messages.length ? 'notification' : 'noNew')
}}</v-toolbar-title>
<v-spacer />
<v-btn small icon :title="t('clearAll')" @click="emptyMessages">
<v-icon>mdi-bell-remove</v-icon>
</v-btn>
<v-btn small icon :title="t('hide')" @click="toggleAll">
<v-icon>$expand</v-icon>
</v-btn>
</v-toolbar>
<v-slide-y-reverse-transition
tag="div"
class="d-flex flex-column message-box"
group
hide-on-leave
>
<AppMessageItem
v-for="message in messagesShown"
:key="message.id"
v-model="message.show"
class="message-item"
:colored-border="showAll"
border="left"
:type="message.type"
:timeout="timeout"
dismissible
:elevation="showAll ? 0 : 10"
@close="deleteMessage(message.id)"
>
<small>{{ formatTime(message.time) }}</small>
<div>
{{ message.text }}
</div>
</AppMessageItem>
</v-slide-y-reverse-transition>
</v-card>
</Portal>
</div>
</template>
<style lang="scss" scoped>
@import '@/assets/styles/variables.scss';
.message-item {
width: 100%;
}
.message-card {
position: fixed;
z-index: 210;
right: 15px;
bottom: calc(#{$footer-height} + 5px);
max-height: 100vh;
visibility: hidden;
&.message-card--open {
visibility: visible;
overflow: hidden;
max-height: calc(100vh - #{$footer-height} - #{$app-bar-height} - 10px);
.message-box {
justify-content: initial;
height: auto;
overflow-y: overlay;
pointer-events: auto;
.message-item {
transition: none !important;
margin: 0;
border-radius: 0;
border-top: 1px solid #5656563d !important;
padding-top: 5px;
padding-bottom: 5px;
}
}
}
}
.message-box {
overflow-y: visible;
visibility: visible;
height: calc(100vh - #{$footer-height} - 5px);
justify-content: end;
pointer-events: none;
.message-item {
pointer-events: initial;
user-select: initial;
}
}
:deep(.v-alert__content) {
max-width: 300px;
}
</style>
<i18n lang="yaml">
zh:
noNew: 没有新的通知
clearAll: 清除所有通知
en:
noNew: No New Notifications
clearAll: Clear All Notifications
</i18n>
================================================
FILE: src/components/layout/AppMessageItem.vue
================================================
<script lang="ts">
import { useVModel } from '@vueuse/core'
export default defineComponent({
props: {
value: {
type: Boolean,
default: false,
},
timeout: {
type: Number,
default: 5000,
},
},
emits: ['close'],
setup(props, { emit }) {
const isActive = useVModel(props, undefined, emit)
const timeout = toRef(props, 'timeout')
let activeTimeout: number
const startTimeout = () => {
clearTimeout(activeTimeout)
if (!isActive.value || timeout.value === -1) {
return
}
activeTimeout = window.setTimeout(() => {
isActive.value = false
}, timeout.value)
}
watch([isActive, timeout], startTimeout)
if (isActive.value) {
startTimeout()
}
},
})
</script>
<template>
<v-alert v-bind="$attrs" v-on="$listeners">
<slot />
<template #close>
<v-btn
icon
small
class="v-alert__dismissible align-self-start mt-0"
@click="$emit('close')"
>
<v-icon small>$close</v-icon>
</v-btn>
</template>
</v-alert>
</template>
<style scoped lang="scss"></style>
================================================
FILE: src/components/layout/AppView.vue
================================================
<script setup lang="ts">
import { useVuetify } from '@/composables/useVuetify'
const showGoTop = ref(false)
const vuetify = useVuetify()
const toTop = () => {
vuetify.goTo(0)
}
const onScroll = () => {
showGoTop.value = scrollY > screen.height / 2
}
</script>
<template>
<v-main>
<v-slide-x-transition mode="out-in">
<router-view />
</v-slide-x-transition>
<v-fab-transition>
<v-btn
v-show="showGoTop"
v-scroll="onScroll"
fixed
dark
fab
right
bottom
elevation="12"
color="primary"
style="z-index: 3; bottom: 40px"
@click="toTop"
>
<v-icon>mdi-chevron-up</v-icon>
</v-btn>
</v-fab-transition>
</v-main>
</template>
================================================
FILE: src/components/layout/ButtonFullScreen.vue
================================================
<script setup lang="ts">
import { useFullscreen } from '@vueuse/core'
const { isFullscreen, toggle } = useFullscreen()
const { t } = useI18n()
</script>
<template>
<v-tooltip bottom>
<template #activator="{ on }">
<v-btn text min-width="0" v-on="on" @click="toggle">
<v-icon>
{{ isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen' }}
</v-icon>
</v-btn>
</template>
<span>{{ t('fullscreen') }}</span>
</v-tooltip>
</template>
================================================
FILE: src/components/layout/ButtonLocale.vue
================================================
<script setup lang="ts">
import { useVuetify } from '@/composables/useVuetify'
const { locale, t } = useI18n()
const vuetify = useVuetify()
const setLang = (lang: string) => {
locale.value = lang
vuetify.lang.current = lang
}
</script>
<template>
<v-menu bottom left offset-y origin="top right" transition="scale-transition">
<template #activator="{ attrs, on: menu }">
<v-tooltip bottom>
<template #activator="{ on: toolTip }">
<v-btn
min-width="0"
text
v-bind="attrs"
v-on="{ ...toolTip, ...menu }"
>
<v-icon>mdi-translate</v-icon>
</v-btn>
</template>
<span>{{ t('language') }}</span>
</v-tooltip>
</template>
<v-list>
<v-list-item
:class="{ 'v-list-item--active': locale === 'zh' }"
@click="setLang('zh')"
>
<v-list-item-icon class="mr-2">
<v-icon> mdi-ideogram-cjk-variant </v-icon>
</v-list-item-icon>
<v-list-item-title link> 简体中文 </v-list-item-title>
</v-list-item>
<v-list-item
:class="{ 'v-list-item--active': locale === 'en' }"
@click="setLang('en')"
>
<v-list-item-icon class="mr-2">
<v-icon> mdi-alphabetical-variant </v-icon>
</v-list-item-icon>
<v-list-item-title link> English </v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
================================================
FILE: src/components/layout/ButtonSettings.vue
================================================
<script setup lang="ts">
import { useAppStore } from '@/stores/app'
import { useVuetify } from '@/composables/useVuetify'
import { useDark, useToggle } from '@vueuse/core'
import drawer1 from '@/assets/images/drawer/1.jpg'
import drawer2 from '@/assets/images/drawer/2.jpg'
import drawer3 from '@/assets/images/drawer/3.jpg'
const appStore = useAppStore()
const { t } = useI18n()
const { drawerImage, drawerImageShow } = storeToRefs(appStore)
if (drawerImage.value) {
drawerImage.value = drawer1
}
const vuetify = useVuetify()
const color = computed({
get() {
return vuetify!.theme.themes.light.primary as string
},
set(val: string) {
localStorage.setItem('theme-primary', val)
vuetify!.theme.themes.light.primary = val
vuetify!.theme.themes.dark.primary = val
},
})
const colors = [
['#0096C7', '#ff9800'],
['#4CAF50', '#FF5252'],
['#9C27b0', '#E91E63'],
['#304156', '#3f51b5'],
['#002FA7', '#492d22'],
]
const images = [drawer1, drawer2, drawer3]
const menuShow = ref(false)
const isDark: WritableComputedRef<boolean> = useDark({
onChanged(dark: boolean) {
vuetify.theme.dark = dark
},
})
const toggleDark = useToggle(isDark)
</script>
<template>
<v-menu
v-model="menuShow"
:close-on-content-click="false"
content-class="v-settings"
top
left
origin="right"
nudge-left="5"
nudge-top="5"
offset-y
transition="slide-x-reverse-transition"
>
<template #activator="{ attrs, on: menu }">
<v-tooltip top>
<template #activator="{ on: toolTip }">
<v-btn text tile small v-bind="attrs" v-on="{ ...toolTip, ...menu }">
<v-icon>mdi-palette-outline</v-icon>
</v-btn>
</template>
<span>{{ t('interfaceSettings') }}</span>
</v-tooltip>
</template>
<v-card class="text-center mb-0" width="320">
<v-card-text>
<strong class="mb-3 d-inline-block">{{ t('themeColor') }}</strong>
<v-color-picker v-model="color" show-swatches :swatches="colors" />
<v-divider class="my-3" />
<v-row align="center" no-gutters>
<v-col cols="auto">{{ t('darkMode') }}</v-col>
<v-spacer />
<v-col cols="auto">
<v-switch
:input-value="isDark"
class="ma-0 pa-0"
color="primary"
hide-details
@change="toggleDark"
/>
</v-col>
</v-row>
<v-divider class="my-3" />
<v-row align="center" no-gutters>
<v-col cols="auto">{{ t('drawerBackground') }}</v-col>
<v-spacer />
<v-col cols="auto">
<v-switch
v-model="drawerImageShow"
class="ma-0 pa-0"
color="primary"
hide-details
/>
</v-col>
</v-row>
<v-card :disabled="!drawerImageShow" flat>
<v-item-group
v-model="drawerImage"
class="d-flex justify-space-between my-3 mx-2"
mandatory
>
<v-item v-for="img in images" :key="img" :value="img">
<template #default="{ active, toggle }">
<v-sheet
class="d-inline-block v-settings__item"
:class="active && 'v-settings__item--active'"
@click="toggle"
>
<v-img :src="img" height="100" width="50" />
</v-sheet>
</template>
</v-item>
</v-item-group>
</v-card>
</v-card-text>
</v-card>
</v-menu>
</template>
<style lang="scss" scoped>
.v-settings {
border-radius: 10px;
.theme--dark.v-sheet {
background-color: #272727;
}
&__item {
cursor: pointer;
border-width: 3px;
border-style: solid;
border-color: transparent;
border-radius: 10px;
&--active {
border-color: var(--v-primary-base) !important;
}
&:hover {
border-color: var(--v-primary-lighten2);
}
.v-image {
border-radius: 7px !important;
}
}
}
</style>
================================================
FILE: src/components/layout/ButtonUser.vue
================================================
<script setup lang="ts">
const router = useRouter()
const { t } = useI18n()
const userStore = useUserStore()
const { name } = storeToRefs(userStore)
const logOut = () => {
userStore.logOut()
router.push('/login')
}
</script>
<template>
<v-menu bottom left offset-y origin="top right" transition="scale-transition">
<template #activator="{ attrs, on: menu }">
<v-tooltip bottom>
<template #activator="{ on: toolTip }">
<v-btn
min-width="0"
text
v-bind="attrs"
v-on="{ ...toolTip, ...menu }"
>
<v-icon>mdi-account</v-icon>
</v-btn>
</template>
<span>{{ name }}</span>
</v-tooltip>
</template>
<v-list>
<v-list-item @click="router.push({ name: 'reset-password' })">
<v-list-item-icon class="mr-2">
<v-icon> mdi-key-variant </v-icon>
</v-list-item-icon>
<v-list-item-title link> {{ t('resetPassword') }} </v-list-item-title>
</v-list-item>
<v-list-item @click="logOut">
<v-list-item-icon class="mr-2">
<v-icon> mdi-logout </v-icon>
</v-list-item-icon>
<v-list-item-title link> {{ t('logout') }} </v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
================================================
FILE: src/components/layout/RouterWrapper.vue
================================================
<script setup lang="ts"></script>
<template>
<div class="d-fake">
<v-slide-x-transition mode="out-in">
<router-view />
</v-slide-x-transition>
</div>
</template>
<style scoped></style>
================================================
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
================================================
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pages/client" />
/// <reference types="vite-plugin-vue-layouts/client" />
/// <reference types="vuetify2-component-types" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_MOCK: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
================================================
FILE: src/layouts/default.vue
================================================
<script setup lang="ts">
import AppBar from '@/components/layout/AppBar.vue'
import AppDrawer from '@/components/layout/AppDrawer.vue'
import AppView from '@/components/layout/AppView.vue'
import AppFooter from '@/components/layout/AppFooter.vue'
import { useTitle } from '@vueuse/core'
const route = useRoute()
const { t } = useI18n()
const title = computed(() => {
const title = t(route.meta?.title || route.matched[0].meta?.title || '')
return title ? `${title} | Vitify Admin` : 'Vitify Admin'
})
useTitle(title)
</script>
<template>
<div style="display: contents">
<AppBar />
<AppDrawer />
<AppView />
<AppFooter />
</div>
</template>
================================================
FILE: src/layouts/empty.vue
================================================
<template>
<div style="display: contents">
<router-view />
</div>
</template>
================================================
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
================================================
<script lang="ts">
export default defineComponent({
name: 'ErrorPage',
})
</script>
<template>
<div>
<div class="wrapper">
<v-icon class="logo mb-4">$nustar</v-icon>
<p class="text-h5">404 Not Found</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.wrapper {
position: relative;
top: calc(50vh - 250px);
text-align: center;
opacity: 0.6;
}
.logo {
font-size: 12em;
opacity: 0.2;
}
</style>
================================================
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
================================================
<script setup lang="ts">
import ChartRadar from '@/components/demo-charts/ChartRadar.vue'
import ChartLine from '../components/demo-charts/ChartLine.vue'
import ChartBar from '../components/demo-charts/ChartBar.vue'
import ChartPie from '../components/demo-charts/ChartPie.vue'
import StatsCard from '../components/StatsCard.vue'
const stats = ref([
{
icon: 'mdi-web',
title: 'Bandwidth',
value: 230,
unit: 'GB',
color: 'primary',
caption: 'Up: 100, Down: 130',
},
{
icon: 'mdi-rss',
title: 'Submissions',
value: 108,
color: 'primary',
caption: 'Too young, too naive',
},
{
icon: 'mdi-send',
title: 'Requests',
value: 1238,
color: 'warning',
caption: 'Limit: 1320',
},
{
icon: 'mdi-bell',
title: 'Messages',
value: 9042,
color: 'primary',
caption: 'Warnings: 300, erros: 47',
},
{
icon: 'mdi-github',
title: 'Github Stars',
value: NaN,
color: 'grey',
caption: 'API has no response',
},
{
icon: 'mdi-currency-cny',
title: 'Total Fee',
value: 2300,
unit: '¥',
color: 'error',
caption: 'Upper Limit: 2000 ¥',
},
])
</script>
<template>
<v-container fluid>
<v-row>
<v-col
v-for="stat in stats"
:key="stat.title"
cols="12"
sm="6"
md="4"
lg="2"
>
<StatsCard
:title="stat.title"
:unit="stat.unit"
:color="stat.color"
:icon="stat.icon"
:value="stat.value"
>
<template #footer>
{{ stat.caption }}
</template>
</StatsCard>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6" lg="12">
<v-card class="pa-2">
<ChartLine />
</v-card>
</v-col>
<v-col cols="12" md="6" lg="4">
<v-card class="pa-2">
<ChartRadar />
</v-card>
</v-col>
<v-col cols="12" md="6" lg="4">
<v-card class="pa-2">
<ChartPie />
</v-card>
</v-col>
<v-col cols="12" md="6" lg="4">
<v-card class="pa-2">
<ChartBar />
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<style scoped lang="scss">
.v-card:not(.stats-card) {
height: 350px;
}
</style>
<route lang="json">
{
"meta": {
"title": "dashboard",
"icon": "mdi-monitor-dashboard",
"drawerIndex": 1
}
}
</route>
================================================
FILE: src/pages/homepage.vue
================================================
<script setup lang="ts">
const { t } = useI18n()
const name = ref('')
function sayHi() {
Message.success(`Hi, ${name.value}!`)
}
function warning() {
Message.warning(t('warnMessage', [name.value]))
}
</script>
<template>
<div>
<div class="wrapper">
<v-icon class="logo mb-4">$vitify</v-icon>
<p class="mb-10">{{ t('description') }}</p>
<v-text-field
v-model="name"
placeholder="Hello World"
outlined
:label="t('inputLabel')"
class="input mx-auto"
/>
<v-btn-toggle />
<v-btn :disabled="!name" class="mr-2" color="primary" @click="sayHi">
{{ t('confirm') }}
</v-btn>
<v-btn :disabled="!name" @click="warning">{{ t('cancel') }}</v-btn>
</div>
</div>
</template>
<style lang="scss" scoped>
.wrapper {
position: relative;
top: max(50vh - 300px, 0px);
text-align: center;
opacity: 0.9;
}
.logo {
font-size: 10em;
opacity: 0.2;
}
.input {
width: 300px;
}
</style>
<route lang="json">
{
"meta": {
"title": "homepage",
"icon": "mdi-home",
"drawerIndex": 0
}
}
</route>
<i18n lang="yaml">
en:
description: Opinionated Admin Starter Template
inputLabel: What's your name?
warnMessage: How dare you refuse me, {0}!
zh:
description: 固执己见的后台项目模板
inputLabel: 你的名字?
warnMessage: 你胆敢拒绝我, {0}!
</i18n>
================================================
FILE: src/pages/index.vue
================================================
<template>
<div />
</template>
<route lang="json">
{
"redirect": "homepage"
}
</route>
================================================
FILE: src/pages/login.vue
================================================
<script setup lang="ts">
import type { VForm } from '@/utils/types'
import background from '@/assets/images/drawer/1.jpg'
import ButtonLocale from '@/components/layout/ButtonLocale.vue'
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const form = ref<VForm | null>(null)
const loginShowed = ref(false)
const username = ref('')
const password = ref('')
const waiting = ref(false)
const showPassword = ref(false)
const valid = ref(true)
const snackbar = ref(false)
const snackMessage = ref('')
const timeout = ref(3000)
const nameRules = [
(v: string) => !!v || t('pleaseEnter', [t('username')]),
(v: string) =>
v.length <= 15 ||
t('form.LTE', { input: t('lengthOf', [t('username')]), limit: 15 }),
]
const passwordRules = [(v: string) => !!v || t('pleaseEnter', [t('password')])]
onMounted(() => {
loginShowed.value = true
})
async function onSubmit() {
if (form.value?.validate()) {
try {
waiting.value = true
await userStore.login(username.value, password.value)
await router.push({ path: '/' }).catch(() => {})
} catch (e) {
snackMessage.value = JSON.stringify(e)
snackbar.value = true
} finally {
waiting.value = false
}
}
}
</script>
<template>
<v-main>
<v-img
:src="background"
gradient="to top, rgba(0,0,0,.5), rgba(0,0,0,.5)"
style="min-height: 100vh; height: 100vh"
>
<v-app-bar color="transparent" flat absolute dark>
<v-img
class="mx-2"
src="/favicon.svg"
max-height="40"
max-width="40"
contain
/>
<v-app-bar-title class="font-weight-bold">
Vitify <span class="primary--text text--lighten-1">Admin</span>
</v-app-bar-title>
<v-spacer />
<v-tooltip bottom>
<template #activator="{ on }">
<v-btn
dark
text
min-width="0"
href="https://kingyue737.github.io/vitify-docs/"
target="_blank"
v-on="on"
>
<v-icon>mdi-book-outline</v-icon>
</v-btn>
</template>
<span>{{ t('documentation') }}</span>
</v-tooltip>
<ButtonLocale />
</v-app-bar>
<v-container fill-height>
<v-row align="center" justify="center">
<v-col cols="12">
<v-head-card light class="px-5 py-3 mx-auto login-card mt-0">
<template #heading>
<v-icon>mdi-login</v-icon>
{{ t('userLogin') }}
</template>
<v-expand-transition>
<v-row v-show="loginShowed" align="center" justify="center">
<v-col>
<v-form ref="form" v-model="valid" lazy-validation>
<v-text-field
v-model.trim="username"
:counter="15"
:rules="nameRules"
:label="t('username')"
prepend-icon="mdi-account-outline"
required
@keydown.enter.prevent="onSubmit"
/>
<v-text-field
v-model="password"
prepend-icon="mdi-lock-outline"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
:type="showPassword ? 'text' : 'password'"
:rules="passwordRules"
:label="t('password')"
required
autocomplete="on"
@click:append="showPassword = !showPassword"
@keydown.enter.prevent="onSubmit"
/>
</v-form>
</v-col>
</v-row>
</v-expand-transition>
<template #actions>
<v-btn
id="login-btn"
color="primary mx-auto"
block
:disabled="waiting || !valid"
:loading="waiting"
@click="onSubmit"
>
{{ t('login') }}
</v-btn>
</template>
</v-head-card>
</v-col>
</v-row>
</v-container>
<v-footer absolute color="transparent" dark>
<v-col class="text-center" cols="12">
© Copyright 2022
<a href="https://github.com/kingyue737" target="blank">Yue JIN</a>
<span> & </span>
<a href="http://www.nustarnuclear.com/" target="blank"
><v-icon class="mt-n1">$nustar</v-icon>NuStar Nuclear</a
>
</v-col>
</v-footer>
</v-img>
<v-snackbar v-model="snackbar" color="error" :timeout="timeout">
{{ snackMessage }}
<template #action="{ attrs }">
<v-btn icon small v-bind="attrs" class="ml-4" @click="snackbar = false">
<v-icon>$cancel</v-icon>
</v-btn>
</template>
</v-snackbar>
</v-main>
</template>
<style lang="scss" scoped>
.v-app-bar-title {
:deep(.v-app-bar-title__content) {
text-overflow: clip !important;
}
}
.login-card {
max-width: 300px;
background-color: rgba(255, 255, 255, 0.85) !important;
:deep(.v-head-card__heading) {
width: 100%;
margin-bottom: -12px !important;
padding: 24px !important;
}
}
a {
color: inherit !important;
}
</style>
<route lang="yaml">
meta:
layout: empty
</route>
================================================
FILE: src/pages/nested/menu1.vue
================================================
<script setup lang="ts"></script>
<template>
<v-container fluid>empty page</v-container>
</template>
<route lang="json">
{
"meta": {
"title": "Menu 1",
"icon": "mdi-animation"
}
}
</route>
================================================
FILE: src/pages/nested/menu2/menu2-1.vue
================================================
<script setup lang="ts"></script>
<template>
<v-container fluid>empty page</v-container>
</template>
<route lang="json">
{
"meta": {
"title": "Menu 2-1",
"icon": "mdi-animation"
}
}
</route>
================================================
FILE: src/pages/nested/menu2/menu2-2.vue
================================================
<script setup lang="ts"></script>
<template>
<v-container fluid>empty page</v-container>
</template>
<route lang="json">
{
"meta": {
"title": "Menu 2-2",
"icon": "mdi-animation"
}
}
</route>
================================================
FILE: src/pages/nested/menu2.vue
================================================
<script setup lang="ts">
import RouterWrapper from '../../components/layout/RouterWrapper.vue'
</script>
<template>
<RouterWrapper />
</template>
<route lang="json">
{
"meta": {
"title": "Menu 2",
"icon": "mdi-view-list"
}
}
</route>
================================================
FILE: src/pages/nested.vue
================================================
<script setup lang="ts">
import RouterWrapper from '../components/layout/RouterWrapper.vue'
</script>
<template>
<RouterWrapper />
</template>
<route lang="json">
{
"meta": {
"title": "nestedRoutes",
"icon": "mdi-view-list",
"breadcrumb": "disabled"
}
}
</route>
================================================
FILE: src/pages/reset-password.vue
================================================
<script setup lang="ts">
import { resetPassword } from '@/api/users'
import type { VForm } from '@/utils/types'
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const showCurrent = ref(false)
const showNew = ref(false)
const showConfirm = ref(false)
const confirm = ref('')
const password = ref('')
const current = ref('')
const valid = ref(true)
const rules = [
(v: string) => !!v || t('pleaseEnter', [t('password')]),
(v: string) =>
v.length <= 20 ||
t('form.LTE', { input: t('lengthOf', [t('password')]), limit: 20 }),
(v: string) =>
v.length >= 5 ||
t('form.GTE', { input: t('lengthOf', [t('password')]), limit: 5 }),
]
const confirmedRules = computed(() => [
...rules,
password.value === confirm.value || t('notEqualErr'),
])
const form = ref<VForm | null>(null)
async function submit() {
if (form.value!.validate()) {
try {
await resetPassword(password.value, current.value)
Message.success(t('passwordUpdated'))
userStore.logOut()
router.push({ name: 'login' }).catch()
} catch (e) {
Message.error(e)
}
}
}
</script>
<template>
<v-container fluid fill-height>
<v-row>
<v-col cols="12">
<v-head-card
class="mx-auto"
style="max-width: 350px; position: relative; top: -100px"
>
<template #heading>
<v-icon>mdi-key-variant</v-icon>
{{ t('resetPassword') }}
</template>
<v-container>
<v-form ref="form" v-model="valid" lazy-validation>
<v-text-field
v-model="current"
:label="t('currentPassword')"
:counter="20"
:append-icon="showCurrent ? 'mdi-eye' : 'mdi-eye-off'"
:type="showCurrent ? 'text' : 'password'"
required
:rules="rules"
autocomplete="current-password"
@click:append="showCurrent = !showCurrent"
/>
<v-text-field
v-model="password"
:label="t('newPassword')"
:counter="20"
:append-icon="showNew ? 'mdi-eye' : 'mdi-eye-off'"
:type="showNew ? 'text' : 'password'"
:rules="rules"
autocomplete="off"
required
@click:append="showNew = !showNew"
/>
<v-text-field
v-model="confirm"
:label="t('confirmPassword')"
:counter="20"
:append-icon="showConfirm ? 'mdi-eye' : 'mdi-eye-off'"
:type="showConfirm ? 'text' : 'password'"
:rules="confirmedRules"
autocomplete="off"
required
@click:append="showConfirm = !showConfirm"
/>
</v-form>
</v-container>
<template #actions>
<v-spacer />
<v-btn color="primary" :disabled="!valid" @click="submit">
{{ t('submit') }}
</v-btn>
</template>
</v-head-card>
</v-col>
</v-row>
</v-container>
</template>
<i18n lang="json">
{
"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": "与新密码不匹配,请重新输入"
}
}
</i18n>
================================================
FILE: src/pages/user-manage/[id].vue
================================================
<template>
<v-container fluid>Username: {{ props.id }}</v-container>
</template>
<script setup lang="ts">
const props = defineProps<{ id: string }>()
</script>
<script lang="ts">
export default defineComponent({ name: 'UserDetail' })
</script>
<route lang="json">
{ "meta": { "title": "userDetail", "breadcrumb": "disabled" } }
</route>
================================================
FILE: src/pages/user-manage/index.vue
================================================
<script setup lang="ts">
import {
getUsers,
getGroups,
updateUser,
deleteUser,
type Group,
type IUserData,
} from '@/api/users'
import type { DataTableHeader } from 'vuetify'
import { formatTime } from '@/utils/date'
import DialogConfirm from '@/components/DialogConfirm.vue'
const { t } = useI18n()
const headers: DataTableHeader[] = [
{ text: t('username'), value: 'username' },
{ text: t('group'), value: 'groups' },
{ text: t('name'), value: 'name' },
{ text: t('email'), value: 'email' },
{ text: t('joinDate'), value: 'joinDate' },
{ text: t('actions'), value: 'actions', sortable: false, align: 'end' },
]
const loading = ref(true)
const users = ref<IUserData[]>([])
const groups = ref<Group[]>([])
getGroups().then((promise) => (groups.value = promise.data))
getUsers()
.then((promise) => {
users.value = promise.data
})
.finally(() => {
loading.value = false
})
function groupColor(id: number) {
const name = groupName(id)
switch (name) {
case 'superuser':
return 'accent'
case 'admin':
return 'primary darken-3'
case 'staff':
return 'primary lighten-1'
default:
return ''
}
}
function groupName(id: number) {
return groups.value.find((group) => group.id === id)?.name || ''
}
const dialogDelete = ref<InstanceType<typeof DialogConfirm> | null>(null)
function showDialogDelete(id: number) {
dialogDelete.value?.open(t('confirmMsg')).then(async (confirmed: boolean) => {
if (confirmed) {
try {
await deleteUser(id)
Message.success(t('deleted'))
users.value = (await getUsers()).data
} catch (e) {
Message.error(e)
}
}
})
}
</script>
<template>
<v-container fluid>
<v-row>
<v-col cols="12">
<v-head-card :title="t('listOf', [t('user')])" icon="mdi-view-list">
<v-data-table
:headers="headers"
:items="users"
fixed-header
:loading="loading"
>
<template #item.username="{ item }">
<router-link :to="item.username">{{ item.username }}</router-link>
</template>
<template #item.groups="{ item }">
<v-chip
v-for="(groupId, i) in item.groups"
:key="i"
:color="groupColor(groupId)"
>
{{ groupName(groupId) }}
</v-chip>
</template>
<template #item.joinDate="{ item }">{{
formatTime(item.joinDate)
}}</template>
<template #item.actions="{ item }">
<v-icon
class="mr-1"
size="20"
:title="t('edit')"
@click="() => {}"
>
mdi-pencil
</v-icon>
<v-icon
size="20"
:title="t('delete')"
@click.stop="showDialogDelete(item.id)"
>
mdi-delete
</v-icon>
</template>
</v-data-table>
<DialogConfirm ref="dialogDelete" />
</v-head-card>
</v-col>
</v-row>
</v-container>
</template>
<route lang="json">
{ "meta": { "icon": "XXX" } }
</route>
<i18n lang="json">
{
"en": {
"confirmMsg": "Are you sure to delete this user?"
},
"zh": {
"confirmMsg": "你确定要删除此用户吗?"
}
}
</i18n>
================================================
FILE: src/pages/user-manage.vue
================================================
<script setup lang="ts">
import RouterWrapper from '@/components/layout/RouterWrapper.vue'
</script>
<template>
<RouterWrapper />
</template>
<route lang="json">
{
"meta": {
"title": "userManagement",
"icon": "mdi-account-group",
"roles": ["admin"],
"drawerGroup": "admin",
"dataCy": "userManage"
}
}
</route>
================================================
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<Component>('@/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<T>(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<typeof mount>[0],
options: Parameters<typeof mount>[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<typeof render>[0],
options: Parameters<typeof render>[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<string, string> = {}
Object.keys(mdicons).forEach((key) => {
const value = (mdicons as Record<string, string>)[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(?<![/\w])(mdi-[\w-]+)\b(?!\.)/,
replace: (match: string) => {
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
},
},
})
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
SYMBOL INDEX (40 symbols across 15 files)
FILE: public/mockServiceWorker.js
constant PACKAGE_VERSION (line 11) | const PACKAGE_VERSION = '2.4.7'
constant INTEGRITY_CHECKSUM (line 12) | const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'
constant IS_MOCKED_RESPONSE (line 13) | const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
function handleRequest (line 118) | async function handleRequest(event, requestId) {
function resolveMainClient (line 155) | async function resolveMainClient(event) {
function getResponse (line 178) | async function getResponse(event, client, requestId) {
function sendToClient (line 248) | function sendToClient(client, message, transferrables = []) {
function respondWithMock (line 267) | async function respondWithMock(response) {
FILE: src/api/users.ts
type Role (line 3) | type Role = 'superuser' | 'admin' | 'staff'
type Group (line 4) | type Group = {
type IUserData (line 10) | interface IUserData {
type Token (line 19) | type Token = {
FILE: src/components.d.ts
type GlobalComponents (line 4) | interface GlobalComponents {
FILE: src/composables/useVuetify.ts
function useVuetify (line 3) | function useVuetify() {
function useParsedTheme (line 11) | function useParsedTheme() {
FILE: src/env.d.ts
type ImportMetaEnv (line 6) | interface ImportMetaEnv {
type ImportMeta (line 11) | interface ImportMeta {
FILE: src/plugins/echarts.ts
type ECOption (line 58) | type ECOption = echarts.ComposeOption<
FILE: src/route-meta.d.ts
type RouteMeta (line 8) | interface RouteMeta {
type RouteRecordRaw (line 28) | type RouteRecordRaw = RouteConfig // shim plugins for vue-router v4
FILE: src/stores/message.ts
type Message (line 1) | interface Message {
method addMessage (line 18) | addMessage(text: string, type: Message['type'] = 'info') {
method delMessage (line 27) | delMessage(id: number) {
FILE: src/stores/user.ts
method login (line 20) | async login(username: string, password: string) {
method getUserInfo (line 32) | async getUserInfo() {
method logOut (line 47) | logOut() {
method refreshToken (line 53) | async refreshToken() {
FILE: src/utils/date.ts
function localeISOString (line 3) | function localeISOString(d?: Date | string | number): string {
function localeISODateString (line 9) | function localeISODateString(d?: Date | string | number): string {
function deltaTime (line 18) | function deltaTime(t1: string | Date, t2: string | Date) {
function formatTime (line 22) | function formatTime(time: string | Date) {
FILE: src/utils/permission.ts
function isPermitted (line 3) | function isPermitted(
function isSubGroup (line 40) | function isSubGroup(role1: Role, role2: Role): boolean {
FILE: src/utils/string.ts
function filename (line 1) | function filename(path: string) {
FILE: src/utils/types.ts
type VForm (line 3) | type VForm = typeof Vue & {
type InstallPlugin (line 9) | type InstallPlugin = (vue: typeof Vue) => any
FILE: test/helpers.ts
function mountComposable (line 11) | function mountComposable<T>(composable: () => T) {
function createWrapper (line 29) | function createWrapper(
function renderWithVuetify (line 46) | function renderWithVuetify(
FILE: vite.config.ts
method onConsoleLog (line 136) | onConsoleLog(log) {
Condensed preview — 101 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (136K chars).
[
{
"path": ".editorconfig",
"chars": 168,
"preview": "root = true\n\n[*]\ncharset = utf-8\nindent_style = space\nindent_size = 2\nend_of_line = lf\ninsert_final_newline = true\ntrim_"
},
{
"path": ".gitattributes",
"chars": 95,
"preview": "# Enforce Unix newlines\n* text=auto eol=lf\n\npublic/mockServiceWorker.js linguist-vendored=true\n"
},
{
"path": ".github/workflows/ci.yml",
"chars": 2252,
"preview": "name: CI\n\non:\n push:\n branches:\n - main\n\n pull_request:\n branches:\n - main\n\njobs:\n lint:\n runs-on:"
},
{
"path": ".gitignore",
"chars": 106,
"preview": ".DS_Store\nnode_modules\n/dist\n*.local\n*.log\n.idea\n/.vite-inspect\n\n/coverage\n/public/docs\n/cypress/videos/*\n"
},
{
"path": ".npmrc",
"chars": 24,
"preview": "auto-install-peers=true\n"
},
{
"path": ".prettierignore",
"chars": 65,
"preview": "pnpm-lock.yaml\nsrc/auto-imports.d.ts\npublic/mockServiceWorker.js\n"
},
{
"path": ".vscode/extensions.json",
"chars": 215,
"preview": "{\n \"recommendations\": [\n \"vue.volar\",\n \"dbaeumer.vscode-eslint\",\n \"esbenp.prettier-vscode\",\n \"editorconfig."
},
{
"path": ".vscode/launch.json",
"chars": 516,
"preview": "{\n // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n \"version\": \"0.2.0\",\n \"configuratio"
},
{
"path": ".vscode/settings.json",
"chars": 324,
"preview": "{\n \"i18n-ally.localesPaths\": [\"src/locales\"],\n \"i18n-ally.keystyle\": \"nested\",\n \"i18n-ally.enabledFrameworks\": [\"vue-"
},
{
"path": "LICENSE",
"chars": 1099,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2022-present Yue JIN & NuStar Nuclear\n\nPermission is hereby granted, free of charge"
},
{
"path": "README.md",
"chars": 10213,
"preview": "<p align=\"center\">\n <img alt=\"Vitify - Opinionated Vuetify Admin Starter Template\" src=\"public/favicon.svg\" width=200px"
},
{
"path": "cypress/e2e/example.spec.ts",
"chars": 552,
"preview": "describe('Example Test', () => {\n it('login', () => {\n cy.visit('/')\n cy.url().should('eq', 'http://localhost:505"
},
{
"path": "cypress.config.ts",
"chars": 273,
"preview": "import { defineConfig } from 'cypress'\n\nexport default defineConfig({\n e2e: {\n baseUrl: 'http://localhost:5050',\n "
},
{
"path": "eslint.config.js",
"chars": 1328,
"preview": "import { includeIgnoreFile } from '@eslint/compat'\nimport js from '@eslint/js'\nimport eslintPluginVue from 'eslint-plugi"
},
{
"path": "index.html",
"chars": 443,
"preview": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <title>Vitify Admin</title>\n <link rel=\"ic"
},
{
"path": "netlify.toml",
"chars": 175,
"preview": "[[redirects]]\n from = \"/*\"\n to = \"/index.html\"\n status = 200\n\n[[headers]]\n for = \"/manifest.webmanifest\"\n [headers."
},
{
"path": "package.json",
"chars": 2757,
"preview": "{\n \"private\": true,\n \"type\": \"module\",\n \"packageManager\": \"pnpm@9.11.0\",\n \"scripts\": {\n \"dev\": \"vite --open --hos"
},
{
"path": "patches/vite-plugin-vue-layouts@0.8.0.patch",
"chars": 665,
"preview": "diff --git a/package.json b/package.json\nindex de999023f87c55dee2e57e2583e1e96b6f95095f..3df13b998cd92bc3a2cfd1767147c4b"
},
{
"path": "prettier.config.js",
"chars": 96,
"preview": "/** @type {import(\"prettier\").Config} */\nexport default {\n semi: false,\n singleQuote: true,\n}\n"
},
{
"path": "public/mockServiceWorker.js",
"chars": 7514,
"preview": "/* eslint-disable */\n/* tslint:disable */\n\n/**\n * Mock Service Worker.\n * @see https://github.com/mswjs/msw\n * - Please "
},
{
"path": "src/App.vue",
"chars": 632,
"preview": "<script setup lang=\"ts\">\nimport { THEME_KEY, INIT_OPTIONS_KEY, UPDATE_OPTIONS_KEY } from 'vue-echarts'\nimport { useVueti"
},
{
"path": "src/api/users.ts",
"chars": 1504,
"preview": "import service from '@/utils/request'\n\nexport type Role = 'superuser' | 'admin' | 'staff'\nexport type Group = {\n id?: n"
},
{
"path": "src/assets/styles/_overrides.scss",
"chars": 2856,
"preview": "@import './variables.scss';\n\n.v-main__wrap {\n > .container--fluid {\n padding-left: 20px;\n padding-right: 20px;\n "
},
{
"path": "src/assets/styles/_scrollbar.scss",
"chars": 353,
"preview": "::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n}\n\n::-webkit-scrollbar-thumb {\n background-color: rgba(149, 149, 149"
},
{
"path": "src/assets/styles/_utils.scss",
"chars": 171,
"preview": ".svg-up {\n transform: rotate(0deg);\n}\n\n.svg-right {\n transform: rotate(90deg);\n}\n\n.svg-down {\n transform: rotate(180d"
},
{
"path": "src/assets/styles/index.scss",
"chars": 59,
"preview": "@import 'scrollbar';\n@import 'utils';\n@import 'overrides';\n"
},
{
"path": "src/assets/styles/variables.scss",
"chars": 73,
"preview": "$footer-height: 30px;\n$app-bar-height: 60px;\n$card-heading-margin: 10px;\n"
},
{
"path": "src/assets/styles/vuetify-variables.scss",
"chars": 109,
"preview": "$grid-breakpoints: (\n 'xs': 0,\n 'sm': 600px,\n 'md': 960px,\n 'lg': 1280px - 0px,\n 'xl': 1920px - 0px,\n);\n"
},
{
"path": "src/auto-imports.d.ts",
"chars": 5068,
"preview": "/* eslint-disable */\n/* prettier-ignore */\n// @ts-nocheck\n// noinspection JSUnusedGlobalSymbols\n// Generated by unplugin"
},
{
"path": "src/components/DialogConfirm.vue",
"chars": 1464,
"preview": "<script lang=\"ts\">\nexport default defineComponent({\n setup() {\n const { t } = useI18n()\n return { t }\n },\n data"
},
{
"path": "src/components/StatsCard.vue",
"chars": 1779,
"preview": "<script setup lang=\"ts\">\nwithDefaults(\n defineProps<{\n icon: string\n iconClass?: string\n color: string\n tit"
},
{
"path": "src/components/VHeadCard.vue",
"chars": 2285,
"preview": "<script setup lang=\"ts\">\ndefineProps({\n color: {\n type: String,\n default: 'primary',\n },\n icon: {\n type: Str"
},
{
"path": "src/components/demo-charts/ChartBar.vue",
"chars": 1133,
"preview": "<script setup lang=\"ts\">\nimport type { ECOption } from '@/plugins/echarts'\nconst option: ECOption = {\n backgroundColor:"
},
{
"path": "src/components/demo-charts/ChartLine.vue",
"chars": 1824,
"preview": "<script setup lang=\"ts\">\nimport type { ECOption } from '@/plugins/echarts'\n\nconst data = [\n ['2022-06-05', 116],\n ['20"
},
{
"path": "src/components/demo-charts/ChartPie.vue",
"chars": 811,
"preview": "<script setup lang=\"ts\">\nimport type { ECOption } from '@/plugins/echarts'\nconst option: ECOption = {\n backgroundColor:"
},
{
"path": "src/components/demo-charts/ChartRadar.vue",
"chars": 1464,
"preview": "<script setup lang=\"ts\">\nimport type { ECOption } from '@/plugins/echarts'\nconst option: ECOption = {\n backgroundColor:"
},
{
"path": "src/components/layout/AppBar.vue",
"chars": 1294,
"preview": "<script setup lang=\"ts\">\nimport AppBreadcrumbs from './AppBreadcrumbs.vue'\nimport ButtonFullScreen from './ButtonFullScr"
},
{
"path": "src/components/layout/AppBreadcrumbs.vue",
"chars": 850,
"preview": "<script setup lang=\"ts\">\nconst route = useRoute()\nconst { t } = useI18n()\nconst items = computed(() => {\n return route!"
},
{
"path": "src/components/layout/AppDrawer.vue",
"chars": 4266,
"preview": "<script setup lang=\"ts\">\nimport { useVuetify } from '@/composables/useVuetify'\nimport AppDrawerItem from './AppDrawerIte"
},
{
"path": "src/components/layout/AppDrawerItem.vue",
"chars": 2401,
"preview": "<script lang=\"ts\">\nimport type { RouteConfig } from 'vue-router'\nimport type { PropType } from 'vue'\nimport type {} from"
},
{
"path": "src/components/layout/AppFooter.vue",
"chars": 1397,
"preview": "<script setup lang=\"ts\">\nimport ButtonSettings from './ButtonSettings.vue'\nimport AppMessage from './AppMessage.vue'\nimp"
},
{
"path": "src/components/layout/AppMessage.vue",
"chars": 3791,
"preview": "<script setup lang=\"ts\">\nimport AppMessageItem from './AppMessageItem.vue'\nimport { formatTime } from '@/utils/date'\n\nco"
},
{
"path": "src/components/layout/AppMessageItem.vue",
"chars": 1143,
"preview": "<script lang=\"ts\">\nimport { useVModel } from '@vueuse/core'\nexport default defineComponent({\n props: {\n value: {\n "
},
{
"path": "src/components/layout/AppView.vue",
"chars": 759,
"preview": "<script setup lang=\"ts\">\nimport { useVuetify } from '@/composables/useVuetify'\nconst showGoTop = ref(false)\nconst vuetif"
},
{
"path": "src/components/layout/ButtonFullScreen.vue",
"chars": 484,
"preview": "<script setup lang=\"ts\">\nimport { useFullscreen } from '@vueuse/core'\nconst { isFullscreen, toggle } = useFullscreen()\nc"
},
{
"path": "src/components/layout/ButtonLocale.vue",
"chars": 1449,
"preview": "<script setup lang=\"ts\">\nimport { useVuetify } from '@/composables/useVuetify'\n\nconst { locale, t } = useI18n()\nconst vu"
},
{
"path": "src/components/layout/ButtonSettings.vue",
"chars": 4080,
"preview": "<script setup lang=\"ts\">\nimport { useAppStore } from '@/stores/app'\nimport { useVuetify } from '@/composables/useVuetify"
},
{
"path": "src/components/layout/ButtonUser.vue",
"chars": 1301,
"preview": "<script setup lang=\"ts\">\nconst router = useRouter()\nconst { t } = useI18n()\nconst userStore = useUserStore()\nconst { nam"
},
{
"path": "src/components/layout/RouterWrapper.vue",
"chars": 205,
"preview": "<script setup lang=\"ts\"></script>\n\n<template>\n <div class=\"d-fake\">\n <v-slide-x-transition mode=\"out-in\">\n <rou"
},
{
"path": "src/components.d.ts",
"chars": 744,
"preview": "import type { DefineComponent } from 'vue'\n\ndeclare module 'vue' {\n export interface GlobalComponents {\n VChart: (ty"
},
{
"path": "src/composables/useVuetify.ts",
"chars": 442,
"preview": "import type { VuetifyParsedTheme } from 'vuetify/types/services/theme'\n\nexport function useVuetify() {\n const instance "
},
{
"path": "src/env.d.ts",
"chars": 343,
"preview": "/// <reference types=\"vite/client\" />\n/// <reference types=\"vite-plugin-pages/client\" />\n/// <reference types=\"vite-plug"
},
{
"path": "src/layouts/default.vue",
"chars": 666,
"preview": "<script setup lang=\"ts\">\nimport AppBar from '@/components/layout/AppBar.vue'\nimport AppDrawer from '@/components/layout/"
},
{
"path": "src/layouts/empty.vue",
"chars": 86,
"preview": "<template>\n <div style=\"display: contents\">\n <router-view />\n </div>\n</template>\n"
},
{
"path": "src/locales/en.json",
"chars": 1206,
"preview": "{\n \"homepage\": \"Homepage\",\n \"fullscreen\": \"Fullscreen\",\n \"userManagement\": \"User Management\",\n \"userDetail\": \"User D"
},
{
"path": "src/locales/zh.json",
"chars": 926,
"preview": "{\n \"homepage\": \"主页\",\n \"fullscreen\": \"全屏\",\n \"userManagement\": \"用户管理\",\n \"userDetail\": \"用户详情\",\n \"dashboard\": \"仪表板\",\n "
},
{
"path": "src/main.ts",
"chars": 668,
"preview": "import Vue from 'vue'\nimport App from './App.vue'\nimport '@/assets/styles/index.scss'\nimport { filename } from './utils/"
},
{
"path": "src/mocks/index.ts",
"chars": 1821,
"preview": "import { setupWorker } from 'msw/browser'\nimport { http } from 'msw'\n\nconst baseURL =\n import.meta.env.VITE_API_URL ||\n"
},
{
"path": "src/pages/[...all].vue",
"chars": 438,
"preview": "<script lang=\"ts\">\nexport default defineComponent({\n name: 'ErrorPage',\n})\n</script>\n\n<template>\n <div>\n <div class"
},
{
"path": "src/pages/__tests__/login.spec.ts",
"chars": 688,
"preview": "import LoginPage from '../login.vue'\nimport { fireEvent } from '@testing-library/vue'\nimport { renderWithVuetify } from "
},
{
"path": "src/pages/dashboard.vue",
"chars": 2438,
"preview": "<script setup lang=\"ts\">\nimport ChartRadar from '@/components/demo-charts/ChartRadar.vue'\nimport ChartLine from '../comp"
},
{
"path": "src/pages/homepage.vue",
"chars": 1342,
"preview": "<script setup lang=\"ts\">\nconst { t } = useI18n()\nconst name = ref('')\nfunction sayHi() {\n Message.success(`Hi, ${name.v"
},
{
"path": "src/pages/index.vue",
"chars": 91,
"preview": "<template>\n <div />\n</template>\n<route lang=\"json\">\n{\n \"redirect\": \"homepage\"\n}\n</route>\n"
},
{
"path": "src/pages/login.vue",
"chars": 5571,
"preview": "<script setup lang=\"ts\">\nimport type { VForm } from '@/utils/types'\nimport background from '@/assets/images/drawer/1.jpg"
},
{
"path": "src/pages/nested/menu1.vue",
"chars": 203,
"preview": "<script setup lang=\"ts\"></script>\n<template>\n <v-container fluid>empty page</v-container>\n</template>\n<route lang=\"json"
},
{
"path": "src/pages/nested/menu2/menu2-1.vue",
"chars": 205,
"preview": "<script setup lang=\"ts\"></script>\n<template>\n <v-container fluid>empty page</v-container>\n</template>\n<route lang=\"json"
},
{
"path": "src/pages/nested/menu2/menu2-2.vue",
"chars": 205,
"preview": "<script setup lang=\"ts\"></script>\n<template>\n <v-container fluid>empty page</v-container>\n</template>\n<route lang=\"json"
},
{
"path": "src/pages/nested/menu2.vue",
"chars": 248,
"preview": "<script setup lang=\"ts\">\nimport RouterWrapper from '../../components/layout/RouterWrapper.vue'\n</script>\n<template>\n <R"
},
{
"path": "src/pages/nested.vue",
"chars": 282,
"preview": "<script setup lang=\"ts\">\nimport RouterWrapper from '../components/layout/RouterWrapper.vue'\n</script>\n<template>\n <Rout"
},
{
"path": "src/pages/reset-password.vue",
"chars": 3628,
"preview": "<script setup lang=\"ts\">\nimport { resetPassword } from '@/api/users'\nimport type { VForm } from '@/utils/types'\n\nconst {"
},
{
"path": "src/pages/user-manage/[id].vue",
"chars": 342,
"preview": "<template>\n <v-container fluid>Username: {{ props.id }}</v-container>\n</template>\n\n<script setup lang=\"ts\">\nconst props"
},
{
"path": "src/pages/user-manage/index.vue",
"chars": 3412,
"preview": "<script setup lang=\"ts\">\nimport {\n getUsers,\n getGroups,\n updateUser,\n deleteUser,\n type Group,\n type IUserData,\n}"
},
{
"path": "src/pages/user-manage.vue",
"chars": 338,
"preview": "<script setup lang=\"ts\">\nimport RouterWrapper from '@/components/layout/RouterWrapper.vue'\n</script>\n\n<template>\n <Rout"
},
{
"path": "src/plugins/README.md",
"chars": 257,
"preview": "## Plugins\n\nA custom user plugin system. Place a `.ts` file with the following template, it will be installed automatica"
},
{
"path": "src/plugins/components.ts",
"chars": 261,
"preview": "import VChart from 'vue-echarts'\nimport VHeadCard from '@/components/VHeadCard.vue'\nimport type { InstallPlugin } from '"
},
{
"path": "src/plugins/echarts.ts",
"chars": 1625,
"preview": "import * as echarts from 'echarts/core'\n\nimport {\n LineChart,\n type LineSeriesOption,\n BarChart,\n type BarSeriesOpti"
},
{
"path": "src/plugins/i18n.ts",
"chars": 554,
"preview": "import VueI18n from 'vue-i18n'\nimport { castToVueI18n, createI18n } from 'vue-i18n-bridge'\n\nimport en from '@/locales/en"
},
{
"path": "src/plugins/pinia.ts",
"chars": 204,
"preview": "import { createPinia, PiniaVuePlugin } from 'pinia'\nimport type { InstallPlugin } from '@/utils/types'\n\nexport const ins"
},
{
"path": "src/plugins/portal-vue.ts",
"chars": 229,
"preview": "/* Replace this component by Vue 3 built-in Teleport in the future */\nimport PortalVue from 'portal-vue'\nimport type { I"
},
{
"path": "src/plugins/router.ts",
"chars": 1421,
"preview": "import Router from 'vue-router'\nimport { setupLayouts } from 'virtual:generated-layouts'\nimport generatedRoutes from '~p"
},
{
"path": "src/plugins/vuetify.ts",
"chars": 1872,
"preview": "import Vuetify from 'vuetify/lib'\nimport type { VuetifyParsedTheme } from 'vuetify/types/services/theme'\nimport { Ripple"
},
{
"path": "src/route-meta.d.ts",
"chars": 825,
"preview": "export {}\n\nimport 'vue-router'\nimport type { RouteConfig } from 'vue-router'\nimport type { Role } from '@/api/users'\n\nde"
},
{
"path": "src/shims.d.ts",
"chars": 163,
"preview": "declare module 'vuetify/lib/locale/*' {\n import type { VuetifyLocale } from 'vuetify/types/services/lang'\n const local"
},
{
"path": "src/stores/__tests__/message.spec.ts",
"chars": 734,
"preview": "describe('Message Store', () => {\n beforeEach(() => {\n // creates a fresh pinia and make it active so it's automatic"
},
{
"path": "src/stores/app.ts",
"chars": 166,
"preview": "export const useAppStore = defineStore('app', {\n state: () => {\n return {\n drawer: true,\n drawerImage: '占位"
},
{
"path": "src/stores/message.ts",
"chars": 1233,
"preview": "interface Message {\n show: boolean\n type: 'info' | 'error' | 'success' | 'warning'\n text: string\n time: Date\n id: n"
},
{
"path": "src/stores/user.ts",
"chars": 1783,
"preview": "import {\n getToken,\n getUser,\n refreshToken,\n getGroup,\n type Role,\n} from '@/api/users'\n\nexport const useUserStore"
},
{
"path": "src/utils/date.ts",
"chars": 672,
"preview": "const utcOffset = new Date().getTimezoneOffset() * 60000\n\nfunction localeISOString(d?: Date | string | number): string {"
},
{
"path": "src/utils/permission.ts",
"chars": 1191,
"preview": "import type { Role } from '@/api/users'\n\nexport function isPermitted(\n allowedRoles: Role[],\n roles: Role[] = useUserS"
},
{
"path": "src/utils/request.ts",
"chars": 1258,
"preview": "import axios, { type AxiosError } from 'axios'\n\nconst service = axios.create({\n baseURL:\n import.meta.env.VITE_API_U"
},
{
"path": "src/utils/string.ts",
"chars": 122,
"preview": "export function filename(path: string) {\n return path\n .split(/(\\\\|\\/)/g)\n .pop()!\n .replace(/\\.[^/.]+$/, '')\n"
},
{
"path": "src/utils/types.ts",
"chars": 198,
"preview": "import type Vue from 'vue'\n\nexport type VForm = typeof Vue & {\n validate: () => boolean\n resetValidation: () => boolea"
},
{
"path": "test/helpers.ts",
"chars": 1821,
"preview": "import { createLocalVue, mount, shallowMount } from '@vue/test-utils'\nimport Vuetify from 'vuetify/lib'\nimport { PiniaVu"
},
{
"path": "test/vitest.setup.ts",
"chars": 625,
"preview": "import Vue from 'vue'\nVue.config.devtools = false\nVue.config.productionTip = false\n\n// import vuetify after suppressing "
},
{
"path": "tsconfig.app.json",
"chars": 833,
"preview": "{\n \"compilerOptions\": {\n \"baseUrl\": \"./\",\n \"target\": \"esnext\",\n \"useDefineForClassFields\": true,\n \"module\":"
},
{
"path": "tsconfig.cypress.json",
"chars": 175,
"preview": "{\n \"extends\": \"./tsconfig.app.json\",\n \"compilerOptions\": {\n \"isolatedModules\": false,\n \"types\": [\"cypress\"]\n },"
},
{
"path": "tsconfig.json",
"chars": 398,
"preview": "{\n \"files\": [],\n \"references\": [\n {\n \"path\": \"./tsconfig.node.json\"\n },\n {\n \"path\": \"./tsconfig.vit"
},
{
"path": "tsconfig.node.json",
"chars": 241,
"preview": "{\n \"extends\": \"./tsconfig.app.json\",\n \"include\": [\"vite.config.*\"],\n \"compilerOptions\": {\n \"composite\": true,\n "
},
{
"path": "tsconfig.vitest.json",
"chars": 283,
"preview": "{\n \"extends\": \"./tsconfig.app.json\",\n \"include\": [\"./src/**/*\", \"env.d.ts\", \"./test/*\"],\n \"exclude\": [],\n \"compilerO"
},
{
"path": "vite.config.preview.ts",
"chars": 203,
"preview": "import { defineConfig } from 'vite'\n// The more plugins, the slower the startup of `vite preview`\n// this file is for in"
},
{
"path": "vite.config.ts",
"chars": 3746,
"preview": "import path from 'path'\nimport { defineConfig } from 'vite'\nimport vue2 from '@vitejs/plugin-vue2'\nimport legacy from '@"
}
]
About this extraction
This page contains the full source code of the kingyue737/vitify-admin GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 101 files (120.4 KB), approximately 36.5k tokens, and a symbol index with 40 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.