mirror of
https://github.com/ccmjga/zhilu-admin
synced 2026-04-08 06:27:36 +00:00
init
This commit is contained in:
9
frontend/.env
Normal file
9
frontend/.env
Normal file
@@ -0,0 +1,9 @@
|
||||
VITE_ENABLE_MOCK=true
|
||||
VITE_APP_PORT=5173
|
||||
VITE_SOURCE_MAP=true
|
||||
# mock
|
||||
VITE_BASE_URL=http://localhost:5173
|
||||
# local
|
||||
#VITE_BASE_URL=http://localhost:8080
|
||||
# dev
|
||||
#VITE_BASE_URL=https://localhost/api
|
||||
181
frontend/.gitignore
vendored
Normal file
181
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,181 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
# Auto-generated type declarations
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
1
frontend/.nvmrc
Normal file
1
frontend/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
lts/jod
|
||||
7
frontend/.vscode/extensions.json
vendored
Normal file
7
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"vitest.explorer",
|
||||
"ms-playwright.playwright"
|
||||
]
|
||||
}
|
||||
18
frontend/.vscode/settings.json
vendored
Normal file
18
frontend/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"tsconfig.json": "tsconfig.*.json, env.d.ts",
|
||||
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
|
||||
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .prettier*, prettier*, .editorconfig"
|
||||
},
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
},
|
||||
"editor.quickSuggestions": {
|
||||
"strings": "on"
|
||||
},
|
||||
"tailwindCSS.classAttributes": ["class", "ui"],
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["ui:\\s*{([^)]*)\\s*}", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
]
|
||||
}
|
||||
34
frontend/biome.json
Normal file
34
frontend/biome.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": ["api/schema/**", "public"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
"noUnusedTemplateLiteral": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
}
|
||||
}
|
||||
4
frontend/e2e/tsconfig.json
Normal file
4
frontend/e2e/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": ["./**/*"]
|
||||
}
|
||||
8
frontend/e2e/vue.spec.ts
Normal file
8
frontend/e2e/vue.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// See here how to get started:
|
||||
// https://playwright.dev/docs/intro
|
||||
test("visits the app root url", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("h1")).toHaveText("You did it!");
|
||||
});
|
||||
25
frontend/env.d.ts
vendored
Normal file
25
frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
/// <reference types="vite/client" />
|
||||
interface ViteTypeOptions {
|
||||
// By adding this line, you can make the type of ImportMetaEnv strict
|
||||
// to disallow unknown keys.
|
||||
strictImportMetaEnv: unknown;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_ENABLE_MOCK: "true" | "false";
|
||||
readonly VITE_BACKEND_PORT: string;
|
||||
readonly VITE_BASE_URL: string;
|
||||
readonly VITE_SOURCE_MAP: "true" | "false";
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
interface AppConfig {
|
||||
errorHandler?: (
|
||||
err: unknown,
|
||||
instance: ComponentPublicInstance | null,
|
||||
info: string,
|
||||
) => void;
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
6449
frontend/package-lock.json
generated
Normal file
6449
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
frontend/package.json
Normal file
55
frontend/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "zhihu-frontend",
|
||||
"version": "1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"generate:api": "openapi-typescript ./src/api/schema/openapi.json -o ./src/api/types/schema.d.ts",
|
||||
"format": "biome format --write .",
|
||||
"lint": "biome lint --write .",
|
||||
"check": "biome check --write .",
|
||||
"test:unit": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:ts": "tsc --noEmit",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
"@vueuse/core": "^13.0.0",
|
||||
"apexcharts": "^3.46.0",
|
||||
"flowbite": "^3.1.2",
|
||||
"openapi-fetch": "^0.13.5",
|
||||
"pinia": "^3.0.1",
|
||||
"tailwindcss": "^4.0.14",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@faker-js/faker": "^9.6.0",
|
||||
"@playwright/test": "^1.51.0",
|
||||
"@tsconfig/node22": "^22.0.0",
|
||||
"@types/node": "^22.13.9",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vitest/browser": "^3.0.9",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"msw": "^2.8.2",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"openapi-typescript": "^7.6.1",
|
||||
"playwright": "^1.51.1",
|
||||
"typescript": "~5.8.0",
|
||||
"vite": "^6.2.1",
|
||||
"vite-plugin-vue-devtools": "^7.7.2",
|
||||
"vitest": "^3.0.8",
|
||||
"vitest-browser-vue": "^0.2.0",
|
||||
"vue-tsc": "^2.2.8"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": ["public"]
|
||||
}
|
||||
}
|
||||
111
frontend/playwright.config.ts
Normal file
111
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import process from "node:process";
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
/**
|
||||
* Maximum time expect() should wait for the condition to be met.
|
||||
* For example in `await expect(locator).toHaveText();`
|
||||
*/
|
||||
timeout: 5000,
|
||||
},
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||
actionTimeout: 0,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: process.env.CI ? "http://localhost:4173" : "http://localhost:5173",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
screenshot: "off",
|
||||
video: "off",
|
||||
/* Only on CI systems run the tests headless */
|
||||
headless: !!process.env.CI,
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: {
|
||||
// ...devices['Pixel 5'],
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: {
|
||||
// ...devices['iPhone 12'],
|
||||
// },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: {
|
||||
// channel: 'msedge',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: {
|
||||
// channel: 'chrome',
|
||||
// },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||
// outputDir: 'test-results/',
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
/**
|
||||
* Use the dev server by default for faster feedback loop.
|
||||
* Use the preview server on CI for more realistic testing.
|
||||
* Playwright will re-use the local server if there is already a dev-server running.
|
||||
*/
|
||||
command: process.env.CI ? "npm run preview" : "npm run dev",
|
||||
port: process.env.CI ? 4173 : 5173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
307
frontend/public/mockServiceWorker.js
Normal file
307
frontend/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,307 @@
|
||||
/* 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.8.2'
|
||||
const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'
|
||||
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: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
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 (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
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() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
BIN
frontend/public/trump.jpg
Normal file
BIN
frontend/public/trump.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
11
frontend/src/App.vue
Normal file
11
frontend/src/App.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from "vue-router";
|
||||
import Alert from "./components/Alert.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<Alert/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
69
frontend/src/api/client.ts
Normal file
69
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import createClient, { type Middleware } from "openapi-fetch";
|
||||
import useAuthStore from "../composables/store/useAuthStore";
|
||||
import {
|
||||
ForbiddenError,
|
||||
SystemError,
|
||||
UnAuthError,
|
||||
InternalServerError,
|
||||
} from "../types/error";
|
||||
import type { paths } from "./types/schema"; // generated by openapi-typescript
|
||||
|
||||
const myMiddleware: Middleware = {
|
||||
onRequest({ request, options }) {
|
||||
const authStore = useAuthStore();
|
||||
request.headers.set("Authorization", authStore.get());
|
||||
return request;
|
||||
},
|
||||
async onResponse({ request, response, options }) {
|
||||
const { body, ...resOptions } = response;
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
if (response.status === 401) {
|
||||
handleAuthError(response);
|
||||
} else if (response.status === 403) {
|
||||
handleForbiddenError(response);
|
||||
} else {
|
||||
handleSystemError(response);
|
||||
}
|
||||
} else if (response.status >= 500) {
|
||||
await handleBusinessError(response);
|
||||
} else {
|
||||
return response;
|
||||
}
|
||||
},
|
||||
async onError({ error }) {
|
||||
// wrap errors thrown by fetch
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
const client = createClient<paths>({
|
||||
baseUrl: `${import.meta.env.VITE_BASE_URL}`,
|
||||
querySerializer: {
|
||||
object: {
|
||||
style: "form",
|
||||
explode: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// register middleware
|
||||
client.use(myMiddleware);
|
||||
|
||||
const handleAuthError = (response: Response) => {
|
||||
throw new UnAuthError(response.status);
|
||||
};
|
||||
|
||||
const handleForbiddenError = (response: Response) => {
|
||||
throw new ForbiddenError(response.status);
|
||||
};
|
||||
|
||||
const handleSystemError = (response: Response) => {
|
||||
throw new SystemError(response.status);
|
||||
};
|
||||
|
||||
const handleBusinessError = async (response: Response) => {
|
||||
const data = await response.json();
|
||||
throw new InternalServerError(response.status, data.detail);
|
||||
};
|
||||
|
||||
export default client;
|
||||
10
frontend/src/api/mocks/authHandlers.ts
Normal file
10
frontend/src/api/mocks/authHandlers.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
export default [
|
||||
http.post("/auth/sign-in", () => {
|
||||
const response = HttpResponse.json();
|
||||
response.headers.set("Authorization", faker.string.alpha(16));
|
||||
return response;
|
||||
}),
|
||||
];
|
||||
39
frontend/src/api/mocks/departmentHandlers.ts
Normal file
39
frontend/src/api/mocks/departmentHandlers.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
export default [
|
||||
http.get("/department/page-query", () => {
|
||||
const generateDepartment = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
name: faker.company.name(),
|
||||
parentId: faker.number.int({ min: 1, max: 100 }),
|
||||
isBound: faker.datatype.boolean(),
|
||||
parentName: faker.company.name(),
|
||||
});
|
||||
const mockData = {
|
||||
data: faker.helpers.multiple(generateDepartment, { count: 10 }),
|
||||
total: 30,
|
||||
};
|
||||
return HttpResponse.json(mockData);
|
||||
}),
|
||||
http.get("/department/query", () => {
|
||||
const generateDepartment = () => ({
|
||||
id: faker.number.int({ min: 1, max: 30 }),
|
||||
name: faker.company.name(),
|
||||
parentId: faker.number.int({ min: 1, max: 30 }),
|
||||
parentName: faker.company.name(),
|
||||
});
|
||||
const mockData = faker.helpers.multiple(generateDepartment, { count: 30 });
|
||||
|
||||
return HttpResponse.json(mockData);
|
||||
}),
|
||||
|
||||
http.post("/department", () => {
|
||||
console.log("Captured department upsert");
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
http.delete("/department", () => {
|
||||
console.log("Captured department delete");
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
];
|
||||
43
frontend/src/api/mocks/permissionHandlers.ts
Normal file
43
frontend/src/api/mocks/permissionHandlers.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
export default [
|
||||
http.get("/iam/permissions", () => {
|
||||
const generatePermission = () => ({
|
||||
id: faker.number.int({ min: 1, max: 20 }),
|
||||
code: `perm_${faker.lorem.words({ min: 1, max: 1 })}`,
|
||||
name: faker.lorem.words({ min: 1, max: 1 }),
|
||||
isBound: faker.datatype.boolean(),
|
||||
});
|
||||
|
||||
const mockData = {
|
||||
data: faker.helpers.multiple(generatePermission, { count: 10 }),
|
||||
total: 20,
|
||||
};
|
||||
return HttpResponse.json(mockData);
|
||||
}),
|
||||
|
||||
http.post("/iam/permission", async ({ request }) => {
|
||||
console.log('Captured a "POST /posts" request');
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
|
||||
http.delete("/iam/permission", ({ params }) => {
|
||||
console.log(`Captured a "DELETE /posts/${params.id}" request`);
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
|
||||
http.post("/iam/roles/:roleId/bind-permission", ({ params, request }) => {
|
||||
console.log(
|
||||
`Captured a "POST /urp/roles/${params.roleId}/bind-permission" request`,
|
||||
);
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
|
||||
http.post("/iam/roles/:roleId/unbind-permission", ({ params, request }) => {
|
||||
console.log(
|
||||
`Captured a "POST /urp/roles/${params.roleId}/unbind-permission" request`,
|
||||
);
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
];
|
||||
35
frontend/src/api/mocks/positionHandlers.ts
Normal file
35
frontend/src/api/mocks/positionHandlers.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
export default [
|
||||
http.get("/position/page-query", () => {
|
||||
const generatePosition = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
name: faker.person.jobTitle(),
|
||||
isBound: faker.datatype.boolean(),
|
||||
});
|
||||
const mockData = {
|
||||
data: faker.helpers.multiple(generatePosition, { count: 10 }),
|
||||
total: 30,
|
||||
};
|
||||
return HttpResponse.json(mockData);
|
||||
}),
|
||||
http.get("/position/query", () => {
|
||||
const generatePosition = () => ({
|
||||
id: faker.number.int({ min: 1, max: 30 }),
|
||||
name: faker.person.jobTitle(),
|
||||
});
|
||||
const mockData = faker.helpers.multiple(generatePosition, { count: 30 });
|
||||
|
||||
return HttpResponse.json(mockData);
|
||||
}),
|
||||
|
||||
http.post("/position", () => {
|
||||
console.log("Captured position upsert");
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
http.delete("/position", () => {
|
||||
console.log("Captured position delete");
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
];
|
||||
77
frontend/src/api/mocks/roleHandlers.ts
Normal file
77
frontend/src/api/mocks/roleHandlers.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
export default [
|
||||
http.get("/iam/roles", () => {
|
||||
const generatePermission = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
code: `perm_${faker.lorem.word()}`,
|
||||
name: faker.lorem.words({ min: 1, max: 3 }),
|
||||
});
|
||||
|
||||
const generateRole = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
code: faker.helpers.arrayElement([
|
||||
"admin",
|
||||
"editor",
|
||||
"viewer",
|
||||
"manager",
|
||||
]),
|
||||
name: faker.person.jobTitle(),
|
||||
isBound: faker.datatype.boolean(),
|
||||
permissions: faker.helpers.multiple(generatePermission, {
|
||||
count: { min: 1, max: 5 },
|
||||
}),
|
||||
});
|
||||
|
||||
const mockData = {
|
||||
data: faker.helpers.multiple(generateRole, { count: 10 }),
|
||||
total: 20,
|
||||
};
|
||||
|
||||
return HttpResponse.json(mockData);
|
||||
}),
|
||||
http.get("/iam/role", ({ params }) => {
|
||||
const generatePermission = () => ({
|
||||
id: faker.number.int({ min: 1, max: 10 }),
|
||||
code: `perm_${faker.lorem.word()}`,
|
||||
name: faker.lorem.words({ min: 1, max: 3 }),
|
||||
});
|
||||
|
||||
const generateRole = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
code: faker.helpers.arrayElement([
|
||||
"admin",
|
||||
"editor",
|
||||
"viewer",
|
||||
"manager",
|
||||
]),
|
||||
name: faker.person.jobTitle(),
|
||||
permissions: faker.helpers.multiple(generatePermission, {
|
||||
count: { min: 1, max: 5 },
|
||||
}),
|
||||
});
|
||||
|
||||
return HttpResponse.json(generateRole());
|
||||
}),
|
||||
|
||||
http.post("/iam/role", async ({ request }) => {
|
||||
console.log('Captured a "POST /urp/role" request');
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
|
||||
http.post("/iam/permission/bind", async ({ request }) => {
|
||||
console.log('Captured a "POST /iam/permission/bind" request');
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
|
||||
http.post("/iam/permission/unbind", async ({ request }) => {
|
||||
console.log('Captured a "POST /iam/permission/unbind" request');
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
|
||||
http.delete("/iam/role", ({ params }) => {
|
||||
console.log(`Captured a "DELETE /urp/role ${params.id}" request`);
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
];
|
||||
53
frontend/src/api/mocks/schedulerHandlers.ts
Normal file
53
frontend/src/api/mocks/schedulerHandlers.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
export default [
|
||||
http.get("/scheduler/page-query", () => {
|
||||
const generateJobs = () => ({
|
||||
name: faker.word.sample(),
|
||||
group: faker.helpers.arrayElement(["default", "system", "custom"]),
|
||||
className: `com.example.jobs.${faker.word.sample()}Job`,
|
||||
jobDataMap: {
|
||||
dirty: faker.datatype.boolean(),
|
||||
allowsTransientData: faker.datatype.boolean(),
|
||||
keys: faker.helpers.multiple(() => faker.word.sample(), { count: 3 }),
|
||||
empty: false,
|
||||
wrappedMap: {},
|
||||
},
|
||||
triggerName: faker.word.sample(),
|
||||
triggerGroup: faker.helpers.arrayElement(["DEFAULT", "SYSTEM"]),
|
||||
schedulerType: faker.helpers.arrayElement(["CRON", "SIMPLE"]),
|
||||
triggerState: faker.helpers.arrayElement(["PAUSE", "WAITING"]),
|
||||
cronExpression: "0 0/30 * * * ?",
|
||||
startTime: faker.date.past().getTime(),
|
||||
endTime: faker.date.future().getTime(),
|
||||
nextFireTime: faker.date.soon().getTime(),
|
||||
previousFireTime: faker.date.recent().getTime(),
|
||||
triggerJobDataMap: {
|
||||
dirty: faker.datatype.boolean(),
|
||||
allowsTransientData: true,
|
||||
keys: [],
|
||||
empty: true,
|
||||
wrappedMap: {},
|
||||
},
|
||||
});
|
||||
|
||||
const mockData = {
|
||||
data: faker.helpers.multiple(generateJobs, { count: 20 }),
|
||||
total: 20,
|
||||
};
|
||||
return HttpResponse.json(mockData);
|
||||
}),
|
||||
http.post("/scheduler/trigger/resume", () => {
|
||||
console.log('Captured a "POST /scheduler/trigger/resume" request');
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
http.post("/scheduler/trigger/pause", () => {
|
||||
console.log('Captured a "POST /scheduler/trigger/pause" request');
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
http.put("/scheduler/job/update", () => {
|
||||
console.log('Captured a "POST /scheduler/job/update" request');
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
];
|
||||
17
frontend/src/api/mocks/setup.ts
Normal file
17
frontend/src/api/mocks/setup.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { setupWorker } from "msw/browser";
|
||||
import authHandlers from "./authHandlers";
|
||||
import jobHandlers from "./schedulerHandlers";
|
||||
import permissionHandlers from "./permissionHandlers";
|
||||
import roleHandlers from "./roleHandlers";
|
||||
import userHandlers from "./userHandlers";
|
||||
import departmentHandlers from "./departmentHandlers";
|
||||
import positionHandlers from "./positionHandlers";
|
||||
export const worker = setupWorker(
|
||||
...userHandlers,
|
||||
...authHandlers,
|
||||
...roleHandlers,
|
||||
...permissionHandlers,
|
||||
...jobHandlers,
|
||||
...departmentHandlers,
|
||||
...positionHandlers,
|
||||
);
|
||||
188
frontend/src/api/mocks/userHandlers.ts
Normal file
188
frontend/src/api/mocks/userHandlers.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { ROLE } from "../../router/constants";
|
||||
|
||||
export default [
|
||||
http.get("/iam/user", () => {
|
||||
const generatePermission = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
code: `perm_${faker.lorem.words({ min: 1, max: 1 })}`,
|
||||
name: faker.lorem.words({ min: 1, max: 1 }),
|
||||
});
|
||||
|
||||
const generateRole = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
code: faker.helpers.arrayElement([
|
||||
ROLE.ADMIN,
|
||||
"editor",
|
||||
"viewer",
|
||||
"manager",
|
||||
]),
|
||||
name: faker.person.jobTitle(),
|
||||
permissions: faker.helpers.multiple(generatePermission, {
|
||||
count: { min: 1, max: 5 },
|
||||
}),
|
||||
});
|
||||
|
||||
const generateDepartment = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
code: `dept_${faker.lorem.word()}`,
|
||||
name: faker.company.name(),
|
||||
parentId: faker.number.int({ min: 1, max: 30 }),
|
||||
enable: faker.datatype.boolean(),
|
||||
});
|
||||
|
||||
const generateUser = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
username: faker.internet.email(),
|
||||
password: faker.internet.password(),
|
||||
enable: faker.datatype.boolean(),
|
||||
roles: faker.helpers.multiple(generateRole, {
|
||||
count: { min: 1, max: 3 },
|
||||
}),
|
||||
createTime: faker.date.recent({ days: 30 }).toISOString(),
|
||||
permissions: faker.helpers.multiple(generatePermission, {
|
||||
count: { min: 1, max: 5 },
|
||||
}),
|
||||
departments: faker.helpers.multiple(generateDepartment, {
|
||||
count: { min: 0, max: 3 },
|
||||
}),
|
||||
});
|
||||
|
||||
return HttpResponse.json(generateUser());
|
||||
}),
|
||||
http.get("/iam/users", () => {
|
||||
const generatePermission = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
code: `perm_${faker.lorem.word()}`,
|
||||
name: faker.lorem.words({ min: 1, max: 3 }),
|
||||
});
|
||||
|
||||
const generateRole = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
code: [ROLE.ADMIN, "editor", "viewer", "manager"],
|
||||
name: faker.person.jobTitle(),
|
||||
permissions: faker.helpers.multiple(generatePermission, {
|
||||
count: { min: 1, max: 5 },
|
||||
}),
|
||||
});
|
||||
|
||||
const generateDepartment = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
code: `dept_${faker.lorem.word()}`,
|
||||
name: faker.company.name(),
|
||||
parentId: faker.number.int({ min: 1, max: 30 }),
|
||||
enable: faker.datatype.boolean(),
|
||||
});
|
||||
|
||||
const generateUser = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
username: faker.internet.email(),
|
||||
password: faker.internet.password(),
|
||||
enable: faker.datatype.boolean(),
|
||||
roles: faker.helpers.multiple(generateRole, {
|
||||
count: { min: 1, max: 3 },
|
||||
}),
|
||||
createTime: faker.date.recent({ days: 30 }).toISOString(),
|
||||
permissions: faker.helpers.multiple(generatePermission, {
|
||||
count: { min: 1, max: 5 },
|
||||
}),
|
||||
departments: faker.helpers.multiple(generateDepartment, {
|
||||
count: { min: 0, max: 3 },
|
||||
}),
|
||||
});
|
||||
|
||||
const mockData = {
|
||||
data: faker.helpers.multiple(generateUser, { count: 10 }),
|
||||
total: 30,
|
||||
};
|
||||
return HttpResponse.json(mockData);
|
||||
}),
|
||||
http.post("/api/users/:userId/departments", () => {
|
||||
console.log('Captured a "POST /api/users/:userId/departments" request');
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.delete("/api/users/:userId/departments", () => {
|
||||
console.log('Captured a "DELETE /api/users/:userId/departments" request');
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.post("/iam/user", () => {
|
||||
console.log('Captured a "POST /posts" request');
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
http.delete("/iam/user", ({ params }) => {
|
||||
console.log(`Captured a "DELETE /posts/${params.id}" request`);
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
http.post("/iam/me", () => {
|
||||
console.log('Captured a "POST /posts" request');
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
http.post("/iam/role/bind", () => {
|
||||
console.log('Captured a "POST /posts" request');
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
http.post("/iam/role/unbind", () => {
|
||||
console.log('Captured a "POST /posts" request');
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
http.get("/iam/me", () => {
|
||||
const generatePermission = () => ({
|
||||
id: faker.number.int({ min: 1, max: 1000 }),
|
||||
code: `perm_${faker.lorem.word()}`,
|
||||
name: faker.lorem.words({ min: 1, max: 3 }),
|
||||
});
|
||||
|
||||
const generateRole = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
code: [ROLE.ADMIN, "editor", "viewer", "manager"],
|
||||
name: faker.person.jobTitle(),
|
||||
permissions: faker.helpers.multiple(generatePermission, {
|
||||
count: { min: 1, max: 5 },
|
||||
}),
|
||||
});
|
||||
|
||||
const generateDepartment = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
code: `dept_${faker.lorem.word()}`,
|
||||
name: faker.company.name(),
|
||||
parentId: faker.number.int({ min: 1, max: 30 }),
|
||||
enable: faker.datatype.boolean(),
|
||||
});
|
||||
|
||||
const generateUser = () => ({
|
||||
id: faker.number.int({ min: 1, max: 100 }),
|
||||
username: faker.internet.email(),
|
||||
password: faker.internet.password(),
|
||||
enable: faker.datatype.boolean(),
|
||||
roles: faker.helpers.multiple(generateRole, {
|
||||
count: { min: 1, max: 3 },
|
||||
}),
|
||||
createTime: faker.date.recent({ days: 30 }).toISOString(),
|
||||
permissions: faker.helpers.multiple(generatePermission, {
|
||||
count: { min: 1, max: 5 },
|
||||
}),
|
||||
departments: faker.helpers.multiple(generateDepartment, {
|
||||
count: { min: 0, max: 3 },
|
||||
}),
|
||||
});
|
||||
const mockData = generateUser();
|
||||
return HttpResponse.json(mockData);
|
||||
}),
|
||||
http.post("/department/unbind", () => {
|
||||
console.log("Captured a 'POST /department/unbind' request");
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
http.post("/department/bind", () => {
|
||||
console.log("Captured a 'POST /department/bind' request");
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
http.post("/iam/position/bind", () => {
|
||||
console.log('Captured a "POST /posts" request');
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
http.post("/iam/position/unbind", () => {
|
||||
console.log('Captured a "POST /posts" request');
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
];
|
||||
1651
frontend/src/api/schema/openapi.json
Normal file
1651
frontend/src/api/schema/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
1512
frontend/src/api/types/schema.d.ts
vendored
Normal file
1512
frontend/src/api/types/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/src/assets/base.css
Normal file
26
frontend/src/assets/base.css
Normal file
@@ -0,0 +1,26 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
@import "tailwindcss";
|
||||
@import "flowbite/src/themes/default";
|
||||
@plugin "flowbite/plugin";
|
||||
@source "../node_modules/flowbite";
|
||||
@source "../node_modules/flowbite-datepicker";
|
||||
|
||||
@theme {
|
||||
--color-primary-50: #eff6ff;
|
||||
--color-primary-100: #dbeafe;
|
||||
--color-primary-200: #bfdbfe;
|
||||
--color-primary-300: #93c5fd;
|
||||
--color-primary-400: #60a5fa;
|
||||
--color-primary-500: #3b82f6;
|
||||
--color-primary-600: #2563eb;
|
||||
--color-primary-700: #1d4ed8;
|
||||
--color-primary-800: #1e40af;
|
||||
--color-primary-900: #1e3a8a;
|
||||
|
||||
--font-sans:
|
||||
"Inter", "ui-sans-serif", "system-ui", "-apple-system", "system-ui", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "Noto Sans", "sans-serif", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-body:
|
||||
"Inter", "ui-sans-serif", "system-ui", "-apple-system", "system-ui", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "Noto Sans", "sans-serif", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-mono: "ui-monospace", "SFMono-Regular", "Menlo", "Monaco", "Consolas",
|
||||
"Liberation Mono", "Courier New", "monospace";
|
||||
}
|
||||
1
frontend/src/assets/logo.svg
Normal file
1
frontend/src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
1
frontend/src/assets/main.css
Normal file
1
frontend/src/assets/main.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "./base.css";
|
||||
15
frontend/src/components/Alert.vue
Normal file
15
frontend/src/components/Alert.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div id="globalAlert" :class="['flex space-x-2 items-center rounded-lg p-4 mb-4 text-sm fixed top-8 right-5 transition-all duration-200 ease-out z-50', alertStore.levelClassName, alertStore.alertStorage.isShow ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-full']" role="alert">
|
||||
<svg v-if="alertStore.alertStorage.level==='info'" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info-icon lucide-info"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
<svg v-else-if="alertStore.alertStorage.level === 'warning'" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert-icon lucide-triangle-alert"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
|
||||
<svg v-if="alertStore.alertStorage.level === 'success'" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-check-icon lucide-circle-check"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
<svg v-if="alertStore.alertStorage.level === 'error'" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
|
||||
<span class="font-medium">{{ alertStore.alertStorage.content }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import useAlertStore from "../composables/store/useAlertStore";
|
||||
const alertStore = useAlertStore();
|
||||
</script>
|
||||
36
frontend/src/components/Breadcrumbs.vue
Normal file
36
frontend/src/components/Breadcrumbs.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<nav class="flex mb-5" aria-label="Breadcrumb">
|
||||
<ol class="inline-flex items-center space-x-1 text-sm font-medium md:space-x-2">
|
||||
<li class="inline-flex items-center">
|
||||
<RouterLink :to="{name: RouteName.USERVIEW}"
|
||||
class="inline-flex items-center text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-white">
|
||||
<svg class="w-5 h-5 mr-2.5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z">
|
||||
</path>
|
||||
</svg>
|
||||
首页
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li v-for="name in names" :key="name">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="ml-1 text-gray-400 hover:text-primary-600 md:ml-2 dark:text-gray-500 dark:hover:text-white">{{
|
||||
name }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouteName } from "@/router/constants";
|
||||
|
||||
const { names } = defineProps<{
|
||||
names: string[];
|
||||
}>();
|
||||
</script>
|
||||
14
frontend/src/components/Dashboard.vue
Normal file
14
frontend/src/components/Dashboard.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from "vue-router";
|
||||
import Headbar from "./Headbar.vue";
|
||||
import Sidebar from "./Sidebar.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Headbar></Headbar>
|
||||
<Sidebar>
|
||||
</Sidebar>
|
||||
<article class="ml-64 mt-14">
|
||||
<RouterView></RouterView>
|
||||
</article>
|
||||
</template>
|
||||
113
frontend/src/components/DepartmentUpsertModal.vue
Normal file
113
frontend/src/components/DepartmentUpsertModal.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
|
||||
<!-- Main modal -->
|
||||
<div id="user-upsert-modal" tabindex="-1" aria-hidden="true"
|
||||
class="bg-gray-900/50 dark:bg-gray-900/80 hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full">
|
||||
<!-- Modal content -->
|
||||
<div class="relative bg-white rounded-lg shadow-sm dark:bg-gray-700">
|
||||
<!-- Modal header -->
|
||||
<div
|
||||
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600 border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
部门管理
|
||||
</h3>
|
||||
<button type="button" @click="closeModal"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white">
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
|
||||
</svg>
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Modal body -->
|
||||
<div class="p-4 md:p-5">
|
||||
<div class="grid gap-4 mb-4 grid-cols-2">
|
||||
<div class="col-span-2">
|
||||
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">部门名称</label>
|
||||
<input type="text" id="name" v-model="formData.name"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
required />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="category" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">上级部门</label>
|
||||
<select id="category" v-model="formData.parentId"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option v-for="department in allDepartments" :key="department.id" :value="department.id"
|
||||
:selected="department.id === formData.parentId">{{
|
||||
department.name
|
||||
}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" @click="handleSubmit"
|
||||
class="text-white flex items-center bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 self-start mt-5">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import useAlertStore from "@/composables/store/useAlertStore";
|
||||
import { initFlowbite } from "flowbite";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { z } from "zod";
|
||||
import type { components } from "../api/types/schema";
|
||||
import type { DepartmentUpsertModel } from "../types/department";
|
||||
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
const { department, allDepartments, onSubmit } = defineProps<{
|
||||
department?: components["schemas"]["Department"];
|
||||
allDepartments: components["schemas"]["Department"][];
|
||||
closeModal: () => void;
|
||||
onSubmit: (department: DepartmentUpsertModel) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const formData = ref();
|
||||
|
||||
watch(
|
||||
() => department,
|
||||
(newDepartment) => {
|
||||
formData.value = {
|
||||
id: newDepartment?.id,
|
||||
name: newDepartment?.name,
|
||||
parentId: newDepartment?.parentId,
|
||||
};
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const schema = z.object({
|
||||
id: z.number().optional(),
|
||||
parentId: z.number().nullable().optional(),
|
||||
name: z
|
||||
.string({
|
||||
message: "部门名称不能为空",
|
||||
})
|
||||
.min(2, "部门名称至少2个字符"),
|
||||
});
|
||||
|
||||
try {
|
||||
const validatedData = schema.parse(formData.value);
|
||||
await onSubmit(validatedData);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
alertStore.showAlert({
|
||||
level: "error",
|
||||
content: error.errors[0].message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initFlowbite();
|
||||
});
|
||||
</script>
|
||||
135
frontend/src/components/Headbar.vue
Normal file
135
frontend/src/components/Headbar.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<nav class="fixed top-0 z-40 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="px-3 py-3 lg:px-5 lg:pl-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-start rtl:justify-end">
|
||||
<button data-drawer-target="logo-sidebar" data-drawer-toggle="logo-sidebar" aria-controls="logo-sidebar"
|
||||
type="button"
|
||||
class="inline-flex items-center p-2 text-sm text-gray-500 rounded-lg sm:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600">
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<svg class="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path clip-rule="evenodd" fill-rule="evenodd"
|
||||
d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="https://www.mjga.cc" target="_blank" class="flex ms-2 md:me-24">
|
||||
<span class="self-center text-xl font-semibold sm:text-2xl whitespace-nowrap dark:text-white">知路后台管理</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<a href="https://github.com/ccmjga/mjga-scaffold" target="_blank"
|
||||
class="flex items-center border rounded-sm border-gray-300">
|
||||
<span class=" bg-gray-200 rounded-r-none border-r border-r-gray-300">
|
||||
<svg class="me-0.5 inline pl-1.5 pb-1 w-6 h-6 text-gray-800 dark:text-white bg-gray-200 "
|
||||
aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-width="2"
|
||||
d="M11.083 5.104c.35-.8 1.485-.8 1.834 0l1.752 4.022a1 1 0 0 0 .84.597l4.463.342c.9.069 1.255 1.2.556 1.771l-3.33 2.723a1 1 0 0 0-.337 1.016l1.03 4.119c.214.858-.71 1.552-1.474 1.106l-3.913-2.281a1 1 0 0 0-1.008 0L7.583 20.8c-.764.446-1.688-.248-1.474-1.106l1.03-4.119A1 1 0 0 0 6.8 14.56l-3.33-2.723c-.698-.571-.342-1.702.557-1.771l4.462-.342a1 1 0 0 0 .84-.597l1.753-4.022Z" />
|
||||
</svg>
|
||||
<span class="text-sm pl-0.5 pr-2 font-medium">Star</span>
|
||||
</span>
|
||||
<span class="text-sm py-0.5 px-2 font-medium">0.45k</span>
|
||||
</a>
|
||||
<span class="flex space-x-2">
|
||||
<svg class="w-6 h-6 text-gray-500" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-6 h-6 text-gray-500" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z">
|
||||
</path>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<div class="flex items-center ms-3">
|
||||
<div>
|
||||
<button type="button" id="dropdown-button"
|
||||
class="flex text-sm bg-gray-800 rounded-full focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
|
||||
aria-expanded="false" data-dropdown-toggle="dropdown-user">
|
||||
<span class="sr-only">Open user menu</span>
|
||||
<img class="w-8 h-8 rounded-full" src="/public/trump.jpg" alt="user photo">
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-sm shadow-sm dark:bg-gray-700 dark:divide-gray-600"
|
||||
id="dropdown-user">
|
||||
<div class="px-4 py-3" role="none">
|
||||
<p class="text-sm font-medium text-gray-900 truncate dark:text-gray-300" role="none">
|
||||
{{ user.username }}
|
||||
</p>
|
||||
</div>
|
||||
<ul class="py-1" role="none">
|
||||
<li>
|
||||
<button @click="() => {
|
||||
userDropDownMenu?.toggle()
|
||||
router.push(RouteName.SETTINGS)
|
||||
}"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
role="menuitem">Settings</button>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="() => {
|
||||
userDropDownMenu?.toggle()
|
||||
router.push(RouteName.USERVIEW)
|
||||
}"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
role="menuitem">Dashboard</button>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="handleLogoutClick"
|
||||
class="flex items-center space-x-1 block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
role="menuitem">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-log-out-icon w-4 h-4 lucide-log-out">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" x2="9" y1="12" y2="12" />
|
||||
</svg><span>
|
||||
Sign out
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import useUserStore from "@/composables/store/useUserStore";
|
||||
import { Dropdown, initFlowbite, type DropdownInterface } from "flowbite";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import useUserAuth from "../composables/auth/useUserAuth";
|
||||
import { RouteName, RoutePath } from "../router/constants";
|
||||
|
||||
const userDropDownMenu = ref<DropdownInterface>();
|
||||
|
||||
onMounted(() => {
|
||||
initFlowbite();
|
||||
const $dropdownUser = document.getElementById("dropdown-user");
|
||||
const $dropdownButton = document.getElementById("dropdown-button");
|
||||
userDropDownMenu.value = new Dropdown(
|
||||
$dropdownUser,
|
||||
$dropdownButton,
|
||||
{},
|
||||
{ id: "dropdownMenu", override: true },
|
||||
);
|
||||
});
|
||||
const { user } = useUserStore();
|
||||
const { signOut } = useUserAuth();
|
||||
const router = useRouter();
|
||||
const handleLogoutClick = () => {
|
||||
signOut();
|
||||
router.push(RoutePath.LOGIN);
|
||||
};
|
||||
</script>
|
||||
103
frontend/src/components/PermissionUpsertModal.vue
Normal file
103
frontend/src/components/PermissionUpsertModal.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div :id tabindex="-1" aria-hidden="true"
|
||||
class="bg-gray-900/50 dark:bg-gray-900/80 hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full">
|
||||
<div class="relative bg-white rounded-lg shadow-sm dark:bg-gray-700">
|
||||
<div
|
||||
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600 border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
权限管理
|
||||
</h3>
|
||||
<button type="button" @click="closeModal"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center">
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
|
||||
</svg>
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 md:p-5">
|
||||
<div class="grid gap-4 mb-4 grid-cols-2">
|
||||
<div class="col-span-2">
|
||||
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">权限名称</label>
|
||||
<input type="text" name="权限名称" id="name" v-model="formData.name"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5"
|
||||
required="true">
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">权限编码</label>
|
||||
<input type="text" id="code" v-model="formData.code"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
|
||||
required />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" @click.prevent="handleSubmit"
|
||||
class="text-white flex items-center bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center self-start mt-5">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import useAlertStore from "@/composables/store/useAlertStore";
|
||||
import type { PermissionUpsertModel } from "@/types/permission";
|
||||
import { ref, watch } from "vue";
|
||||
import { z } from "zod";
|
||||
import type { components } from "../api/types/schema";
|
||||
|
||||
const { permission, onSubmit, closeModal } = defineProps<{
|
||||
id: string;
|
||||
permission?: components["schemas"]["PermissionRespDto"];
|
||||
closeModal: () => void;
|
||||
onSubmit: (data: PermissionUpsertModel) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
const formData = ref();
|
||||
|
||||
watch(
|
||||
() => permission,
|
||||
(newPermission) => {
|
||||
formData.value = {
|
||||
id: newPermission?.id,
|
||||
name: newPermission?.name,
|
||||
code: newPermission?.code,
|
||||
};
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const permissionSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
name: z
|
||||
.string({
|
||||
message: "权限名称不能为空",
|
||||
})
|
||||
.min(2, "权限名称至少2个字符"),
|
||||
code: z
|
||||
.string({
|
||||
message: "权限代码不能为空",
|
||||
})
|
||||
.min(2, "权限代码至少2个字符"),
|
||||
});
|
||||
|
||||
try {
|
||||
const validatedData = permissionSchema.parse(formData.value);
|
||||
await onSubmit(validatedData);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
alertStore.showAlert({
|
||||
level: "error",
|
||||
content: error.errors[0].message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
52
frontend/src/components/PopupModal.vue
Normal file
52
frontend/src/components/PopupModal.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div :id tabindex="-1"
|
||||
class="bg-gray-900/50 dark:bg-gray-900/80 hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full">
|
||||
<div class="relative bg-white rounded-lg shadow-sm dark:bg-gray-700">
|
||||
<button type="button" @click="closeModal"
|
||||
class="absolute top-3 end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white">
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
|
||||
</svg>
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
<div class="p-4 md:p-5 text-center flex flex-col items-center gap-y-3">
|
||||
<svg class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 11V6m0 8h.01M19 10a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<span>
|
||||
<button type="button" @click="onSubmit"
|
||||
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center">
|
||||
是
|
||||
</button>
|
||||
<button type="button" @click="closeModal"
|
||||
class="py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">否</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { initFlowbite } from "flowbite";
|
||||
import { onMounted } from "vue";
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
id: string;
|
||||
closeModal: () => void;
|
||||
onSubmit: (event: Event) => Promise<void>;
|
||||
}>();
|
||||
|
||||
onMounted(() => {
|
||||
initFlowbite();
|
||||
});
|
||||
</script>
|
||||
102
frontend/src/components/PositionUpsertModal.vue
Normal file
102
frontend/src/components/PositionUpsertModal.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<!-- Main modal -->
|
||||
<div :id tabindex="-1" aria-hidden="true"
|
||||
class="bg-gray-900/50 dark:bg-gray-900/80 hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full">
|
||||
<!-- Modal content -->
|
||||
<div class="relative bg-white rounded-lg shadow-sm dark:bg-gray-700">
|
||||
<!-- Modal header -->
|
||||
<div
|
||||
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600 border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
岗位管理
|
||||
</h3>
|
||||
<button type="button" @click="closeModal"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white">
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
|
||||
</svg>
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Modal body -->
|
||||
<div class="p-4 md:p-5">
|
||||
<div class="grid gap-4 mb-4 grid-cols-2">
|
||||
<div class="col-span-2">
|
||||
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">岗位名称</label>
|
||||
<input type="text" id="name" v-model="formData.name"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
required />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<button type="submit" @click="handleSubmit"
|
||||
class="text-white flex items-center bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 self-start mt-5">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import useAlertStore from "@/composables/store/useAlertStore";
|
||||
import { initFlowbite } from "flowbite";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { z } from "zod";
|
||||
import type { components } from "../api/types/schema";
|
||||
import type { PositionUpsertModel } from "../types/position";
|
||||
import { tr } from "@faker-js/faker";
|
||||
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
const { id, position, onSubmit } = defineProps<{
|
||||
id: string;
|
||||
position?: components["schemas"]["Position"];
|
||||
closeModal: () => void;
|
||||
onSubmit: (position: PositionUpsertModel) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const formData = ref();
|
||||
|
||||
watch(
|
||||
() => position,
|
||||
(newPosition) => {
|
||||
formData.value = {
|
||||
id: newPosition?.id,
|
||||
name: newPosition?.name,
|
||||
};
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const schema = z.object({
|
||||
id: z.number().optional(),
|
||||
name: z
|
||||
.string({
|
||||
message: "岗位名称不能为空",
|
||||
})
|
||||
.min(2, "岗位名称至少2个字符"),
|
||||
});
|
||||
|
||||
try {
|
||||
const validatedData = schema.parse(formData.value);
|
||||
await onSubmit(validatedData);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
alertStore.showAlert({
|
||||
level: "error",
|
||||
content: error.errors[0].message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initFlowbite();
|
||||
});
|
||||
</script>
|
||||
107
frontend/src/components/RoleUpsertModal.vue
Normal file
107
frontend/src/components/RoleUpsertModal.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div id="role-upsert-modal" tabindex="-1" aria-hidden="true"
|
||||
class="bg-gray-900/50 dark:bg-gray-900/80 hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full">
|
||||
<div class="relative bg-white rounded-lg shadow-sm dark:bg-gray-700">
|
||||
<div
|
||||
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600 border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
角色管理
|
||||
</h3>
|
||||
<button type="button" @click="closeModal"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center">
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
|
||||
</svg>
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 md:p-5">
|
||||
<div class="grid gap-4 mb-4 grid-cols-2">
|
||||
<div class="col-span-2">
|
||||
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">角色名称</label>
|
||||
<input type="text" name="角色名称" id="name" v-model="formData.name"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5"
|
||||
required="true">
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">角色代码</label>
|
||||
<input type="text" id="code" v-model="formData.code"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
|
||||
required />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" @click.prevent="handleSubmit"
|
||||
class="text-white flex items-center bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center self-start mt-5">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import useAlertStore from "@/composables/store/useAlertStore";
|
||||
import type { RoleUpsertModel } from "@/types/role";
|
||||
import { initFlowbite } from "flowbite";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { z } from "zod";
|
||||
import type { components } from "../api/types/schema";
|
||||
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
const { role, onSubmit } = defineProps<{
|
||||
role?: components["schemas"]["RoleDto"];
|
||||
closeModal: () => void;
|
||||
onSubmit: (data: RoleUpsertModel) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const formData = ref();
|
||||
|
||||
watch(
|
||||
() => role,
|
||||
(newRole) => {
|
||||
formData.value = {
|
||||
id: newRole?.id,
|
||||
name: newRole?.name,
|
||||
code: newRole?.code,
|
||||
};
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const roleSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
name: z
|
||||
.string({
|
||||
message: "角色名称不能为空",
|
||||
})
|
||||
.min(2, "角色名称至少2个字符"),
|
||||
code: z
|
||||
.string({
|
||||
message: "角色代码不能为空",
|
||||
})
|
||||
.min(2, "角色代码至少2个字符"),
|
||||
});
|
||||
|
||||
try {
|
||||
const validatedData = roleSchema.parse(formData.value);
|
||||
await onSubmit(validatedData);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
alertStore.showAlert({
|
||||
level: "error",
|
||||
content: error.errors[0].message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initFlowbite();
|
||||
});
|
||||
</script>
|
||||
88
frontend/src/components/SchedulerUpdateModal.vue
Normal file
88
frontend/src/components/SchedulerUpdateModal.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div :id tabindex="-1" aria-hidden="true"
|
||||
class="bg-gray-900/50 dark:bg-gray-900/80 hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full">
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||
<div
|
||||
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600 border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ '更新表达式' }}
|
||||
</h3>
|
||||
<button @click="closeModal" type="button"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white">
|
||||
<svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="handleSubmit" class="p-4 md:p-5">
|
||||
<div class="grid gap-4 mb-4">
|
||||
<div>
|
||||
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Cron 表达式</label>
|
||||
<input type="text" v-model="formData.cronExpression" name="name" id="name"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="text-white inline-flex items-center bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center">
|
||||
提交
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import useAlertStore from "@/composables/store/useAlertStore";
|
||||
import type { components } from "@/api/types/schema";
|
||||
import { ref, watch } from "vue";
|
||||
import { z } from "zod";
|
||||
|
||||
const { job, closeModal, onSubmit } = defineProps<{
|
||||
id: string;
|
||||
job?: components["schemas"]["JobTriggerDto"];
|
||||
closeModal: () => void;
|
||||
onSubmit: (cronExpression: string) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
const formData = ref({});
|
||||
|
||||
watch(
|
||||
() => job,
|
||||
(newJob) => {
|
||||
if (newJob) {
|
||||
formData.value = {
|
||||
cronExpression: newJob.cronExpression,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const jobSchema = z.object({
|
||||
cronExpression: z
|
||||
.string({
|
||||
message: "表达式不可为空",
|
||||
})
|
||||
.min(5, "表达式的长度非法"),
|
||||
});
|
||||
|
||||
try {
|
||||
const validatedData = jobSchema.parse(formData.value);
|
||||
await onSubmit(validatedData.cronExpression);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
alertStore.showAlert({
|
||||
level: "error",
|
||||
content: error.errors[0].message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
147
frontend/src/components/Sidebar.vue
Normal file
147
frontend/src/components/Sidebar.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<aside id="logo-sidebar"
|
||||
class="fixed top-0 left-0 z-30 w-64 min-h-screen overflow-y-auto pt-20 transition-transform -translate-x-full bg-white border-r border-gray-200 sm:translate-x-0 dark:bg-gray-800 dark:border-gray-700"
|
||||
aria-label="Sidebar">
|
||||
<div class="h-full px-3 pb-4 overflow-y-auto bg-white dark:bg-gray-800">
|
||||
<ul class="space-y-2 font-medium">
|
||||
<!-- <li>
|
||||
<RouterLink :to="`${RoutePath.DASHBOARD}/${RoutePath.OVERVIEW}`"
|
||||
class="flex items-center p-2 gap-x-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide shrink-0 text-gray-600 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white lucide-chart-pie-icon lucide-chart-pie">
|
||||
<path
|
||||
d="M21 12c.552 0 1.005-.449.95-.998a10 10 0 0 0-8.953-8.951c-.55-.055-.998.398-.998.95v8a1 1 0 0 0 1 1z" />
|
||||
<path d="M21.21 15.89A10 10 0 1 1 8 2.83" />
|
||||
</svg>
|
||||
<span>
|
||||
总览
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li> -->
|
||||
<li>
|
||||
<RouterLink :to="`${RoutePath.DASHBOARD}/${RoutePath.USERVIEW}`"
|
||||
class="flex items-center p-2 gap-x-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-users-icon lucide-users shrink-0 text-gray-700 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
<span>
|
||||
用户管理
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li>
|
||||
<RouterLink :to="`${RoutePath.DASHBOARD}/${RoutePath.ROLEVIEW}`"
|
||||
class="flex items-center p-2 gap-x-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-shield-user-icon lucide-shield-user shrink-0 text-gray-700 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white">
|
||||
<path
|
||||
d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
<path d="M6.376 18.91a6 6 0 0 1 11.249.003" />
|
||||
<circle cx="12" cy="11" r="4" />
|
||||
</svg>
|
||||
<span>
|
||||
角色管理
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li>
|
||||
<RouterLink :to="`${RoutePath.DASHBOARD}/${RoutePath.PERMISSIONVIEW}`"
|
||||
class="flex items-center p-2 gap-x-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-key-round-icon lucide-key-round shrink-0 text-gray-700 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white">
|
||||
<path
|
||||
d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" />
|
||||
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
</svg>
|
||||
<span>
|
||||
权限管理
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li>
|
||||
<RouterLink :to="`${RoutePath.DASHBOARD}/${RoutePath.DEPARTMENTVIEW}`"
|
||||
class="flex items-center p-2 gap-x-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||
<svg class=" text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24"
|
||||
height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 15v3c0 .5523.44772 1 1 1h4v-4m-5 0v-4m0 4h5m-5-4V6c0-.55228.44772-1 1-1h16c.5523 0 1 .44772 1 1v1.98935M3 11h5v4m9.4708 4.1718-.8696-1.4388-2.8164-.235-2.573-4.2573 1.4873-2.8362 1.4441 2.3893c.3865.6396 1.2183.8447 1.8579.4582.6396-.3866.8447-1.2184.4582-1.858l-1.444-2.38925h3.1353l2.6101 4.27715-1.0713 2.5847.8695 1.4388" />
|
||||
</svg>
|
||||
|
||||
<span>
|
||||
部门管理
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li>
|
||||
<RouterLink :to="`${RoutePath.DASHBOARD}/${RoutePath.POSITIONVIEW}`"
|
||||
class="flex items-center p-2 gap-x-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
|
||||
width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M14.6144 7.19994c.3479.48981.5999 1.15357.5999 1.80006 0 1.6569-1.3432 3-3 3-1.6569 0-3.00004-1.3431-3.00004-3 0-.67539.22319-1.29865.59983-1.80006M6.21426 6v4m0-4 6.00004-3 6 3-6 2-2.40021-.80006M6.21426 6l3.59983 1.19994M6.21426 19.8013v-2.1525c0-1.6825 1.27251-3.3075 2.95093-3.6488l3.04911 2.9345 3-2.9441c1.7026.3193 3 1.9596 3 3.6584v2.1525c0 .6312-.5373 1.1429-1.2 1.1429H7.41426c-.66274 0-1.2-.5117-1.2-1.1429Z" />
|
||||
</svg>
|
||||
|
||||
<span>
|
||||
岗位管理
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li>
|
||||
<RouterLink :to="`${RoutePath.DASHBOARD}/${RoutePath.SETTINGS}`"
|
||||
class="flex items-center p-2 gap-x-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-user-cog-icon lucide-user-cog shrink-0 text-gray-700 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white">
|
||||
<circle cx="18" cy="15" r="3" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M10 15H6a4 4 0 0 0-4 4v2" />
|
||||
<path d="m21.7 16.4-.9-.3" />
|
||||
<path d="m15.2 13.9-.9-.3" />
|
||||
<path d="m16.6 18.7.3-.9" />
|
||||
<path d="m19.1 12.2.3-.9" />
|
||||
<path d="m19.6 18.7-.4-1" />
|
||||
<path d="m16.8 12.3-.4-1" />
|
||||
<path d="m14.3 16.6 1-.4" />
|
||||
<path d="m20.7 13.8 1-.4" />
|
||||
</svg>
|
||||
<span>
|
||||
个人中心
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li>
|
||||
<RouterLink :to="`${RoutePath.DASHBOARD}/${RoutePath.SCHEDULERVIEW}`"
|
||||
class="flex items-center p-2 gap-x-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
|
||||
width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
<span>
|
||||
定时任务
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RoutePath } from "@/router/constants";
|
||||
import { initFlowbite } from "flowbite";
|
||||
import { onMounted } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
|
||||
onMounted(() => {
|
||||
initFlowbite();
|
||||
});
|
||||
</script>
|
||||
78
frontend/src/components/TablePagination.vue
Normal file
78
frontend/src/components/TablePagination.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<nav class="flex items-center flex-column flex-wrap md:flex-row justify-between pt-4 px-5 pb-5"
|
||||
aria-label="Table navigation">
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto">
|
||||
显示
|
||||
<span class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ displayRange.start }}-{{ displayRange.end }}
|
||||
</span>
|
||||
共
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ total }}</span> 条
|
||||
</span>
|
||||
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
|
||||
<li>
|
||||
<a href="#" @click.prevent="handlePageChangeClick(currentPage - 1)" :class="[
|
||||
'flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white',
|
||||
{ 'opacity-50 cursor-not-allowed': isFirstPage }
|
||||
]">上一页</a>
|
||||
</li>
|
||||
<li v-for="page in pageNumbers" :key="page">
|
||||
<button @click.prevent="handlePageChangeClick(page)" :class="[
|
||||
'flex items-center justify-center px-3 h-8 leading-tight border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700 dark:hover:text-white',
|
||||
currentPage === page
|
||||
? 'text-blue-600 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white'
|
||||
: 'text-gray-500 bg-white dark:text-gray-400'
|
||||
]">{{ page }}</button>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<button @click.prevent="handlePageChangeClick(currentPage + 1)" :class="[
|
||||
'flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white',
|
||||
{ 'opacity-50 cursor-not-allowed': isLastPage }
|
||||
]">下一页</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePagination } from "@/composables/page";
|
||||
import { watch } from "vue";
|
||||
|
||||
const { pageChange, total } = defineProps<{
|
||||
pageChange: (page: number, size: number) => Promise<void>;
|
||||
total: number;
|
||||
}>();
|
||||
|
||||
const {
|
||||
currentPage,
|
||||
pageNumbers,
|
||||
pageSize,
|
||||
displayRange,
|
||||
isFirstPage,
|
||||
isLastPage,
|
||||
totalPages,
|
||||
updatePaginationState,
|
||||
} = usePagination();
|
||||
|
||||
const handlePageChangeClick = async (page: number) => {
|
||||
if (page < 1 || page > totalPages.value) return;
|
||||
await pageChange(page, pageSize.value);
|
||||
updatePaginationState({
|
||||
currentPage: page,
|
||||
pageSize: pageSize.value,
|
||||
total,
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => total,
|
||||
() => {
|
||||
updatePaginationState({
|
||||
currentPage: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
total,
|
||||
});
|
||||
},
|
||||
);
|
||||
</script>
|
||||
150
frontend/src/components/UserUpsertModal.vue
Normal file
150
frontend/src/components/UserUpsertModal.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<!-- Main modal -->
|
||||
<div id="user-upsert-modal" tabindex="-1" aria-hidden="true"
|
||||
class="bg-gray-900/50 dark:bg-gray-900/80 hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full">
|
||||
<!-- Modal content -->
|
||||
<div class="relative bg-white rounded-lg shadow-sm dark:bg-gray-700">
|
||||
<!-- Modal header -->
|
||||
<div
|
||||
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600 border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
用户管理
|
||||
</h3>
|
||||
<button type="button" @click="closeModal"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white">
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
|
||||
</svg>
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Modal body -->
|
||||
<form class="p-4 md:p-5">
|
||||
<div class="grid gap-4 mb-4 grid-cols-2">
|
||||
<div class="col-span-2">
|
||||
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">用户名</label>
|
||||
<input type="text" name="用户名" id="name" v-model="formData.username"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
required="true">
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="password"
|
||||
class="block mb-2 text-sm font-medium autocompletetext-gray-900 dark:text-white">密码</label>
|
||||
<input type="password" id="password" autocomplete="new-password" v-model="formData.password"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="非必填" required />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="confirm_password"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">确认密码</label>
|
||||
<input type="password" id="confirm_password" autocomplete="new-password"
|
||||
v-model="formData.confirmPassword"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
required placeholder="非必填" />
|
||||
</div>
|
||||
<div class="col-span-2 sm:col-span-1">
|
||||
<label for="category" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">状态</label>
|
||||
<select id="category" v-model="formData.enable"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500">
|
||||
<option :value=true>启用</option>
|
||||
<option :value=false>禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" @click.prevent="handleSubmit"
|
||||
class="text-white flex items-center bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 self-start mt-5">
|
||||
保存
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import useAlertStore from "@/composables/store/useAlertStore";
|
||||
import type { UserUpsertSubmitModel } from "@/types/user";
|
||||
import { initFlowbite } from "flowbite";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { z } from "zod";
|
||||
import type { components } from "../api/types/schema";
|
||||
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
const { user, onSubmit } = defineProps<{
|
||||
user?: components["schemas"]["UserRolePermissionDto"];
|
||||
closeModal: () => void;
|
||||
onSubmit: (data: UserUpsertSubmitModel) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const formData = ref();
|
||||
|
||||
watch(
|
||||
() => user,
|
||||
(newUser) => {
|
||||
formData.value = {
|
||||
id: newUser?.id,
|
||||
username: newUser?.username,
|
||||
password: undefined,
|
||||
enable: newUser?.enable,
|
||||
confirmPassword: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const userSchema = z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
username: z
|
||||
.string({
|
||||
message: "用户名不能为空",
|
||||
})
|
||||
.min(4, "用户名至少4个字符"),
|
||||
enable: z.boolean(),
|
||||
password: z
|
||||
.string({
|
||||
message: "密码不能为空",
|
||||
})
|
||||
.min(5, "密码至少5个字符")
|
||||
.optional(),
|
||||
confirmPassword: z
|
||||
.string({
|
||||
message: "密码不能为空",
|
||||
})
|
||||
.min(5, "密码至少5个字符")
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.password) return true;
|
||||
return data.password === data.confirmPassword;
|
||||
},
|
||||
{
|
||||
message: "密码输入不一致。",
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const validatedData = userSchema.parse(formData.value);
|
||||
await onSubmit(validatedData);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
alertStore.showAlert({
|
||||
level: "error",
|
||||
content: error.errors[0].message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initFlowbite();
|
||||
});
|
||||
</script>
|
||||
71
frontend/src/composables/auth/useUserAuth.ts
Normal file
71
frontend/src/composables/auth/useUserAuth.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import client from "@/api/client";
|
||||
import { ref } from "vue";
|
||||
import useAuthStore from "../store/useAuthStore";
|
||||
import useUserStore from "../store/useUserStore";
|
||||
|
||||
const useUserAuth = () => {
|
||||
const isAuthenticated = ref(false);
|
||||
const authStore = useAuthStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const queryCurrentUser = async () => {
|
||||
const { data } = await client.GET("/iam/me");
|
||||
return data;
|
||||
};
|
||||
|
||||
const refreshCurrentUser = async () => {
|
||||
const currentUser = await queryCurrentUser();
|
||||
if (currentUser) {
|
||||
userStore.set(currentUser);
|
||||
isAuthenticated.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const upsertCurrentUser = async ({
|
||||
username,
|
||||
password,
|
||||
enable,
|
||||
}: {
|
||||
username: string;
|
||||
password?: string | null;
|
||||
enable: boolean;
|
||||
}) => {
|
||||
await client.POST("/iam/me", {
|
||||
body: {
|
||||
username,
|
||||
password: password ?? undefined,
|
||||
enable,
|
||||
},
|
||||
});
|
||||
await refreshCurrentUser();
|
||||
};
|
||||
|
||||
const signIn = async (username: string, password: string) => {
|
||||
const signInResponse = await client.POST("/auth/sign-in", {
|
||||
body: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
authStore.set(
|
||||
signInResponse.response.headers.get("authorization") ?? undefined,
|
||||
);
|
||||
await refreshCurrentUser();
|
||||
};
|
||||
|
||||
const signOut = () => {
|
||||
authStore.remove();
|
||||
isAuthenticated.value = false;
|
||||
userStore.remove();
|
||||
};
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
signIn,
|
||||
signOut,
|
||||
queryCurrentUser,
|
||||
upsertCurrentUser,
|
||||
};
|
||||
};
|
||||
|
||||
export default useUserAuth;
|
||||
38
frontend/src/composables/department/useDepartmentBind.ts
Normal file
38
frontend/src/composables/department/useDepartmentBind.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import client from "@/api/client";
|
||||
|
||||
export function useDepartmentBind() {
|
||||
const bindDepartment = async (userId: number, departmentIds: number[]) => {
|
||||
try {
|
||||
await client.POST("/iam/department/bind", {
|
||||
body: {
|
||||
userId,
|
||||
departmentIds,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error binding departments:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const unbindDepartment = async (userId: number, departmentIds: number[]) => {
|
||||
try {
|
||||
await client.POST("/iam/department/unbind", {
|
||||
body: {
|
||||
userId,
|
||||
departmentIds,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error unbinding departments:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
bindDepartment,
|
||||
unbindDepartment,
|
||||
};
|
||||
}
|
||||
18
frontend/src/composables/department/useDepartmentDelete.ts
Normal file
18
frontend/src/composables/department/useDepartmentDelete.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import client from "@/api/client";
|
||||
|
||||
export const useDepartmentDelete = () => {
|
||||
const deleteDepartment = async (departmentId: number) => {
|
||||
await client.DELETE("/department", {
|
||||
params: {
|
||||
query: {
|
||||
id: departmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
deleteDepartment,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDepartmentDelete;
|
||||
45
frontend/src/composables/department/useDepartmentQuery.ts
Normal file
45
frontend/src/composables/department/useDepartmentQuery.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import client from "@/api/client";
|
||||
import { ref } from "vue";
|
||||
import type { components } from "../../api/types/schema";
|
||||
|
||||
export const useDepartmentQuery = () => {
|
||||
const total = ref<number>(0);
|
||||
const departments = ref<components["schemas"]["DepartmentRespDto"][]>([]);
|
||||
const allDepartments = ref<components["schemas"]["Department"][]>([]);
|
||||
|
||||
const fetchAllDepartments = async () => {
|
||||
const { data } = await client.GET("/department/query");
|
||||
allDepartments.value = data ?? [];
|
||||
};
|
||||
const fetchDepartmentWith = async (
|
||||
param: {
|
||||
name?: string;
|
||||
enable?: boolean;
|
||||
userId?: number;
|
||||
bindState?: "ALL" | "BIND" | "UNBIND";
|
||||
},
|
||||
page = 1,
|
||||
size = 10,
|
||||
) => {
|
||||
const { data } = await client.GET("/department/page-query", {
|
||||
params: {
|
||||
query: {
|
||||
pageRequestDto: {
|
||||
page,
|
||||
size,
|
||||
},
|
||||
departmentQueryDto: param,
|
||||
},
|
||||
},
|
||||
});
|
||||
total.value = !data || !data.total ? 0 : data.total;
|
||||
departments.value = data?.data ?? [];
|
||||
};
|
||||
return {
|
||||
total,
|
||||
departments,
|
||||
allDepartments,
|
||||
fetchDepartmentWith,
|
||||
fetchAllDepartments,
|
||||
};
|
||||
};
|
||||
18
frontend/src/composables/department/useDepartmentUpsert.ts
Normal file
18
frontend/src/composables/department/useDepartmentUpsert.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import client from "../../api/client";
|
||||
import type { DepartmentUpsertModel } from "../../types/department";
|
||||
|
||||
export const useDepartmentUpsert = () => {
|
||||
const upsertDepartment = async (department: DepartmentUpsertModel) => {
|
||||
await client.POST("/department", {
|
||||
body: {
|
||||
id: department.id,
|
||||
name: department.name,
|
||||
parentId: department.parentId ?? undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
upsertDepartment,
|
||||
};
|
||||
};
|
||||
38
frontend/src/composables/job/useJobControl.ts
Normal file
38
frontend/src/composables/job/useJobControl.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import client from "@/api/client";
|
||||
|
||||
export const useJobControl = () => {
|
||||
const resumeTrigger = async (trigger: {
|
||||
triggerName: string;
|
||||
triggerGroup: string;
|
||||
jobQueryParam?: {
|
||||
name?: string;
|
||||
};
|
||||
}) => {
|
||||
await client.POST("/scheduler/trigger/resume", {
|
||||
body: {
|
||||
name: trigger.triggerName,
|
||||
group: trigger.triggerGroup,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const pauseTrigger = async (trigger: {
|
||||
triggerName: string;
|
||||
triggerGroup: string;
|
||||
jobQueryParam?: {
|
||||
name?: string;
|
||||
};
|
||||
}) => {
|
||||
await client.POST("/scheduler/trigger/pause", {
|
||||
body: {
|
||||
name: trigger.triggerName,
|
||||
group: trigger.triggerGroup,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
pauseTrigger,
|
||||
resumeTrigger,
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user