mirror of
https://github.com/ccmjga/zhilu-admin
synced 2026-03-23 11:23:42 +08:00
init
This commit is contained in:
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,
|
||||
};
|
||||
};
|
||||
36
frontend/src/composables/job/useJobQuery.ts
Normal file
36
frontend/src/composables/job/useJobQuery.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import client from "@/api/client";
|
||||
import { ref } from "vue";
|
||||
import type { components } from "../../api/types/schema";
|
||||
export const useJobsPaginationQuery = () => {
|
||||
const total = ref<number>(0);
|
||||
const jobs = ref<components["schemas"]["JobTriggerDto"][]>();
|
||||
const fetchJobsWith = async (
|
||||
queryParam?: {
|
||||
name?: string;
|
||||
},
|
||||
page = 1,
|
||||
size = 10,
|
||||
) => {
|
||||
const { data } = await client.GET("/scheduler/page-query", {
|
||||
params: {
|
||||
query: {
|
||||
pageRequestDto: {
|
||||
page: page,
|
||||
size: size,
|
||||
},
|
||||
queryDto: {
|
||||
name: queryParam?.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
total.value = !data || !data.total ? 0 : data.total;
|
||||
jobs.value = data?.data ?? [];
|
||||
};
|
||||
|
||||
return {
|
||||
total,
|
||||
jobs,
|
||||
fetchJobsWith,
|
||||
};
|
||||
};
|
||||
24
frontend/src/composables/job/useJobUpdate.ts
Normal file
24
frontend/src/composables/job/useJobUpdate.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import client from "@/api/client";
|
||||
|
||||
export const useJobUpdate = () => {
|
||||
const updateCron = async (trigger: {
|
||||
triggerName: string;
|
||||
triggerGroup: string;
|
||||
cron: string;
|
||||
}) => {
|
||||
await client.PUT("/scheduler/job/update", {
|
||||
params: {
|
||||
query: {
|
||||
cron: trigger.cron,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
name: trigger.triggerName,
|
||||
group: trigger.triggerGroup,
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
updateCron,
|
||||
};
|
||||
};
|
||||
67
frontend/src/composables/page.ts
Normal file
67
frontend/src/composables/page.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
export interface PaginationState {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UsePaginationOptions {
|
||||
initialPage?: number;
|
||||
initialPageSize?: number;
|
||||
initialTotal?: number;
|
||||
}
|
||||
|
||||
export function usePagination(options: UsePaginationOptions = {}) {
|
||||
const { initialPage = 1, initialPageSize = 10, initialTotal = 0 } = options;
|
||||
|
||||
const currentPage = ref(initialPage);
|
||||
const pageSize = ref(initialPageSize);
|
||||
const total = ref(initialTotal);
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value));
|
||||
|
||||
const pageNumbers = computed(() => {
|
||||
const pages = [];
|
||||
for (let i = 1; i <= totalPages.value; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
|
||||
const displayRange = computed(() => {
|
||||
const start =
|
||||
total.value === 0 ? 0 : (currentPage.value - 1) * pageSize.value + 1;
|
||||
const end =
|
||||
total.value === 0
|
||||
? 0
|
||||
: Math.min(currentPage.value * pageSize.value, total.value);
|
||||
return { start, end };
|
||||
});
|
||||
|
||||
const isFirstPage = computed(
|
||||
() => total.value === 0 || currentPage.value === 1,
|
||||
);
|
||||
|
||||
const isLastPage = computed(
|
||||
() => total.value === 0 || currentPage.value === totalPages.value,
|
||||
);
|
||||
|
||||
const updatePaginationState = (state: Partial<PaginationState>) => {
|
||||
if (state.currentPage !== undefined) currentPage.value = state.currentPage;
|
||||
if (state.pageSize !== undefined) pageSize.value = state.pageSize;
|
||||
if (state.total !== undefined) total.value = state.total;
|
||||
};
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
pageNumbers,
|
||||
displayRange,
|
||||
isFirstPage,
|
||||
isLastPage,
|
||||
updatePaginationState,
|
||||
};
|
||||
}
|
||||
36
frontend/src/composables/permission/usePermissionBind.ts
Normal file
36
frontend/src/composables/permission/usePermissionBind.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import client from "@/api/client";
|
||||
export const usePermissionBind = () => {
|
||||
const bindPermission = async ({
|
||||
roleId,
|
||||
permissionIds,
|
||||
}: {
|
||||
roleId: number;
|
||||
permissionIds: number[];
|
||||
}) => {
|
||||
await client.POST("/iam/permission/bind", {
|
||||
body: {
|
||||
roleId,
|
||||
permissionIds,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const unbindPermission = async ({
|
||||
roleId,
|
||||
permissionIds,
|
||||
}: {
|
||||
roleId: number;
|
||||
permissionIds: number[];
|
||||
}) => {
|
||||
await client.POST("/iam/permission/unbind", {
|
||||
body: {
|
||||
roleId,
|
||||
permissionIds,
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
bindPermission,
|
||||
unbindPermission,
|
||||
};
|
||||
};
|
||||
19
frontend/src/composables/permission/usePermissionDelete.ts
Normal file
19
frontend/src/composables/permission/usePermissionDelete.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import client from "@/api/client";
|
||||
|
||||
const usePermissionDelete = () => {
|
||||
const deletePermission = async (id: number) => {
|
||||
await client.DELETE("/iam/permission", {
|
||||
params: {
|
||||
query: {
|
||||
permissionId: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
deletePermission,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePermissionDelete;
|
||||
48
frontend/src/composables/permission/usePermissionQuery.ts
Normal file
48
frontend/src/composables/permission/usePermissionQuery.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import client from "@/api/client";
|
||||
import { ref } from "vue";
|
||||
import type { components } from "../../api/types/schema";
|
||||
|
||||
const usePermissionsQuery = () => {
|
||||
const total = ref<number>(0);
|
||||
const permissions = ref<components["schemas"]["PermissionRespDto"][]>([]);
|
||||
const fetchPermissionsWith = async (
|
||||
query: {
|
||||
name?: string;
|
||||
roleId?: number;
|
||||
bindState?: "BIND" | "ALL" | "UNBIND";
|
||||
},
|
||||
page = 1,
|
||||
size = 10,
|
||||
) => {
|
||||
const { data } = await client.GET("/iam/permissions", {
|
||||
params: {
|
||||
query: {
|
||||
pageRequestDto: {
|
||||
page,
|
||||
size,
|
||||
},
|
||||
permissionQueryDto: {
|
||||
permissionName: query.name,
|
||||
roleId: query.roleId,
|
||||
bindState: query.bindState,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new Error("获取权限数据失败");
|
||||
}
|
||||
|
||||
total.value = data.total ?? 0;
|
||||
permissions.value = data.data ?? [];
|
||||
};
|
||||
|
||||
return {
|
||||
total,
|
||||
permissions,
|
||||
fetchPermissionsWith,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePermissionsQuery;
|
||||
19
frontend/src/composables/permission/usePermissionUpsert.ts
Normal file
19
frontend/src/composables/permission/usePermissionUpsert.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import client from "../../api/client";
|
||||
import type { PermissionUpsertModel } from "../../types/permission";
|
||||
|
||||
const usePermissionUpsert = () => {
|
||||
const upsertPermission = async (permission: PermissionUpsertModel) => {
|
||||
await client.POST("/iam/permission", {
|
||||
body: {
|
||||
id: permission.id,
|
||||
name: permission.name,
|
||||
code: permission.code,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
upsertPermission,
|
||||
};
|
||||
};
|
||||
export default usePermissionUpsert;
|
||||
38
frontend/src/composables/position/usePositionBind.ts
Normal file
38
frontend/src/composables/position/usePositionBind.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import client from "@/api/client";
|
||||
|
||||
export function usePositionBind() {
|
||||
const bindPosition = async (userId: number, positionIds: number[]) => {
|
||||
try {
|
||||
await client.POST("/iam/position/bind", {
|
||||
body: {
|
||||
userId,
|
||||
positionIds,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error binding positions:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const unbindPosition = async (userId: number, positionIds: number[]) => {
|
||||
try {
|
||||
await client.POST("/iam/position/unbind", {
|
||||
body: {
|
||||
userId,
|
||||
positionIds,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error unbinding positions:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
bindPosition,
|
||||
unbindPosition,
|
||||
};
|
||||
}
|
||||
18
frontend/src/composables/position/usePositionDelete.ts
Normal file
18
frontend/src/composables/position/usePositionDelete.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import client from "@/api/client";
|
||||
|
||||
export const usePositionDelete = () => {
|
||||
const deletePosition = async (positionId: number) => {
|
||||
await client.DELETE("/position", {
|
||||
params: {
|
||||
query: {
|
||||
id: positionId,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
deletePosition,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePositionDelete;
|
||||
45
frontend/src/composables/position/usePositionQuery.ts
Normal file
45
frontend/src/composables/position/usePositionQuery.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 usePositionQuery = () => {
|
||||
const total = ref<number>(0);
|
||||
const positions = ref<components["schemas"]["PositionRespDto"][]>([]);
|
||||
const allPositions = ref<components["schemas"]["Position"][]>([]);
|
||||
|
||||
const fetchAllPositions = async () => {
|
||||
const { data } = await client.GET("/position/query");
|
||||
allPositions.value = data ?? [];
|
||||
};
|
||||
const fetchPositionWith = async (
|
||||
param: {
|
||||
name?: string;
|
||||
enable?: boolean;
|
||||
userId?: number;
|
||||
bindState?: "ALL" | "BIND" | "UNBIND";
|
||||
},
|
||||
page = 1,
|
||||
size = 10,
|
||||
) => {
|
||||
const { data } = await client.GET("/position/page-query", {
|
||||
params: {
|
||||
query: {
|
||||
pageRequestDto: {
|
||||
page,
|
||||
size,
|
||||
},
|
||||
positionQueryDto: param,
|
||||
},
|
||||
},
|
||||
});
|
||||
total.value = !data || !data.total ? 0 : data.total;
|
||||
positions.value = data?.data ?? [];
|
||||
};
|
||||
return {
|
||||
total,
|
||||
positions,
|
||||
allPositions,
|
||||
fetchPositionWith,
|
||||
fetchAllPositions,
|
||||
};
|
||||
};
|
||||
16
frontend/src/composables/position/usePositionUpsert.ts
Normal file
16
frontend/src/composables/position/usePositionUpsert.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import client from "../../api/client";
|
||||
import type { components } from "../../api/types/schema";
|
||||
|
||||
export const usePositionUpsert = () => {
|
||||
const upsertPosition = async (
|
||||
position: components["schemas"]["Position"],
|
||||
) => {
|
||||
await client.POST("/position", {
|
||||
body: position,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
upsertPosition,
|
||||
};
|
||||
};
|
||||
28
frontend/src/composables/role/useRoleBind.ts
Normal file
28
frontend/src/composables/role/useRoleBind.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import client from "@/api/client";
|
||||
|
||||
export const useRoleBind = () => {
|
||||
const bindRole = async ({
|
||||
userId,
|
||||
roleIds,
|
||||
}: { userId: number; roleIds: number[] }) => {
|
||||
await client.POST("/iam/role/bind", {
|
||||
body: {
|
||||
userId,
|
||||
roleIds,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const unbindRole = async (userId: number, roleIds: number[]) => {
|
||||
await client.POST("/iam/role/unbind", {
|
||||
body: {
|
||||
userId,
|
||||
roleIds,
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
bindRole,
|
||||
unbindRole,
|
||||
};
|
||||
};
|
||||
19
frontend/src/composables/role/useRoleDelete.ts
Normal file
19
frontend/src/composables/role/useRoleDelete.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import client from "../../api/client";
|
||||
|
||||
const useRoleDelete = () => {
|
||||
const deleteRole = async (roleId: number) => {
|
||||
await client.DELETE("/iam/role", {
|
||||
params: {
|
||||
query: {
|
||||
roleId,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
deleteRole,
|
||||
};
|
||||
};
|
||||
|
||||
export default useRoleDelete;
|
||||
17
frontend/src/composables/role/useRoleUpsert.ts
Normal file
17
frontend/src/composables/role/useRoleUpsert.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import client from "../../api/client";
|
||||
|
||||
export const useRoleUpsert = () => {
|
||||
const upsertRole = async (role: {
|
||||
id?: number;
|
||||
name: string;
|
||||
code: string;
|
||||
}) => {
|
||||
await client.POST("/iam/role", {
|
||||
body: role,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
upsertRole,
|
||||
};
|
||||
};
|
||||
61
frontend/src/composables/role/useRolesQuery.ts
Normal file
61
frontend/src/composables/role/useRolesQuery.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import client from "@/api/client";
|
||||
import { ref } from "vue";
|
||||
import type { components } from "../../api/types/schema";
|
||||
|
||||
export const useRolesQuery = () => {
|
||||
const total = ref<number>(0);
|
||||
const roles = ref<components["schemas"]["RoleDto"][]>();
|
||||
const roleWithDetail = ref<components["schemas"]["RoleDto"]>();
|
||||
|
||||
const getRoleWithDetail = async (roleId: number) => {
|
||||
const { data } = await client.GET("/iam/role", {
|
||||
params: {
|
||||
query: {
|
||||
roleId,
|
||||
},
|
||||
},
|
||||
});
|
||||
roleWithDetail.value = data;
|
||||
};
|
||||
|
||||
const fetchRolesWith = async (
|
||||
param: {
|
||||
name?: string;
|
||||
userId?: number;
|
||||
bindState?: "BIND" | "ALL" | "UNBIND";
|
||||
},
|
||||
page = 1,
|
||||
size = 10,
|
||||
) => {
|
||||
const { data } = await client.GET("/iam/roles", {
|
||||
params: {
|
||||
query: {
|
||||
pageRequestDto: {
|
||||
page,
|
||||
size,
|
||||
},
|
||||
roleQueryDto: {
|
||||
roleName: param.name,
|
||||
userId: param.userId,
|
||||
bindState: param.bindState,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new Error("获取角色数据失败");
|
||||
}
|
||||
|
||||
total.value = data.total ?? 0;
|
||||
roles.value = data.data ?? [];
|
||||
};
|
||||
|
||||
return {
|
||||
total,
|
||||
roles,
|
||||
roleWithDetail,
|
||||
getRoleWithDetail,
|
||||
fetchRolesWith,
|
||||
};
|
||||
};
|
||||
57
frontend/src/composables/store/useAlertStore.ts
Normal file
57
frontend/src/composables/store/useAlertStore.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { StorageSerializers, useStorage } from "@vueuse/core";
|
||||
import { defineStore } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import type { AlertProps } from "../../types/alert";
|
||||
|
||||
const useAlertStore = defineStore("alertStore", () => {
|
||||
const alertStorage = useStorage<AlertProps>(
|
||||
"alert-storage",
|
||||
{
|
||||
content: undefined,
|
||||
level: undefined,
|
||||
isShow: undefined,
|
||||
timer: undefined,
|
||||
},
|
||||
localStorage,
|
||||
{
|
||||
serializer: StorageSerializers.object,
|
||||
},
|
||||
);
|
||||
|
||||
const showAlert = ({
|
||||
content: newContent,
|
||||
level: newLevel,
|
||||
}: { content: string; level: "info" | "success" | "warning" | "error" }) => {
|
||||
clearTimeout(alertStorage.value.timer);
|
||||
alertStorage.value = {
|
||||
content: newContent,
|
||||
level: newLevel,
|
||||
isShow: true,
|
||||
timer: setTimeout(() => {
|
||||
alertStorage.value.isShow = false;
|
||||
}, 3000),
|
||||
};
|
||||
};
|
||||
|
||||
const levelClassName = computed(() => {
|
||||
if (!alertStorage.value.level) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
info: "text-blue-800 bg-blue-50 dark:bg-gray-800 dark:text-blue-400 ",
|
||||
success:
|
||||
"text-green-800 bg-green-50 dark:bg-gray-800 dark:text-green-400",
|
||||
warning:
|
||||
"text-yellow-800 bg-yellow-50 dark:bg-gray-800 dark:text-yellow-300",
|
||||
error: "text-red-800 bg-red-50 dark:bg-gray-800 dark:text-red-400",
|
||||
}[alertStorage.value.level];
|
||||
});
|
||||
|
||||
return {
|
||||
alertStorage,
|
||||
showAlert,
|
||||
levelClassName,
|
||||
};
|
||||
});
|
||||
|
||||
export default useAlertStore;
|
||||
28
frontend/src/composables/store/useAuthStore.ts
Normal file
28
frontend/src/composables/store/useAuthStore.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { StorageSerializers, useStorage } from "@vueuse/core";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
const useAuthStore = defineStore("authStore", () => {
|
||||
const tokenStore = useStorage<string>("auth-storage", null, localStorage, {
|
||||
serializer: StorageSerializers.object,
|
||||
});
|
||||
|
||||
const set = (token?: string) => {
|
||||
tokenStore.value = token;
|
||||
};
|
||||
|
||||
const get = () => {
|
||||
return tokenStore.value;
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
tokenStore.value = undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
set,
|
||||
get,
|
||||
remove,
|
||||
};
|
||||
});
|
||||
|
||||
export default useAuthStore;
|
||||
44
frontend/src/composables/store/useUserStore.ts
Normal file
44
frontend/src/composables/store/useUserStore.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { StorageSerializers, useStorage } from "@vueuse/core";
|
||||
import { defineStore } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import type { components } from "../../api/types/schema";
|
||||
const useUserStore = defineStore("userStore", () => {
|
||||
const user = useStorage<components["schemas"]["UserRolePermissionDto"]>(
|
||||
"user-storage",
|
||||
null,
|
||||
localStorage,
|
||||
{
|
||||
serializer: StorageSerializers.object,
|
||||
},
|
||||
);
|
||||
|
||||
const set: (
|
||||
userRolePermission?: components["schemas"]["UserRolePermissionDto"],
|
||||
) => void = (userRolePermission) => {
|
||||
user.value = userRolePermission;
|
||||
};
|
||||
|
||||
function remove() {
|
||||
user.value = null;
|
||||
}
|
||||
|
||||
const roleCodes = computed(() => {
|
||||
return user.value?.roles?.flatMap((role) => role.code);
|
||||
});
|
||||
|
||||
const permissionCodes = computed(() => {
|
||||
return user.value?.roles?.flatMap((role) =>
|
||||
role.permissions?.map((permission) => permission.code),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
set,
|
||||
remove,
|
||||
roleCodes,
|
||||
permissionCodes,
|
||||
};
|
||||
});
|
||||
|
||||
export default useUserStore;
|
||||
19
frontend/src/composables/user/useUserDelete.ts
Normal file
19
frontend/src/composables/user/useUserDelete.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ref } from "vue";
|
||||
import client from "../../api/client";
|
||||
|
||||
const useUserDelete = () => {
|
||||
const deleteUser = async (userId: number) => {
|
||||
await client.DELETE("/iam/user", {
|
||||
params: {
|
||||
query: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
deleteUser,
|
||||
};
|
||||
};
|
||||
|
||||
export default useUserDelete;
|
||||
50
frontend/src/composables/user/useUserQuery.ts
Normal file
50
frontend/src/composables/user/useUserQuery.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import client from "@/api/client";
|
||||
import { ref } from "vue";
|
||||
import type { components } from "../../api/types/schema";
|
||||
|
||||
export const useUserQuery = () => {
|
||||
const total = ref<number>(0);
|
||||
const users = ref<components["schemas"]["UserRolePermissionDto"][]>([]);
|
||||
const user = ref<components["schemas"]["UserRolePermissionDto"]>();
|
||||
|
||||
const getUserWithDetail = async (userId: number) => {
|
||||
const { data } = await client.GET("/iam/user", {
|
||||
params: {
|
||||
query: {
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
user.value = data;
|
||||
};
|
||||
|
||||
const fetchUsersWith = async (
|
||||
param: {
|
||||
username?: string;
|
||||
},
|
||||
page = 1,
|
||||
size = 10,
|
||||
) => {
|
||||
const { data } = await client.GET("/iam/users", {
|
||||
params: {
|
||||
query: {
|
||||
pageRequestDto: {
|
||||
page,
|
||||
size,
|
||||
},
|
||||
userQueryDto: param,
|
||||
},
|
||||
},
|
||||
});
|
||||
total.value = !data || !data.total ? 0 : data.total;
|
||||
users.value = data?.data ?? [];
|
||||
};
|
||||
|
||||
return {
|
||||
total,
|
||||
users,
|
||||
user,
|
||||
fetchUsersWith,
|
||||
getUserWithDetail,
|
||||
};
|
||||
};
|
||||
20
frontend/src/composables/user/useUserUpsert.ts
Normal file
20
frontend/src/composables/user/useUserUpsert.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ref } from "vue";
|
||||
import client from "../../api/client";
|
||||
import type { UserUpsertSubmitModel } from "../../types/user";
|
||||
|
||||
export const useUserUpsert = () => {
|
||||
const upsertUser = async (user: UserUpsertSubmitModel) => {
|
||||
const { data } = await client.POST("/iam/user", {
|
||||
body: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
enable: user.enable,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
upsertUser,
|
||||
};
|
||||
};
|
||||
33
frontend/src/main.ts
Normal file
33
frontend/src/main.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import "./assets/main.css";
|
||||
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
|
||||
import App from "./App.vue";
|
||||
import useUserAuth from "./composables/auth/useUserAuth";
|
||||
import useAlertStore from "./composables/store/useAlertStore";
|
||||
import router from "./router";
|
||||
import makeErrorHandler from "./utils/errorHandler";
|
||||
|
||||
async function enableMocking() {
|
||||
if (import.meta.env.VITE_ENABLE_MOCK === "false") {
|
||||
return;
|
||||
}
|
||||
|
||||
const { worker } = await import("./api/mocks/setup");
|
||||
|
||||
// `worker.start()` returns a Promise that resolves
|
||||
// once the Service Worker is up and ready to intercept requests.
|
||||
return worker.start();
|
||||
}
|
||||
|
||||
enableMocking().then(() => {
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
const { signOut } = useUserAuth();
|
||||
const { showAlert } = useAlertStore();
|
||||
app.use(router);
|
||||
const errorHandler = makeErrorHandler(router, signOut, showAlert);
|
||||
app.config.errorHandler = errorHandler;
|
||||
app.mount("#app");
|
||||
});
|
||||
63
frontend/src/router/constants.ts
Normal file
63
frontend/src/router/constants.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export enum RoutePath {
|
||||
HOME = "/",
|
||||
LOGIN = "/login",
|
||||
DASHBOARD = "/dashboard",
|
||||
GLOBAL_NOTFOUND = "/:pathMatch(.*)*",
|
||||
NOTFOUND = ":pathMatch(.*)*",
|
||||
OVERVIEW = "overview",
|
||||
USERVIEW = "users",
|
||||
ROLEVIEW = "roles",
|
||||
BINDROLEVIEW = "bind-roles/:userId",
|
||||
BINDPERMISSIONVIEW = "bind-permissions/:roleId",
|
||||
BINDDEPARTMENTVIEW = "bind-departments/:userId",
|
||||
BINDPOSITIONVIEW = "bind-positions/:userId",
|
||||
PERMISSIONVIEW = "permissions",
|
||||
DEPARTMENTVIEW = "departments",
|
||||
POSITIONVIEW = "positions",
|
||||
CREATEUSERVIEW = "create-user",
|
||||
SCHEDULERVIEW = "scheduler",
|
||||
UPSERTUSERVIEW = "upsert-user",
|
||||
UPSERTROLEVIEW = "upsert-role",
|
||||
UPSERTPERMISSIONVIEW = "upsert-permission",
|
||||
UPSERTDEPARTMENTVIEW = "upsert-department",
|
||||
UPSERTPOSITIONVIEW = "upsert-position",
|
||||
SETTINGS = "settings",
|
||||
}
|
||||
|
||||
export enum RouteName {
|
||||
HOME = "home",
|
||||
LOGIN = "login",
|
||||
DASHBOARD = "dashboard",
|
||||
OVERVIEW = "overview",
|
||||
USERVIEW = "users",
|
||||
ROLEVIEW = "roles",
|
||||
BINDROLEVIEW = "bind-roles",
|
||||
BINDPERMISSIONVIEW = "bind-permissions",
|
||||
BINDDEPARTMENTVIEW = "bind-departments",
|
||||
BINDPOSITIONVIEW = "bind-positions",
|
||||
PERMISSIONVIEW = "permissions",
|
||||
DEPARTMENTVIEW = "departments",
|
||||
POSITIONVIEW = "positions",
|
||||
CREATEUSERVIEW = "create-user",
|
||||
SCHEDULERVIEW = "scheduler",
|
||||
UPSERTUSERVIEW = "upsert-user",
|
||||
UPSERTROLEVIEW = "upsert-role",
|
||||
UPSERTPERMISSIONVIEW = "upsert-permission",
|
||||
UPSERTDEPARTMENTVIEW = "upsert-department",
|
||||
UPSERTPOSITIONVIEW = "upsert-position",
|
||||
SETTINGS = "settings",
|
||||
NOTFOUND = "notfound",
|
||||
GLOBAL_NOTFOUND = "global-notfound",
|
||||
}
|
||||
|
||||
export enum ROLE {
|
||||
ADMIN = "ADMIN",
|
||||
USER = "USER",
|
||||
}
|
||||
|
||||
export enum PERMISSION {
|
||||
USER_VIEW = "user:view",
|
||||
USER_ADD = "user:add",
|
||||
USER_EDIT = "user:edit",
|
||||
USER_DELETE = "user:delete",
|
||||
}
|
||||
47
frontend/src/router/guards.ts
Normal file
47
frontend/src/router/guards.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import useUserStore from "@/composables/store/useUserStore";
|
||||
import type { NavigationGuard, Router } from "vue-router";
|
||||
import type { RouteMeta } from "../types/router";
|
||||
import { RoutePath } from "./constants";
|
||||
|
||||
export const authGuard: NavigationGuard = (to) => {
|
||||
const userStore = useUserStore();
|
||||
if (to.meta.requiresAuth && !userStore.user) {
|
||||
return {
|
||||
path: RoutePath.LOGIN,
|
||||
query: { redirect: to.fullPath },
|
||||
};
|
||||
}
|
||||
if (to.path === RoutePath.LOGIN && userStore.user) {
|
||||
return { path: `${RoutePath.DASHBOARD}/${RoutePath.USERVIEW}` };
|
||||
}
|
||||
};
|
||||
|
||||
export const permissionGuard: NavigationGuard = (to) => {
|
||||
const userStore = useUserStore();
|
||||
const routeMeta: RouteMeta = to.meta;
|
||||
if (routeMeta.hasPermission) {
|
||||
const hasPermission = userStore.permissionCodes?.includes(
|
||||
routeMeta.hasPermission,
|
||||
);
|
||||
if (!hasPermission) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const roleGuard: NavigationGuard = (to) => {
|
||||
const userStore = useUserStore();
|
||||
const routeMeta: RouteMeta = to.meta;
|
||||
if (routeMeta.hasRole) {
|
||||
const hasRole = userStore.roleCodes?.includes(routeMeta.hasRole);
|
||||
if (!hasRole) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const setupGuards = (router: Router) => {
|
||||
router.beforeEach(authGuard);
|
||||
router.beforeEach(permissionGuard);
|
||||
router.beforeEach(roleGuard);
|
||||
};
|
||||
29
frontend/src/router/index.ts
Normal file
29
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import { setupGuards } from "./guards";
|
||||
|
||||
import authRoutes from "./modules/auth";
|
||||
import dashboardRoutes from "./modules/dashboard";
|
||||
import errorRoutes from "./modules/error";
|
||||
import { RouteName } from "./constants";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
dashboardRoutes,
|
||||
...authRoutes,
|
||||
...errorRoutes,
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
});
|
||||
|
||||
router.onError((err) => {
|
||||
console.error("router err:", err);
|
||||
// TODO 增加一个错误页面
|
||||
router.push(RouteName.USERVIEW);
|
||||
return false;
|
||||
});
|
||||
setupGuards(router);
|
||||
|
||||
export default router;
|
||||
19
frontend/src/router/modules/auth.ts
Normal file
19
frontend/src/router/modules/auth.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import { RouteName, RoutePath } from "../constants";
|
||||
|
||||
const authRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: RoutePath.HOME,
|
||||
name: RouteName.HOME,
|
||||
redirect: {
|
||||
name: RouteName.LOGIN,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: RoutePath.LOGIN,
|
||||
name: RouteName.LOGIN,
|
||||
component: () => import("../../views/LoginView.vue"),
|
||||
},
|
||||
];
|
||||
|
||||
export default authRoutes;
|
||||
67
frontend/src/router/modules/dashboard.ts
Normal file
67
frontend/src/router/modules/dashboard.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import Dashboard from "../../components/Dashboard.vue";
|
||||
import OverView from "../../views/OverView.vue";
|
||||
import { ROLE, RouteName, RoutePath } from "../constants";
|
||||
import userManagementRoutes from "./user";
|
||||
|
||||
const dashboardRoutes: RouteRecordRaw = {
|
||||
path: RoutePath.DASHBOARD,
|
||||
name: RouteName.DASHBOARD,
|
||||
component: Dashboard,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: RoutePath.OVERVIEW,
|
||||
name: RouteName.OVERVIEW,
|
||||
component: () => import("@/views/OverView.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: RoutePath.SETTINGS,
|
||||
name: RouteName.SETTINGS,
|
||||
component: () => import("@/views/SettingsView.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
...userManagementRoutes,
|
||||
{
|
||||
path: RoutePath.NOTFOUND,
|
||||
name: RouteName.NOTFOUND,
|
||||
component: () => import("@/views/NotFound.vue"),
|
||||
},
|
||||
{
|
||||
path: RoutePath.SCHEDULERVIEW,
|
||||
name: RouteName.SCHEDULERVIEW,
|
||||
component: () => import("@/views/SchedulerView.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hasRole: ROLE.ADMIN,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: RoutePath.DEPARTMENTVIEW,
|
||||
name: RouteName.DEPARTMENTVIEW,
|
||||
component: () => import("@/views/DepartmentView.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hasRole: ROLE.ADMIN,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: RoutePath.POSITIONVIEW,
|
||||
name: RouteName.POSITIONVIEW,
|
||||
component: () => import("@/views/PositionView.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hasRole: ROLE.ADMIN,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default dashboardRoutes;
|
||||
12
frontend/src/router/modules/error.ts
Normal file
12
frontend/src/router/modules/error.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import { RouteName, RoutePath } from "../constants";
|
||||
|
||||
const errorRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: RoutePath.GLOBAL_NOTFOUND,
|
||||
name: RouteName.GLOBAL_NOTFOUND,
|
||||
component: () => import("../../views/NotFound.vue"),
|
||||
},
|
||||
];
|
||||
|
||||
export default errorRoutes;
|
||||
70
frontend/src/router/modules/user.ts
Normal file
70
frontend/src/router/modules/user.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import { ROLE, RouteName, RoutePath } from "../constants";
|
||||
|
||||
const userManagementRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: RoutePath.USERVIEW,
|
||||
name: RouteName.USERVIEW,
|
||||
component: () => import("@/views/UserView.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hasRole: ROLE.ADMIN,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: RoutePath.ROLEVIEW,
|
||||
name: RouteName.ROLEVIEW,
|
||||
component: () => import("@/views/RoleView.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hasRole: ROLE.ADMIN,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: RoutePath.BINDROLEVIEW,
|
||||
name: RouteName.BINDROLEVIEW,
|
||||
component: () => import("@/views/BindRoleView.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hasRole: ROLE.ADMIN,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: RoutePath.BINDDEPARTMENTVIEW,
|
||||
name: RouteName.BINDDEPARTMENTVIEW,
|
||||
component: () => import("@/views/BindDepartmentView.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hasRole: ROLE.ADMIN,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: RoutePath.BINDPERMISSIONVIEW,
|
||||
name: RouteName.BINDPERMISSIONVIEW,
|
||||
component: () => import("@/views/BindPermissionView.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hasRole: ROLE.ADMIN,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: RoutePath.PERMISSIONVIEW,
|
||||
name: RouteName.PERMISSIONVIEW,
|
||||
component: () => import("@/views/PermissionView.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hasRole: ROLE.ADMIN,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: RoutePath.BINDPOSITIONVIEW,
|
||||
name: RouteName.BINDPOSITIONVIEW,
|
||||
component: () => import("@/views/BindPositionView.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hasRole: ROLE.ADMIN,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default userManagementRoutes;
|
||||
6
frontend/src/types/alert.d.ts
vendored
Normal file
6
frontend/src/types/alert.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface AlertProps {
|
||||
content?: string;
|
||||
level?: "info" | "success" | "warning" | "error";
|
||||
isShow?: boolean;
|
||||
timer?: number;
|
||||
}
|
||||
5
frontend/src/types/department.d.ts
vendored
Normal file
5
frontend/src/types/department.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface DepartmentUpsertModel {
|
||||
id?: number;
|
||||
name: string;
|
||||
parentId?: number | null;
|
||||
}
|
||||
53
frontend/src/types/error.ts
Normal file
53
frontend/src/types/error.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
class HttpError extends Error {
|
||||
status: number;
|
||||
detail: string | undefined;
|
||||
constructor(message: string, status: number, detail: string | undefined) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
class UnAuthError extends HttpError {
|
||||
constructor(status: number, detail?: string) {
|
||||
super("身份认证异常", status, detail);
|
||||
this.name = "UnAuthError";
|
||||
}
|
||||
}
|
||||
|
||||
class ForbiddenError extends HttpError {
|
||||
constructor(status: number, detail?: string) {
|
||||
super("权限校验异常", status, detail);
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
class SystemError extends HttpError {
|
||||
constructor(status: number, detail?: string) {
|
||||
super("系统错误,请稍候再试", status, detail);
|
||||
this.name = "SystemError";
|
||||
}
|
||||
}
|
||||
|
||||
class InternalServerError extends HttpError {
|
||||
constructor(status: number, detail?: string) {
|
||||
super("服务器错误,请稍候再试", status, detail);
|
||||
this.name = "InternalServerError";
|
||||
}
|
||||
}
|
||||
|
||||
class BadRequestError extends HttpError {
|
||||
constructor(status: number, detail?: string) {
|
||||
super("请求非法", status, detail);
|
||||
this.name = "BadRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
UnAuthError,
|
||||
ForbiddenError,
|
||||
SystemError,
|
||||
InternalServerError,
|
||||
BadRequestError,
|
||||
};
|
||||
5
frontend/src/types/permission.d.ts
vendored
Normal file
5
frontend/src/types/permission.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface PermissionUpsertModel {
|
||||
id?: number;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
4
frontend/src/types/position.d.ts
vendored
Normal file
4
frontend/src/types/position.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface PositionUpsertModel {
|
||||
id?: number;
|
||||
name: string;
|
||||
}
|
||||
5
frontend/src/types/role.d.ts
vendored
Normal file
5
frontend/src/types/role.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface RoleUpsertModel {
|
||||
id?: number;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
11
frontend/src/types/router.d.ts
vendored
Normal file
11
frontend/src/types/router.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import "vue-router";
|
||||
|
||||
declare module "vue-router" {
|
||||
interface RouteMeta {
|
||||
requiresAuth?: boolean;
|
||||
hasPermission?: string;
|
||||
hasRole?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export { RouteMeta };
|
||||
8
frontend/src/types/user.d.ts
vendored
Normal file
8
frontend/src/types/user.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface UserUpsertSubmitModel {
|
||||
id?: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
enable: boolean;
|
||||
}
|
||||
|
||||
export type User = UserRolePermissionModel | null;
|
||||
50
frontend/src/utils/errorHandler.ts
Normal file
50
frontend/src/utils/errorHandler.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ComponentPublicInstance } from "vue";
|
||||
import type { Router } from "vue-router";
|
||||
import { RoutePath } from "../router/constants";
|
||||
import {
|
||||
ForbiddenError,
|
||||
InternalServerError,
|
||||
SystemError,
|
||||
UnAuthError,
|
||||
} from "../types/error";
|
||||
|
||||
const makeErrorHandler =
|
||||
(
|
||||
router: Router,
|
||||
signOut: () => void,
|
||||
showAlert: ({
|
||||
content,
|
||||
level,
|
||||
}: {
|
||||
content: string;
|
||||
level: "info" | "success" | "warning" | "error";
|
||||
}) => void,
|
||||
) =>
|
||||
(err: unknown, instance: ComponentPublicInstance | null, info: string) => {
|
||||
console.error(err);
|
||||
if (err instanceof UnAuthError) {
|
||||
signOut();
|
||||
router.push(RoutePath.LOGIN);
|
||||
showAlert({
|
||||
level: "error",
|
||||
content: err.message,
|
||||
});
|
||||
} else if (err instanceof ForbiddenError) {
|
||||
showAlert({
|
||||
level: "error",
|
||||
content: err.message,
|
||||
});
|
||||
} else if (err instanceof SystemError) {
|
||||
showAlert({
|
||||
level: "error",
|
||||
content: err.message,
|
||||
});
|
||||
} else if (err instanceof InternalServerError) {
|
||||
showAlert({
|
||||
level: "error",
|
||||
content: err.detail ?? err.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default makeErrorHandler;
|
||||
242
frontend/src/views/BindDepartmentView.vue
Normal file
242
frontend/src/views/BindDepartmentView.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="relative overflow-x-auto px-4 pt-6 xl:grid-cols-3 xl:gap-4 sm:rounded-lg">
|
||||
<div class="mb-4 col-span-full">
|
||||
<Breadcrumbs :names="['部门分配']" />
|
||||
<h1 class="text-xl mb-2 font-semibold text-gray-900 sm:text-2xl dark:text-white">部门分配</h1>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<form class="max-w-sm mb-4 grid grid-cols-5 gap-y-4">
|
||||
<div class="col-span-3">
|
||||
<label for="default-search"
|
||||
class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" 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="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search" id="default-search" v-model="departmentName"
|
||||
class="block p-3 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 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>
|
||||
<select id="countries" v-model="bindState"
|
||||
class="col-span-2 block bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 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 value="BIND">已绑定</option>
|
||||
<option value="UNBIND">未绑定</option>
|
||||
<option value="ALL">全部</option>
|
||||
</select>
|
||||
<button type="submit"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
@click.prevent="handleSearch">搜索</button>
|
||||
</form>
|
||||
<div class="flex items-center justify-end gap-2 absolute right-5 bottom-2">
|
||||
<button @click="() => {
|
||||
if (checkedDepartmentIds.length === 0) {
|
||||
alertStore.showAlert({
|
||||
content: '没有选择部门',
|
||||
level: 'error',
|
||||
});
|
||||
} else {
|
||||
departmentBindModal?.show();
|
||||
}
|
||||
}"
|
||||
class="flex items-center block text-white 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"
|
||||
type="button">
|
||||
绑定
|
||||
</button>
|
||||
<button @click="() => {
|
||||
if (checkedDepartmentIds.length === 0) {
|
||||
alertStore.showAlert({
|
||||
content: '没有选择部门',
|
||||
level: 'error',
|
||||
});
|
||||
} else {
|
||||
departmentUnbindModal?.show();
|
||||
}
|
||||
}"
|
||||
class="flex items-center block text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
|
||||
type="button">
|
||||
解绑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
|
||||
<thead class="text-xs uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="p-4">
|
||||
<div class="flex items-center">
|
||||
<input id="checkbox-all-search" type="checkbox" v-model="allChecked"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="checkbox-all-search" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">上级部门</th>
|
||||
<th scope="col" class="px-6 py-3">部门名称</th>
|
||||
<th scope="col" class="px-6 py-3">绑定状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="department in departments" :key="department.id"
|
||||
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<td class="w-4 p-4">
|
||||
<div class="flex items-center">
|
||||
<input :id="'checkbox-table-search-' + department.id" :value="department.id" type="checkbox"
|
||||
v-model="checkedDepartmentIds"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label :for="'checkbox-table-search-' + department.id" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</td>
|
||||
<td scope="row" class="px-6 py-4whitespace-nowrap dark:text-white">
|
||||
{{ !department.parentName ? '无' : department.parentName }}
|
||||
</td>
|
||||
<td scope="row" class="px-6 py-4whitespace-nowrap font-medium text-gray-900 dark:text-white">
|
||||
{{ department.name }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="h-2.5 w-2.5 rounded-full me-2" :class="department.isBound ? 'bg-green-500' : 'bg-red-500'">
|
||||
</div> {{
|
||||
department.isBound === true ? "已绑定" : "未绑定" }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<TablePagination :pageChange="handlePageChange" :total="total" />
|
||||
</div>
|
||||
|
||||
<BindModal :id="'department-bind-modal'" :closeModal="() => {
|
||||
departmentBindModal!.hide();
|
||||
}" :onSubmit="handleBindDepartmentSubmit" title="绑定选中的部门吗"></BindModal>
|
||||
<UnModal :id="'department-unbind-modal'" :closeModal="() => {
|
||||
departmentUnbindModal!.hide();
|
||||
}" :onSubmit="handleUnbindDepartmentSubmit" title="解绑选中的部门吗"></UnModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BindModal from "@/components/PopupModal.vue";
|
||||
import UnModal from "@/components/PopupModal.vue";
|
||||
import TablePagination from "@/components/TablePagination.vue";
|
||||
import { useDepartmentQuery } from "@/composables/department/useDepartmentQuery";
|
||||
import { RouteName } from "@/router/constants";
|
||||
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useDepartmentBind } from "../composables/department/useDepartmentBind";
|
||||
import useAlertStore from "../composables/store/useAlertStore";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs.vue";
|
||||
|
||||
const departmentName = ref<string>("");
|
||||
const checkedDepartmentIds = ref<number[]>([]);
|
||||
const departmentBindModal = ref<ModalInterface>();
|
||||
const departmentUnbindModal = ref<ModalInterface>();
|
||||
const allChecked = ref<boolean>(false);
|
||||
const $route = useRoute();
|
||||
const bindState = ref<"BIND" | "ALL" | "UNBIND">("BIND");
|
||||
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
const { total, departments, fetchDepartmentWith } = useDepartmentQuery();
|
||||
|
||||
const { bindDepartment, unbindDepartment } = useDepartmentBind();
|
||||
|
||||
const handleBindDepartmentSubmit = async () => {
|
||||
await bindDepartment(
|
||||
Number($route.params.userId),
|
||||
checkedDepartmentIds.value,
|
||||
);
|
||||
clearCheckedDepartment();
|
||||
departmentBindModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "操作成功",
|
||||
level: "success",
|
||||
});
|
||||
await fetchDepartmentWith({
|
||||
name: departmentName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnbindDepartmentSubmit = async () => {
|
||||
await unbindDepartment(
|
||||
Number($route.params.userId),
|
||||
checkedDepartmentIds.value,
|
||||
);
|
||||
clearCheckedDepartment();
|
||||
departmentUnbindModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "操作成功",
|
||||
level: "success",
|
||||
});
|
||||
await fetchDepartmentWith({
|
||||
name: departmentName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchDepartmentWith({
|
||||
name: departmentName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
initFlowbite();
|
||||
const $bindModalElement: HTMLElement | null = document.querySelector(
|
||||
"#department-bind-modal",
|
||||
);
|
||||
departmentBindModal.value = new Modal(
|
||||
$bindModalElement,
|
||||
{},
|
||||
{ id: "department-bind-modal" },
|
||||
);
|
||||
const $unbindModalElement: HTMLElement | null = document.querySelector(
|
||||
"#department-unbind-modal",
|
||||
);
|
||||
departmentUnbindModal.value = new Modal(
|
||||
$unbindModalElement,
|
||||
{},
|
||||
{ id: "department-unbind-modal" },
|
||||
);
|
||||
});
|
||||
|
||||
const handleSearch = async () => {
|
||||
await fetchDepartmentWith({
|
||||
name: departmentName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = async (page: number, pageSize: number) => {
|
||||
await fetchDepartmentWith(
|
||||
{
|
||||
name: departmentName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
},
|
||||
page,
|
||||
pageSize,
|
||||
);
|
||||
};
|
||||
|
||||
watch(allChecked, async () => {
|
||||
if (allChecked.value) {
|
||||
checkedDepartmentIds.value = departments.value?.map((d) => d.id!) ?? [];
|
||||
} else {
|
||||
checkedDepartmentIds.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
const clearCheckedDepartment = () => {
|
||||
checkedDepartmentIds.value = [];
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
241
frontend/src/views/BindPermissionView.vue
Normal file
241
frontend/src/views/BindPermissionView.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="relative overflow-x-auto px-4 pt-6 xl:grid-cols-3 xl:gap-4 sm:rounded-lg">
|
||||
<div class="mb-4 col-span-full">
|
||||
<Breadcrumbs :names="['绑定权限']" />
|
||||
<h1 class="text-xl mb-2 font-semibold text-gray-900 sm:text-2xl dark:text-white">
|
||||
绑定权限</h1>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<form class="max-w-sm mb-4 grid grid-cols-5 gap-y-4">
|
||||
<div class="col-span-3">
|
||||
<label for="default-search"
|
||||
class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" 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="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search" id="default-search" v-model="permissionName"
|
||||
class="block p-3 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 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>
|
||||
<select id="countries" v-model="bindState"
|
||||
class="col-span-2 block bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 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 value="BIND">已绑定</option>
|
||||
<option value="UNBIND">未绑定</option>
|
||||
<option value="ALL">全部</option>
|
||||
</select>
|
||||
<button type="submit"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
@click.prevent="handleSearch">搜索</button>
|
||||
</form>
|
||||
<div class="flex items-center justify-end gap-2 absolute right-5 bottom-2">
|
||||
<button @click="() => {
|
||||
if (checkedPermissionIds.length === 0) {
|
||||
alertStore.showAlert({
|
||||
content: '没有选择权限',
|
||||
level: 'error',
|
||||
});
|
||||
} else {
|
||||
permissionBindModal?.show();
|
||||
}
|
||||
}"
|
||||
class="flex items-center block text-white 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"
|
||||
type="button">
|
||||
绑定
|
||||
</button>
|
||||
<button @click="() => {
|
||||
if (checkedPermissionIds.length === 0) {
|
||||
alertStore.showAlert({
|
||||
content: '没有选择权限',
|
||||
level: 'error',
|
||||
});
|
||||
} else {
|
||||
permissionUnbindModal?.show();
|
||||
}
|
||||
}"
|
||||
class="flex items-center block text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
|
||||
type="button">
|
||||
解绑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="p-4">
|
||||
<div class="flex items-center">
|
||||
<input id="checkbox-all-search" type="checkbox" v-model="allChecked"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="checkbox-all-search" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">权限编码</th>
|
||||
<th scope="col" class="px-6 py-3">权限名称</th>
|
||||
<th scope="col" class="px-6 py-3">绑定状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="permission in permissions" :key="permission.id"
|
||||
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<td class="w-4 p-4">
|
||||
<div class="flex items-center">
|
||||
<input :id="'checkbox-table-search-' + permission.id" :value="permission.id" type="checkbox"
|
||||
v-model="checkedPermissionIds"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label :for="'checkbox-table-search-' + permission.id" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</td>
|
||||
<td scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ permission.code }}
|
||||
</td>
|
||||
<td scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ permission.name }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="h-2.5 w-2.5 rounded-full me-2" :class="permission.isBound ? 'bg-green-500' : 'bg-red-500'">
|
||||
</div> {{
|
||||
permission.isBound === true ? "已绑定" : "未绑定" }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<TablePagination :pageChange="handlePageChange" :total="total" />
|
||||
</div>
|
||||
|
||||
<BindModal :id="'permission-bind-modal'" :closeModal="() => {
|
||||
permissionBindModal!.hide();
|
||||
}" :onSubmit="handleBindPermissionSubmit" title="确定绑定选中的权限吗"></BindModal>
|
||||
<UnModal :id="'permission-unbind-modal'" :closeModal="() => {
|
||||
permissionUnbindModal!.hide();
|
||||
}" :onSubmit="handleUnbindPermissionSubmit" title="确定解绑选中的权限吗"></UnModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BindModal from "@/components/PopupModal.vue";
|
||||
import UnModal from "@/components/PopupModal.vue";
|
||||
import TablePagination from "@/components/TablePagination.vue";
|
||||
import { RouteName } from "@/router/constants";
|
||||
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { usePermissionBind } from "../composables/permission/usePermissionBind";
|
||||
import usePermissionsQuery from "../composables/permission/usePermissionQuery";
|
||||
import useAlertStore from "../composables/store/useAlertStore";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs.vue";
|
||||
|
||||
const permissionName = ref<string>("");
|
||||
const checkedPermissionIds = ref<number[]>([]);
|
||||
const permissionBindModal = ref<ModalInterface>();
|
||||
const permissionUnbindModal = ref<ModalInterface>();
|
||||
const allChecked = ref<boolean>(false);
|
||||
const $route = useRoute();
|
||||
const bindState = ref<"BIND" | "ALL" | "UNBIND">("BIND");
|
||||
|
||||
const alertStore = useAlertStore();
|
||||
const { total, permissions, fetchPermissionsWith } = usePermissionsQuery();
|
||||
const { bindPermission, unbindPermission } = usePermissionBind();
|
||||
|
||||
const handleBindPermissionSubmit = async () => {
|
||||
await bindPermission({
|
||||
roleId: Number($route.params.roleId),
|
||||
permissionIds: checkedPermissionIds.value,
|
||||
});
|
||||
permissionBindModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "操作成功",
|
||||
level: "success",
|
||||
});
|
||||
clearCheckedRoleIds();
|
||||
await fetchPermissionsWith({
|
||||
name: permissionName.value,
|
||||
roleId: Number($route.params.roleId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnbindPermissionSubmit = async () => {
|
||||
await unbindPermission({
|
||||
roleId: Number($route.params.roleId),
|
||||
permissionIds: checkedPermissionIds.value,
|
||||
});
|
||||
permissionUnbindModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "操作成功",
|
||||
level: "success",
|
||||
});
|
||||
clearCheckedRoleIds();
|
||||
await fetchPermissionsWith({
|
||||
name: permissionName.value,
|
||||
roleId: Number($route.params.roleId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchPermissionsWith({
|
||||
name: permissionName.value,
|
||||
roleId: Number($route.params.roleId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
initFlowbite();
|
||||
const $bindModalElement: HTMLElement | null = document.querySelector(
|
||||
"#permission-bind-modal",
|
||||
);
|
||||
permissionBindModal.value = new Modal(
|
||||
$bindModalElement,
|
||||
{},
|
||||
{ id: "permission-bind-modal" },
|
||||
);
|
||||
const $unbindModalElement: HTMLElement | null = document.querySelector(
|
||||
"#permission-unbind-modal",
|
||||
);
|
||||
permissionUnbindModal.value = new Modal(
|
||||
$unbindModalElement,
|
||||
{},
|
||||
{ id: "permission-unbind-modal" },
|
||||
);
|
||||
});
|
||||
|
||||
const handleSearch = async () => {
|
||||
await fetchPermissionsWith({
|
||||
name: permissionName.value,
|
||||
roleId: Number($route.params.roleId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = async (page: number, pageSize: number) => {
|
||||
await fetchPermissionsWith(
|
||||
{
|
||||
name: permissionName.value,
|
||||
roleId: Number($route.params.roleId),
|
||||
bindState: bindState.value,
|
||||
},
|
||||
page,
|
||||
pageSize,
|
||||
);
|
||||
};
|
||||
|
||||
watch(allChecked, async () => {
|
||||
if (allChecked.value) {
|
||||
checkedPermissionIds.value = permissions.value?.map((p) => p.id!) ?? [];
|
||||
} else {
|
||||
checkedPermissionIds.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
const clearCheckedRoleIds = () => {
|
||||
checkedPermissionIds.value = [];
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
233
frontend/src/views/BindPositionView.vue
Normal file
233
frontend/src/views/BindPositionView.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="relative overflow-x-auto px-4 pt-6 xl:grid-cols-3 xl:gap-4 sm:rounded-lg">
|
||||
<div class="mb-4 col-span-full">
|
||||
<Breadcrumbs :names="['岗位分配']" />
|
||||
<h1 class="text-xl mb-2 font-semibold text-gray-900 sm:text-2xl dark:text-white">岗位分配</h1>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<form class="max-w-sm mb-4 grid grid-cols-5 gap-y-4">
|
||||
<div class="col-span-3">
|
||||
<label for="default-search"
|
||||
class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" 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="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search" id="default-search" v-model="positionName"
|
||||
class="block p-3 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 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>
|
||||
<select id="countries" v-model="bindState"
|
||||
class="col-span-2 block bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 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 value="BIND">已绑定</option>
|
||||
<option value="UNBIND">未绑定</option>
|
||||
<option value="ALL">全部</option>
|
||||
</select>
|
||||
<button type="submit"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
@click.prevent="handleSearch">搜索</button>
|
||||
</form>
|
||||
<div class="flex items-center justify-end gap-2 absolute right-5 bottom-2">
|
||||
<button @click="() => {
|
||||
if (checkedPositionIds.length === 0) {
|
||||
alertStore.showAlert({
|
||||
content: '没有选择岗位',
|
||||
level: 'error',
|
||||
});
|
||||
} else {
|
||||
positionBindModal?.show();
|
||||
}
|
||||
}"
|
||||
class="flex items-center block text-white 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"
|
||||
type="button">
|
||||
绑定
|
||||
</button>
|
||||
<button @click="() => {
|
||||
if (checkedPositionIds.length === 0) {
|
||||
alertStore.showAlert({
|
||||
content: '没有选择岗位',
|
||||
level: 'error',
|
||||
});
|
||||
} else {
|
||||
positionUnbindModal?.show();
|
||||
}
|
||||
}"
|
||||
class="flex items-center block text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
|
||||
type="button">
|
||||
解绑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
|
||||
<thead class="text-xs uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="p-4">
|
||||
<div class="flex items-center">
|
||||
<input id="checkbox-all-search" type="checkbox" v-model="allChecked"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="checkbox-all-search" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">岗位名称</th>
|
||||
<th scope="col" class="px-6 py-3">绑定状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="position in positions" :key="position.id"
|
||||
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<td class="w-4 p-4">
|
||||
<div class="flex items-center">
|
||||
<input :id="'checkbox-table-search-' + position.id" :value="position.id" type="checkbox"
|
||||
v-model="checkedPositionIds"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label :for="'checkbox-table-search-' + position.id" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ position.name }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="h-2.5 w-2.5 rounded-full me-2" :class="position.isBound ? 'bg-green-500' : 'bg-red-500'">
|
||||
</div> {{
|
||||
position.isBound === true ? "已绑定" : "未绑定" }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<TablePagination :pageChange="handlePageChange" :total="total" />
|
||||
</div>
|
||||
|
||||
<BindModal :id="'position-bind-modal'" :closeModal="() => {
|
||||
positionBindModal!.hide();
|
||||
}" :onSubmit="handleBindPositionSubmit" title="绑定选中的岗位吗"></BindModal>
|
||||
<UnModal :id="'position-unbind-modal'" :closeModal="() => {
|
||||
positionUnbindModal!.hide();
|
||||
}" :onSubmit="handleUnbindPositionSubmit" title="解绑选中的岗位吗"></UnModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BindModal from "@/components/PopupModal.vue";
|
||||
import UnModal from "@/components/PopupModal.vue";
|
||||
import TablePagination from "@/components/TablePagination.vue";
|
||||
import { usePositionBind } from "@/composables/position/usePositionBind";
|
||||
import { usePositionQuery } from "@/composables/position/usePositionQuery";
|
||||
import { RouteName } from "@/router/constants";
|
||||
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import useAlertStore from "../composables/store/useAlertStore";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs.vue";
|
||||
|
||||
const positionName = ref<string>("");
|
||||
const checkedPositionIds = ref<number[]>([]);
|
||||
const positionBindModal = ref<ModalInterface>();
|
||||
const positionUnbindModal = ref<ModalInterface>();
|
||||
const allChecked = ref<boolean>(false);
|
||||
const $route = useRoute();
|
||||
const bindState = ref<"BIND" | "ALL" | "UNBIND">("BIND");
|
||||
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
const { total, positions, fetchPositionWith } = usePositionQuery();
|
||||
|
||||
const { bindPosition, unbindPosition } = usePositionBind();
|
||||
|
||||
const handleBindPositionSubmit = async () => {
|
||||
await bindPosition(Number($route.params.userId), checkedPositionIds.value);
|
||||
positionBindModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "操作成功",
|
||||
level: "success",
|
||||
});
|
||||
await fetchPositionWith({
|
||||
name: positionName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
clearCheckedPositionIds();
|
||||
};
|
||||
|
||||
const handleUnbindPositionSubmit = async () => {
|
||||
await unbindPosition(Number($route.params.userId), checkedPositionIds.value);
|
||||
positionUnbindModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "操作成功",
|
||||
level: "success",
|
||||
});
|
||||
await fetchPositionWith({
|
||||
name: positionName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
clearCheckedPositionIds();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchPositionWith({
|
||||
name: positionName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
initFlowbite();
|
||||
const $bindModalElement: HTMLElement | null = document.querySelector(
|
||||
"#position-bind-modal",
|
||||
);
|
||||
positionBindModal.value = new Modal(
|
||||
$bindModalElement,
|
||||
{},
|
||||
{ id: "position-bind-modal" },
|
||||
);
|
||||
const $unbindModalElement: HTMLElement | null = document.querySelector(
|
||||
"#position-unbind-modal",
|
||||
);
|
||||
positionUnbindModal.value = new Modal(
|
||||
$unbindModalElement,
|
||||
{},
|
||||
{ id: "position-unbind-modal" },
|
||||
);
|
||||
});
|
||||
|
||||
const handleSearch = async () => {
|
||||
await fetchPositionWith({
|
||||
name: positionName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = async (page: number, pageSize: number) => {
|
||||
await fetchPositionWith(
|
||||
{
|
||||
name: positionName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
},
|
||||
page,
|
||||
pageSize,
|
||||
);
|
||||
};
|
||||
|
||||
watch(allChecked, async () => {
|
||||
if (allChecked.value) {
|
||||
checkedPositionIds.value = positions.value?.map((d) => d.id!) ?? [];
|
||||
} else {
|
||||
checkedPositionIds.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
const clearCheckedPositionIds = () => {
|
||||
checkedPositionIds.value = [];
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
235
frontend/src/views/BindRoleView.vue
Normal file
235
frontend/src/views/BindRoleView.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="relative overflow-x-auto px-4 pt-6 xl:grid-cols-3 xl:gap-4 sm:rounded-lg">
|
||||
<div class="mb-4 col-span-full">
|
||||
<Breadcrumbs :names="['角色分配']" />
|
||||
<h1 class="text-xl mb-2 font-semibold text-gray-900 sm:text-2xl dark:text-white">角色分配</h1>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<form class="max-w-sm mb-4 grid grid-cols-5 gap-y-4">
|
||||
<div class="col-span-3">
|
||||
<label for="default-search"
|
||||
class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" 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="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search" id="default-search" v-model="roleName"
|
||||
class="block p-3 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 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>
|
||||
<select id="countries" v-model="bindState"
|
||||
class="col-span-2 block bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 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 value="BIND">已绑定</option>
|
||||
<option value="UNBIND">未绑定</option>
|
||||
<option value="ALL">全部</option>
|
||||
</select>
|
||||
<button type="submit"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
@click.prevent="handleSearch">搜索</button>
|
||||
</form>
|
||||
<div class="flex items-center justify-end gap-2 absolute right-5 bottom-2">
|
||||
<button @click="() => {
|
||||
if (checkedRoleIds.length === 0) {
|
||||
alertStore.showAlert({
|
||||
content: '没有选择角色',
|
||||
level: 'error',
|
||||
});
|
||||
} else {
|
||||
roleBindModal?.show();
|
||||
}
|
||||
}"
|
||||
class="flex items-center block text-white 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"
|
||||
type="button">
|
||||
绑定
|
||||
</button>
|
||||
<button @click="() => {
|
||||
if (checkedRoleIds.length === 0) {
|
||||
alertStore.showAlert({
|
||||
content: '没有选择角色',
|
||||
level: 'error',
|
||||
});
|
||||
} else {
|
||||
roleUnbindModal?.show();
|
||||
}
|
||||
}"
|
||||
class="flex items-center block text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
|
||||
type="button">
|
||||
解绑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
|
||||
<thead class="text-xs uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="p-4">
|
||||
<div class="flex items-center">
|
||||
<input id="checkbox-all-search" type="checkbox" v-model="allChecked"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="checkbox-all-search" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">角色编码</th>
|
||||
<th scope="col" class="px-6 py-3">角色名称</th>
|
||||
<th scope="col" class="px-6 py-3">绑定状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="role in roles" :key="role.id"
|
||||
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<td class="w-4 p-4">
|
||||
<div class="flex items-center">
|
||||
<input :id="'checkbox-table-search-' + role.id" :value="role.id" type="checkbox" v-model="checkedRoleIds"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label :for="'checkbox-table-search-' + role.id" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</td>
|
||||
<td scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ role.code }}
|
||||
</td>
|
||||
<td scope="row" class="px-6 py-4 whitespace-nowrap dark:text-white">
|
||||
{{ role.name }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="h-2.5 w-2.5 rounded-full me-2" :class="role.isBound ? 'bg-green-500' : 'bg-red-500'">
|
||||
</div> {{
|
||||
role.isBound === true ? "已绑定" : "未绑定" }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<TablePagination :pageChange="handlePageChange" :total="total" />
|
||||
</div>
|
||||
|
||||
<BindModal :id="'role-bind-modal'" :closeModal="() => {
|
||||
roleBindModal!.hide();
|
||||
}" :onSubmit="handleBindRoleSubmit" title="确定绑定选中的角色吗"></BindModal>
|
||||
<UnModal :id="'role-unbind-modal'" :closeModal="() => {
|
||||
roleUnbindModal!.hide();
|
||||
}" :onSubmit="handleUnbindRoleSubmit" title="确定解绑选中的角色吗"></UnModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BindModal from "@/components/PopupModal.vue";
|
||||
import UnModal from "@/components/PopupModal.vue";
|
||||
import TablePagination from "@/components/TablePagination.vue";
|
||||
import { useRolesQuery } from "@/composables/role/useRolesQuery";
|
||||
import { RouteName } from "@/router/constants";
|
||||
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useRoleBind } from "../composables/role/useRoleBind";
|
||||
import useAlertStore from "../composables/store/useAlertStore";
|
||||
import { tr } from "@faker-js/faker";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs.vue";
|
||||
|
||||
const roleName = ref<string>("");
|
||||
const checkedRoleIds = ref<number[]>([]);
|
||||
const roleBindModal = ref<ModalInterface>();
|
||||
const roleUnbindModal = ref<ModalInterface>();
|
||||
const allChecked = ref<boolean>(false);
|
||||
const $route = useRoute();
|
||||
const bindState = ref<"BIND" | "ALL" | "UNBIND">("BIND");
|
||||
|
||||
const alertStore = useAlertStore();
|
||||
const { total, roles, fetchRolesWith } = useRolesQuery();
|
||||
const { bindRole, unbindRole } = useRoleBind();
|
||||
|
||||
const handleBindRoleSubmit = async () => {
|
||||
await bindRole({
|
||||
userId: Number($route.params.userId),
|
||||
roleIds: checkedRoleIds.value,
|
||||
});
|
||||
roleBindModal.value?.hide();
|
||||
clearCheckedRoleIds();
|
||||
alertStore.showAlert({
|
||||
content: "操作成功",
|
||||
level: "success",
|
||||
});
|
||||
await fetchRolesWith({
|
||||
name: roleName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnbindRoleSubmit = async () => {
|
||||
await unbindRole(Number($route.params.userId), checkedRoleIds.value);
|
||||
clearCheckedRoleIds();
|
||||
roleUnbindModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "操作成功",
|
||||
level: "success",
|
||||
});
|
||||
await fetchRolesWith({
|
||||
name: roleName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchRolesWith({
|
||||
name: roleName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
initFlowbite();
|
||||
const $bindModalElement: HTMLElement | null =
|
||||
document.querySelector("#role-bind-modal");
|
||||
roleBindModal.value = new Modal(
|
||||
$bindModalElement,
|
||||
{},
|
||||
{ id: "role-bind-modal" },
|
||||
);
|
||||
const $unbindModalElement: HTMLElement | null =
|
||||
document.querySelector("#role-unbind-modal");
|
||||
roleUnbindModal.value = new Modal(
|
||||
$unbindModalElement,
|
||||
{},
|
||||
{ id: "role-unbind-modal" },
|
||||
);
|
||||
});
|
||||
|
||||
const handleSearch = async () => {
|
||||
await fetchRolesWith({
|
||||
name: roleName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = async (page: number, pageSize: number) => {
|
||||
await fetchRolesWith(
|
||||
{
|
||||
name: roleName.value,
|
||||
userId: Number($route.params.userId),
|
||||
bindState: bindState.value,
|
||||
},
|
||||
page,
|
||||
pageSize,
|
||||
);
|
||||
};
|
||||
|
||||
watch(allChecked, () => {
|
||||
if (allChecked.value) {
|
||||
checkedRoleIds.value = roles.value?.map((r) => r.id!) ?? [];
|
||||
} else {
|
||||
checkedRoleIds.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
const clearCheckedRoleIds = () => {
|
||||
checkedRoleIds.value = [];
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
233
frontend/src/views/DepartmentView.vue
Normal file
233
frontend/src/views/DepartmentView.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="relative overflow-x-auto px-4 pt-6 xl:grid-cols-3 xl:gap-4 sm:rounded-lg">
|
||||
<div class="mb-4 col-span-full">
|
||||
<Breadcrumbs :names="['部门管理']" />
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">部门管理</h1>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<form class="max-w-sm mb-4">
|
||||
<label for="default-search"
|
||||
class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" 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="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search" id="default-search" v-model="name"
|
||||
class="block w-full p-4 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 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 />
|
||||
<button type="submit"
|
||||
class="text-white absolute end-2.5 bottom-2.5 bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
@click.prevent="handleSearch">搜索</button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Create Modal toggle -->
|
||||
<button @click="handleUpsertDepartmentClick()"
|
||||
class="flex items-center block text-white 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 absolute right-5 bottom-2"
|
||||
type="button">
|
||||
新增部门
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm text-left rtl:text-right shadow-lg rounded-lg text-gray-500 dark:text-gray-400">
|
||||
<thead class="text-xs uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="p-4">
|
||||
<div class="flex items-center">
|
||||
<input id="checkbox-all-search" disabled type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="checkbox-all-search" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">上级部门</th>
|
||||
<th scope="col" class="px-6 py-3">部门名称</th>
|
||||
<th scope="col" class="px-6 py-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="department in departments" :key="department.id"
|
||||
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<td class="w-4 p-4">
|
||||
<div class="flex items-center">
|
||||
<input :id="'checkbox-table-search-' + department.id" type="checkbox" disabled
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label :for="'checkbox-table-search-' + department.id" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{{ !department.parentName ? '无' : department.parentName }}
|
||||
</td>
|
||||
<td class="px-6 py-4 font-medium text-gray-900 ">
|
||||
{{ department.name }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<button @click="handleUpsertDepartmentClick(department)"
|
||||
class="flex items-center gap-x-1 text-white 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 "
|
||||
type="button">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"></path>
|
||||
<path fill-rule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center block gap-x-1
|
||||
bg-red-700 hover:bg-red-800 focus:outline-none dark:bg-red-600 dark:hover:bg-red-700
|
||||
focus:ring-red-500 block text-white focus:ring-4 focus:outline-none font-medium rounded-lg text-sm px-5 py-2.5 text-center"
|
||||
@click="handleDeleteDepartmentClick(department)" type="button">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<TablePagination :total="total" :pageChange="handlePageChange" />
|
||||
</div>
|
||||
|
||||
<DepartmentDeleteModal :id="'department-delete-modal'" :closeModal="() => {
|
||||
departmentDeleteModal!.hide();
|
||||
}" :onSubmit="handleDeleteDepartmentSubmit" title="确定删除该部门吗" content="删除部门"></DepartmentDeleteModal>
|
||||
<DepartmentUpsertModal :id="'department-upsert-modal'" :onSubmit="handleUpsertDepartmentSubmit" :closeModal="() => {
|
||||
departmentUpsertModal!.hide();
|
||||
}" :department="selectedDepartment" :allDepartments="allDepartments">
|
||||
</DepartmentUpsertModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DepartmentDeleteModal from "@/components/PopupModal.vue";
|
||||
import TablePagination from "@/components/TablePagination.vue";
|
||||
import DepartmentUpsertModal from "@/components/DepartmentUpsertModal.vue";
|
||||
import { RouteName } from "@/router/constants";
|
||||
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
import type { components } from "../api/types/schema";
|
||||
import useDepartmentDelete from "../composables/department/useDepartmentDelete";
|
||||
import { useDepartmentQuery } from "../composables/department/useDepartmentQuery";
|
||||
import { useDepartmentUpsert } from "../composables/department/useDepartmentUpsert";
|
||||
import useAlertStore from "../composables/store/useAlertStore";
|
||||
import type { DepartmentUpsertModel } from "@/types/department";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs.vue";
|
||||
|
||||
const name = ref<string>("");
|
||||
const selectedDepartment = ref<components["schemas"]["Department"]>();
|
||||
const departmentUpsertModal = ref<ModalInterface>();
|
||||
const departmentDeleteModal = ref<ModalInterface>();
|
||||
|
||||
const {
|
||||
departments,
|
||||
allDepartments,
|
||||
fetchDepartmentWith,
|
||||
fetchAllDepartments,
|
||||
total,
|
||||
} = useDepartmentQuery();
|
||||
|
||||
const { deleteDepartment } = useDepartmentDelete();
|
||||
const { upsertDepartment } = useDepartmentUpsert();
|
||||
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAllDepartments();
|
||||
await fetchDepartmentWith({
|
||||
name: name.value,
|
||||
});
|
||||
initFlowbite();
|
||||
const $upsertModalElement: HTMLElement | null = document.querySelector(
|
||||
"#department-upsert-modal",
|
||||
);
|
||||
const $deleteModalElement: HTMLElement | null = document.querySelector(
|
||||
"#department-delete-modal",
|
||||
);
|
||||
departmentUpsertModal.value = new Modal(
|
||||
$upsertModalElement,
|
||||
{},
|
||||
{
|
||||
id: "department-upsert-modal",
|
||||
},
|
||||
);
|
||||
departmentDeleteModal.value = new Modal(
|
||||
$deleteModalElement,
|
||||
{},
|
||||
{
|
||||
id: "department-delete-modal",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const handleUpsertDepartmentSubmit = async (
|
||||
department: DepartmentUpsertModel,
|
||||
) => {
|
||||
await upsertDepartment(department);
|
||||
departmentUpsertModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "操作成功",
|
||||
level: "success",
|
||||
});
|
||||
await fetchDepartmentWith({
|
||||
name: name.value,
|
||||
});
|
||||
fetchAllDepartments();
|
||||
};
|
||||
|
||||
const handleUpsertDepartmentClick = async (
|
||||
department?: components["schemas"]["Department"],
|
||||
) => {
|
||||
selectedDepartment.value = department;
|
||||
await nextTick(() => {
|
||||
departmentUpsertModal.value?.show();
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteDepartmentSubmit = async () => {
|
||||
if (!selectedDepartment?.value?.id) return;
|
||||
await deleteDepartment(selectedDepartment.value.id);
|
||||
departmentDeleteModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "删除成功",
|
||||
level: "success",
|
||||
});
|
||||
await fetchDepartmentWith({
|
||||
name: name.value,
|
||||
});
|
||||
fetchAllDepartments();
|
||||
};
|
||||
|
||||
const handleDeleteDepartmentClick = async (
|
||||
department: components["schemas"]["Department"],
|
||||
) => {
|
||||
selectedDepartment.value = department;
|
||||
await nextTick(() => {
|
||||
departmentDeleteModal.value?.show();
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
await fetchDepartmentWith({
|
||||
name: name.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = async (page: number, size: number) => {
|
||||
await fetchDepartmentWith(
|
||||
{
|
||||
name: name.value,
|
||||
},
|
||||
page,
|
||||
size,
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
76
frontend/src/views/LoginView.vue
Normal file
76
frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div
|
||||
class="w-full max-w-sm p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:p-6 md:p-8 dark:bg-gray-800 dark:border-gray-700">
|
||||
<form class="flex flex-col gap-y-4" action="#">
|
||||
<h5 class="text-xl font-medium text-gray-900 dark:text-white">知路管理后台</h5>
|
||||
<div>
|
||||
<label for="username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">用户名</label>
|
||||
<input type="text" name="email" id="username" v-model="username"
|
||||
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-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
|
||||
placeholder="输入任意值" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">密码</label>
|
||||
<input type="password" name="password" id="password" v-model="password" placeholder="••••••••"
|
||||
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-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
|
||||
required />
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full text-white 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"
|
||||
@click.prevent="handleLogin">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { initFlowbite } from "flowbite";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { z } from "zod";
|
||||
import useUserAuth from "../composables/auth/useUserAuth";
|
||||
import useAlertStore from "../composables/store/useAlertStore";
|
||||
import { RoutePath } from "../router/constants";
|
||||
|
||||
const username = ref("admin");
|
||||
const password = ref("admin");
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userAuth = useUserAuth();
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
const handleLogin = async () => {
|
||||
const userSchema = z.object({
|
||||
username: z.string().min(1, "用户名至少1个字符"),
|
||||
password: z.string().min(1, "密码至少1个字符"),
|
||||
});
|
||||
|
||||
try {
|
||||
const validatedData = userSchema.parse({
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
});
|
||||
await userAuth.signIn(validatedData.username, validatedData.password);
|
||||
alertStore.showAlert({
|
||||
level: "success",
|
||||
content: "登录成功",
|
||||
});
|
||||
const redirectPath =
|
||||
(route.query.redirect as string) ||
|
||||
`${RoutePath.DASHBOARD}/${RoutePath.USERVIEW}`;
|
||||
router.push(redirectPath);
|
||||
} catch (e) {
|
||||
alertStore.showAlert({
|
||||
level: "error",
|
||||
content: e instanceof z.ZodError ? e.errors[0].message : "账号或密码错误",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initFlowbite();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
17
frontend/src/views/NotFound.vue
Normal file
17
frontend/src/views/NotFound.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { RoutePath } from "../router/constants";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="grid min-h-full place-items-center bg-white px-6 py-24 sm:py-32 lg:px-8">
|
||||
<div class="text-center">
|
||||
<p class="text-base font-semibold text-blue-700">404</p>
|
||||
<h1 class="mt-4 text-5xl font-semibold tracking-tight text-balance text-gray-900 sm:text-7xl">Page not found</h1>
|
||||
<p class="mt-6 text-lg font-medium text-pretty text-gray-500 sm:text-xl/8">您访问的资源未找到,请点击浏览器后退按钮返回</p>
|
||||
<div class="mt-10 flex items-center justify-center gap-x-6">
|
||||
<RouterLink :to="`${RoutePath.DASHBOARD}/${RoutePath.OVERVIEW}`" class="rounded-md px-3.5 py-2.5 text-sm font-semibold bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 text-white shadow-xs focus-visible:outline-2 focus-visible:outline-offset-2">回到主页</RouterLink>
|
||||
<a href="#" class="text-sm font-semibold text-gray-900">联系我们<span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
584
frontend/src/views/OverView.vue
Normal file
584
frontend/src/views/OverView.vue
Normal file
@@ -0,0 +1,584 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-5 gap-4 p-4">
|
||||
<div class="col-span-3 bg-white rounded-lg shadow-sm dark:bg-gray-800 p-4 md:p-6">
|
||||
<div class="flex justify-between pb-4 mb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center me-3">
|
||||
<svg class="w-6 h-6 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor" viewBox="0 0 20 19">
|
||||
<path
|
||||
d="M14.5 0A3.987 3.987 0 0 0 11 2.1a4.977 4.977 0 0 1 3.9 5.858A3.989 3.989 0 0 0 14.5 0ZM9 13h2a4 4 0 0 1 4 4v2H5v-2a4 4 0 0 1 4-4Z" />
|
||||
<path
|
||||
d="M5 19h10v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2ZM5 7a5.008 5.008 0 0 1 4-4.9 3.988 3.988 0 1 0-3.9 5.859A4.974 4.974 0 0 1 5 7Zm5 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm5-1h-.424a5.016 5.016 0 0 1-1.942 2.232A6.007 6.007 0 0 1 17 17h2a1 1 0 0 0 1-1v-2a5.006 5.006 0 0 0-5-5ZM5.424 9H5a5.006 5.006 0 0 0-5 5v2a1 1 0 0 0 1 1h2a6.007 6.007 0 0 1 4.366-5.768A5.016 5.016 0 0 1 5.424 9Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="leading-none text-2xl font-bold text-gray-900 dark:text-white pb-1">3.4k</h5>
|
||||
<p class="text-sm font-normal text-gray-500 dark:text-gray-400">Leads generated per week</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-1 rounded-md dark:bg-green-900 dark:text-green-300">
|
||||
<svg class="w-2.5 h-2.5 me-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 10 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M5 13V1m0 0L1 5m4-4 4 4" />
|
||||
</svg>
|
||||
42.5%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2">
|
||||
<dl class="flex items-center">
|
||||
<dt class="text-gray-500 dark:text-gray-400 text-sm font-normal me-1">Money spent:</dt>
|
||||
<dd class="text-gray-900 text-sm dark:text-white font-semibold">$3,232</dd>
|
||||
</dl>
|
||||
<dl class="flex items-center justify-end">
|
||||
<dt class="text-gray-500 dark:text-gray-400 text-sm font-normal me-1">Conversion rate:</dt>
|
||||
<dd class="text-gray-900 text-sm dark:text-white font-semibold">1.2%</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div id="column-chart"></div>
|
||||
<div class="grid grid-cols-1 items-center border-gray-200 border-t dark:border-gray-700 justify-between">
|
||||
<div class="flex justify-between items-center pt-5">
|
||||
<!-- Button -->
|
||||
<button id="dropdownDefaultButton" data-dropdown-toggle="lastDaysdropdown" data-dropdown-placement="bottom"
|
||||
class="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 text-center inline-flex items-center dark:hover:text-white"
|
||||
type="button">
|
||||
Last 7 days
|
||||
<svg class="w-2.5 m-2.5 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m1 1 4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div id="lastDaysdropdown"
|
||||
class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700">
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="dropdownDefaultButton">
|
||||
<li>
|
||||
<a href="#"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Yesterday</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Today</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Last
|
||||
7 days</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Last
|
||||
30 days</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Last
|
||||
90 days</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<a href="#"
|
||||
class="uppercase text-sm font-semibold inline-flex items-center rounded-lg text-blue-600 hover:text-blue-700 dark:hover:text-blue-500 hover:bg-gray-100 dark:hover:bg-gray-700 dark:focus:ring-gray-700 dark:border-gray-700 px-3 py-2">
|
||||
Leads Report
|
||||
<svg class="w-2.5 h-2.5 ms-1.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 6 10">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m1 9 4-4-4-4" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="col-span-1 bg-white border border-gray-200 rounded-lg shadow-sm sm:p-8 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">Latest Customers</h5>
|
||||
<a href="#" class="text-sm font-medium text-blue-600 hover:underline dark:text-blue-500">
|
||||
View all
|
||||
</a>
|
||||
</div>
|
||||
<div class="flow-root">
|
||||
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<li class="py-3 sm:py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="shrink-0">
|
||||
<img src="/trump.jpg" class="w-8 h-8 rounded-full" </div>
|
||||
<div class="flex-1 min-w-0 ms-4">
|
||||
<p class="text-sm font-medium text-gray-900 truncate dark:text-white">
|
||||
Neil Sims
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
|
||||
email@windster.com
|
||||
</p>
|
||||
</div>
|
||||
<div class="inline-flex items-center text-base font-semibold text-gray-900 dark:text-white">
|
||||
$320
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="py-3 sm:py-4">
|
||||
<div class="flex items-center ">
|
||||
<div class="shrink-0">
|
||||
<img src="/trump.jpg" class="w-8 h-8 rounded-full" </div>
|
||||
<div class="flex-1 min-w-0 ms-4">
|
||||
<p class="text-sm font-medium text-gray-900 truncate dark:text-white">
|
||||
Bonnie Green
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
|
||||
email@windster.com
|
||||
</p>
|
||||
</div>
|
||||
<div class="inline-flex items-center text-base font-semibold text-gray-900 dark:text-white">
|
||||
$3467
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="py-3 sm:py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="shrink-0">
|
||||
<img src="/trump.jpg" class="w-8 h-8 rounded-full" </div>
|
||||
<div class="flex-1 min-w-0 ms-4">
|
||||
<p class="text-sm font-medium text-gray-900 truncate dark:text-white">
|
||||
Michael Gough
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
|
||||
email@windster.com
|
||||
</p>
|
||||
</div>
|
||||
<div class="inline-flex items-center text-base font-semibold text-gray-900 dark:text-white">
|
||||
$67
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="py-3 sm:py-4">
|
||||
<div class="flex items-center ">
|
||||
<div class="shrink-0">
|
||||
<img src="/trump.jpg" class="w-8 h-8 rounded-full" </div>
|
||||
<div class="flex-1 min-w-0 ms-4">
|
||||
<p class="text-sm font-medium text-gray-900 truncate dark:text-white">
|
||||
Lana Byrd
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
|
||||
email@windster.com
|
||||
</p>
|
||||
</div>
|
||||
<div class="inline-flex items-center text-base font-semibold text-gray-900 dark:text-white">
|
||||
$367
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="pt-3 pb-0 sm:pt-4">
|
||||
<div class="flex items-center ">
|
||||
<div class="shrink-0">
|
||||
<img src="/trump.jpg" class="w-8 h-8 rounded-full" </div>
|
||||
<div class="flex-1 min-w-0 ms-4">
|
||||
<p class="text-sm font-medium text-gray-900 truncate dark:text-white">
|
||||
Thomes Lean
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
|
||||
email@windster.com
|
||||
</p>
|
||||
</div>
|
||||
<div class="inline-flex items-center text-base font-semibold text-gray-900 dark:text-white">
|
||||
$2367
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2 max-w-sm w-full bg-white rounded-lg shadow-sm dark:bg-gray-800 p-4 md:p-6">
|
||||
|
||||
<div class="flex justify-between items-start w-full">
|
||||
<div class="flex-col items-center">
|
||||
<div class="flex items-center mb-1">
|
||||
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white me-1">Website traffic</h5>
|
||||
<svg data-popover-target="chart-info" data-popover-placement="bottom"
|
||||
class="w-3.5 h-3.5 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white cursor-pointer ms-1"
|
||||
aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm0 16a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm1-5.034V12a1 1 0 0 1-2 0v-1.418a1 1 0 0 1 1.038-.999 1.436 1.436 0 0 0 1.488-1.441 1.501 1.501 0 1 0-3-.116.986.986 0 0 1-1.037.961 1 1 0 0 1-.96-1.037A3.5 3.5 0 1 1 11 11.466Z" />
|
||||
</svg>
|
||||
<div data-popover id="chart-info" role="tooltip"
|
||||
class="absolute z-10 invisible inline-block text-sm text-gray-500 transition-opacity duration-300 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 w-72 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
|
||||
<div class="p-3 space-y-2">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">Activity growth - Incremental</h3>
|
||||
<p>Report helps navigate cumulative growth of community activities. Ideally, the chart should have a
|
||||
growing trend, as stagnating chart signifies a significant decrease of community activity.</p>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">Calculation</h3>
|
||||
<p>For each date bucket, the all-time volume of activities is calculated. This means that activities in
|
||||
period n contain all activities up to period n, plus the activities generated by your community in
|
||||
period.</p>
|
||||
<a href="#"
|
||||
class="flex items-center font-medium text-blue-600 dark:text-blue-500 dark:hover:text-blue-600 hover:text-blue-700 hover:underline">Read
|
||||
more <svg class="w-2 h-2 ms-1.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 6 10">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m1 9 4-4-4-4" />
|
||||
</svg></a>
|
||||
</div>
|
||||
<div data-popper-arrow></div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="dateRangeButton" data-dropdown-toggle="dateRangeDropdown"
|
||||
data-dropdown-ignore-click-outside-class="datepicker" type="button"
|
||||
class="inline-flex items-center text-blue-700 dark:text-blue-600 font-medium hover:underline">31 Nov - 31
|
||||
Dev <svg class="w-3 h-3 ms-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m1 1 4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="dateRangeDropdown"
|
||||
class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-80 lg:w-96 dark:bg-gray-700 dark:divide-gray-600">
|
||||
<div class="p-3" aria-labelledby="dateRangeButton">
|
||||
<div date-rangepicker datepicker-autohide class="flex items-center">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M20 4a2 2 0 0 0-2-2h-2V1a1 1 0 0 0-2 0v1h-3V1a1 1 0 0 0-2 0v1H6V1a1 1 0 0 0-2 0v1H2a2 2 0 0 0-2 2v2h20V4ZM0 18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8H0v10Zm5-8h10a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input name="start" type="text"
|
||||
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 ps-10 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="Start date">
|
||||
</div>
|
||||
<span class="mx-2 text-gray-500 dark:text-gray-400">to</span>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M20 4a2 2 0 0 0-2-2h-2V1a1 1 0 0 0-2 0v1h-3V1a1 1 0 0 0-2 0v1H6V1a1 1 0 0 0-2 0v1H2a2 2 0 0 0-2 2v2h20V4ZM0 18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8H0v10Zm5-8h10a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input name="end" type="text"
|
||||
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 ps-10 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="End date">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end items-center">
|
||||
<button id="widgetDropdownButton" data-dropdown-toggle="widgetDropdown" data-dropdown-placement="bottom"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center text-gray-500 w-8 h-8 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm"><svg
|
||||
class="w-3.5 h-3.5 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor" viewBox="0 0 16 3">
|
||||
<path
|
||||
d="M2 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm6.041 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM14 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Z" />
|
||||
</svg><span class="sr-only">Open dropdown</span>
|
||||
</button>
|
||||
<div id="widgetDropdown"
|
||||
class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700">
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="widgetDropdownButton">
|
||||
<li>
|
||||
<a href="#"
|
||||
class="flex items-center px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"><svg
|
||||
class="w-3 h-3 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 21 21">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M7.418 17.861 1 20l2.139-6.418m4.279 4.279 10.7-10.7a3.027 3.027 0 0 0-2.14-5.165c-.802 0-1.571.319-2.139.886l-10.7 10.7m4.279 4.279-4.279-4.279m2.139 2.14 7.844-7.844m-1.426-2.853 4.279 4.279" />
|
||||
</svg>Edit widget
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"
|
||||
class="flex items-center px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"><svg
|
||||
class="w-3 h-3 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor"
|
||||
viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M14.707 7.793a1 1 0 0 0-1.414 0L11 10.086V1.5a1 1 0 0 0-2 0v8.586L6.707 7.793a1 1 0 1 0-1.414 1.414l4 4a1 1 0 0 0 1.416 0l4-4a1 1 0 0 0-.002-1.414Z" />
|
||||
<path
|
||||
d="M18 12h-2.55l-2.975 2.975a3.5 3.5 0 0 1-4.95 0L4.55 12H2a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2Zm-3 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z" />
|
||||
</svg>Download data
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"
|
||||
class="flex items-center px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"><svg
|
||||
class="w-3 h-3 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 18 18">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m5.953 7.467 6.094-2.612m.096 8.114L5.857 9.676m.305-1.192a2.581 2.581 0 1 1-5.162 0 2.581 2.581 0 0 1 5.162 0ZM17 3.84a2.581 2.581 0 1 1-5.162 0 2.581 2.581 0 0 1 5.162 0Zm0 10.322a2.581 2.581 0 1 1-5.162 0 2.581 2.581 0 0 1 5.162 0Z" />
|
||||
</svg>Add to repository
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"
|
||||
class="flex items-center px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"><svg
|
||||
class="w-3 h-3 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor"
|
||||
viewBox="0 0 18 20">
|
||||
<path
|
||||
d="M17 4h-4V2a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v2H1a1 1 0 0 0 0 2h1v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6h1a1 1 0 1 0 0-2ZM7 2h4v2H7V2Zm1 14a1 1 0 1 1-2 0V8a1 1 0 0 1 2 0v8Zm4 0a1 1 0 0 1-2 0V8a1 1 0 0 1 2 0v8Z" />
|
||||
</svg>Delete widget
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line Chart -->
|
||||
<div class="py-6" id="pie-chart"></div>
|
||||
|
||||
<div class="grid grid-cols-1 items-center border-gray-200 border-t dark:border-gray-700 justify-between">
|
||||
<div class="flex justify-between items-center pt-5">
|
||||
<!-- Button -->
|
||||
<button id="dropdownDefaultButton" data-dropdown-toggle="lastDaysdropdown" data-dropdown-placement="bottom"
|
||||
class="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 text-center inline-flex items-center dark:hover:text-white"
|
||||
type="button">
|
||||
Last 7 days
|
||||
<svg class="w-2.5 m-2.5 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m1 1 4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="lastDaysdropdown"
|
||||
class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700">
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="dropdownDefaultButton">
|
||||
<li>
|
||||
<a href="#"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Yesterday</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Today</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Last
|
||||
7 days</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Last
|
||||
30 days</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Last
|
||||
90 days</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<a href="#"
|
||||
class="uppercase text-sm font-semibold inline-flex items-center rounded-lg text-blue-600 hover:text-blue-700 dark:hover:text-blue-500 hover:bg-gray-100 dark:hover:bg-gray-700 dark:focus:ring-gray-700 dark:border-gray-700 px-3 py-2">
|
||||
Traffic analysis
|
||||
<svg class="w-2.5 h-2.5 ms-1.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 6 10">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m1 9 4-4-4-4" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ApexCharts from "apexcharts";
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
onMounted(() => {
|
||||
const options = {
|
||||
colors: ["#1A56DB", "#FDBA8C"],
|
||||
series: [
|
||||
{
|
||||
name: "Organic",
|
||||
color: "#1A56DB",
|
||||
data: [
|
||||
{ x: "Mon", y: 231 },
|
||||
{ x: "Tue", y: 122 },
|
||||
{ x: "Wed", y: 63 },
|
||||
{ x: "Thu", y: 421 },
|
||||
{ x: "Fri", y: 122 },
|
||||
{ x: "Sat", y: 323 },
|
||||
{ x: "Sun", y: 111 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Social media",
|
||||
color: "#FDBA8C",
|
||||
data: [
|
||||
{ x: "Mon", y: 232 },
|
||||
{ x: "Tue", y: 113 },
|
||||
{ x: "Wed", y: 341 },
|
||||
{ x: "Thu", y: 224 },
|
||||
{ x: "Fri", y: 522 },
|
||||
{ x: "Sat", y: 411 },
|
||||
{ x: "Sun", y: 243 },
|
||||
],
|
||||
},
|
||||
],
|
||||
chart: {
|
||||
type: "bar",
|
||||
height: "320px",
|
||||
fontFamily: "Inter, sans-serif",
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: false,
|
||||
columnWidth: "70%",
|
||||
borderRadiusApplication: "end",
|
||||
borderRadius: 8,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
shared: true,
|
||||
intersect: false,
|
||||
style: {
|
||||
fontFamily: "Inter, sans-serif",
|
||||
},
|
||||
},
|
||||
states: {
|
||||
hover: {
|
||||
filter: {
|
||||
type: "darken",
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
stroke: {
|
||||
show: true,
|
||||
width: 0,
|
||||
colors: ["transparent"],
|
||||
},
|
||||
grid: {
|
||||
show: false,
|
||||
strokeDashArray: 4,
|
||||
padding: {
|
||||
left: 2,
|
||||
right: 2,
|
||||
top: -14,
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
xaxis: {
|
||||
floating: false,
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
fontFamily: "Inter, sans-serif",
|
||||
cssClass: "text-xs font-normal fill-gray-500 dark:fill-gray-400",
|
||||
},
|
||||
},
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
show: false,
|
||||
},
|
||||
fill: {
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const getChartOptions = () => {
|
||||
return {
|
||||
series: [52.8, 26.8, 20.4],
|
||||
colors: ["#1C64F2", "#16BDCA", "#9061F9"],
|
||||
chart: {
|
||||
height: 420,
|
||||
width: "100%",
|
||||
type: "pie",
|
||||
},
|
||||
stroke: {
|
||||
colors: ["white"],
|
||||
lineCap: "",
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
labels: {
|
||||
show: true,
|
||||
},
|
||||
size: "100%",
|
||||
dataLabels: {
|
||||
offset: -25,
|
||||
},
|
||||
},
|
||||
},
|
||||
labels: ["Direct", "Organic search", "Referrals"],
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
style: {
|
||||
fontFamily: "Inter, sans-serif",
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
position: "bottom",
|
||||
fontFamily: "Inter, sans-serif",
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
formatter: (value: string) => `${value}%`,
|
||||
},
|
||||
},
|
||||
xaxis: {
|
||||
labels: {
|
||||
formatter: (value: string) => `${value}%`,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
if (
|
||||
document.getElementById("pie-chart") &&
|
||||
typeof ApexCharts !== "undefined"
|
||||
) {
|
||||
const chart = new ApexCharts(
|
||||
document.getElementById("pie-chart"),
|
||||
getChartOptions(),
|
||||
);
|
||||
chart.render();
|
||||
}
|
||||
|
||||
if (
|
||||
document.getElementById("column-chart") &&
|
||||
typeof ApexCharts !== "undefined"
|
||||
) {
|
||||
const chart = new ApexCharts(
|
||||
document.getElementById("column-chart"),
|
||||
options,
|
||||
);
|
||||
chart.render();
|
||||
}
|
||||
|
||||
if (
|
||||
document.getElementById("data-series-chart") &&
|
||||
typeof ApexCharts !== "undefined"
|
||||
) {
|
||||
const chart = new ApexCharts(
|
||||
document.getElementById("data-series-chart"),
|
||||
options,
|
||||
);
|
||||
chart.render();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
214
frontend/src/views/PermissionView.vue
Normal file
214
frontend/src/views/PermissionView.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="relative overflow-x-auto px-4 pt-6 xl:grid-cols-3 xl:gap-4 sm:rounded-lg">
|
||||
<div class="mb-4 col-span-full">
|
||||
<Breadcrumbs :names="['权限管理']" />
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">权限管理</h1>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<form class="max-w-sm mb-4">
|
||||
<label for="default-search"
|
||||
class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" 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="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search" id="default-search" v-model="permissionName"
|
||||
class="block w-full p-4 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 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 />
|
||||
<button type="submit"
|
||||
class="text-white absolute end-2.5 bottom-2.5 bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
@click.prevent="handleSearch">搜索</button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Create Modal toggle -->
|
||||
<button @click="handleUpsertPermissionClick(undefined)"
|
||||
class="flex items-center block gap-x-1 text-white 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 absolute right-5 bottom-2"
|
||||
type="button">
|
||||
新增权限
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm text-left rtl:text-right shadow-lg rounded-lg text-gray-500 dark:text-gray-400">
|
||||
<thead class="text-xs uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="p-4">
|
||||
<div class="flex items-center">
|
||||
<input id="checkbox-all-search" disabled type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="checkbox-all-search" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">权限名称</th>
|
||||
<th scope="col" class="px-6 py-3">权限编码</th>
|
||||
<th scope="col" class="px-6 py-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="permission in permissions" :key="permission.id"
|
||||
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<td class="w-4 p-4">
|
||||
<div class="flex items-center">
|
||||
<input :id="'checkbox-table-search-' + permission.id" type="checkbox" disabled
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label :for="'checkbox-table-search-' + permission.id" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</td>
|
||||
<td scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ permission.name }}
|
||||
</td>
|
||||
<td class="px-6 py-4">{{ permission.code }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<button @click="handleUpsertPermissionClick(permission)"
|
||||
class="flex items-center block gap-x-1 text-white 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 "
|
||||
type="button">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"></path>
|
||||
<path fill-rule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center block gap-x-1
|
||||
bg-red-700 hover:bg-red-800 focus:outline-none dark:bg-red-600 dark:hover:bg-red-700
|
||||
focus:ring-red-500 block text-white focus:ring-4 focus:outline-none font-medium rounded-lg text-sm px-5 py-2.5 text-center"
|
||||
@click="handleDeletePermissionClick(permission)" type="button">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<TablePagination :pageChange="handlePageChange" :total="total" />
|
||||
</div>
|
||||
|
||||
<PermissionDeleteModal :id="'permission-delete-modal'" :closeModal="() => {
|
||||
permissionDeleteModal!.hide();
|
||||
}" :onSubmit="handleDeleteModalSubmit" title="确定删除该权限吗" content="删除权限"></PermissionDeleteModal>
|
||||
<PermissionUpsertModal :id="'permission-upsert-modal'" :onSubmit="handleUpsertModalSubmit" :closeModal="() => {
|
||||
permissionUpsertModal!.hide();
|
||||
}" :permission="selectedPermission">
|
||||
</PermissionUpsertModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PermissionUpsertModal from "@/components/PermissionUpsertModal.vue";
|
||||
import PermissionDeleteModal from "@/components/PopupModal.vue";
|
||||
import usePermissionDelete from "@/composables/permission/usePermissionDelete";
|
||||
|
||||
import type { components } from "@/api/types/schema";
|
||||
import TablePagination from "@/components/TablePagination.vue";
|
||||
import { RouteName } from "@/router/constants";
|
||||
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
import usePermissionsQuery from "../composables/permission/usePermissionQuery";
|
||||
import usePermissionUpsert from "../composables/permission/usePermissionUpsert";
|
||||
import useAlertStore from "../composables/store/useAlertStore";
|
||||
import type { PermissionUpsertModel } from "../types/permission";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs.vue";
|
||||
|
||||
const permissionName = ref<string>("");
|
||||
const selectedPermission = ref<components["schemas"]["PermissionRespDto"]>();
|
||||
const permissionUpsertModal = ref<ModalInterface>();
|
||||
const permissionDeleteModal = ref<ModalInterface>();
|
||||
|
||||
const { total, permissions, fetchPermissionsWith } = usePermissionsQuery();
|
||||
|
||||
const { deletePermission } = usePermissionDelete();
|
||||
const permissionUpsert = usePermissionUpsert();
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchPermissionsWith({
|
||||
name: permissionName.value,
|
||||
});
|
||||
initFlowbite();
|
||||
const $upsertModalElement: HTMLElement | null = document.querySelector(
|
||||
"#permission-upsert-modal",
|
||||
);
|
||||
const $deleteModalElement: HTMLElement | null = document.querySelector(
|
||||
"#permission-delete-modal",
|
||||
);
|
||||
permissionUpsertModal.value = new Modal(
|
||||
$upsertModalElement,
|
||||
{},
|
||||
{ id: "permission-upsert-modal" },
|
||||
);
|
||||
permissionDeleteModal.value = new Modal(
|
||||
$deleteModalElement,
|
||||
{},
|
||||
{ id: "permission-delete-modal" },
|
||||
);
|
||||
});
|
||||
|
||||
const handleUpsertModalSubmit = async (data: PermissionUpsertModel) => {
|
||||
await permissionUpsert.upsertPermission(data);
|
||||
await fetchPermissionsWith({
|
||||
name: permissionName.value,
|
||||
});
|
||||
permissionUpsertModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "操作成功",
|
||||
level: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpsertPermissionClick = async (
|
||||
permission?: components["schemas"]["PermissionRespDto"],
|
||||
) => {
|
||||
selectedPermission.value = permission;
|
||||
await nextTick(() => {
|
||||
permissionUpsertModal.value?.show();
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteModalSubmit = async (event: Event) => {
|
||||
if (!selectedPermission?.value?.id) return;
|
||||
await deletePermission(selectedPermission.value.id);
|
||||
permissionDeleteModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "删除成功",
|
||||
level: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeletePermissionClick = async (
|
||||
permission: components["schemas"]["PermissionRespDto"],
|
||||
) => {
|
||||
selectedPermission.value = permission;
|
||||
await nextTick(() => {
|
||||
permissionDeleteModal.value?.show();
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
await fetchPermissionsWith({
|
||||
name: permissionName.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = async (page: number, pageSize: number) => {
|
||||
await fetchPermissionsWith(
|
||||
{
|
||||
name: permissionName.value,
|
||||
},
|
||||
page,
|
||||
pageSize,
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
224
frontend/src/views/PositionView.vue
Normal file
224
frontend/src/views/PositionView.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="relative overflow-x-auto px-4 pt-6 xl:grid-cols-3 xl:gap-4 sm:rounded-lg">
|
||||
<div class="mb-4 col-span-full">
|
||||
<Breadcrumbs :names="['岗位管理']" />
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">岗位管理</h1>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<form class="max-w-sm mb-4 ">
|
||||
<label for="default-search"
|
||||
class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" 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="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search" id="default-search" v-model="name"
|
||||
class="block w-full p-4 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 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 />
|
||||
<button type="submit"
|
||||
class="text-white absolute end-2.5 bottom-2.5 bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
@click.prevent="handleSearch">搜索</button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Create Modal toggle -->
|
||||
<button @click="handleUpsertPositionClick()"
|
||||
class="flex items-center block text-white 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 absolute right-5 bottom-2"
|
||||
type="button">
|
||||
新增岗位
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm text-left rtl:text-right shadow-lg rounded-lg text-gray-500 dark:text-gray-400">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="p-4">
|
||||
<div class="flex items-center">
|
||||
<input id="checkbox-all-search" disabled type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="checkbox-all-search" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">岗位名称</th>
|
||||
<th scope="col" class="px-6 py-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="position in positions" :key="position.id"
|
||||
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<td class="w-4 p-4">
|
||||
<div class="flex items-center">
|
||||
<input :id="'checkbox-table-search-' + position.id" type="checkbox" disabled
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label :for="'checkbox-table-search-' + position.id" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 font-medium text-gray-900 ">
|
||||
{{ position.name }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<button @click="handleUpsertPositionClick(position)"
|
||||
class="flex items-center block gap-x-1 text-white 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 "
|
||||
type="button">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"></path>
|
||||
<path fill-rule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center block gap-x-1
|
||||
bg-red-700 hover:bg-red-800 focus:outline-none dark:bg-red-600 dark:hover:bg-red-700
|
||||
focus:ring-red-500 block text-white focus:ring-4 focus:outline-none font-medium rounded-lg text-sm px-5 py-2.5 text-center"
|
||||
@click="handleDeletePositionClick(position)" type="button">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<TablePagination :total="total" :pageChange="handlePageChange" />
|
||||
</div>
|
||||
|
||||
<PositionDeleteModal :id="'position-delete-modal'" :closeModal="() => {
|
||||
positionDeleteModal!.hide();
|
||||
}" :onSubmit="handleDeletePositionSubmit" title="确定删除该岗位吗" content="删除岗位"></PositionDeleteModal>
|
||||
|
||||
<PositionUpsertModal :id="'position-upsert-modal'" :onSubmit="handleUpsertPositionSubmit" :closeModal="() => {
|
||||
positionUpsertModal!.hide();
|
||||
}" :position="selectedPosition" :allPositions="allPositions">
|
||||
</PositionUpsertModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PositionDeleteModal from "@/components/PopupModal.vue";
|
||||
import TablePagination from "@/components/TablePagination.vue";
|
||||
import PositionUpsertModal from "@/components/PositionUpsertModal.vue";
|
||||
import usePositionDelete from "@/composables/position/usePositionDelete";
|
||||
import { usePositionQuery } from "@/composables/position/usePositionQuery";
|
||||
import { usePositionUpsert } from "@/composables/position/usePositionUpsert";
|
||||
import { RouteName } from "@/router/constants";
|
||||
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
import type { components } from "../api/types/schema";
|
||||
import useAlertStore from "../composables/store/useAlertStore";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs.vue";
|
||||
|
||||
const name = ref<string>("");
|
||||
const selectedPosition = ref<components["schemas"]["Position"]>();
|
||||
const positionUpsertModal = ref<ModalInterface>();
|
||||
const positionDeleteModal = ref<ModalInterface>();
|
||||
|
||||
const { positions, allPositions, fetchPositionWith, fetchAllPositions, total } =
|
||||
usePositionQuery();
|
||||
|
||||
const { deletePosition } = usePositionDelete();
|
||||
const { upsertPosition } = usePositionUpsert();
|
||||
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAllPositions();
|
||||
await fetchPositionWith({
|
||||
name: name.value,
|
||||
});
|
||||
initFlowbite();
|
||||
const $upsertModalElement: HTMLElement | null = document.querySelector(
|
||||
"#position-upsert-modal",
|
||||
);
|
||||
const $deleteModalElement: HTMLElement | null = document.querySelector(
|
||||
"#position-delete-modal",
|
||||
);
|
||||
positionUpsertModal.value = new Modal(
|
||||
$upsertModalElement,
|
||||
{},
|
||||
{
|
||||
id: "position-upsert-modal",
|
||||
},
|
||||
);
|
||||
positionDeleteModal.value = new Modal(
|
||||
$deleteModalElement,
|
||||
{},
|
||||
{
|
||||
id: "position-delete-modal",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const handleUpsertPositionSubmit = async (
|
||||
position: components["schemas"]["Position"],
|
||||
) => {
|
||||
await upsertPosition(position);
|
||||
positionUpsertModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "操作成功",
|
||||
level: "success",
|
||||
});
|
||||
fetchAllPositions();
|
||||
await fetchPositionWith({
|
||||
name: name.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpsertPositionClick = async (
|
||||
position?: components["schemas"]["Position"],
|
||||
) => {
|
||||
selectedPosition.value = position;
|
||||
await nextTick(() => {
|
||||
positionUpsertModal.value?.show();
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeletePositionSubmit = async () => {
|
||||
if (!selectedPosition?.value?.id) return;
|
||||
await deletePosition(selectedPosition.value.id);
|
||||
positionDeleteModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "删除成功",
|
||||
level: "success",
|
||||
});
|
||||
fetchAllPositions();
|
||||
await fetchPositionWith({
|
||||
name: name.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeletePositionClick = async (
|
||||
position: components["schemas"]["Position"],
|
||||
) => {
|
||||
selectedPosition.value = position;
|
||||
await nextTick(() => {
|
||||
positionDeleteModal.value?.show();
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
await fetchPositionWith({
|
||||
name: name.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = async (page: number, size: number) => {
|
||||
await fetchPositionWith(
|
||||
{
|
||||
name: name.value,
|
||||
},
|
||||
page,
|
||||
size,
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
233
frontend/src/views/RoleView.vue
Normal file
233
frontend/src/views/RoleView.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="relative overflow-x-auto px-4 pt-6 xl:grid-cols-3 xl:gap-4 sm:rounded-lg">
|
||||
<div class="mb-4 col-span-full">
|
||||
<Breadcrumbs :names="['角色管理']" />
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">角色管理</h1>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<form class="max-w-sm mb-4">
|
||||
<label for="default-search"
|
||||
class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" 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="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search" id="default-search" v-model="roleName"
|
||||
class="block w-full p-4 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 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 />
|
||||
<button type="submit"
|
||||
class="text-white absolute end-2.5 bottom-2.5 bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
@click.prevent="handleSearch">搜索</button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Create Modal toggle -->
|
||||
<button @click="handleUpsertRoleClick(undefined)"
|
||||
class="flex items-center block gap-x-1 text-white 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 absolute right-5 bottom-2"
|
||||
type="button">
|
||||
新增角色
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm text-left rtl:text-right shadow-lg rounded-lg text-gray-500 dark:text-gray-400">
|
||||
<thead class="text-xs uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="p-4">
|
||||
<div class="flex items-center">
|
||||
<input id="checkbox-all-search" disabled type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="checkbox-all-search" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">角色名称</th>
|
||||
<th scope="col" class="px-6 py-3">角色编码</th>
|
||||
<th scope="col" class="px-6 py-3">操作</th>
|
||||
<th scope="col" class="px-6 py-3">分配</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="role in roles" :key="role.id"
|
||||
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<td class="w-4 p-4">
|
||||
<div class="flex items-center">
|
||||
<input :id="'checkbox-table-search-' + role.id" type="checkbox" disabled
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label :for="'checkbox-table-search-' + role.id" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</td>
|
||||
<td scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ role.name }}
|
||||
</td>
|
||||
<td class="px-6 py-4">{{ role.code }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<button @click="handleUpsertRoleClick(role)"
|
||||
class="flex items-center block gap-x-1 text-white 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 "
|
||||
type="button">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"></path>
|
||||
<path fill-rule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center block gap-x-1
|
||||
bg-red-700 hover:bg-red-800 focus:outline-none dark:bg-red-600 dark:hover:bg-red-700
|
||||
focus:ring-red-500 block text-white focus:ring-4 focus:outline-none font-medium rounded-lg text-sm px-5 py-2.5 text-center"
|
||||
@click="handleDeleteRoleClick(role)" type="button">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div>
|
||||
<button
|
||||
class="flex itmes-center text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 font-medium rounded-lg text-sm px-4 py-2.5 text-center dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700"
|
||||
@click="handleBindPermissionClick(role)" type="button">
|
||||
分配权限
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<TablePagination :pageChange="handlePageChange" :total="total" />
|
||||
</div>
|
||||
|
||||
<RoleDeleteModal :id="'role-delete-modal'" :closeModal="() => {
|
||||
roleDeleteModal!.hide();
|
||||
}" :onSubmit="handleDeletedModalSubmit" title="确定删除该角色吗" content="删除角色"></RoleDeleteModal>
|
||||
<RoleUpsertModal :onSubmit="handleUpsertModalSubmit" :closeModal="() => {
|
||||
roleUpsertModal!.hide();
|
||||
}" :role="selectedRole">
|
||||
</RoleUpsertModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RoleDeleteModal from "@/components/PopupModal.vue";
|
||||
import RoleUpsertModal from "@/components/RoleUpsertModal.vue";
|
||||
import TablePagination from "@/components/TablePagination.vue";
|
||||
import useRoleDelete from "@/composables/role/useRoleDelete";
|
||||
import { useRolesQuery } from "@/composables/role/useRolesQuery";
|
||||
import { RouteName } from "@/router/constants";
|
||||
import type { RoleUpsertModel } from "@/types/role";
|
||||
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { components } from "../api/types/schema";
|
||||
import { useRoleUpsert } from "../composables/role/useRoleUpsert";
|
||||
import useAlertStore from "../composables/store/useAlertStore";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs.vue";
|
||||
|
||||
const roleName = ref<string>("");
|
||||
const selectedRole = ref<components["schemas"]["RoleDto"]>();
|
||||
const roleUpsertModal = ref<ModalInterface>();
|
||||
const roleDeleteModal = ref<ModalInterface>();
|
||||
|
||||
const { total, roles, fetchRolesWith } = useRolesQuery();
|
||||
|
||||
const { deleteRole } = useRoleDelete();
|
||||
const alertStore = useAlertStore();
|
||||
const router = useRouter();
|
||||
const upsertRole = useRoleUpsert();
|
||||
onMounted(async () => {
|
||||
await fetchRolesWith({
|
||||
name: roleName.value,
|
||||
});
|
||||
initFlowbite();
|
||||
const $upsertModalElement: HTMLElement | null =
|
||||
document.querySelector("#role-upsert-modal");
|
||||
const $deleteModalElement: HTMLElement | null =
|
||||
document.querySelector("#role-delete-modal");
|
||||
roleUpsertModal.value = new Modal(
|
||||
$upsertModalElement,
|
||||
{},
|
||||
{ id: "role-upsert-modal" },
|
||||
);
|
||||
roleDeleteModal.value = new Modal(
|
||||
$deleteModalElement,
|
||||
{},
|
||||
{ id: "role-delete-modal" },
|
||||
);
|
||||
});
|
||||
|
||||
const handleUpsertModalSubmit = async (data: RoleUpsertModel) => {
|
||||
await upsertRole.upsertRole(data);
|
||||
await fetchRolesWith({
|
||||
name: roleName.value,
|
||||
});
|
||||
roleUpsertModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "操作成功",
|
||||
level: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpsertRoleClick = async (
|
||||
role?: components["schemas"]["RoleDto"],
|
||||
) => {
|
||||
selectedRole.value = role;
|
||||
await nextTick(() => {
|
||||
roleUpsertModal.value?.show();
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeletedModalSubmit = async () => {
|
||||
if (!selectedRole?.value?.id) return;
|
||||
await deleteRole(selectedRole.value.id);
|
||||
await fetchRolesWith({
|
||||
name: roleName.value,
|
||||
});
|
||||
roleDeleteModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "删除成功",
|
||||
level: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteRoleClick = async (
|
||||
role: components["schemas"]["RoleDto"],
|
||||
) => {
|
||||
selectedRole.value = role;
|
||||
await nextTick(() => {
|
||||
roleDeleteModal.value?.show();
|
||||
});
|
||||
};
|
||||
|
||||
const handleBindPermissionClick = async (
|
||||
role: components["schemas"]["RoleDto"],
|
||||
) => {
|
||||
router.push({
|
||||
name: RouteName.BINDPERMISSIONVIEW,
|
||||
params: { roleId: role.id },
|
||||
});
|
||||
};
|
||||
const handleSearch = async () => {
|
||||
await fetchRolesWith({
|
||||
name: roleName.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = async (page: number, pageSize: number) => {
|
||||
await fetchRolesWith(
|
||||
{
|
||||
name: roleName.value,
|
||||
},
|
||||
page,
|
||||
pageSize,
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
277
frontend/src/views/SchedulerView.vue
Normal file
277
frontend/src/views/SchedulerView.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="relative overflow-x-auto px-4 pt-6 xl:grid-cols-3 xl:gap-4 sm:rounded-lg">
|
||||
<div class="mb-4 col-span-full">
|
||||
<Breadcrumbs :names="['任务管理']" />
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">任务管理</h1>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<form class="max-w-sm mb-4 ">
|
||||
<label for="default-search"
|
||||
class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" 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="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search" id="default-search" v-model="jobName"
|
||||
class="block w-full p-4 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 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 />
|
||||
<button type="submit"
|
||||
class="text-white absolute end-2.5 bottom-2.5 bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
@click.prevent="handleSearch">搜索</button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Create Modal toggle -->
|
||||
</div>
|
||||
|
||||
<table
|
||||
class="w-full text-sm text-left rtl:text-right shadow-lg rounded-lg text-gray-500 dark:text-gray-400 overflow-x-auto">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="p-4">
|
||||
<div class="flex items-center">
|
||||
<input id="checkbox-all-search" disabled type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="checkbox-all-search" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">任务</th>
|
||||
<th scope="col" class="px-6 py-3">触发器</th>
|
||||
<th scope="col" class="px-6 py-3">开始</th>
|
||||
<th scope="col" class="px-6 py-3">结束</th>
|
||||
<th scope="col" class="px-6 py-3">下次执行</th>
|
||||
<th scope="col" class="px-6 py-3">上次执行</th>
|
||||
<th scope="col" class="px-6 py-3">类型</th>
|
||||
<th scope="col" class="px-6 py-3">Cron</th>
|
||||
<th scope="col" class="px-6 py-3">状态</th>
|
||||
<th scope="col" class="px-6 py-3">编辑</th>
|
||||
<th scope="col" class="px-6 py-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="job in jobs" :key="job.triggerName"
|
||||
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<td class="w-4 p-4">
|
||||
<div class="flex items-center">
|
||||
<input :id="'checkbox-table-search-' + job.name" type="checkbox" disabled
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label :for="'checkbox-table-search-' + job.name" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{{
|
||||
`${job.name}:${job.group}` }}</td>
|
||||
<td class="px-6 py-4">{{ `${job.triggerName}:${job.triggerGroup}` }}</td>
|
||||
<td class="px-6 py-4">{{ new Date(job.startTime!).toLocaleString() }}</td>
|
||||
<td class="px-6 py-4">{{ job.endTime ? new Date(job.endTime).toLocaleString() : undefined }}</td>
|
||||
<td class="px-6 py-4">{{ job.nextFireTime ? new Date(job.nextFireTime).toLocaleString() : undefined}}</td>
|
||||
<td class="px-6 py-4">{{ job.previousFireTime && job.previousFireTime > 0 ? new
|
||||
Date(job.previousFireTime).toLocaleString() :
|
||||
undefined
|
||||
}}
|
||||
</td>
|
||||
<td class="px-6 py-4">{{ job.schedulerType }}</td>
|
||||
<td class="px-6 py-4">{{ job.cronExpression }}</td>
|
||||
<td class="px-6 py-4">{{ job.triggerState }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<button @click="handleCronUpdateClick(job)" :disabled="job.schedulerType !== 'CRON'"
|
||||
:class="['flex items-center gap-x-1 block text-white 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' , { 'opacity-50 cursor-not-allowed': job.schedulerType !== 'CRON' }]"
|
||||
type="button">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"></path>
|
||||
<path fill-rule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
编辑
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<button
|
||||
:class="['text-white bg-green-700 hover:bg-green-800 focus:ring-green-300 dark:bg-green-600 dark:hover:bg-green-700 dark:focus:ring-green-900 focus:ring-4 focus:outline-none font-medium rounded-lg text-sm px-5 py-2.5 text-center']"
|
||||
@click="handleResumeJobClick(job)" type="button">
|
||||
恢复
|
||||
</button>
|
||||
<button
|
||||
:class="['bg-red-700 hover:bg-red-800 focus:ring-red-300 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-900 text-white focus:ring-4 focus:outline-none font-medium rounded-lg text-sm px-5 py-2.5 text-center']"
|
||||
@click="handlePauseJobClick(job)" type="button">
|
||||
暂停
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<TablePagination :pageChange="handlePageChange" :total="total" />
|
||||
</div>
|
||||
|
||||
<PopupModal :id="'job-resume-modal'" :closeModal="() => {
|
||||
jobResumeModal!.hide();
|
||||
}" :onSubmit="handleResumeModalSubmit" title="确定恢复该任务吗?" content="恢复任务"></PopupModal>
|
||||
<PopupModal :id="'job-pause-modal'" :closeModal="() => {
|
||||
jobPauseModal!.hide();
|
||||
}" :onSubmit="handlePauseModalSubmit" title="确定暂停该任务吗" content="暂停任务"></PopupModal>
|
||||
<SchedulerUpdateModal :job="selectedJob" :id="'job-update-modal'" :closeModal="() => {
|
||||
jobUpdateModal!.hide();
|
||||
}" :onSubmit="handleUpdateModalSubmit"></SchedulerUpdateModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SchedulerUpdateModal from "@/components/SchedulerUpdateModal.vue";
|
||||
import PopupModal from "@/components/PopupModal.vue";
|
||||
import TablePagination from "@/components/TablePagination.vue";
|
||||
import { useJobControl } from "@/composables/job/useJobControl";
|
||||
import { useJobsPaginationQuery } from "@/composables/job/useJobQuery";
|
||||
import { useJobUpdate } from "@/composables/job/useJobUpdate";
|
||||
import useAlertStore from "@/composables/store/useAlertStore";
|
||||
import { RouteName } from "@/router/constants";
|
||||
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
import type { components } from "../api/types/schema";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs.vue";
|
||||
|
||||
const jobName = ref<string>("");
|
||||
const jobResumeModal = ref<ModalInterface>();
|
||||
const jobPauseModal = ref<ModalInterface>();
|
||||
const jobUpdateModal = ref<ModalInterface>();
|
||||
const selectedJob = ref<components["schemas"]["JobTriggerDto"]>();
|
||||
|
||||
const { jobs, total, fetchJobsWith } = useJobsPaginationQuery();
|
||||
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
const { resumeTrigger, pauseTrigger } = useJobControl();
|
||||
|
||||
const { updateCron } = useJobUpdate();
|
||||
|
||||
const handleResumeJobClick = async (
|
||||
currentJob: components["schemas"]["JobTriggerDto"],
|
||||
) => {
|
||||
selectedJob.value = currentJob;
|
||||
await nextTick(() => {
|
||||
jobResumeModal.value?.show();
|
||||
});
|
||||
};
|
||||
|
||||
const handleCronUpdateClick = async (
|
||||
currentJob: components["schemas"]["JobTriggerDto"],
|
||||
) => {
|
||||
selectedJob.value = currentJob;
|
||||
await nextTick(() => {
|
||||
jobUpdateModal.value?.show();
|
||||
});
|
||||
};
|
||||
|
||||
const handlePauseJobClick = async (
|
||||
currentJob: components["schemas"]["JobTriggerDto"],
|
||||
) => {
|
||||
selectedJob.value = currentJob;
|
||||
await nextTick(() => {
|
||||
jobPauseModal.value?.show();
|
||||
});
|
||||
};
|
||||
|
||||
const handleResumeModalSubmit = async () => {
|
||||
await resumeTrigger({
|
||||
triggerName: selectedJob.value!.triggerName!,
|
||||
triggerGroup: selectedJob.value!.triggerGroup!,
|
||||
});
|
||||
jobResumeModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
level: "success",
|
||||
content: "操作成功",
|
||||
});
|
||||
await fetchJobsWith({
|
||||
name: jobName.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateModalSubmit = async (cronExpression: string) => {
|
||||
await updateCron({
|
||||
triggerName: selectedJob.value!.triggerName!,
|
||||
triggerGroup: selectedJob.value!.triggerGroup!,
|
||||
cron: cronExpression,
|
||||
});
|
||||
jobUpdateModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
level: "success",
|
||||
content: "操作成功",
|
||||
});
|
||||
await fetchJobsWith({
|
||||
name: jobName.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePauseModalSubmit = async () => {
|
||||
await pauseTrigger({
|
||||
triggerName: selectedJob!.value!.triggerName!,
|
||||
triggerGroup: selectedJob!.value!.triggerGroup!,
|
||||
});
|
||||
jobPauseModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
level: "success",
|
||||
content: "操作成功",
|
||||
});
|
||||
await fetchJobsWith({
|
||||
name: jobName.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
await fetchJobsWith({
|
||||
name: jobName.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = async (page: number, pageSize: number) => {
|
||||
await fetchJobsWith(
|
||||
{
|
||||
name: jobName.value,
|
||||
},
|
||||
page,
|
||||
pageSize,
|
||||
);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchJobsWith({
|
||||
name: jobName.value,
|
||||
});
|
||||
initFlowbite();
|
||||
const $jobResumeModalElement: HTMLElement | null =
|
||||
document.querySelector("#job-resume-modal");
|
||||
const $jobPauseModalElement: HTMLElement | null =
|
||||
document.querySelector("#job-pause-modal");
|
||||
const $jobUpdateModalElement: HTMLElement | null =
|
||||
document.querySelector("#job-update-modal");
|
||||
|
||||
jobResumeModal.value = new Modal(
|
||||
$jobResumeModalElement,
|
||||
{},
|
||||
{
|
||||
id: "job-resume-modal",
|
||||
},
|
||||
);
|
||||
jobPauseModal.value = new Modal(
|
||||
$jobPauseModalElement,
|
||||
{},
|
||||
{
|
||||
id: "job-pause-modal",
|
||||
},
|
||||
);
|
||||
jobUpdateModal.value = new Modal(
|
||||
$jobUpdateModalElement,
|
||||
{},
|
||||
{
|
||||
id: "job-update-modal",
|
||||
},
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
154
frontend/src/views/SettingsView.vue
Normal file
154
frontend/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900 ">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumbs :names="['用户设置']" />
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">用户设置</h1>
|
||||
</div>
|
||||
<!-- Right Content -->
|
||||
<div class="col-span-full xl:col-auto">
|
||||
<div
|
||||
class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="items-center sm:flex xl:block 2xl:flex sm:space-x-4 xl:space-x-0 2xl:space-x-4">
|
||||
<img class="mb-4 rounded-lg w-28 h-28 sm:mb-0 xl:mb-4 2xl:mb-0" src="/trump.jpg" alt="Jese picture">
|
||||
<div>
|
||||
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">个人资料</h3>
|
||||
<div class="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
JPG, GIF or PNG. Max size of 800K
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button type="button" disabled
|
||||
class="cursor-not-allowed inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white rounded-lg bg-blue-400 dark:bg-blue-500 ">
|
||||
<svg class="w-4 h-4 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.5 13a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 1113.5 13H11V9.413l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.414V13H5.5z">
|
||||
</path>
|
||||
<path d="M9 13h2v5a1 1 0 11-2 0v-5z"></path>
|
||||
</svg>
|
||||
Upload picture
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="col-span-1 row-start-3">
|
||||
<div
|
||||
class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-xl font-semibold dark:text-white">个人信息</h3>
|
||||
<form action="#">
|
||||
<div class="grid grid-cols-6 gap-6">
|
||||
<div class="col-span-6 ">
|
||||
<label for="current-username"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">用户名</label>
|
||||
<input type="text" name="current-username" id="current-username" v-model="userForm.username"
|
||||
class="shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-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-primary-500 dark:focus:border-primary-500"
|
||||
required>
|
||||
</div>
|
||||
<div class="col-span-6 ">
|
||||
<label for="current-password"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">密码</label>
|
||||
<input type="password" name="current-password" id="current-password" v-model="userForm.password"
|
||||
class="shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-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-primary-500 dark:focus:border-primary-500"
|
||||
placeholder="非必填" required>
|
||||
</div>
|
||||
<div class="col-span-6 ">
|
||||
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">确认密码</label>
|
||||
<input type="password" id="password" v-model="userForm.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"
|
||||
placeholder="非必填" required>
|
||||
</div>
|
||||
<div class="col-span-6 ">
|
||||
<label for="category" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">状态</label>
|
||||
<select id="category" v-model="userForm.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 class="col-span-6 sm:col-full">
|
||||
<button
|
||||
class="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||
@click.prevent="handleUpdateClick" type="submit">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import useUserAuth from "@/composables/auth/useUserAuth";
|
||||
import useUserStore from "@/composables/store/useUserStore";
|
||||
import { initFlowbite } from "flowbite";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { z } from "zod";
|
||||
import useAlertStore from "../composables/store/useAlertStore";
|
||||
import { RouteName } from "../router/constants";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs.vue";
|
||||
|
||||
const { user } = useUserStore();
|
||||
const { upsertCurrentUser } = useUserAuth();
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
const userForm = ref({
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
enable: user.enable,
|
||||
confirmPassword: user.password,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
initFlowbite();
|
||||
});
|
||||
|
||||
const handleUpdateClick = async () => {
|
||||
let validatedData = undefined;
|
||||
try {
|
||||
validatedData = z
|
||||
.object({
|
||||
username: z
|
||||
.string({
|
||||
message: "用户名不能为空",
|
||||
})
|
||||
.min(4, "用户名长度不能小于4个字符"),
|
||||
password: z
|
||||
.string()
|
||||
.min(5, "密码长度不能小于5个字符")
|
||||
.optional()
|
||||
.nullable(),
|
||||
confirmPassword: z.string().optional().nullable(),
|
||||
enable: z.boolean({
|
||||
message: "状态不能为空",
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.password) {
|
||||
return data.password === data.confirmPassword;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: "密码输入不一致。" },
|
||||
)
|
||||
.parse(userForm.value);
|
||||
await upsertCurrentUser(validatedData);
|
||||
alertStore.showAlert({
|
||||
content: "操作成功",
|
||||
level: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
alertStore.showAlert({
|
||||
level: "error",
|
||||
content: error.errors[0].message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
288
frontend/src/views/UserView.vue
Normal file
288
frontend/src/views/UserView.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div class="relative overflow-x-auto px-4 pt-6 xl:grid-cols-3 xl:gap-4 sm:rounded-lg">
|
||||
<div class="mb-4 col-span-full">
|
||||
<Breadcrumbs :names="['用户管理']" />
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">用户管理</h1>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<form class="max-w-sm mb-4 ">
|
||||
<label for="default-search"
|
||||
class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" 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="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search" id="default-search" v-model="username"
|
||||
class="block w-full p-4 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 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 />
|
||||
<button type="submit"
|
||||
class="text-white absolute end-2.5 bottom-2.5 bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
@click.prevent="handleSearch">搜索</button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Create Modal toggle -->
|
||||
<button @click="handleUpsertUserClick(undefined)"
|
||||
class="flex items-center block text-white 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 absolute right-5 bottom-2"
|
||||
type="button">
|
||||
新增用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm text-left rtl:text-right shadow-lg rounded-lg text-gray-500 dark:text-gray-400">
|
||||
<thead class="text-xs uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="p-4">
|
||||
<div class="flex items-center">
|
||||
<input id="checkbox-all-search" disabled type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="checkbox-all-search" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">用户名</th>
|
||||
<th scope="col" class="px-6 py-3">邮箱</th>
|
||||
<th scope="col" class="px-6 py-3">创建时间</th>
|
||||
<th scope="col" class="px-6 py-3">状态</th>
|
||||
<th scope="col" class="px-6 py-3">操作</th>
|
||||
<th scope="col" class="px-6 py-3">分配</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id"
|
||||
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<td class="w-4 p-4">
|
||||
<div class="flex items-center">
|
||||
<input :id="'checkbox-table-search-' + user.id" type="checkbox" disabled
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label :for="'checkbox-table-search-' + user.id" class="sr-only">checkbox</label>
|
||||
</div>
|
||||
</td>
|
||||
<td scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ user.username }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{{ user.username }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{{ user.createTime }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="h-2.5 w-2.5 rounded-full me-2" :class="user.enable ? 'bg-blue-500' : 'bg-red-500'"></div> {{
|
||||
user.enable === true ? "启用" : "禁用" }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<!-- Edit Modal toggle -->
|
||||
<div class="flex items-center gap-x-2">
|
||||
<button @click="handleUpsertUserClick(user)"
|
||||
class="flex items-center block gap-x-1 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
type="button">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"></path>
|
||||
<path fill-rule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center block gap-x-1
|
||||
bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-red-300 dark:bg-red-600 dark:hover:bg-red-700
|
||||
dark:focus:ring-red-900 block text-white focus:ring-4 focus:outline-nonefont-medium rounded-lg text-sm px-4 py-2.5 text-center"
|
||||
@click="handleDeleteUserClick(user)" type="button">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<button
|
||||
class="flex itmes-center text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 font-medium rounded-lg text-sm px-4 py-2.5 text-center dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700"
|
||||
@click="handleBindRoleClick(user)" type="button">
|
||||
分配角色
|
||||
</button>
|
||||
<button
|
||||
class="flex itmes-center text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 font-medium rounded-lg text-sm px-4 py-2.5 text-center dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700"
|
||||
@click="handleBindPositionClick(user)" type="button">
|
||||
分配岗位
|
||||
</button>
|
||||
<button
|
||||
class="flex itmes-center text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 font-medium rounded-lg text-sm px-4 py-2.5 text-center dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700"
|
||||
@click="handleBindDepartmentClick(user)" type="button">
|
||||
分配部门
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<TablePagination :pageChange="handlePageChange" :total="total" />
|
||||
</div>
|
||||
|
||||
<UserDeleteModal :id="'user-delete-modal'" :closeModal="() => {
|
||||
userDeleteModal!.hide();
|
||||
}" :onSubmit="handleDeleteUserSubmit" title="确定删除该用户吗" content="删除用户"></UserDeleteModal>
|
||||
<UserUpsertModal :id="'user-upsert-modal'" :onSubmit="handleUpsertUserSubmit" :closeModal="() => {
|
||||
userUpsertModal!.hide();
|
||||
}" :user="selectedUser">
|
||||
</UserUpsertModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import UserDeleteModal from "@/components/PopupModal.vue";
|
||||
import UserUpsertModal from "@/components/UserUpsertModal.vue";
|
||||
import useUserDelete from "@/composables/user/useUserDelete";
|
||||
import { useUserQuery } from "@/composables/user/useUserQuery";
|
||||
import { RouteName } from "@/router/constants";
|
||||
import type { UserUpsertSubmitModel } from "@/types/user";
|
||||
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { components } from "../api/types/schema";
|
||||
import useAlertStore from "../composables/store/useAlertStore";
|
||||
import { useUserUpsert } from "../composables/user/useUserUpsert";
|
||||
import TablePagination from "@/components/TablePagination.vue";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs.vue";
|
||||
|
||||
const username = ref<string>("");
|
||||
const selectedUser = ref<components["schemas"]["UserRolePermissionDto"]>();
|
||||
const userUpsertModal = ref<ModalInterface>();
|
||||
const userDeleteModal = ref<ModalInterface>();
|
||||
const router = useRouter();
|
||||
|
||||
const { total, users, fetchUsersWith } = useUserQuery();
|
||||
|
||||
const { deleteUser } = useUserDelete();
|
||||
const userUpsert = useUserUpsert();
|
||||
|
||||
const alertStore = useAlertStore();
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchUsersWith({
|
||||
username: username.value,
|
||||
});
|
||||
initFlowbite();
|
||||
const $upsertModalElement: HTMLElement | null =
|
||||
document.querySelector("#user-upsert-modal");
|
||||
const $deleteModalElement: HTMLElement | null =
|
||||
document.querySelector("#user-delete-modal");
|
||||
userUpsertModal.value = new Modal(
|
||||
$upsertModalElement,
|
||||
{},
|
||||
{
|
||||
id: "user-upsert-modal",
|
||||
},
|
||||
);
|
||||
userDeleteModal.value = new Modal(
|
||||
$deleteModalElement,
|
||||
{},
|
||||
{
|
||||
id: "user-delete-modal",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const handleUpsertUserSubmit = async (data: UserUpsertSubmitModel) => {
|
||||
await userUpsert.upsertUser(data);
|
||||
userUpsertModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "操作成功",
|
||||
level: "success",
|
||||
});
|
||||
await fetchUsersWith({
|
||||
username: username.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpsertUserClick = async (
|
||||
user?: components["schemas"]["UserRolePermissionDto"],
|
||||
) => {
|
||||
selectedUser.value = user;
|
||||
await nextTick(() => {
|
||||
userUpsertModal.value?.show();
|
||||
});
|
||||
};
|
||||
|
||||
const handleBindRoleClick = async (
|
||||
user: components["schemas"]["UserRolePermissionDto"],
|
||||
) => {
|
||||
router.push({
|
||||
name: RouteName.BINDROLEVIEW,
|
||||
params: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBindDepartmentClick = async (
|
||||
user: components["schemas"]["UserRolePermissionDto"],
|
||||
) => {
|
||||
router.push({
|
||||
name: RouteName.BINDDEPARTMENTVIEW,
|
||||
params: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBindPositionClick = async (
|
||||
user: components["schemas"]["UserRolePermissionDto"],
|
||||
) => {
|
||||
router.push({
|
||||
name: RouteName.BINDPOSITIONVIEW,
|
||||
params: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteUserSubmit = async () => {
|
||||
if (!selectedUser?.value?.id) return;
|
||||
await deleteUser(selectedUser.value.id);
|
||||
userDeleteModal.value?.hide();
|
||||
alertStore.showAlert({
|
||||
content: "删除成功",
|
||||
level: "success",
|
||||
});
|
||||
await fetchUsersWith({
|
||||
username: username.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteUserClick = async (
|
||||
user: components["schemas"]["UserRolePermissionDto"],
|
||||
) => {
|
||||
selectedUser.value = user;
|
||||
await nextTick(() => {
|
||||
userDeleteModal.value?.show();
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
await fetchUsersWith({
|
||||
username: username.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = async (page: number, pageSize: number) => {
|
||||
await fetchUsersWith(
|
||||
{
|
||||
username: username.value,
|
||||
},
|
||||
page,
|
||||
pageSize,
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
Reference in New Issue
Block a user