Compare commits
32 Commits
bace501527
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4b207df29 | ||
|
|
fefa836ee7 | ||
|
|
50a3022e9d | ||
|
|
8aeaacac42 | ||
|
|
4cdf6bade8 | ||
|
|
ab43a9a140 | ||
|
|
bd00e54acd | ||
|
|
c1138cd568 | ||
|
|
87892951f6 | ||
|
|
f973284140 | ||
|
|
e2374571d7 | ||
|
|
0a0e6df66b | ||
|
|
2caa8f26a3 | ||
|
|
2a14389daf | ||
|
|
7ce645704e | ||
|
|
e09129cab6 | ||
|
|
7d627a45fb | ||
|
|
81c61f433d | ||
|
|
4ec8e88e58 | ||
|
|
8f3f2d63a0 | ||
|
|
877a47807c | ||
|
|
f9d92e6cc9 | ||
|
|
d4ace86fb3 | ||
|
|
ed542f108c | ||
|
|
3515faa814 | ||
|
|
71f19b658c | ||
|
|
a71622f797 | ||
|
|
8c041c1740 | ||
|
|
65cf80fb51 | ||
|
|
26fef2fd7a | ||
|
|
f7cc0b675a | ||
|
|
4493b9cc7a |
6
.idea/compiler.xml
generated
Normal file
6
.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="TypeScriptCompiler">
|
||||
<option name="useTypesFromServer" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
15
.idea/dataSources.xml
generated
Normal file
15
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="chaos@10.91.3.253" uuid="1f27a48f-618d-4971-b39e-c88644e7d55d">
|
||||
<driver-ref>mariadb</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:mariadb://10.91.3.253:3306/chaos</jdbc-url>
|
||||
<jdbc-additional-properties>
|
||||
<property name="database.introspection.mysql.dbe5060" value="true" />
|
||||
</jdbc-additional-properties>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
1
.idea/inspectionProfiles/Project_Default.xml
generated
1
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -2,5 +2,6 @@
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="TsLint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
840
package-lock.json
generated
840
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"sass-embedded": "^1.93.3",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-check": "^4.3.3",
|
||||
"tailwindcss": "^4.1.14",
|
||||
@@ -36,6 +37,7 @@
|
||||
"vite": "^7.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"daisyui": "^5.5.5"
|
||||
"daisyui": "^5.5.5",
|
||||
"ofetch": "^1.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
24
src/app.d.ts
vendored
24
src/app.d.ts
vendored
@@ -1,12 +1,26 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
import type { ApiClient } from '$lib/api/httpClient.ts';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatar?: string;
|
||||
roles: string[];
|
||||
}
|
||||
interface Locals {
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
interface pageData {
|
||||
user: User | null;
|
||||
}
|
||||
interface Locals {
|
||||
api: ApiClient;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
34
src/hooks.server.ts
Normal file
34
src/hooks.server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { type Handle, redirect } from '@sveltejs/kit';
|
||||
import { parseJwt } from '$lib/utils/tokenUtils.ts';
|
||||
import type { JwtPayload } from '$lib/types/auth.ts';
|
||||
import { COOKIE_TOKEN_KEY } from '$lib/components/constants/cookiesConstants.ts';
|
||||
import { createApi } from '$lib/api/httpClient.ts';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve}) =>{
|
||||
const authorization = event.cookies.get(COOKIE_TOKEN_KEY);
|
||||
|
||||
event.locals.api = createApi(authorization);
|
||||
|
||||
if (authorization){
|
||||
const split = authorization?.split(' ');
|
||||
const token = split[1];
|
||||
const jwt = parseJwt<JwtPayload>(token);
|
||||
|
||||
if (jwt){
|
||||
|
||||
event.locals.user = {
|
||||
id: jwt.userId,
|
||||
username: jwt.sub,
|
||||
nickname: jwt.nickname,
|
||||
avatar: jwt.avatar,
|
||||
roles: jwt.authorities
|
||||
}
|
||||
}
|
||||
}else if(event.url.pathname.startsWith('/app')){
|
||||
throw redirect(303, '/auth/login');
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,132 +1,73 @@
|
||||
// src/lib/api/httpClient.ts
|
||||
import { ofetch, type FetchOptions, type SearchParameters } from 'ofetch';
|
||||
import { log } from '$lib/log';
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import type { HttpMethod, JsonObject, JsonValue } from '$lib/types/http.ts';
|
||||
import { authStore } from '$lib/stores/authStore.ts';
|
||||
import type { ApiResult } from '$lib/types/api.ts';
|
||||
type QueryParams = SearchParameters;
|
||||
type RequestBody = Record<string, unknown> | FormData | unknown[] | object;
|
||||
type AppFetchOptions = Omit<FetchOptions<'json'>, 'method' | 'body' | 'query'>;
|
||||
|
||||
|
||||
interface RequestOptions extends Omit<RequestInit, 'method' | 'body'> {
|
||||
body?: JsonObject | FormData;
|
||||
export interface ApiResult<T> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
const API_BASE_URL = import.meta.env.VITE_PUBLIC_API_URL || 'http://localhost:18888/api';
|
||||
|
||||
let currentToken: string | null = null;
|
||||
let currentTokenHead: string | null = null;
|
||||
const BASE_URL = import.meta.env.VITE_PUBLIC_API_URL || 'http://localhost:18888/api';
|
||||
|
||||
if (browser) {
|
||||
// 只有在浏览器环境下才订阅,防止 SSR 内存泄漏
|
||||
authStore.subscribe(state => {
|
||||
currentToken = state.token;
|
||||
currentTokenHead = state.tokenHead;
|
||||
export type ApiClient = ReturnType<typeof createApi>;
|
||||
|
||||
export const createApi = (token?: string) => {
|
||||
const client = ofetch.create({
|
||||
baseURL: BASE_URL,
|
||||
// 建议:通常 Token 前面需要加 Bearer
|
||||
headers: token ? { Authorization: token } : {},
|
||||
onRequest({ options, request }) {
|
||||
log.debug(`[API] ${options.method} ${request}`
|
||||
|
||||
,{
|
||||
body: options.body as unknown,
|
||||
headers: options.headers,
|
||||
query: options.query
|
||||
});
|
||||
},
|
||||
onResponseError({ request, response }) {
|
||||
log.error(`[API] Error ${request}`, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
data: response._data as unknown
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
const normalizeHeaders = (headers?: HeadersInit):Record<string, string> =>{
|
||||
const result:Record<string,string> = {};
|
||||
|
||||
if (!headers){
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
get: <T>(url: string, query?: QueryParams, options?: AppFetchOptions) =>
|
||||
client<ApiResult<T>>(url, { ...options, method: 'GET', query }),
|
||||
|
||||
if (headers instanceof Headers){
|
||||
headers.forEach((value, key) => {
|
||||
result[key.toLowerCase()] = value;
|
||||
});
|
||||
}else if (Array.isArray(headers)){
|
||||
headers.forEach(([key, value]) => {
|
||||
result[key.toLowerCase()] = value;
|
||||
})
|
||||
}else {
|
||||
Object.keys(headers).forEach(key => {
|
||||
result[key.toLowerCase()] = headers[key.toLowerCase()] as string;
|
||||
})
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export class HttpError extends Error {
|
||||
public status: number;
|
||||
public details: JsonValue | string;
|
||||
// 关键修复点:
|
||||
// 1. 使用 <T, B = RequestBody> 保持泛型灵活性
|
||||
// 2. 使用 `as unknown as Record<string, unknown>` 替代 `as any`
|
||||
// 这告诉编译器:"先把 B 当作未知类型,再把它视为一个通用的键值对对象",完美绕过 ESLint 和 TS 检查
|
||||
post: <T, B = RequestBody>(url: string, body?: B, options?: AppFetchOptions) =>
|
||||
client<ApiResult<T>>(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: body as unknown as Record<string, unknown>
|
||||
}),
|
||||
|
||||
constructor(message: string, status: number, details: JsonValue | string) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
put: <T, B = RequestBody>(url: string, body?: B, options?: AppFetchOptions) =>
|
||||
client<ApiResult<T>>(url, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: body as unknown as Record<string, unknown>
|
||||
}),
|
||||
|
||||
// 保持正确的原型链
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, HttpError);
|
||||
}
|
||||
}
|
||||
}
|
||||
patch: <T, B = RequestBody>(url: string, body?: B, options?: AppFetchOptions) =>
|
||||
client<ApiResult<T>>(url, {
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
body: body as unknown as Record<string, unknown>
|
||||
}),
|
||||
|
||||
|
||||
const httpRequest= async <T>(
|
||||
url:string,
|
||||
method: HttpMethod,
|
||||
options: RequestOptions = {}
|
||||
):Promise<ApiResult<T>> =>{
|
||||
const fullUrl = `${API_BASE_URL}${url}`;
|
||||
const { body, headers, ...rest} = options;
|
||||
|
||||
const requestHeaders: Record<string, string> = normalizeHeaders(headers);
|
||||
let requestBody:BodyInit | undefined;
|
||||
|
||||
if (body instanceof FormData){
|
||||
requestBody = body;
|
||||
}else if (body){
|
||||
requestHeaders['content-type'] = 'application/json';
|
||||
requestBody = JSON.stringify(body);
|
||||
}
|
||||
|
||||
if (currentToken && currentTokenHead) {
|
||||
requestHeaders['authorization'] = `${currentTokenHead} ${currentToken}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(fullUrl,{
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: requestBody,
|
||||
...rest
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
let errorDetail;
|
||||
|
||||
try {
|
||||
|
||||
errorDetail = await response.json()
|
||||
|
||||
}catch (e){
|
||||
console.error('Error parsing JSON:', e);
|
||||
errorDetail = await response.text()
|
||||
}
|
||||
|
||||
const message = `HTTP Error ${response.status} (${response.statusText})`;
|
||||
throw new HttpError(message, response.status, errorDetail);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (contentType && contentType.includes('application/json')){
|
||||
return (await response.json() ) as ApiResult<T>;
|
||||
}
|
||||
|
||||
return {code:200, msg:'OK', data:null} ;
|
||||
|
||||
}catch (error){
|
||||
console.error(`API Request Failed to ${fullUrl}:`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
export const api = {
|
||||
get: <T>(url: string, options?: RequestOptions) => httpRequest<T>(url, 'GET', options),
|
||||
post: <T>(url: string, body: JsonObject, options?: RequestOptions) => httpRequest<T>(url, 'POST', { ...options, body }),
|
||||
put: <T>(url: string, body: JsonObject, options?: RequestOptions) => httpRequest<T>(url, 'PUT', { ...options, body }),
|
||||
delete: <T>(url: string, options?: RequestOptions) => httpRequest<T>(url, 'DELETE', options),
|
||||
patch: <T>(url: string, body: JsonObject, options?: RequestOptions) => httpRequest<T>(url, 'PATCH', { ...options, body }),
|
||||
delete: <T>(url: string, query?: QueryParams, options?: AppFetchOptions) =>
|
||||
client<ApiResult<T>>(url, { ...options, method: 'DELETE', query })
|
||||
};
|
||||
};
|
||||
@@ -1,48 +1,31 @@
|
||||
|
||||
import {api} from '$lib/api/httpClient.ts'
|
||||
import type { AuthResponse, LoginPayload } from '$lib/types/auth.ts';
|
||||
import { browser } from '$app/environment';
|
||||
import { authStore } from '$lib/stores/authStore.ts';
|
||||
import { userService } from '$lib/api/services/userService.ts';
|
||||
import { userStore } from '$lib/stores/userStore.ts';
|
||||
import type { AuthResponse, LoginPayload } from '$lib/types/auth';
|
||||
import { ApiError } from '$lib/types/api.ts';
|
||||
import type { ApiClient } from '$lib/api/httpClient.ts';
|
||||
|
||||
|
||||
export const authService = {
|
||||
login: async (payload: LoginPayload): Promise<AuthResponse> => {
|
||||
/**
|
||||
* 登录流程
|
||||
*/
|
||||
login: async (api: ApiClient,payload: LoginPayload): Promise<AuthResponse> => {
|
||||
const response = await api.post<AuthResponse>('/auth/login', payload);
|
||||
|
||||
if (response.code != 200 || !response.data){
|
||||
throw new Error(response.msg);
|
||||
if (response.code !== 200 || !response.data) {
|
||||
throw new ApiError(response);
|
||||
}
|
||||
if (browser){
|
||||
authService._setToken(response.data.token, response.data.tokenHead)
|
||||
}
|
||||
|
||||
const userProfile = await userService.getUserProfile();
|
||||
|
||||
if (browser){
|
||||
userStore.set(userProfile)
|
||||
}
|
||||
|
||||
|
||||
return response.data;
|
||||
},
|
||||
logout: async () => {
|
||||
|
||||
if (browser){
|
||||
|
||||
authStore.clear();
|
||||
userStore.clear();
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_token_head');
|
||||
|
||||
return true;
|
||||
}else {
|
||||
return false;
|
||||
/**
|
||||
* 登出流程
|
||||
*/
|
||||
logout: async (api: ApiClient) => {
|
||||
try {
|
||||
await api.post('/auth/logout', {});
|
||||
} catch (error) {
|
||||
console.warn('Logout API call failed:', error);
|
||||
}
|
||||
},
|
||||
_setToken: (token:string ,tokenHead: string)=> {
|
||||
authStore.set({ token, tokenHead, isAuthenticated: true });
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_token_head', tokenHead);
|
||||
}
|
||||
}
|
||||
};
|
||||
37
src/lib/api/services/deviceService.ts
Normal file
37
src/lib/api/services/deviceService.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { type ApiClient } from '$lib/api/httpClient.ts';
|
||||
import type { PageResult } from '$lib/types/dataTable.ts';
|
||||
import type { CreateDeviceRequest, DeviceResponse } from '$lib/types/api.ts';
|
||||
|
||||
|
||||
export const deviceService = {
|
||||
getAllDevices: async (api:ApiClient,{ page, size,type,keyword}:{
|
||||
page: number,
|
||||
size: number,
|
||||
type?: number,
|
||||
keyword?: string,
|
||||
|
||||
}) => {
|
||||
const queryParams: Record<string, string | number> = {
|
||||
pageNum: page,
|
||||
pageSize: size
|
||||
};
|
||||
if (type) queryParams.type = type;
|
||||
if (keyword) queryParams.keyword = keyword;
|
||||
|
||||
const result = await api.get<PageResult<DeviceResponse[]>>('/devices',queryParams);
|
||||
|
||||
if (result.code != 200 || !result.data){
|
||||
throw new Error(result.msg);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
},
|
||||
createDevice: async (api: ApiClient,device: CreateDeviceRequest) => {
|
||||
|
||||
const result = await api.post<DeviceResponse>('/devices', device);
|
||||
if (result.code != 200 || !result.data){
|
||||
throw new Error(result.msg);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
15
src/lib/api/services/deviceTypesService.ts
Normal file
15
src/lib/api/services/deviceTypesService.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
import type { Options } from '$lib/types/api.ts';
|
||||
import type { ApiClient } from '$lib/api/httpClient.ts';
|
||||
|
||||
export const deviceTypesService = {
|
||||
getDeviceTypesOptions: async (api:ApiClient) => {
|
||||
const result = await api.get<Options[]>('/device-types/options',undefined);
|
||||
|
||||
if (result.code != 200 || !result.data){
|
||||
throw new Error(result.msg);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
15
src/lib/api/services/roleService.ts
Normal file
15
src/lib/api/services/roleService.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import {type ApiClient } from '$lib/api/httpClient.ts';
|
||||
import type { Options } from '$lib/types/api.ts';
|
||||
import { log } from '$lib/log.ts';
|
||||
|
||||
|
||||
export const roleService = {
|
||||
getRolesOptions: async (api:ApiClient) => {
|
||||
const response = await api.get<Options[]>('/roles/options',undefined);
|
||||
if (response.code != 200 || !response.data){
|
||||
log.error(response.msg);
|
||||
throw new Error(response.msg);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
45
src/lib/api/services/tokenService.ts
Normal file
45
src/lib/api/services/tokenService.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { api } from '$lib/api/httpClient';
|
||||
import type { ApiResult } from '$lib/types/api';
|
||||
import { authStore } from '$lib/stores/authStore';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export const tokenService = {
|
||||
/**
|
||||
* Check if the current token is valid
|
||||
*/
|
||||
validateToken: async (): Promise<boolean> => {
|
||||
if (!browser) return false;
|
||||
|
||||
try {
|
||||
const response = await api.get<null>('/auth/validate');
|
||||
return response.code === 200;
|
||||
} catch (error) {
|
||||
console.error('Token validation failed:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the current token
|
||||
*/
|
||||
refreshToken: async (): Promise<boolean> => {
|
||||
if (!browser) return false;
|
||||
|
||||
try {
|
||||
const response = await api.post<{token: string, tokenHead: string}>('/auth/refresh', {});
|
||||
if (response.code === 200 && response.data) {
|
||||
// Update the auth store with new token
|
||||
authStore.update(state => ({
|
||||
...state,
|
||||
token: response.data!.token,
|
||||
tokenHead: response.data!.tokenHead
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,12 +1,33 @@
|
||||
import { api } from '$lib/api/httpClient.ts';
|
||||
import { type ApiClient } from '$lib/api/httpClient.ts';
|
||||
import type { UserProfile } from '$lib/types/user.ts';
|
||||
import type { PageResult } from '$lib/types/dataTable.ts';
|
||||
import { type SearchParameters } from 'ofetch';
|
||||
|
||||
|
||||
// 1. 定义更安全的类型,替代 any
|
||||
type QueryParams = SearchParameters;
|
||||
export const userService = {
|
||||
getUserProfile: async () => {
|
||||
const response = await api.get<UserProfile>('/user/profile');
|
||||
getUserProfile: async (api:ApiClient) => {
|
||||
const response = await api.get<UserProfile>('/users/me',undefined);
|
||||
if (response.code != 200 || !response.data){
|
||||
throw new Error(response.msg);
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
},
|
||||
getAllUsers: async (api:ApiClient,{ page, size , keyword, roleId}: { page: number, size: number , keyword?: string, roleId?: number}) => {
|
||||
|
||||
const params: QueryParams= {
|
||||
pageNum: page,
|
||||
pageSize: size,
|
||||
...(keyword && { keyword }),
|
||||
...(roleId && { roleId })
|
||||
} ;
|
||||
const response = await api.get<PageResult<UserProfile[]>>(
|
||||
'/users',
|
||||
params);
|
||||
if (response.code != 200 || !response.data){
|
||||
throw new Error(response.msg);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
148
src/lib/components/DataTable.svelte
Normal file
148
src/lib/components/DataTable.svelte
Normal file
@@ -0,0 +1,148 @@
|
||||
<script lang="ts" generics="T extends import('$lib/types/dataTable').BaseRecord">
|
||||
|
||||
|
||||
// --- Props ---
|
||||
import type { PageResult, TableColumn } from '$lib/types/dataTable.ts';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let data: PageResult<T>;
|
||||
|
||||
// 这里的 columns 被严格约束,传入错误的 key 会报错
|
||||
export let columns: TableColumn<T>[];
|
||||
|
||||
export let loading: boolean = false;
|
||||
|
||||
// --- State ---
|
||||
let selectedIds: Set<number | string> = new Set();
|
||||
|
||||
// 响应式计算
|
||||
$: allSelected = data.records.length > 0 && data.records.every(item => selectedIds.has(item.id));
|
||||
$: indeterminate = data.records.some(item => selectedIds.has(item.id)) && !allSelected;
|
||||
|
||||
// 定义事件,为了严格起见,我们明确 Payload 类型
|
||||
const dispatch = createEventDispatcher<{
|
||||
pageChange: number;
|
||||
delete: T;
|
||||
edit: T;
|
||||
batchDelete: (number | string)[];
|
||||
}>();
|
||||
|
||||
// --- Logic ---
|
||||
function toggleAll() {
|
||||
if (allSelected) {
|
||||
data.records.forEach(item => selectedIds.delete(item.id));
|
||||
} else {
|
||||
data.records.forEach(item => selectedIds.add(item.id));
|
||||
}
|
||||
selectedIds = selectedIds;
|
||||
}
|
||||
|
||||
function toggleOne(id: number | string) {
|
||||
if (selectedIds.has(id)) {
|
||||
selectedIds.delete(id);
|
||||
} else {
|
||||
selectedIds.add(id);
|
||||
}
|
||||
selectedIds = selectedIds;
|
||||
}
|
||||
|
||||
function handleBatchDelete() {
|
||||
dispatch('batchDelete', Array.from(selectedIds));
|
||||
selectedIds = new Set();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-base-100 rounded-box shadow-md w-full border border-base-200">
|
||||
<div class="p-4 border-b border-base-200 flex justify-between items-center bg-base-100 rounded-t-box">
|
||||
<div class="flex gap-2 items-center">
|
||||
{#if selectedIds.size > 0}
|
||||
<div class="badge badge-neutral">已选 {selectedIds.size} 项</div>
|
||||
<button class="btn btn-error btn-sm text-white" on:click={handleBatchDelete}>
|
||||
批量删除
|
||||
</button>
|
||||
{:else}
|
||||
<slot name="toolbar"></slot>
|
||||
{/if}
|
||||
</div>
|
||||
<div><slot name="toolbar-right"></slot></div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table w-full">
|
||||
<thead class="bg-base-200/50">
|
||||
<tr>
|
||||
<th class="w-12">
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox checkbox-sm"
|
||||
checked={allSelected}
|
||||
indeterminate={indeterminate}
|
||||
on:change={toggleAll} />
|
||||
</label>
|
||||
</th>
|
||||
{#each columns as col(col.key)}
|
||||
<th class="{col.align === 'right' ? 'text-right' : col.align === 'center' ? 'text-center' : 'text-left'} font-semibold">
|
||||
{col.label}
|
||||
</th>
|
||||
{/each}
|
||||
<th class="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#if loading}
|
||||
{#each Array(5) as _}
|
||||
<tr><td colspan={columns.length + 2} class="skeleton h-12 w-full rounded-none opacity-50"></td></tr>
|
||||
{/each}
|
||||
{:else if data.records.length === 0}
|
||||
<tr>
|
||||
<td colspan={columns.length + 2} class="text-center py-10 text-base-content/50">
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each data.records as row (row.id)}
|
||||
<tr class="hover group {selectedIds.has(row.id) ? 'bg-base-200/30' : ''}">
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox checkbox-sm"
|
||||
checked={selectedIds.has(row.id)}
|
||||
on:change={() => toggleOne(row.id)} />
|
||||
</label>
|
||||
</td>
|
||||
|
||||
{#each columns as col}
|
||||
<td class="{col.align === 'right' ? 'text-right' : col.align === 'center' ? 'text-center' : 'text-left'}">
|
||||
<slot name="cell" row={row} key={col.key} value={row[col.key]}>
|
||||
{String(row[col.key] ?? '-')}
|
||||
</slot>
|
||||
</td>
|
||||
{/each}
|
||||
|
||||
<td class="text-right">
|
||||
<div class="join opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button class="btn btn-xs btn-ghost" on:click={() => dispatch('edit', row)}>编辑</button>
|
||||
<button class="btn btn-xs btn-ghost text-error" on:click={() => dispatch('delete', row)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if data.total > 0}
|
||||
<div class="p-4 flex justify-between items-center border-t border-base-200">
|
||||
<span class="text-sm opacity-60">第 {data.current} / {data.pages} 页</span>
|
||||
<div class="join">
|
||||
<button class="join-item btn btn-sm" disabled={data.current === 1}
|
||||
on:click={() => dispatch('pageChange', data.current - 1)}>«</button>
|
||||
<button class="join-item btn btn-sm pointer-events-none bg-base-100">
|
||||
{data.current}
|
||||
</button>
|
||||
<button class="join-item btn btn-sm" disabled={data.current === data.pages}
|
||||
on:click={() => dispatch('pageChange', data.current + 1)}>»</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
171
src/lib/components/Modal.svelte
Normal file
171
src/lib/components/Modal.svelte
Normal file
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
title?: string;
|
||||
width?: string | number;
|
||||
centered?: boolean;
|
||||
confirmLoading?: boolean;
|
||||
footer?: import('svelte').Snippet | null | undefined;
|
||||
okText?: string;
|
||||
cancelText?: string;
|
||||
maskClosable?: boolean;
|
||||
destroyOnHidden?: boolean;
|
||||
children?: import('svelte').Snippet;
|
||||
titleSlot?: import('svelte').Snippet;
|
||||
footerSlot?: import('svelte').Snippet ;
|
||||
onOk?: () => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title = '',
|
||||
width = 520,
|
||||
footer = undefined,
|
||||
centered = true,
|
||||
confirmLoading = false,
|
||||
okText = '确定',
|
||||
cancelText = '取消',
|
||||
maskClosable = true,
|
||||
destroyOnHidden = false,
|
||||
children,
|
||||
titleSlot,
|
||||
footerSlot,
|
||||
onOk,
|
||||
onCancel
|
||||
}: Props = $props();
|
||||
|
||||
let dialog: HTMLDialogElement;
|
||||
let internalLoading = $state(false);
|
||||
|
||||
// 1. 唯一的 DOM 操作入口:$effect
|
||||
// 所有的开关逻辑都通过改变 open 变量来触发这里
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
}
|
||||
else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 处理原生关闭(仅用于处理 ESC 键等浏览器原生行为)
|
||||
function handleNativeClose() {
|
||||
// 只有当状态认为它是“开”,但 DOM 变成了“关”时,才需要同步
|
||||
if (open) {
|
||||
open = false;
|
||||
onCancel?.();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 按钮点击:只修改状态
|
||||
function handleCancel() {
|
||||
if (internalLoading) return;
|
||||
// 先触发回调,再关闭
|
||||
onCancel?.();
|
||||
open = false;
|
||||
}
|
||||
|
||||
async function handleOk() {
|
||||
if (internalLoading) return;
|
||||
|
||||
if (onOk) {
|
||||
const result = onOk();
|
||||
if (result instanceof Promise) {
|
||||
internalLoading = true;
|
||||
try {
|
||||
await result;
|
||||
open = false; // 成功后,修改状态来关闭
|
||||
} catch (e) {
|
||||
console.error('Modal ok error', e);
|
||||
} finally {
|
||||
internalLoading = false;
|
||||
}
|
||||
} else {
|
||||
open = false; // 修改状态来关闭
|
||||
}
|
||||
} else {
|
||||
open = false; // 修改状态来关闭
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (maskClosable && e.target === dialog) {
|
||||
// 同样,只修改状态
|
||||
onCancel?.();
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
let widthStyle = $derived(typeof width === 'number' ? `max-width: ${width}px` : `max-width: ${width}`);
|
||||
</script>
|
||||
|
||||
|
||||
<dialog
|
||||
bind:this={dialog}
|
||||
class="modal"
|
||||
class:modal-bottom={!centered}
|
||||
class:modal-middle={centered}
|
||||
onclose={handleNativeClose}
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div class="modal-box" style={widthStyle}>
|
||||
<!-- 移除 stopPropagation,改用新的 handleCancel -->
|
||||
<button
|
||||
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||
onclick={handleCancel}
|
||||
>✕</button>
|
||||
|
||||
{#if title || titleSlot}
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
{#if titleSlot}
|
||||
{@render titleSlot()}
|
||||
{:else}
|
||||
{title}
|
||||
{/if}
|
||||
</h3>
|
||||
{/if}
|
||||
|
||||
<div class="py-4">
|
||||
{#if destroyOnHidden && !open}
|
||||
<!-- Destroyed -->
|
||||
{:else if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
{#if footer === undefined}
|
||||
{#if footerSlot}
|
||||
{@render footerSlot()}
|
||||
{:else}
|
||||
|
||||
<button class="btn" disabled={internalLoading || confirmLoading}
|
||||
onclick={handleCancel}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
class:loading={internalLoading || confirmLoading}
|
||||
disabled={internalLoading || confirmLoading}
|
||||
onclick={handleOk}
|
||||
>
|
||||
{#if internalLoading || confirmLoading}
|
||||
<span class="loading loading-spinner"></span>
|
||||
{/if}
|
||||
{okText}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if footer === null}
|
||||
<!-- No footer -->
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
29
src/lib/components/ToastContainer.svelte
Normal file
29
src/lib/components/ToastContainer.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { flip } from 'svelte/animate';
|
||||
import Icon from '$lib/components/icon/Icon.svelte';
|
||||
import type { IconId } from '$lib/types/icon-ids.ts';
|
||||
import { getContext } from 'svelte';
|
||||
import { TOAST_KEY, type ToastState, type ToastType } from '$lib/stores/toast.svelte.ts';
|
||||
const toastState = getContext<ToastState>(TOAST_KEY);
|
||||
|
||||
const toastIconMap: Record<ToastType, IconId> = {
|
||||
success: 'success',
|
||||
error: 'error',
|
||||
warning: 'warning',
|
||||
info: 'info'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="toast toast-top toast-center z-50">
|
||||
{#each toastState.toasts as t (t.id)}
|
||||
<div
|
||||
animate:flip={{ duration: 300 }}
|
||||
transition:fly={{ x: 100, duration: 300 }}
|
||||
class="alert bg-base-100 text-base-content border-0 shadow-base-300/50 shadow-lg min-w-[200px] flex justify-start"
|
||||
>
|
||||
<span><Icon Cid={toastIconMap[t.type]} size="24"></Icon></span>
|
||||
<span>{t.message}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
8
src/lib/components/button/LogoutButton.svelte
Normal file
8
src/lib/components/button/LogoutButton.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { getContext } from 'svelte';
|
||||
import { TOAST_KEY, type ToastState } from '$lib/stores/toast.svelte.ts';
|
||||
import Icon from '$lib/components/icon/Icon.svelte'; // 假设你的路径
|
||||
|
||||
|
||||
</script>
|
||||
2
src/lib/components/constants/cookiesConstants.ts
Normal file
2
src/lib/components/constants/cookiesConstants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const COOKIE_TOKEN_KEY = 'authorization';
|
||||
export const COOKIE_THEME_KEY = 'theme';
|
||||
22
src/lib/components/error/TableLoadingError.svelte
Normal file
22
src/lib/components/error/TableLoadingError.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
|
||||
import Icon from '$lib/components/icon/Icon.svelte';
|
||||
|
||||
const { error } = $props();
|
||||
|
||||
|
||||
let message = $state("");
|
||||
|
||||
if (error){
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<div class="flex-1 inset-0 flex flex-col items-center justify-center text-error ">
|
||||
<Icon id="error" size="56" />
|
||||
<p class="font-bold">加载失败</p>
|
||||
<p class="text-sm opacity-80">{message}</p>
|
||||
<button class="btn btn-sm btn-outline btn-error mt-4" onclick={() => location.reload()}>重新加载</button>
|
||||
</div>
|
||||
265
src/lib/components/form/AddDevice.svelte
Normal file
265
src/lib/components/form/AddDevice.svelte
Normal file
@@ -0,0 +1,265 @@
|
||||
<script lang="ts">
|
||||
|
||||
import type { CreateDeviceRequest, Options } from '$lib/types/api.ts';
|
||||
import { log } from '$lib/log.ts';
|
||||
|
||||
let {
|
||||
deviceTypeOptions = [],
|
||||
open = $bindable(false)
|
||||
} = $props<{ deviceTypeOptions: Options[] , open: boolean}>();
|
||||
|
||||
|
||||
log.info('device type options',deviceTypeOptions);
|
||||
let formData = $state<CreateDeviceRequest>({
|
||||
name: '',
|
||||
typeId: null,
|
||||
model: '',
|
||||
manufacturer: '',
|
||||
purchaseDate: '',
|
||||
interfaces: []
|
||||
});
|
||||
|
||||
// 错误信息状态
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
|
||||
function addInterface() {
|
||||
formData.interfaces.push({
|
||||
name: '',
|
||||
type: 1,
|
||||
addressConfigs: []
|
||||
});
|
||||
}
|
||||
|
||||
function removeInterface(index: number) {
|
||||
formData.interfaces.splice(index, 1);
|
||||
}
|
||||
|
||||
function addAddressConfig(interfaceIndex: number) {
|
||||
formData.interfaces[interfaceIndex].addressConfigs.push({
|
||||
isPrimary: false,
|
||||
isDhcp: false,
|
||||
ipAddress: ''
|
||||
});
|
||||
}
|
||||
|
||||
function removeAddressConfig(interfaceIndex: number, configIndex: number) {
|
||||
formData.interfaces[interfaceIndex].addressConfigs.splice(configIndex, 1);
|
||||
}
|
||||
|
||||
|
||||
function validate(): boolean {
|
||||
let newErrors: Record<string, string> = {};
|
||||
let isValid = true;
|
||||
const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/;
|
||||
const ipRegex = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
|
||||
// 基础字段验证
|
||||
if (!formData.name) newErrors['name'] = '设备名称不能为空';
|
||||
if (!formData.typeId) newErrors['typeId'] = '设备类型ID不能为空';
|
||||
if (!formData.model) newErrors['model'] = '设备型号不能为空';
|
||||
if (!formData.manufacturer) newErrors['manufacturer'] = '厂商不能为空';
|
||||
|
||||
// 嵌套验证
|
||||
formData.interfaces.forEach((iface, i) => {
|
||||
if (!iface.name) newErrors[`iface_${i}_name`] = '接口名称不能为空';
|
||||
if (iface.macAddress && !macRegex.test(iface.macAddress)) {
|
||||
newErrors[`iface_${i}_mac`] = 'MAC地址格式错误';
|
||||
}
|
||||
|
||||
iface.addressConfigs.forEach((addr, j) => {
|
||||
if (addr.ipAddress && !ipRegex.test(addr.ipAddress)) {
|
||||
newErrors[`iface_${i}_addr_${j}_ip`] = 'IP地址格式错误';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
errors = newErrors;
|
||||
isValid = false;
|
||||
} else {
|
||||
errors = {};
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// --- 【关键】导出方法供父组件调用 ---
|
||||
|
||||
export function submitAndGetPayload(): CreateDeviceRequest | null {
|
||||
if (validate()) {
|
||||
const snapshot = $state.snapshot(formData);
|
||||
|
||||
fetch('/api/devices', {
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: JSON.stringify(snapshot)
|
||||
}).then(res => res.json())
|
||||
.then(res => {
|
||||
if (res.ok) {
|
||||
log.info('设备创建成功', res);
|
||||
open = false;
|
||||
} else {
|
||||
log.error('设备创建失败', res);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
log.error('设备创建失败', err);
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
return null; // 验证失败
|
||||
|
||||
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const payload = submitAndGetPayload();
|
||||
if (payload) {
|
||||
log.info('设备创建成功', payload);
|
||||
open = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="card bg-base-100 shadow-md border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-primary">基础信息</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label class="form-control w-full">
|
||||
<div class="label"><span class="label-text">设备名称 *</span></div>
|
||||
<input type="text" bind:value={formData.name} class="input input-bordered w-full {errors.name ? 'input-error' : ''}" />
|
||||
{#if errors.name}<div class="label"><span class="label-text-alt text-error">{errors.name}</span></div>{/if}
|
||||
</label>
|
||||
|
||||
<label class="form-control w-full">
|
||||
<div class="label"><span class="label-text">厂商 *</span></div>
|
||||
<input type="text" bind:value={formData.manufacturer} class="input input-bordered w-full {errors.manufacturer ? 'input-error' : ''}" />
|
||||
</label>
|
||||
|
||||
<label class="form-control w-full">
|
||||
<div class="label"><span class="label-text">型号 *</span></div>
|
||||
<input type="text" bind:value={formData.model} class="input input-bordered w-full {errors.model ? 'input-error' : ''}" />
|
||||
</label>
|
||||
|
||||
<label class="form-control w-full">
|
||||
<div class="label"><span class="label-text">设备类型 *</span></div>
|
||||
|
||||
<select
|
||||
bind:value={formData.typeId}
|
||||
class="select select-bordered w-full {errors.typeId ? 'select-error' : ''}"
|
||||
>
|
||||
<option disabled selected value={null}>请选择设备类型</option>
|
||||
|
||||
{#each deviceTypeOptions as opt (opt.value)}
|
||||
<option value={Number(opt.value)}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if errors.typeId}
|
||||
<div class="label"><span class="label-text-alt text-error">{errors.typeId}</span></div>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<label class="form-control w-full">
|
||||
<div class="label"><span class="label-text">采购日期</span></div>
|
||||
<input type="date" bind:value={formData.purchaseDate} class="input input-bordered w-full" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-md border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title text-secondary">网络接口 (Interfaces)</h2>
|
||||
<button class="btn btn-sm btn-outline btn-secondary" onclick={addInterface}>+ 添加接口</button>
|
||||
</div>
|
||||
|
||||
{#each formData.interfaces as iface, i (i)}
|
||||
<div class="collapse collapse-arrow border border-base-300 bg-base-100 mb-2">
|
||||
<input type="checkbox" checked={true} />
|
||||
<div class="collapse-title text-lg font-medium flex justify-between pr-12">
|
||||
<span>接口 #{i + 1}: {iface.name || '(未命名)'}</span>
|
||||
<button class="btn btn-xs btn-error z-10" onclick={() => removeInterface(i)}>删除</button>
|
||||
</div>
|
||||
|
||||
<div class="collapse-content space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-2">
|
||||
<label class="form-control">
|
||||
<span class="label-text mb-1">接口名称 *</span>
|
||||
<input type="text" bind:value={iface.name} class="input input-bordered input-sm {errors[`iface_${i}_name`] ? 'input-error' : ''}" />
|
||||
{#if errors[`iface_${i}_name`]}<span class="text-xs text-error mt-1">{errors[`iface_${i}_name`]}</span>{/if}
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label-text mb-1">接口类型</span>
|
||||
<select bind:value={iface.type} class="select select-bordered select-sm">
|
||||
<option value={1}>物理口</option>
|
||||
<option value={2}>聚合口</option>
|
||||
<option value={3}>虚拟口</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label-text mb-1">MAC 地址</span>
|
||||
<input type="text" bind:value={iface.macAddress} placeholder="XX:XX:XX..." class="input input-bordered input-sm {errors[`iface_${i}_mac`] ? 'input-error' : ''}" />
|
||||
{#if errors[`iface_${i}_mac`]}<span class="text-xs text-error mt-1">{errors[`iface_${i}_mac`]}</span>{/if}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-200 p-4 rounded-lg">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h4 class="text-sm font-bold opacity-70">IP / VLAN 配置</h4>
|
||||
<button class="btn btn-xs btn-neutral" onclick={() => addAddressConfig(i)}>+ 添加 IP</button>
|
||||
</div>
|
||||
|
||||
{#if iface.addressConfigs.length === 0}
|
||||
<div class="text-xs text-center opacity-50 py-2">暂无 IP 配置</div>
|
||||
{/if}
|
||||
|
||||
{#each iface.addressConfigs as addr, j (j)}
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-2 items-end mb-2 border-b border-base-300 pb-2 last:border-0">
|
||||
<div class="md:col-span-1">
|
||||
<label class="label-text text-xs">VLAN</label>
|
||||
<input type="number" bind:value={addr.vlanId} class="input input-bordered input-xs w-full" />
|
||||
</div>
|
||||
<div class="md:col-span-3">
|
||||
<label class="label-text text-xs">IP地址</label>
|
||||
<input type="text" bind:value={addr.ipAddress} class="input input-bordered input-xs w-full {errors[`iface_${i}_addr_${j}_ip`] ? 'input-error' : ''}" />
|
||||
</div>
|
||||
<div class="md:col-span-3">
|
||||
<label class="label-text text-xs">子网掩码</label>
|
||||
<input type="text" bind:value={addr.subnetMask} class="input input-bordered input-xs w-full" />
|
||||
</div>
|
||||
<div class="md:col-span-2 flex flex-col gap-1">
|
||||
<label class="label cursor-pointer justify-start gap-2 p-0 h-6">
|
||||
<input type="checkbox" bind:checked={addr.isPrimary} class="checkbox checkbox-xs" />
|
||||
<span class="label-text text-xs">主IP</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-2 p-0 h-6">
|
||||
<input type="checkbox" bind:checked={addr.isDhcp} class="checkbox checkbox-xs" />
|
||||
<span class="label-text text-xs">DHCP</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="md:col-span-1 flex justify-end">
|
||||
<button class="btn btn-square btn-xs btn-ghost text-error" onclick={() => removeAddressConfig(i, j)}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="join flex justify-center" >
|
||||
<button class="btn btn-error join-item" onclick={onreset}>重置</button>
|
||||
<button class="btn join-item" onclick={draft}>保存草稿</button>
|
||||
<button class="btn btn-primary btn-wide join-item" onclick={handleSubmit}>提交</button>
|
||||
</div>
|
||||
</div>
|
||||
36
src/lib/components/icon/Icon.svelte
Normal file
36
src/lib/components/icon/Icon.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { IconId } from '$lib/types/icon-ids.ts';
|
||||
|
||||
export let id: IconId;
|
||||
export let size: number | string ;
|
||||
export let className: string = '';
|
||||
$: dimensions = typeof size === 'number' ? `${size}px` : size;
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<svg {...$$restProps}
|
||||
role="img"
|
||||
class={className}
|
||||
aria-hidden="true"
|
||||
width={dimensions?dimensions:24}
|
||||
height={dimensions?dimensions:24}
|
||||
>
|
||||
<use href={`#${id}`} />
|
||||
</svg>
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
.app-icon {
|
||||
/* 确保图标与文本基线对齐 */
|
||||
vertical-align: middle;
|
||||
/* 防止用户选择图标,提高用户体验 */
|
||||
user-select: none;
|
||||
/* 默认显示为行内块级元素 */
|
||||
display: inline-block;
|
||||
/* 确保它能响应 CSS 动画 */
|
||||
transition: color 0.2s, transform 0.2s;
|
||||
}
|
||||
</style>
|
||||
|
||||
101
src/lib/components/icon/Sprite.svelte
Normal file
101
src/lib/components/icon/Sprite.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;" >
|
||||
<!-- <!– eslint-disable-next-line svelte/no-at-html-tags –>-->
|
||||
<!-- {@html SpriteSvg}-->
|
||||
<symbol id="panel-right-close" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
|
||||
<path d="M15 3.5v17M8 9l3 3l-3 3" />
|
||||
<path d="M3 9.4c0-2.24 0-3.36.436-4.216a4 4 0 0 1 1.748-1.748C6.04 3 7.16 3 9.4 3h5.2c2.24 0 3.36 0 4.216.436a4 4 0 0 1 1.748 1.748C21 6.04 21 7.16 21 9.4v5.2c0 2.24 0 3.36-.436 4.216a4 4 0 0 1-1.748 1.748C17.96 21 16.84 21 14.6 21H9.4c-2.24 0-3.36 0-4.216-.436a4 4 0 0 1-1.748-1.748C3 17.96 3 16.84 3 14.6z" />
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="panel-right-close-solid" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M9.367 2.25h5.266c1.092 0 1.958 0 2.655.057c.714.058 1.317.18 1.869.46a4.75 4.75 0 0 1 2.075 2.077c.281.55.403 1.154.461 1.868c.057.697.057 1.563.057 2.655v5.266c0 1.092 0 1.958-.057 2.655c-.058.714-.18 1.317-.46 1.869a4.75 4.75 0 0 1-2.076 2.075c-.552.281-1.155.403-1.869.461c-.697.057-1.563.057-2.655.057H9.367c-1.092 0-1.958 0-2.655-.057c-.714-.058-1.317-.18-1.868-.46a4.75 4.75 0 0 1-2.076-2.076c-.281-.552-.403-1.155-.461-1.869c-.057-.697-.057-1.563-.057-2.655V9.367c0-1.092 0-1.958.057-2.655c.058-.714.18-1.317.46-1.868a4.75 4.75 0 0 1 2.077-2.076c.55-.281 1.154-.403 1.868-.461c.697-.057 1.563-.057 2.655-.057m6.383 17.997a20 20 0 0 0 1.416-.049c.62-.05 1.005-.147 1.31-.302a3.25 3.25 0 0 0 1.42-1.42c.155-.305.251-.69.302-1.31c.051-.63.052-1.434.052-2.566V9.4c0-1.132 0-1.937-.052-2.566c-.05-.62-.147-1.005-.302-1.31a3.25 3.25 0 0 0-1.42-1.42c-.305-.155-.69-.251-1.31-.302a20 20 0 0 0-1.416-.05zM7.47 8.47a.75.75 0 0 0 0 1.06L9.94 12l-2.47 2.47a.75.75 0 1 0 1.06 1.06l3-3a.75.75 0 0 0 0-1.06l-3-3a.75.75 0 0 0-1.06 0" />
|
||||
</symbol>
|
||||
|
||||
<symbol id="panel-left-close" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
|
||||
<path d="M9 3.5v17m7-5.5l-3-3l3-3" />
|
||||
<path d="M3 9.4c0-2.24 0-3.36.436-4.216a4 4 0 0 1 1.748-1.748C6.04 3 7.16 3 9.4 3h5.2c2.24 0 3.36 0 4.216.436a4 4 0 0 1 1.748 1.748C21 6.04 21 7.16 21 9.4v5.2c0 2.24 0 3.36-.436 4.216a4 4 0 0 1-1.748 1.748C17.96 21 16.84 21 14.6 21H9.4c-2.24 0-3.36 0-4.216-.436a4 4 0 0 1-1.748-1.748C3 17.96 3 16.84 3 14.6z" />
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="panel-left-close-solid" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M9.367 2.25h5.266c1.092 0 1.958 0 2.655.057c.714.058 1.317.18 1.869.46a4.75 4.75 0 0 1 2.075 2.077c.281.55.403 1.154.461 1.868c.057.697.057 1.563.057 2.655v5.266c0 1.092 0 1.958-.057 2.655c-.058.714-.18 1.317-.46 1.869a4.75 4.75 0 0 1-2.076 2.075c-.552.281-1.155.403-1.869.461c-.697.057-1.563.057-2.655.057H9.367c-1.092 0-1.958 0-2.655-.057c-.714-.058-1.317-.18-1.868-.46a4.75 4.75 0 0 1-2.076-2.076c-.281-.552-.403-1.155-.461-1.869c-.057-.697-.057-1.563-.057-2.655V9.367c0-1.092 0-1.958.057-2.655c.058-.714.18-1.317.46-1.868a4.75 4.75 0 0 1 2.077-2.076c.55-.281 1.154-.403 1.868-.461c.697-.057 1.563-.057 2.655-.057M6.834 3.802c-.62.05-1.005.147-1.31.302a3.25 3.25 0 0 0-1.42 1.42c-.155.305-.251.69-.302 1.31c-.051.63-.052 1.434-.052 2.566v5.2c0 1.133 0 1.937.052 2.566c.05.62.147 1.005.302 1.31a3.25 3.25 0 0 0 1.42 1.42c.305.155.69.251 1.31.302c.392.032.851.044 1.416.05V3.752c-.565.005-1.024.017-1.416.049M16.53 8.47a.75.75 0 0 0-1.06 0l-3 3a.75.75 0 0 0 0 1.06l3 3a.75.75 0 1 0 1.06-1.06L14.06 12l2.47-2.47a.75.75 0 0 0 0-1.06" />
|
||||
</symbol>
|
||||
|
||||
<symbol id="starburst" viewBox="0 0 48 48">
|
||||
<g fill="none">
|
||||
<path fill="url(#SVGvr5ORdWH)" d="M25.183 2.58a1.5 1.5 0 0 0-2.368 0l-3.388 4.356l-5.112-2.078a1.5 1.5 0 0 0-2.051 1.184l-.756 5.467l-5.467.756a1.5 1.5 0 0 0-1.184 2.05l2.078 5.113l-4.356 3.388a1.5 1.5 0 0 0 0 2.368l4.356 3.388l-2.078 5.113a1.5 1.5 0 0 0 1.184 2.05l5.467.757l.756 5.466a1.5 1.5 0 0 0 2.05 1.184l5.113-2.078l3.388 4.356a1.5 1.5 0 0 0 2.368 0l3.388-4.356l5.113 2.078a1.5 1.5 0 0 0 2.05-1.184l.756-5.466l5.467-.757a1.5 1.5 0 0 0 1.184-2.05l-2.078-5.113l4.356-3.388a1.5 1.5 0 0 0 0-2.368l-4.356-3.388l2.078-5.113a1.5 1.5 0 0 0-1.184-2.05l-5.467-.756l-.756-5.467a1.5 1.5 0 0 0-2.05-1.184L28.57 6.936z" />
|
||||
<path fill="url(#SVGRWDvEe1n)" fill-opacity="0.95" d="M24 14c.69 0 1.25.56 1.25 1.25v7.5h7.5a1.25 1.25 0 1 1 0 2.5h-7.5v7.5a1.25 1.25 0 1 1-2.5 0v-7.5h-7.5a1.25 1.25 0 1 1 0-2.5h7.5v-7.5c0-.69.56-1.25 1.25-1.25" />
|
||||
<defs>
|
||||
<radialGradient id="SVGvr5ORdWH" cx="0" cy="0" r="1" gradientTransform="rotate(-119.49 41.522 10.903)scale(97.2587 93.1572)" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ffc470" />
|
||||
<stop offset=".251" stop-color="#ff835c" />
|
||||
<stop offset=".55" stop-color="#f24a9d" />
|
||||
<stop offset=".814" stop-color="#b339f0" />
|
||||
</radialGradient>
|
||||
<linearGradient id="SVGRWDvEe1n" x1="32.611" x2="11.626" y1="39.646" y2="26.053" gradientUnits="userSpaceOnUse">
|
||||
<stop offset=".024" stop-color="#ffc8d7" />
|
||||
<stop offset=".807" stop-color="#fff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</g>
|
||||
</symbol>
|
||||
|
||||
<symbol id="data" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M10 4a2 2 0 1 0-4 0v10h4zM5 7H4a2 2 0 0 0-2 2v4.5a.5.5 0 0 0 .5.5H5zm6 7h2.5a.5.5 0 0 0 .5-.5V7a2 2 0 0 0-2-2h-1z" />
|
||||
</symbol>
|
||||
|
||||
<symbol id="home" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M8.687 1.262a1 1 0 0 0-1.374 0L2.469 5.84A1.5 1.5 0 0 0 2 6.931v5.57A1.5 1.5 0 0 0 3.5 14H5a1.5 1.5 0 0 0 1.5-1.5V10a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v2.5A1.5 1.5 0 0 0 11 14h1.5a1.5 1.5 0 0 0 1.5-1.5V6.93a1.5 1.5 0 0 0-.47-1.09z" />
|
||||
</symbol>
|
||||
|
||||
<symbol id="menu" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M3.75 6.5a.75.75 0 0 1 .75-.75h15a.75.75 0 0 1 0 1.5h-15a.75.75 0 0 1-.75-.75m0 5.5a.75.75 0 0 1 .75-.75h15a.75.75 0 0 1 0 1.5h-15a.75.75 0 0 1-.75-.75m0 5.5a.75.75 0 0 1 .75-.75h15a.75.75 0 0 1 0 1.5h-15a.75.75 0 0 1-.75-.75" />
|
||||
</symbol>
|
||||
|
||||
|
||||
<symbol id="info" viewBox="0 0 24 24">
|
||||
<path fill="#3B82F6" d="M12 1.999c5.524 0 10.002 4.478 10.002 10.002c0 5.523-4.478 10.001-10.002 10.001S2 17.524 2 12.001C1.999 6.477 6.476 1.999 12 1.999" class="duoicon-secondary-layer" opacity="0.3" />
|
||||
<path fill="#3B82F6" d="M12.001 6.5a1.252 1.252 0 1 0 .002 2.503A1.252 1.252 0 0 0 12 6.5zm-.005 3.749a1 1 0 0 0-.992.885l-.007.116l.004 5.502l.006.117a1 1 0 0 0 1.987-.002L13 16.75l-.004-5.501l-.007-.117a1 1 0 0 0-.994-.882z" class="duoicon-primary-layer" />
|
||||
</symbol>
|
||||
<symbol id="success" viewBox="0 0 24 24">
|
||||
<path fill="#10B981" fill-rule="evenodd" d="M10.586 2.1a2 2 0 0 1 2.7-.116l.128.117L15.314 4H18a2 2 0 0 1 1.994 1.85L20 6v2.686l1.9 1.9a2 2 0 0 1 .116 2.701l-.117.127l-1.9 1.9V18a2 2 0 0 1-1.85 1.995L18 20h-2.685l-1.9 1.9a2 2 0 0 1-2.701.116l-.127-.116l-1.9-1.9H6a2 2 0 0 1-1.995-1.85L4 18v-2.686l-1.9-1.9a2 2 0 0 1-.116-2.701l.116-.127l1.9-1.9V6a2 2 0 0 1 1.85-1.994L6 4h2.686z" class="duoicon-secondary-layer" opacity="0.3" />
|
||||
<path fill="#10B981" fill-rule="evenodd" d="m15.079 8.983l-4.244 4.244l-1.768-1.768a1 1 0 1 0-1.414 1.415l2.404 2.404a1.1 1.1 0 0 0 1.556 0l4.88-4.881a1 1 0 0 0-1.414-1.414" class="duoicon-primary-layer" />
|
||||
</symbol>
|
||||
<symbol id="warning" viewBox="0 0 24 24">
|
||||
<path fill="#F59E0B" fill-rule="evenodd" d="M15.314 2a2 2 0 0 1 1.414.586l4.686 4.686A2 2 0 0 1 22 8.686v6.628a2 2 0 0 1-.586 1.414l-4.686 4.686a2 2 0 0 1-1.414.586H8.686a2 2 0 0 1-1.414-.586l-4.686-4.686A2 2 0 0 1 2 15.314V8.686a2 2 0 0 1 .586-1.414l4.686-4.686A2 2 0 0 1 8.686 2z" class="duoicon-secondary-layer" opacity="0.3" />
|
||||
<path fill="#F59E0B" fill-rule="evenodd" d="M12 6a1 1 0 0 0-.993.883L11 7v6a1 1 0 0 0 1.993.117L13 13V7a1 1 0 0 0-1-1m0 9a1 1 0 1 0 0 2a1 1 0 0 0 0-2" class="duoicon-primary-layer" />
|
||||
</symbol>
|
||||
<symbol id="error" viewBox="0 0 24 24">
|
||||
<path fill="#EF4444" d="m13.299 3.148l8.634 14.954a1.5 1.5 0 0 1-1.299 2.25H3.366a1.5 1.5 0 0 1-1.299-2.25l8.634-14.954c.577-1 2.02-1 2.598 0" class="duoicon-secondary-layer" opacity="0.3" />
|
||||
<path fill="#EF4444" d="M12 8a1 1 0 0 0-.993.883L11 9v4a1 1 0 0 0 1.993.117L13 13V9a1 1 0 0 0-1-1m0 7a1 1 0 1 0 0 2a1 1 0 0 0 0-2" class="duoicon-primary-layer" />
|
||||
</symbol>
|
||||
|
||||
|
||||
<symbol id="settings" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M2.267 6.153A6 6 0 0 1 3.53 3.98a.36.36 0 0 1 .382-.095l1.36.484a.71.71 0 0 0 .935-.538l.26-1.416a.35.35 0 0 1 .274-.282a6.1 6.1 0 0 1 2.52 0c.14.03.248.141.274.282l.26 1.416a.708.708 0 0 0 .935.538l1.36-.484a.36.36 0 0 1 .382.095a6 6 0 0 1 1.262 2.173a.35.35 0 0 1-.108.378l-1.102.931a.703.703 0 0 0 0 1.076l1.102.931c.11.093.152.242.108.378a6 6 0 0 1-1.262 2.173a.36.36 0 0 1-.382.095l-1.36-.484a.71.71 0 0 0-.935.538l-.26 1.416a.35.35 0 0 1-.275.282a6.1 6.1 0 0 1-2.519 0a.35.35 0 0 1-.275-.282l-.259-1.416a.708.708 0 0 0-.935-.538l-1.36.484a.36.36 0 0 1-.382-.095a6 6 0 0 1-1.262-2.173a.35.35 0 0 1 .108-.378l1.102-.931a.704.704 0 0 0 0-1.076l-1.102-.931a.35.35 0 0 1-.108-.378M6.25 8a1.75 1.75 0 1 0 3.5 0a1.75 1.75 0 0 0-3.5 0" />
|
||||
</symbol>
|
||||
<symbol id="user-settings" viewBox="0 0 32 32">
|
||||
<path fill="currentColor" d="M25.303 16.86a7.5 7.5 0 0 1 2.749 1.596l-.495 1.725a1.52 1.52 0 0 0 1.095 1.892l1.698.423a7.5 7.5 0 0 1-.04 3.189l-1.536.351a1.52 1.52 0 0 0-1.117 1.927l.467 1.514a7.5 7.5 0 0 1-2.737 1.635L24.15 29.84a1.53 1.53 0 0 0-2.192 0l-1.26 1.3a7.5 7.5 0 0 1-2.75-1.597l.495-1.724a1.52 1.52 0 0 0-1.095-1.892l-1.698-.424a7.5 7.5 0 0 1 .04-3.189l1.536-.35a1.52 1.52 0 0 0 1.117-1.928l-.467-1.513a7.5 7.5 0 0 1 2.737-1.635l1.237 1.272a1.53 1.53 0 0 0 2.192 0zM16 17c.387 0 .757.075 1.097.209a8.98 8.98 0 0 0-2.962 8.342c-.995.28-2.192.449-3.635.449C2.04 26 2 20.205 2 20.15V20a3 3 0 0 1 3-3zm7 5a2 2 0 1 0 0 4a2 2 0 0 0 0-4M10.5 4a5.5 5.5 0 1 1 0 11a5.5 5.5 0 0 1 0-11M23 7a4 4 0 1 1 0 8a4 4 0 0 1 0-8" />
|
||||
</symbol>
|
||||
|
||||
|
||||
<symbol id="user-profile" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M1 4.75C1 3.784 1.784 3 2.75 3h10.5c.966 0 1.75.784 1.75 1.75v6.5A1.75 1.75 0 0 1 13.25 13H2.75A1.75 1.75 0 0 1 1 11.25zM2.75 4a.75.75 0 0 0-.75.75v6.5c0 .414.336.75.75.75h10.5a.75.75 0 0 0 .75-.75v-6.5a.75.75 0 0 0-.75-.75zM9.5 6a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zm0 3a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zM5.261 7.714a1.357 1.357 0 1 0 0-2.714a1.357 1.357 0 0 0 0 2.714m-1.403.678A.86.86 0 0 0 3 9.25a1.67 1.67 0 0 0 1.265 1.62l.053.014c.62.155 1.267.155 1.886 0l.054-.013a1.67 1.67 0 0 0 1.265-1.62a.86.86 0 0 0-.858-.859z" />
|
||||
</symbol>
|
||||
|
||||
<symbol id="sign-out" viewBox="0 0 20 20"><path fill="currentColor" d="M8.5 11.25a.75.75 0 1 0 0-1.5a.75.75 0 0 0 0 1.5M11 3.5a.5.5 0 0 0-.576-.494l-7 1.07A.5.5 0 0 0 3 4.57v10.86a.5.5 0 0 0 .424.494l7 1.071a.5.5 0 0 0 .576-.494V10h5.172l-.997.874a.5.5 0 0 0 .658.752l1.996-1.75a.5.5 0 0 0 0-.752l-1.996-1.75a.499.499 0 1 0-.658.752l.997.874H11zm-1 .582V15.92L4 15V4.999zM12.5 16H12v-5h1v4.5a.5.5 0 0 1-.5.5M12 8V4h.5a.5.5 0 0 1 .5.5V8z" /></symbol>
|
||||
<symbol id="auth" viewBox="0 0 16 16"><path fill="currentColor" d="M9.07 6.746a2 2 0 0 0 2.91.001l.324-.344c.297.14.577.316.835.519l-.126.422a2 2 0 0 0 1.456 2.518l.348.083a4.7 4.7 0 0 1 .011 1.017l-.46.117a2 2 0 0 0-1.431 2.479l.156.556q-.383.294-.822.497l-.337-.357a2 2 0 0 0-2.91-.002l-.325.345a4.3 4.3 0 0 1-.835-.519l.126-.423a2 2 0 0 0-1.456-2.518l-.35-.083a4.7 4.7 0 0 1-.01-1.016l.462-.118a2 2 0 0 0 1.43-2.478l-.156-.557q.383-.294.822-.497zm-1.423-4.6a.5.5 0 0 1 .707 0C9.594 3.39 10.97 4 12.5 4a.5.5 0 0 1 .5.5v1.1a5.5 5.5 0 0 0-7.494 7.204C3.846 11.59 3 9.812 3 7.502V4.5a.5.5 0 0 1 .5-.5c1.53 0 2.904-.61 4.147-1.854M10.501 9.5a1 1 0 1 0 0 2a1 1 0 0 0 0-2"/></symbol>
|
||||
<symbol id="chevron-up-down" viewBox="0 0 16 16"><path fill="currentColor" d="M4.22 6.53a.75.75 0 0 0 1.06 0L8 3.81l2.72 2.72a.75.75 0 1 0 1.06-1.06L8.53 2.22a.75.75 0 0 0-1.06 0L4.22 5.47a.75.75 0 0 0 0 1.06m0 2.94a.75.75 0 0 1 1.06 0L8 12.19l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0l-3.25-3.25a.75.75 0 0 1 0-1.06"/></symbol>
|
||||
<symbol id="laptop-settings" viewBox="0 0 20 20"><path fill="currentColor" d="M4.5 5A1.5 1.5 0 0 0 3 6.5v6A1.5 1.5 0 0 0 4.5 14h4.522A5.5 5.5 0 0 1 17 9.6V6.5A1.5 1.5 0 0 0 15.5 5zm-2 10h6.522q.047.516.185 1H2.5a.5.5 0 0 1 0-1m9.565-3.558a2 2 0 0 1-1.43 2.478l-.462.118a4.7 4.7 0 0 0 .01 1.016l.35.083a2 2 0 0 1 1.456 2.519l-.127.423q.388.306.835.517l.325-.344a2 2 0 0 1 2.91.002l.337.358q.44-.203.822-.498l-.156-.556a2 2 0 0 1 1.43-2.478l.46-.118a4.7 4.7 0 0 0-.01-1.017l-.348-.082a2 2 0 0 1-1.456-2.52l.126-.421a4.3 4.3 0 0 0-.835-.519l-.325.344a2 2 0 0 1-2.91-.001l-.337-.358a4.3 4.3 0 0 0-.821.497zm2.434 4.058a1 1 0 1 1 0-2a1 1 0 0 1 0 2"/></symbol>
|
||||
<symbol id="people-search" viewBox="0 0 20 20"><path fill="currentColor" d="M10 2a4 4 0 1 0 0 8a4 4 0 0 0 0-8m4.865 14.797c-1.071.683-2.454 1.064-3.962 1.171a1.5 1.5 0 0 0-.342-.529l-2-1.999A4.5 4.5 0 0 0 9 13.5a4.5 4.5 0 0 0-.758-2.5H15a2 2 0 0 1 2 2c0 1.691-.833 2.966-2.135 3.797M4.5 17c.786 0 1.512-.26 2.096-.697l2.55 2.55a.5.5 0 1 0 .708-.707l-2.55-2.55A3.5 3.5 0 1 0 4.5 17m0-1a2.5 2.5 0 1 1 0-5a2.5 2.5 0 0 1 0 5"/></symbol>
|
||||
<symbol id="search-12" viewBox="0 0 12 12"><path fill="currentColor" d="M5 1a4 4 0 1 0 2.248 7.31l2.472 2.47a.75.75 0 1 0 1.06-1.06L8.31 7.248A4 4 0 0 0 5 1M2.5 5a2.5 2.5 0 1 1 5 0a2.5 2.5 0 0 1-5 0"/></symbol>
|
||||
<symbol id="delete-12" viewBox="0 0 12 12"><path fill="currentColor" d="M5 3h2a1 1 0 0 0-2 0M4 3a2 2 0 1 1 4 0h2.5a.5.5 0 0 1 0 1h-.441l-.443 5.17A2 2 0 0 1 7.623 11H4.377a2 2 0 0 1-1.993-1.83L1.941 4H1.5a.5.5 0 0 1 0-1zm3.5 3a.5.5 0 0 0-1 0v2a.5.5 0 0 0 1 0zM5 5.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5M3.38 9.085a1 1 0 0 0 .997.915h3.246a1 1 0 0 0 .996-.915L9.055 4h-6.11z"/></symbol>
|
||||
<symbol id="person-add" viewBox="0 0 16 16"><path fill="currentColor" d="M9.626 5.07a5.5 5.5 0 0 0-3.299 1.847A2.751 2.751 0 1 1 9.626 5.07M5.6 8c-.384.75-.6 1.6-.6 2.5c0 1.31.458 2.512 1.222 3.457C3.555 13.653 2 11.803 2 10v-.5A1.5 1.5 0 0 1 3.5 8zm4.9 7a4.5 4.5 0 1 0 0-9a4.5 4.5 0 0 0 0 9m0-7a.5.5 0 0 1 .5.5V10h1.5a.5.5 0 0 1 0 1H11v1.5a.5.5 0 0 1-1 0V11H8.5a.5.5 0 0 1 0-1H10V8.5a.5.5 0 0 1 .5-.5"/></symbol>
|
||||
|
||||
<symbol id="logo" viewBox="0 0 1028 1024"><path d="M550.68864 672c25.6-54.4 76.8-96 134.4-115.2l41.6-89.6c-3.2-6.4-6.4-12.8-6.4-19.2-3.2-6.4-3.2-16-6.4-22.4-25.6-6.4-41.6-19.2-51.2-38.4-16-32 3.2-76.8 41.6-115.2-25.6-35.2-57.6-64-92.8-89.6-38.4 41.6-80 60.8-112 48-35.2-12.8-51.2-57.6-51.2-112-41.6-6.4-86.4-3.2-128 3.2 3.2 60.8-12.8 105.6-44.8 121.6-35.2 12.8-76.8-3.2-118.4-41.6-35.2 25.6-64 57.6-89.6 92.8 41.6 38.4 60.8 80 48 112-12.8 35.2-57.6 51.2-112 51.2-6.4 41.6-3.2 86.4 3.2 128 54.4-3.2 99.2 12.8 115.2 44.8 16 32-3.2 76.8-41.6 115.2 25.6 35.2 57.6 64 92.8 89.6 38.4-41.6 80-60.8 112-48 35.2 12.8 51.2 57.6 51.2 112 41.6 6.4 86.4 3.2 128-3.2-3.2-54.4 12.8-99.2 44.8-115.2 3.2-3.2 9.6-3.2 12.8-3.2 6.4-35.2 12.8-73.6 28.8-105.6z m-156.8 6.4C304.28864 678.4 227.48864 604.8 227.48864 512c0-92.8 73.6-166.4 166.4-166.4s166.4 73.6 166.4 166.4c3.2 92.8-73.6 166.4-166.4 166.4z" fill="currentColor" ></path><path d="M1001.88864 288l-54.4 96-99.2-48 41.6-102.4c-48 3.2-96 28.8-118.4 76.8-22.4 48-16 105.6 16 144L707.48864 620.8c-48 3.2-92.8 28.8-115.2 76.8-22.4 48-16 99.2 12.8 140.8l60.8-102.4 99.2 48-44.8 112c48-3.2 96-28.8 118.4-76.8 22.4-48 16-102.4-16-144l80-166.4c48-3.2 92.8-28.8 115.2-76.8 19.2-51.2 12.8-105.6-16-144z" fill="currentColor" ></path></symbol>
|
||||
|
||||
</svg>
|
||||
@@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<div>
|
||||
header
|
||||
</div>
|
||||
61
src/lib/components/layout/app/AppHeader.svelte
Normal file
61
src/lib/components/layout/app/AppHeader.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import ThemeSelector from '$lib/widget/ThemeSelector.svelte';
|
||||
|
||||
</script>
|
||||
|
||||
<header class="w-full h-18 flex justify-between items-center px-4 bg-base-300 flex-shrink-0 z-10">
|
||||
<div>
|
||||
<!-- <button-->
|
||||
<!-- class="btn btn-square btn-ghost"-->
|
||||
<!-- aria-label="Toggle Sidebar"-->
|
||||
<!-- onclick={sidebarState.toggleSidebar}-->
|
||||
<!-- >-->
|
||||
<!-- <Icon Cid="menu" size="24" />-->
|
||||
<!-- </button>-->
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="flex justify-center items-center gap-4 select-none">
|
||||
<ThemeSelector/>
|
||||
|
||||
{#if page.data.user }
|
||||
<div class="dropdown dropdown-end ">
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="rounded-full cursor-pointer shadow-base-content bg-base-100/50 p-0.5 flex items-center justify-center text-primary-content font-bold"
|
||||
>
|
||||
{#if page.data.user.avatar}
|
||||
<img
|
||||
class="w-8 h-8 rounded-full "
|
||||
src="{page.data.user.avatar}"
|
||||
alt="Avatar"
|
||||
/>
|
||||
{:else}
|
||||
<span>{page.data.user.nickname.slice(0, 1)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="dropdown-content mt-2 w-64 shadow-base-300 p-12 shadow-2xl bg-base-200 border border-base-content/10 rounded-box ">
|
||||
<div class="text-center ">
|
||||
<p class="font-bold">{page.data.user.nickname}</p>
|
||||
<p class="text-xs mt-2">{page.data.user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="flex items-center">
|
||||
<div class="">
|
||||
<button class="btn btn-primary btn-sm" onclick={() => goto(resolve("/auth/login"))}>
|
||||
登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
235
src/lib/components/layout/app/AppSidebar.svelte
Normal file
235
src/lib/components/layout/app/AppSidebar.svelte
Normal file
@@ -0,0 +1,235 @@
|
||||
<script lang="ts">
|
||||
|
||||
import {resolve as _resolve} from '$app/paths'
|
||||
import type { NavItem } from '$lib/types/layout.ts';
|
||||
import { getContext } from 'svelte';
|
||||
import Icon from '$lib/components/icon/Icon.svelte';
|
||||
import { TOAST_KEY, type ToastState } from '$lib/stores/toast.svelte.ts';
|
||||
import type { RouteId } from '$app/types';
|
||||
import { page } from '$app/state';
|
||||
|
||||
|
||||
|
||||
const rawNavItems: NavItem[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: '仪表盘',
|
||||
icon: 'home',
|
||||
href: '/app/dashboard'
|
||||
},
|
||||
{
|
||||
id: 'statistics',
|
||||
label: '数据看板',
|
||||
icon: 'data',
|
||||
href: '/app/statistics'
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: '系统设置',
|
||||
icon: 'settings',
|
||||
href: '/app/settings', // 父级带链接
|
||||
subItems: [
|
||||
{
|
||||
id: 'auth',
|
||||
label: '认证管理',
|
||||
href: '/app/settings/auth',
|
||||
icon: 'auth',
|
||||
subItems: [
|
||||
{
|
||||
id: 'users',
|
||||
label: '用户管理',
|
||||
href: '/app/settings/auth/users'
|
||||
},
|
||||
{
|
||||
id: 'roles',
|
||||
label: '角色权限',
|
||||
href: '/app/settings/auth/roles'
|
||||
},
|
||||
{
|
||||
id: 'permissions',
|
||||
label: '权限管理',
|
||||
href: '/app/settings/auth/permissions'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'device',
|
||||
label: '设备管理',
|
||||
icon: 'laptop-settings',
|
||||
href: '/app/settings/devices',
|
||||
subItems: [
|
||||
{
|
||||
id: 'device-type',
|
||||
label: '类型管理',
|
||||
href: '/app/settings/devices/type'
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
|
||||
const toast = getContext<ToastState>(TOAST_KEY);
|
||||
// @ts-expect-error : ES + TS 混合报错手动忽略
|
||||
const resolve = (href: RouteId) => _resolve(href);
|
||||
|
||||
let expandedIds = $state<string[]>([]);
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
if (expandedIds.includes(id)) {
|
||||
expandedIds = expandedIds.filter(item => item !== id);
|
||||
} else {
|
||||
expandedIds = [...expandedIds, id];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleClick = (item: NavItem) => {
|
||||
|
||||
|
||||
if (item.subItems && item.subItems.length > 0) {
|
||||
toggleExpand(item.id);
|
||||
} else {
|
||||
// 叶子节点逻辑
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const computeNavState = (items: NavItem[], currentPath: string, openIds: string[]): NavItem[] => {
|
||||
return items.map((item) => {
|
||||
const newItem = { ...item };
|
||||
|
||||
newItem.isActive = currentPath === newItem.href;
|
||||
|
||||
// 递归处理子项
|
||||
let hasActiveChild = false;
|
||||
if (newItem.subItems && newItem.subItems.length > 0) {
|
||||
newItem.subItems = computeNavState(newItem.subItems, currentPath, openIds);
|
||||
hasActiveChild = newItem.subItems.some(child => child.isActive || child.isOpen);
|
||||
}
|
||||
|
||||
const isManuallyOpen = openIds.includes(newItem.id);
|
||||
|
||||
// 如果你希望“进入父级页面自动展开子菜单”,请保留 `|| newItem.isActive`
|
||||
newItem.isOpen = isManuallyOpen || hasActiveChild || newItem.isActive;
|
||||
|
||||
return newItem;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
let menuItems = $derived(computeNavState(rawNavItems, page.url.pathname, expandedIds));
|
||||
|
||||
let userProfileOpen = $state(false);
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<aside class="custom-scrollbar h-screen bg-base-200 flex flex-col rounded-r-box overflow-hidden w-64">
|
||||
<div class="flex items-center h-18 w-full bg-base-200">
|
||||
<div class="space-x-4 pl-6 w-full">
|
||||
<Icon id="logo" className="inline" size="36"/><h1 class="font-mono font-bold text-[0.85rem] inline align-bottom">IT Management System</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 ">
|
||||
<ul class="menu bg-base-200 w-full px-4 pb-4 pt-0 text-base-content flex-nowrap ">
|
||||
{#each menuItems as item (item.id)}
|
||||
<li class="{item.isActive ? 'menu-active' : ''} w-full rounded-box ">
|
||||
{#if item.subItems && item.subItems.length > 0}
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={resolve(item.href)} class="p-2" onclick="{() => handleClick(item)}">
|
||||
{#if item.icon}
|
||||
<Icon id={item.icon} size="24"/>
|
||||
{/if}
|
||||
<span class="menu-dropdown-toggle {item.isOpen ? 'menu-dropdown-show ' : ''}">{item.label}</span>
|
||||
</a>
|
||||
<ul class="menu-dropdown rounded-box {item.isOpen ? 'menu-dropdown-show' : ''}">
|
||||
{#each item.subItems as subItem (subItem.id)}
|
||||
<li class="{subItem.isActive ? 'menu-active' : ''} rounded-box ">
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={resolve(subItem.href)} class="p-2 " onclick={() => handleClick(subItem)}>
|
||||
{#if subItem.icon}
|
||||
<Icon id={subItem.icon} size="24"/>
|
||||
{/if}
|
||||
<span class="menu-dropdown-toggle">
|
||||
{subItem.label}
|
||||
</span>
|
||||
</a>
|
||||
{#if subItem.subItems && subItem.subItems.length > 0}
|
||||
<ul class="menu-dropdown {subItem.isOpen ? 'menu-dropdown-show' : ''}" >
|
||||
{#each subItem.subItems as childItem (childItem.id)}
|
||||
<li class="{childItem.isActive ? 'menu-active' : ''} rounded-box">
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={resolve(childItem.href)} class="p-2">
|
||||
{#if childItem.icon}
|
||||
<Icon id={childItem.icon} size="24"/>
|
||||
{:else}
|
||||
<div class="w-0.5/2 h-1">
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
{childItem.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{:else }
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={resolve(item.href)} class="p-2">
|
||||
{#if item.icon}
|
||||
<Icon id={item.icon} size="24"/>
|
||||
{/if}
|
||||
{item.label}
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
<div class="h-24 w-full bg-base-200 ">
|
||||
{@render UserCard( )}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
<style>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.3);
|
||||
border-radius: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
{#snippet UserCard()}
|
||||
{#if page.data.user}
|
||||
<div class="flex items-center px-4 h-full select-none w-full overflow-hidden hover:bg-base-100 cursor-pointer transition-all dropdown dropdown-top dropdown-end">
|
||||
<div class="flex gap-4 w-full" tabindex="0" role="button" >
|
||||
<div class="w-12 h-12 rounded-box overflow-hidden flex-shrink-0"> <img src={page.data.user.avatar} alt="avatar" class="w-full h-full object-cover">
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="truncate font-medium">{page.data.user.nickname}</p>
|
||||
<p class="text-xs mt-2 truncate text-base-content/60">@{page.data.user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul tabindex="-1" class="dropdown-contents menu bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm">
|
||||
<li><a>Item 1</a></li>
|
||||
<li><a>Item 2</a></li>
|
||||
</ul>
|
||||
{/if}
|
||||
{/snippet}
|
||||
4
src/lib/components/loading/TableLoadingState.svelte
Normal file
4
src/lib/components/loading/TableLoadingState.svelte
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="flex-1 inset-0 flex flex-col items-center justify-center bg-base-100/50 backdrop-blur-sm z-10">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="text-base-content/70 mt-4 font-medium animate-pulse">正在加载数据...</p>
|
||||
</div>
|
||||
147
src/lib/components/table/DevicesTable.svelte
Normal file
147
src/lib/components/table/DevicesTable.svelte
Normal file
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
|
||||
|
||||
import type { PageResult } from '$lib/types/dataTable.ts';
|
||||
import Icon from '$lib/components/icon/Icon.svelte';
|
||||
import type { DeviceResponse, } from '$lib/types/api.ts';
|
||||
|
||||
let { devices } = $props<{
|
||||
devices: PageResult<DeviceResponse[]>,
|
||||
}>();
|
||||
|
||||
|
||||
|
||||
const newRowTitles = [
|
||||
{ title: 'ID', width: 5}
|
||||
, { title: '用户名', width: 15 }
|
||||
, { title: '昵称', width: 20 }
|
||||
, { title: '头像', width: 10 }
|
||||
, { title: '用户组', width: 45 }
|
||||
];
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<div class="h-full">
|
||||
<div class="overflow-x-auto rounded-box shadow bg-base-100 mt-1 min-h-1/2">
|
||||
<div class="flex items-center justify-between px-4 pt-4 pb-2 ">
|
||||
<div class="flex gap-4">
|
||||
<label class="input">
|
||||
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.3-4.3"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<input type="search" required placeholder="Search" />
|
||||
<button class="btn btn-xs btn-primary">搜索</button>
|
||||
</label>
|
||||
<!--{#if rolesOptions}-->
|
||||
<!-- <div class="filter w-64">-->
|
||||
<!-- <input class="btn filter-reset " type="radio" value='' name="metaframeworks" aria-label="All" onchange={handleRoleChange} />-->
|
||||
<!-- {#each rolesOptions as role(role.value)}-->
|
||||
<!-- <input class="btn" type="radio" name="metaframeworks" aria-label="{role.label}" value={role.value} onchange={handleRoleChange} />-->
|
||||
<!-- {/each}-->
|
||||
<!-- </div>-->
|
||||
<!--{/if}-->
|
||||
</div>
|
||||
<div class=" flex items-center justify-center gap-4">
|
||||
<button class="btn btn-primary">添加设备</button>
|
||||
|
||||
<div class="dropdown dropdown-bottom dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn" ><Icon Cid="menu" size="24" /></div>
|
||||
<ul tabindex="-1" class="dropdown-content menu bg-base-200 rounded-box z-1 w-52 p-2 mt-2 shadow-sm" >
|
||||
<li><div>删除</div></li>
|
||||
<li><div>封禁</div></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
{#if devices.total > 0}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 5%">
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
{#each newRowTitles as item,index(index)}
|
||||
<th style="width: {item.width}%" >{item.title}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
|
||||
|
||||
<!--{#if users.records}-->
|
||||
<!-- <tbody>-->
|
||||
<!-- {#each users.records as record(record.id)}-->
|
||||
<!-- <tr>-->
|
||||
<!-- <th>-->
|
||||
<!-- <label>-->
|
||||
<!-- <input type="checkbox" class="checkbox" />-->
|
||||
<!-- </label>-->
|
||||
<!-- </th>-->
|
||||
<!-- <td>{record.id}</td>-->
|
||||
<!-- <td>{record.username}</td>-->
|
||||
<!-- <td>{record.nickname}</td>-->
|
||||
<!-- <td>-->
|
||||
<!-- <div class="w-8 h-8 rounded-box bg-primary-content/10 border-0">-->
|
||||
<!-- {#if record.avatar}-->
|
||||
<!-- <img class="w-8 h-8 rounded-box bg-primary-content/10 border-0" src="{record.avatar}" alt=" ">-->
|
||||
<!-- {/if}-->
|
||||
<!-- </div>-->
|
||||
<!-- </td>-->
|
||||
<!-- <td class="">-->
|
||||
<!-- {#each record.roles as role (role.id)}-->
|
||||
<!-- <span class="badge select-none mr-2 last:mr-0 {role.id === 1 ? 'badge-primary' : 'badge-secondary'}">{role.name}</span>-->
|
||||
<!-- {/each}-->
|
||||
<!-- </td>-->
|
||||
<!-- </tr>-->
|
||||
<!-- {/each}-->
|
||||
|
||||
<!-- </tbody>-->
|
||||
<!-- <tfoot>-->
|
||||
<!-- <tr>-->
|
||||
<!-- <th colspan={newRowTitles.length + 1} class="text-center py-4 ">-->
|
||||
<!-- <div class=" flex items-center justify-between">-->
|
||||
<!-- <div>-->
|
||||
<!-- page {users.current} of {users.pages}-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div class="join">-->
|
||||
<!-- <button class="join-item btn">1</button>-->
|
||||
<!-- <button class="join-item btn">2</button>-->
|
||||
<!-- <button class="join-item btn btn-disabled">...</button>-->
|
||||
<!-- <button class="join-item btn">99</button>-->
|
||||
<!-- <button class="join-item btn">100</button>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div>-->
|
||||
<!-- <button class="btn btn-primary">下一页</button>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- </th>-->
|
||||
<!-- </tr>-->
|
||||
<!-- </tfoot>-->
|
||||
|
||||
<!--{/if}-->
|
||||
|
||||
</table>
|
||||
{:else }
|
||||
<p>No users found</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
201
src/lib/components/table/UserTableOld.svelte
Normal file
201
src/lib/components/table/UserTableOld.svelte
Normal file
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import Icon from '$lib/components/icon/Icon.svelte';
|
||||
import type { UserProfile } from '$lib/types/user.ts';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
console.log("data", data);
|
||||
const newRowTitles = [
|
||||
{ title: 'ID', width: 5}
|
||||
, { title: '用户名', width: 15 }
|
||||
, { title: '昵称', width: 20 }
|
||||
, { title: '头像', width: 10 }
|
||||
, { title: '用户组', width: 45 }
|
||||
];
|
||||
|
||||
let x ;
|
||||
|
||||
const handleRoleChange = (e) => {
|
||||
console.log(e.target.value);
|
||||
x = e.target.value;
|
||||
}
|
||||
|
||||
let users: UserProfile[] ;
|
||||
|
||||
if (data.streamed.userList){
|
||||
users = data.streamed.userList.records;
|
||||
}
|
||||
|
||||
Promise
|
||||
</script>
|
||||
|
||||
<div class=" ">
|
||||
|
||||
<div class="flex justify-between items-center ">
|
||||
<p class="font-bold">用户管理</p>
|
||||
<div class="breadcrumbs ">
|
||||
<ul>
|
||||
<li><a href={resolve('/app/dashboard')}>仪表盘</a></li>
|
||||
<li><a href={resolve('/app/settings')}>系统设置</a></li>
|
||||
<li><a href={resolve('/app/settings/auth')}>认证管理</a></li>
|
||||
<li><a href={resolve('/app/settings/auth/users')}>用户管理</a></li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="overflow-x-auto rounded-box shadow bg-base-100 mt-1 ">
|
||||
<div class="flex items-center justify-between px-4 pt-4 pb-2">
|
||||
<div class="flex gap-4">
|
||||
<label class="input">
|
||||
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.3-4.3"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<input type="search" required placeholder="Search" />
|
||||
<button class="btn btn-xs btn-primary">搜索</button>
|
||||
</label>
|
||||
{#await data.streamed.roles}
|
||||
<div></div>
|
||||
{:then roles }
|
||||
<div class="filter w-64">
|
||||
<input class="btn filter-reset " type="radio" value='' name="metaframeworks" aria-label="All" onchange={handleRoleChange} />
|
||||
{#each roles as role(role.id)}
|
||||
<input class="btn " type="radio" name="metaframeworks" aria-label="{role.name}" value={role.id} onchange={handleRoleChange} />
|
||||
{/each}
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
<div class=" flex items-center justify-center gap-4">
|
||||
<button class="btn btn-primary">添加用户</button>
|
||||
|
||||
<div class="dropdown dropdown-bottom dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn" ><Icon Cid="menu" size="24" /></div>
|
||||
<ul tabindex="-1" class="dropdown-content menu bg-base-200 rounded-box z-1 w-52 p-2 mt-2 shadow-sm" >
|
||||
<li><div>删除</div></li>
|
||||
<li><div>封禁</div></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 5%">
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
{#each newRowTitles as item,index(index)}
|
||||
<th style="width: {item.width}%" >{item.title}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{#await data.streamed.userList}
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan={newRowTitles.length + 1} class="text-center py-4 ">
|
||||
<div class="min-h-96 flex items-center justify-center">
|
||||
<div class="loading text-base-content"></div>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{:then userList}
|
||||
<tbody>
|
||||
{#each userList.records as record(record.id)}
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
<td>{record.id}</td>
|
||||
<td>{record.username}</td>
|
||||
<td>{record.nickname}</td>
|
||||
<td>
|
||||
<div class="w-8 h-8 rounded-box bg-primary-content/10 border-0">
|
||||
{#if record.avatar}
|
||||
<img class="w-8 h-8 rounded-box bg-primary-content/10 border-0" src="{record.avatar}" alt=" ">
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="">
|
||||
{#each record.roles as role (role.id)}
|
||||
<span class="badge mr-2 last:mr-0 {role.id === 1 ? 'badge-primary' : 'badge-secondary'}">{role.name}</span>
|
||||
{/each}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan={newRowTitles.length + 1} class="text-center py-4 ">
|
||||
<div class=" flex items-center justify-between">
|
||||
<div>
|
||||
page {userList.current} of {userList.pages}
|
||||
</div>
|
||||
|
||||
<div class="join">
|
||||
<button class="join-item btn">1</button>
|
||||
<button class="join-item btn">2</button>
|
||||
<button class="join-item btn btn-disabled">...</button>
|
||||
<button class="join-item btn">99</button>
|
||||
<button class="join-item btn">100</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-primary">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{:catch error}
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan={newRowTitles.length + 1} class="text-center py-4 ">
|
||||
<div class="min-h-96 flex items-center justify-center">
|
||||
<p class="error">组件加载失败: {error.message}</p>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{/await}
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<style lang="scss">
|
||||
.loading {
|
||||
padding: 20px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
196
src/lib/components/table/UsersTable.svelte
Normal file
196
src/lib/components/table/UsersTable.svelte
Normal file
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import type { PageResult } from '$lib/types/dataTable';
|
||||
import type { UserProfile } from '$lib/types/user';
|
||||
import Icon from '$lib/components/icon/Icon.svelte';
|
||||
|
||||
let {
|
||||
users,
|
||||
selectedIds = $bindable([]),
|
||||
onPageChange
|
||||
} = $props<{
|
||||
users: PageResult<UserProfile[]>;
|
||||
selectedIds: number[];
|
||||
onPageChange: (page: number) => void;
|
||||
}>();
|
||||
|
||||
// --- 内部状态逻辑 (与UI展示紧密相关) ---
|
||||
|
||||
// 计算属性:是否全选
|
||||
let isAllSelected = $derived(
|
||||
users.records.length > 0 && selectedIds.length === users.records.length
|
||||
);
|
||||
|
||||
// 计算属性:是否部分选中 (indeterminate)
|
||||
let isIndeterminate = $derived(
|
||||
selectedIds.length > 0 && selectedIds.length < users.records.length
|
||||
);
|
||||
|
||||
function toggleAll() {
|
||||
if (isAllSelected) {
|
||||
selectedIds = [];
|
||||
} else {
|
||||
selectedIds = users.records.map((u) => u.id);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOne(id: number) {
|
||||
if (selectedIds.includes(id)) {
|
||||
selectedIds = selectedIds.filter((itemId) => itemId !== id);
|
||||
} else {
|
||||
selectedIds = [...selectedIds, id];
|
||||
}
|
||||
}
|
||||
|
||||
// Action: 处理 checkbox 的 indeterminate 视觉状态
|
||||
function indeterminate(node: HTMLInputElement, isIndeterminate: boolean) {
|
||||
$effect(() => {
|
||||
node.indeterminate = isIndeterminate;
|
||||
});
|
||||
}
|
||||
|
||||
// 分页逻辑辅助函数
|
||||
function getPaginationRange(current: number, total: number) {
|
||||
const delta = 2;
|
||||
const range = [];
|
||||
const rangeWithDots: (number | string)[] = [];
|
||||
let l: number | undefined;
|
||||
|
||||
for (let i = 1; i <= total; i++) {
|
||||
if (i === 1 || i === total || (i >= current - delta && i <= current + delta)) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i of range) {
|
||||
if (l) {
|
||||
if (i - l === 2) {
|
||||
rangeWithDots.push(l + 1);
|
||||
} else if (i - l !== 1) {
|
||||
rangeWithDots.push('...');
|
||||
}
|
||||
}
|
||||
rangeWithDots.push(i);
|
||||
l = i;
|
||||
}
|
||||
return rangeWithDots;
|
||||
}
|
||||
|
||||
const newRowTitles = [
|
||||
{ title: 'ID', width: 5 },
|
||||
{ title: '用户名', width: 15 },
|
||||
{ title: '昵称', width: 20 },
|
||||
{ title: '头像', width: 10 },
|
||||
{ title: '用户组', width: 45 }
|
||||
];
|
||||
</script>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="bg-base-100">
|
||||
<table class="table table-pin-rows">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-12 bg-base-100"> <label>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
checked={isAllSelected}
|
||||
use:indeterminate={isIndeterminate}
|
||||
onchange={toggleAll}
|
||||
/>
|
||||
</label>
|
||||
</th>
|
||||
{#each newRowTitles as item (item.title)}
|
||||
<th style="width: {item.width}%" class="bg-base-100">{item.title}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="w-full">
|
||||
{#each users.records as record (record.id)}
|
||||
<tr class="hover">
|
||||
<th>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
checked={selectedIds.includes(record.id)}
|
||||
onchange={() => toggleOne(record.id)}
|
||||
/>
|
||||
</label>
|
||||
</th>
|
||||
<td class="font-mono text-xs opacity-70">{record.id}</td>
|
||||
<td class="font-bold">{record.username}</td>
|
||||
<td>{record.nickname || '-'}</td>
|
||||
<td>
|
||||
<div class="avatar">
|
||||
<div class="w-8 h-8 rounded-full bg-base-300 ring ring-base-200 ring-offset-base-100 ring-offset-2">
|
||||
{#if record.avatar}
|
||||
<img src={record.avatar} alt={record.username} />
|
||||
{:else}
|
||||
<span class="text-xs flex items-center justify-center h-full w-full uppercase">
|
||||
{record.username.slice(0, 2)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each record.roles as role (role.id)}
|
||||
<span class="badge badge-sm {role.id === 1 ? 'badge-primary' : 'badge-ghost'}">
|
||||
{role.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{#if users.records.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-10 text-base-content/50">
|
||||
<Icon id="search-off" size="48" />
|
||||
<p class="mt-2">未找到匹配的用户</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{#if users.total > 0}
|
||||
<div class="border-t border-base-200 p-4 flex items-center justify-between bg-base-100 ">
|
||||
<div class="text-sm text-base-content/70">
|
||||
显示 {(users.current - 1) * users.size + 1} 到 {Math.min(users.current * users.size, users.total)} 条,共 {users.total} 条
|
||||
</div>
|
||||
|
||||
<div class="join">
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
disabled={users.current === 1}
|
||||
onclick={() => onPageChange(users.current - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
|
||||
{#each getPaginationRange(users.current, users.pages) as pageNum (pageNum)}
|
||||
{#if pageNum === '...'}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{:else}
|
||||
<button
|
||||
class="join-item btn btn-sm {users.current === pageNum ? 'btn-active btn-primary' : ''}"
|
||||
onclick={() => onPageChange(Number(pageNum))}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
disabled={users.current === users.pages}
|
||||
onclick={() => onPageChange(users.current + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
41
src/lib/hooks/useAuth.ts
Normal file
41
src/lib/hooks/useAuth.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { authStore } from '$lib/stores/authStore';
|
||||
import { goto } from '$app/navigation';
|
||||
import { isAuthenticated } from '$lib/utils/authUtils';
|
||||
import type { UserProfile } from '$lib/types/user';
|
||||
|
||||
/**
|
||||
* Hook to protect routes and provide auth utilities
|
||||
*/
|
||||
export const useAuth = () => {
|
||||
/**
|
||||
* Protect a route by checking authentication
|
||||
*/
|
||||
const protectRoute = () => {
|
||||
onMount(async () => {
|
||||
const authenticated = await isAuthenticated();
|
||||
if (!authenticated) {
|
||||
goto('/auth/login');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
const getUser = (): UserProfile | null => {
|
||||
let user: UserProfile | null = null;
|
||||
const unsubscribe = authStore.subscribe(state => {
|
||||
user = state.user;
|
||||
});
|
||||
unsubscribe(); // Immediately unsubscribe
|
||||
return user;
|
||||
};
|
||||
|
||||
return {
|
||||
protectRoute,
|
||||
getUser,
|
||||
logout: authStore.logout,
|
||||
isAuthenticated
|
||||
};
|
||||
};
|
||||
43
src/lib/log.ts
Normal file
43
src/lib/log.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
|
||||
type LogArgs = unknown[];
|
||||
|
||||
const getTime = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
||||
|
||||
|
||||
function print(level: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR', message: string, args: LogArgs) {
|
||||
if (browser) {
|
||||
|
||||
const styles = {
|
||||
DEBUG: 'color: #999; font-weight: bold;',
|
||||
INFO: 'color: #2196f3; font-weight: bold;',
|
||||
WARN: 'color: #ff9800; font-weight: bold;',
|
||||
ERROR: 'color: #f44336; font-weight: bold;',
|
||||
};
|
||||
|
||||
console.log(`%c[${level}] ${message}`, styles[level], ...args);
|
||||
} else {
|
||||
|
||||
const colors = {
|
||||
DEBUG: '\x1b[90m',
|
||||
INFO: '\x1b[34m',
|
||||
WARN: '\x1b[33m',
|
||||
ERROR: '\x1b[31m',
|
||||
};
|
||||
const reset = '\x1b[0m';
|
||||
|
||||
console.log(
|
||||
`${colors[level]}[${getTime()}] [${level}] ${message}${reset}`,
|
||||
...args
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const log = {
|
||||
debug: (message: string, ...args: LogArgs) => print('DEBUG', message, args),
|
||||
info: (message: string, ...args: LogArgs) => print('INFO', message, args),
|
||||
warn: (message: string, ...args: LogArgs) => print('WARN', message, args),
|
||||
error: (message: string, ...args: LogArgs) => print('ERROR', message, args),
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import {writable} from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface AuthStore {
|
||||
token: string | null;
|
||||
tokenHead: string | null;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
let initialToken: string | null = null;
|
||||
let initialTokenHead: string | null = null;
|
||||
|
||||
|
||||
if (browser) {
|
||||
initialToken = localStorage.getItem('auth_token');
|
||||
initialTokenHead = localStorage.getItem('auth_token_head');
|
||||
}
|
||||
|
||||
const initialAuthStore: AuthStore = {
|
||||
token: initialToken,
|
||||
tokenHead: initialTokenHead,
|
||||
isAuthenticated: initialToken !== null
|
||||
}
|
||||
|
||||
const authStatusStore = writable<AuthStore>({
|
||||
token: initialToken,
|
||||
tokenHead: initialTokenHead,
|
||||
isAuthenticated: initialToken !== null
|
||||
})
|
||||
|
||||
export const authStore = {
|
||||
subscribe: authStatusStore.subscribe,
|
||||
set: authStatusStore.set,
|
||||
update: authStatusStore.update,
|
||||
clear: () => {
|
||||
authStatusStore.set(initialAuthStore);
|
||||
},
|
||||
};
|
||||
20
src/lib/stores/sidebar.svelte.ts
Normal file
20
src/lib/stores/sidebar.svelte.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export class SidebarState {
|
||||
isSidebarExpanded = $state(true);
|
||||
|
||||
constructor(initialIsSidebarExpanded = true) {
|
||||
this.isSidebarExpanded = initialIsSidebarExpanded;
|
||||
}
|
||||
toggleSidebar = ()=> {
|
||||
this.isSidebarExpanded = !this.isSidebarExpanded;
|
||||
}
|
||||
|
||||
closeSidebar() {
|
||||
this.isSidebarExpanded = false;
|
||||
}
|
||||
|
||||
openSidebar() {
|
||||
this.isSidebarExpanded = true;
|
||||
}
|
||||
}
|
||||
|
||||
export const SIDEBAR_KEY = Symbol('SIDEBAR');
|
||||
16
src/lib/stores/theme.svelte.ts
Normal file
16
src/lib/stores/theme.svelte.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { DaisyUIThemeID } from '$lib/types/theme.ts';
|
||||
|
||||
export class ThemeState {
|
||||
theme: DaisyUIThemeID = $state('dark');
|
||||
|
||||
constructor(initialTheme = 'dark' as DaisyUIThemeID) {
|
||||
this.theme = initialTheme;
|
||||
}
|
||||
|
||||
setTheme(theme: DaisyUIThemeID) {
|
||||
this.theme = theme;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const THEME_KEY = Symbol('THEME');
|
||||
@@ -1,24 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { DaisyUIThemeID } from '$lib/types/theme.ts';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
|
||||
let initialTheme: DaisyUIThemeID = 'light';
|
||||
|
||||
if (browser){
|
||||
initialTheme = localStorage.getItem('theme') as DaisyUIThemeID || 'light';
|
||||
}
|
||||
|
||||
|
||||
const themeStatusStore = writable<DaisyUIThemeID>(initialTheme);
|
||||
|
||||
export const themeStore = {
|
||||
subscribe: themeStatusStore.subscribe,
|
||||
set: (theme: DaisyUIThemeID) => {
|
||||
if (browser){
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
themeStatusStore.set(theme);
|
||||
},
|
||||
};
|
||||
|
||||
33
src/lib/stores/toast.svelte.ts
Normal file
33
src/lib/stores/toast.svelte.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export class ToastState {
|
||||
toasts = $state<ToastMessage[]>([]);
|
||||
add(message:string, type:ToastType = 'info', duration = 3000){
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
this.toasts.push({id,type,message,duration});
|
||||
if (duration > 0){
|
||||
setTimeout(()=>{
|
||||
this.remove(id);
|
||||
},duration)
|
||||
}
|
||||
}
|
||||
|
||||
remove(id:string) {
|
||||
this.toasts = this.toasts.filter(toast => toast.id !== id)
|
||||
}
|
||||
success(msg: string, duration = 3000) { this.add(msg, 'success', duration); }
|
||||
error(msg: string, duration = 3000) { this.add(msg, 'error', duration); }
|
||||
warning(msg: string, duration = 3000) { this.add(msg, 'warning', duration); }
|
||||
info(msg: string, duration = 3000) { this.add(msg, 'info', duration); }
|
||||
}
|
||||
|
||||
export const TOAST_KEY = Symbol('TOAST');
|
||||
@@ -1,37 +0,0 @@
|
||||
import { type Writable, writable} from 'svelte/store';
|
||||
import type { UserProfile } from '$lib/types/user.ts';
|
||||
|
||||
export const userStateStore:Writable<UserProfile > = writable<UserProfile>( {
|
||||
id: '',
|
||||
name: '',
|
||||
nickname: '',
|
||||
roles: [],
|
||||
});
|
||||
|
||||
|
||||
|
||||
const initialUserProfile: UserProfile = {
|
||||
id: '',
|
||||
name: '',
|
||||
nickname: '',
|
||||
roles: [],
|
||||
};
|
||||
|
||||
|
||||
const clearUserProfile = () => {
|
||||
userStore.set(initialUserProfile);
|
||||
};
|
||||
|
||||
export const userStore = {
|
||||
// 导出 subscribe 方法供组件订阅
|
||||
subscribe: userStateStore.subscribe,
|
||||
|
||||
// 导出 set 方法
|
||||
set: userStateStore.set,
|
||||
|
||||
// 导出 update 方法
|
||||
update: userStateStore.update,
|
||||
|
||||
// 导出清晰的 'clear' 方法
|
||||
clear: clearUserProfile
|
||||
};
|
||||
@@ -1,5 +1,88 @@
|
||||
import type { AuthResponse } from '$lib/types/auth.ts';
|
||||
|
||||
export interface ApiResult<T> {
|
||||
code: number,
|
||||
msg: string,
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T | null;
|
||||
}
|
||||
|
||||
class HttpError extends Error{
|
||||
constructor(
|
||||
public message: string,
|
||||
public code: number,
|
||||
public data: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError<T> extends HttpError {
|
||||
constructor(ApiResult: ApiResult<T>) {
|
||||
super(ApiResult.msg, ApiResult.code, JSON.stringify(ApiResult.data));
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoginFailure {
|
||||
message: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface LoginSuccess {
|
||||
message: string;
|
||||
data: AuthResponse;
|
||||
}
|
||||
|
||||
|
||||
export interface DeviceResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
model: string;
|
||||
typeId: number;
|
||||
locationId: number;
|
||||
snmpCommunity: string;
|
||||
manufacturer: string;
|
||||
purchaseDate: Date;
|
||||
status: number;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
|
||||
export interface Options {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
|
||||
export interface InterfaceAddressConfigRequest {
|
||||
vlanId?: number | null;
|
||||
ipAddress?: string;
|
||||
subnetMask?: string;
|
||||
gatewayIp?: string;
|
||||
broadcastAddress?: string;
|
||||
isPrimary: boolean;
|
||||
isDhcp: boolean;
|
||||
mtu?: number | null;
|
||||
dnsServers?: string[]; // 简化处理,前端可用逗号分隔字符串转换
|
||||
}
|
||||
|
||||
export interface NetworkInterfaceRequest {
|
||||
name: string;
|
||||
type: number | null; // 1:物理口, 2:聚合口, 3:虚拟口
|
||||
macAddress?: string;
|
||||
portSpeed?: number | null;
|
||||
duplex?: number | null;
|
||||
remark?: string;
|
||||
addressConfigs: InterfaceAddressConfigRequest[];
|
||||
}
|
||||
|
||||
export interface CreateDeviceRequest {
|
||||
name: string;
|
||||
typeId: number | null;
|
||||
locationId?: number | null;
|
||||
model: string;
|
||||
manufacturer: string;
|
||||
snmpCommunity?: string;
|
||||
purchaseDate?: string; // 对应 Java LocalDate (YYYY-MM-DD)
|
||||
remark?: string;
|
||||
interfaces: NetworkInterfaceRequest[];
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { JsonObject } from '$lib/types/http.ts';
|
||||
import type { UserProfile } from '$lib/types/user.ts';
|
||||
|
||||
|
||||
export interface LoginPayload extends JsonObject {
|
||||
username: string;
|
||||
@@ -8,4 +10,15 @@ export interface LoginPayload extends JsonObject {
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
tokenHead: string;
|
||||
userProfile: UserProfile;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string; // 用户标识
|
||||
iat: number; // 签发时间(Unix 时间戳)
|
||||
exp: number; // 过期时间(Unix 时间戳)
|
||||
authorities: string[]; // 权限列表
|
||||
userId: string; // 用户ID
|
||||
nickname: string; // 昵称
|
||||
avatar: string; // 头像URL
|
||||
}
|
||||
21
src/lib/types/dataTable.ts
Normal file
21
src/lib/types/dataTable.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
|
||||
export interface BaseRecord {
|
||||
id: number | string;
|
||||
}
|
||||
|
||||
|
||||
export interface TableColumn<T> {
|
||||
key: keyof T; // 核心修改:强制 key 必须存在于数据模型中
|
||||
label: string;
|
||||
width?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
|
||||
export interface PageResult<T> {
|
||||
records: T[];
|
||||
total: number;
|
||||
size: number;
|
||||
current: number;
|
||||
pages: number;
|
||||
}
|
||||
@@ -4,4 +4,4 @@ export interface JsonObject {
|
||||
[key:string] : JsonValue;
|
||||
}
|
||||
export type JsonArray = JsonValue[];
|
||||
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
|
||||
export type JsonValue = JsonPrimitive | JsonObject | JsonArray | object;
|
||||
|
||||
25
src/lib/types/icon-ids.ts
Normal file
25
src/lib/types/icon-ids.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type IconId =
|
||||
"panel-right-close" |
|
||||
"panel-right-close-solid"|
|
||||
"panel-left-close"|
|
||||
"panel-left-close-solid"|
|
||||
"data"|
|
||||
"starburst"|
|
||||
"home"|
|
||||
"menu"|
|
||||
"logo"|
|
||||
"success"|
|
||||
"error"|
|
||||
"warning"|
|
||||
"info"|
|
||||
"settings"|
|
||||
"user-settings" |
|
||||
"user-profile"|
|
||||
"auth"|
|
||||
"chevron-up-down"|
|
||||
"laptop-settings"|
|
||||
"people-search"|
|
||||
"search-12"|
|
||||
"delete-12" |
|
||||
"person-add"
|
||||
;
|
||||
21
src/lib/types/layout.ts
Normal file
21
src/lib/types/layout.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IconId } from '$lib/types/icon-ids.ts';
|
||||
import type { RouteId } from '$app/types';
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
icon?: IconId;
|
||||
label: string;
|
||||
href: RouteId ;
|
||||
isActive?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isHidden?: boolean;
|
||||
isOpen?: boolean;
|
||||
subItems?: NavItem[];
|
||||
}
|
||||
|
||||
export interface ProcessedNavItem extends Omit<NavItem, 'subItems'> {
|
||||
isActive: boolean;
|
||||
isChildActive: boolean;
|
||||
// 递归定义:子项也是 ProcessedNavItem
|
||||
subItems?: ProcessedNavItem[];
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
export interface UserProfile{
|
||||
id: string;
|
||||
name : string;
|
||||
id: number;
|
||||
username : string;
|
||||
nickname : string;
|
||||
roles : string[];
|
||||
roles : RoleResponse[];
|
||||
avatar? : string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface RoleResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import {user} from '$lib/stores/userStore';
|
||||
import {get} from 'svelte/store';
|
||||
|
||||
export const hasRole = (role: string[]) => {
|
||||
const userProfile = get(user);
|
||||
|
||||
if (!userProfile){
|
||||
return false;
|
||||
}
|
||||
return role.some(r => userProfile.roles.includes(r))
|
||||
};
|
||||
60
src/lib/utils/authUtils.ts
Normal file
60
src/lib/utils/authUtils.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { authStore } from '$lib/stores/authStore';
|
||||
import { tokenService } from '$lib/api/services/tokenService';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
* This function will validate the token if needed
|
||||
*/
|
||||
export const isAuthenticated = async (): Promise<boolean> => {
|
||||
if (!browser) return false;
|
||||
|
||||
const state = authStore.isAuthenticated();
|
||||
if (!state) return false;
|
||||
|
||||
// Optionally validate token with server
|
||||
const isValid = await tokenService.validateToken();
|
||||
if (!isValid) {
|
||||
// If token is invalid, logout user
|
||||
authStore.logout();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Require authentication for a page
|
||||
* Redirects to login if not authenticated
|
||||
*/
|
||||
export const requireAuth = async (): Promise<void> => {
|
||||
if (!browser) return;
|
||||
|
||||
const authenticated = await isAuthenticated();
|
||||
if (!authenticated) {
|
||||
// Redirect to login page
|
||||
goto('/auth/login?redirect=' + encodeURIComponent(window.location.pathname));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Logout user and redirect to login
|
||||
*/
|
||||
export const logout = async (): Promise<void> => {
|
||||
if (!browser) return;
|
||||
|
||||
// Call logout API
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
}).catch(() => {
|
||||
// Ignore errors during logout
|
||||
});
|
||||
|
||||
// Clear local state
|
||||
authStore.logout();
|
||||
|
||||
// Redirect to login
|
||||
goto('/auth/login');
|
||||
};
|
||||
14
src/lib/utils/errorUtils.ts
Normal file
14
src/lib/utils/errorUtils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { HttpError } from '$lib/api/httpClient.ts';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
|
||||
export const handleError = (error: Error) => {
|
||||
if (error instanceof HttpError) {
|
||||
return fail(error.s, {
|
||||
message: error.details?.msg || '认证失败,请检查凭证'
|
||||
});
|
||||
|
||||
|
||||
if (error instanceof TypeError || (error instanceof Error && error.message.includes('fetch'))) {
|
||||
return { message: '无法连接服务器,请检查网络' };
|
||||
}
|
||||
};
|
||||
20
src/lib/utils/tokenUtils.ts
Normal file
20
src/lib/utils/tokenUtils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
export const parseJwt = <T>(token:string):T | null => {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map(function(c) {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
})
|
||||
.join('')
|
||||
);
|
||||
|
||||
return JSON.parse(jsonPayload) as T;
|
||||
}catch (e){
|
||||
console.error('parseJwt error', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { themeStore } from '$lib/stores/themeStore.ts';
|
||||
import { DAISYUI_THEME_OPTIONS, type DaisyUIThemeID } from '$lib/types/theme.ts';
|
||||
import { DAISYUI_THEME_OPTIONS } from '$lib/types/theme.ts';
|
||||
import ThemePreview from '$lib/widget/ThemePreview.svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { THEME_KEY, ThemeState } from '$lib/stores/theme.svelte.ts';
|
||||
const themeState = getContext<ThemeState>(THEME_KEY);
|
||||
|
||||
// ... 逻辑保持不变 ...
|
||||
const handleThemeChange = (themeValue: DaisyUIThemeID) => {
|
||||
themeStore.set(themeValue);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="rounded btn btn-ghost p-2 overflow-hidden flex items-center gap-2">
|
||||
<ThemePreview themeId={$themeStore} />
|
||||
<svg width="12px" height="12px" class="mt-px text-base-content fill-current opacity-60 sm:inline-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2048 2048"><path d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z"></path></svg>
|
||||
<div class="dropdown dropdown-center md:dropdown-end ">
|
||||
<div tabindex="0" role="button" class="rounded hover:bg-base-100 active:bg-base-200 p-2 overflow-hidden flex items-center gap-2">
|
||||
<ThemePreview themeId={themeState.theme} />
|
||||
<svg width="12px" height="12px" class="mt-px text-base-content size-2 fill-current opacity-60 sm:inline-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2048 2048"><path d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z"></path></svg>
|
||||
</div>
|
||||
|
||||
<ul class="dropdown-content shadow bg-base-200 border border-base-content/10 z-[1] rounded-box w-64 max-h-80 overflow-x-hidden overflow-y-auto flex flex-col ">
|
||||
<ul
|
||||
|
||||
class="dropdown-content shadow bg-base-200 border border-base-content/10 z-[1] rounded-box w-64 max-h-80 overflow-x-hidden overflow-y-auto ">
|
||||
|
||||
{#each DAISYUI_THEME_OPTIONS as theme (theme.value)}
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={() => handleThemeChange(theme.value)}
|
||||
on:keydown={(e) => {
|
||||
onclick={() => themeState.setTheme(theme.value)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleThemeChange(theme.value);
|
||||
themeState.setTheme(theme.value);
|
||||
}
|
||||
}}
|
||||
class="gap-3 w-full flex hover:bg-base-300 active:bg-base-100 p-2 items-center {theme.value === $themeStore ? 'active' : ''}"
|
||||
class="gap-3 w-full flex hover:bg-base-300 active:bg-primary p-2 items-center {theme.value === themeState.theme ? 'active' : ''}"
|
||||
>
|
||||
<ThemePreview themeId={theme.value} />
|
||||
<div class=" ">{theme.name}</div>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from '../../.svelte-kit/types/src/routes/$types';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { RouteId } from '$app/types';
|
||||
import { COOKIE_THEME_KEY } from '$lib/components/constants/cookiesConstants.ts';
|
||||
|
||||
export const load: LayoutServerLoad = async ({url}) => {
|
||||
|
||||
const targetPath = '/app/dashboard';
|
||||
export const load: LayoutServerLoad = async ({url,cookies,locals}) => {
|
||||
|
||||
const targetPath: RouteId = '/app/dashboard';
|
||||
|
||||
// 2. 检查当前访问的路径是否为根路径 '/'
|
||||
// 并且确保当前路径不是目标路径本身,以避免无限循环
|
||||
if (url.pathname === '/') {
|
||||
// 如果是根路径,则执行重定向到目标路径
|
||||
throw redirect(302, targetPath);
|
||||
throw redirect(302, resolve(targetPath));
|
||||
}
|
||||
|
||||
return{
|
||||
theme: cookies.get(COOKIE_THEME_KEY) || 'dark',
|
||||
user: locals.user
|
||||
}
|
||||
|
||||
};
|
||||
@@ -1,14 +1,38 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { themeStore } from '$lib/stores/themeStore.ts';
|
||||
let { children } = $props();
|
||||
import favicon from '$lib/assets/favicon.svg?url';
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import Sprite from '$lib/components/icon/Sprite.svelte';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
import {ThemeState,THEME_KEY} from '$lib/stores/theme.svelte.ts';
|
||||
import type { DaisyUIThemeID } from '$lib/types/theme.ts';
|
||||
import { COOKIE_THEME_KEY } from '$lib/components/constants/cookiesConstants.ts';
|
||||
import { TOAST_KEY, ToastState } from '$lib/stores/toast.svelte.ts';
|
||||
import { SIDEBAR_KEY, SidebarState } from '$lib/stores/sidebar.svelte.ts';
|
||||
let { data ,children} = $props();
|
||||
|
||||
setContext(THEME_KEY, new ThemeState(data.theme as DaisyUIThemeID ?? 'dark'))
|
||||
setContext(TOAST_KEY,new ToastState())
|
||||
setContext(SIDEBAR_KEY,new SidebarState())
|
||||
const themeState = getContext<ThemeState>(THEME_KEY);
|
||||
|
||||
|
||||
$effect(() => {
|
||||
document.documentElement.setAttribute('data-theme', themeState.theme);
|
||||
document.cookie = `${COOKIE_THEME_KEY}=${themeState.theme}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
|
||||
|
||||
|
||||
</svelte:head>
|
||||
|
||||
<div data-theme={$themeStore} class="text-base-content">
|
||||
<Sprite />
|
||||
<div class="text-base-content">
|
||||
<ToastContainer />
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
|
||||
redirect(302, `${resolve}/app/dashboard`);
|
||||
</script>
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { hasRole } from '$lib/utils/auth.ts';
|
||||
|
||||
const isAdmin = hasRole(['admin'])
|
||||
|
||||
</script>
|
||||
<div class="h-screen w-screen">
|
||||
|
||||
{#if isAdmin}
|
||||
<div>
|
||||
是管理员
|
||||
</div>
|
||||
{:else }
|
||||
<div>
|
||||
没有权限
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
29
src/routes/api/devices/+server.ts
Normal file
29
src/routes/api/devices/+server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { log } from '$lib/log.ts';
|
||||
import type { CreateDeviceRequest } from '$lib/types/api.ts';
|
||||
import { deviceService } from '$lib/api/services/deviceService.ts';
|
||||
import { COOKIE_TOKEN_KEY } from '$lib/components/constants/cookiesConstants.ts';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
|
||||
export async function POST({ request ,cookies }) {
|
||||
const data = await request.json() as CreateDeviceRequest;
|
||||
const token = cookies.get(COOKIE_TOKEN_KEY);
|
||||
|
||||
if (!token) {
|
||||
throw redirect(302, '/auth/login');
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 实际应用中:将 data 存入数据库
|
||||
|
||||
log.info('client request data', data)
|
||||
|
||||
const device = await deviceService.createDevice( data, token );
|
||||
|
||||
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ message: 'Device created successfully', device: device }),
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
<script lang="ts">
|
||||
import AppHeader from '$lib/components/layout/app/AppHeader.svelte';
|
||||
import AppSidebar from '$lib/components/layout/app/AppSidebar.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<main class="">
|
||||
{@render children()}
|
||||
</main>
|
||||
<div class="flex h-screen bg-base-300 overflow-hidden relative">
|
||||
<AppSidebar />
|
||||
<div class="flex-1 flex flex-col min-w-0 overflow-hidden relative h-full">
|
||||
<AppHeader />
|
||||
<main class="flex-1 flex flex-col min-h-0 overflow-hidden px-4 pb-4 ">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
@@ -1,68 +1,11 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { authStore } from '$lib/stores/authStore.ts';
|
||||
import { userStore } from '$lib/stores/userStore.ts';
|
||||
import { authService } from '$lib/api/services/authService.ts';
|
||||
import ThemeSelector from '$lib/widget/ThemeSelector.svelte';
|
||||
|
||||
let isExpanded = false;
|
||||
|
||||
function toggleSidebar() {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
|
||||
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { THEME_KEY, ThemeState } from '$lib/stores/theme.svelte.ts';
|
||||
|
||||
const themeState = getContext<ThemeState>(THEME_KEY);
|
||||
</script>
|
||||
<div class=" text-base-content flex h-screen bg-base-300 font-sans overflow-hidden">
|
||||
<aside class="flex-shrink-0 flex flex-col border-r bg-base-200 border-gray-700/30
|
||||
transition-all duration-200 ease-in-out relative
|
||||
{isExpanded ? 'w-[280px]' : 'w-[72px]'}">
|
||||
|
||||
<div class="h-16 flex items-center px-4 justify-start">
|
||||
<button
|
||||
on:click={toggleSidebar}
|
||||
class="p-2 bg-base-content rounded-full transition-colors"
|
||||
aria-label="Toggle Menu"
|
||||
>
|
||||
123
|
||||
</button>
|
||||
</div>
|
||||
<div>1</div>
|
||||
<div>1</div>
|
||||
<div>1</div>
|
||||
<div>1</div>
|
||||
<div>1</div>
|
||||
{$userStore.name}
|
||||
{$userStore.id}
|
||||
{$userStore.nickname}
|
||||
</aside>
|
||||
|
||||
<div class="w-full">
|
||||
<header class="w-full h-18 flex justify-end items-center px-4 bg-base-300 gap-4 ">
|
||||
<ThemeSelector/>
|
||||
{#if $authStore.isAuthenticated}
|
||||
<button
|
||||
tabindex="0"
|
||||
class="rounded-full bg-primary h-12 w-12 "
|
||||
on:click={()=>{
|
||||
console.log("退出登录")
|
||||
authService.logout()
|
||||
}}
|
||||
aria-label="Logout"
|
||||
>
|
||||
</button>
|
||||
{:else }
|
||||
<div class="flex items-center">
|
||||
|
||||
<div class="w-24">
|
||||
<button class="btn btn-primary btn-wide " on:click={()=>{goto(resolve("/auth/login"))}}>登录</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
你好
|
||||
{themeState.theme}
|
||||
</div>
|
||||
3
src/routes/app/settings/+page.svelte
Normal file
3
src/routes/app/settings/+page.svelte
Normal file
@@ -0,0 +1,3 @@
|
||||
<div>
|
||||
123
|
||||
</div>
|
||||
3
src/routes/app/settings/auth/+page.svelte
Normal file
3
src/routes/app/settings/auth/+page.svelte
Normal file
@@ -0,0 +1,3 @@
|
||||
<div>
|
||||
234
|
||||
</div>
|
||||
0
src/routes/app/settings/auth/roles/+page.svelte
Normal file
0
src/routes/app/settings/auth/roles/+page.svelte
Normal file
36
src/routes/app/settings/auth/users/+page.server.ts
Normal file
36
src/routes/app/settings/auth/users/+page.server.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { userService } from '$lib/api/services/userService.ts';
|
||||
|
||||
import { roleService } from '$lib/api/services/roleService.ts';
|
||||
import { log } from '$lib/log.ts';
|
||||
|
||||
export const load:PageServerLoad = async ({ locals ,url }) => {
|
||||
|
||||
|
||||
|
||||
const page = Number(url.searchParams.get('page')) || 1;
|
||||
const size = Number(url.searchParams.get('size')) || 12;
|
||||
const keyword = url.searchParams.get('q') || undefined;
|
||||
const role = Number(url.searchParams.get('role')) || undefined;
|
||||
|
||||
log.debug('getAllUsers', { page, size, keyword, role });
|
||||
|
||||
|
||||
const getRoles = async() => {
|
||||
return await roleService.getRolesOptions(locals.api);
|
||||
}
|
||||
|
||||
|
||||
const getUserList = async() => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return await userService.getAllUsers(locals.api,{ page: page, size: size , keyword:keyword, roleId:role});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
streamed:{
|
||||
userList: getUserList(),
|
||||
rolesOptions: getRoles(),
|
||||
}
|
||||
};
|
||||
};
|
||||
136
src/routes/app/settings/auth/users/+page.svelte
Normal file
136
src/routes/app/settings/auth/users/+page.svelte
Normal file
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import Icon from '$lib/components/icon/Icon.svelte';
|
||||
|
||||
|
||||
import TableLoadingState from '$lib/components/loading/TableLoadingState.svelte';
|
||||
import TableLoadingError from '$lib/components/error/TableLoadingError.svelte';
|
||||
import UsersTable from '$lib/components/table/UsersTable.svelte';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
// --- 1. 状态管理 ---
|
||||
// selectedIds 需要在父组件,因为"批量操作按钮"在 Toolbar 里
|
||||
let selectedIds = $state<number[]>([]);
|
||||
|
||||
let searchQuery = $state(page.url.searchParams.get('q') || '');
|
||||
let currentRole = $derived(page.url.searchParams.get('role') || '');
|
||||
|
||||
// --- 2. URL 参数更新逻辑 ---
|
||||
function updateParams(key: string, value: string | number | null) {
|
||||
const url = new URL(page.url);
|
||||
if (value === null || value === '') {
|
||||
url.searchParams.delete(key);
|
||||
} else {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
if (key !== 'page') {
|
||||
url.searchParams.set('page', '1');
|
||||
}
|
||||
// 切换筛选条件时清空选中状态,避免误操作
|
||||
selectedIds = [];
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
updateParams('q', searchQuery);
|
||||
}
|
||||
|
||||
function handleRoleChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
updateParams('role', target.value);
|
||||
}
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
updateParams('page', newPage);
|
||||
}
|
||||
|
||||
function handleBatchAction(action: 'delete' | 'ban') {
|
||||
if (selectedIds.length === 0) return alert('请先选择用户');
|
||||
console.log(`执行批量操作: ${action}`, selectedIds);
|
||||
// TODO: 调用 API...
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>用户管理 | 系统设置</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class=" h-full flex flex-col rounded-box overflow-hidden ">
|
||||
<div class="flex justify-between items-center select-none pb-2">
|
||||
<p class="font-bold text-lg">用户管理</p>
|
||||
<div class="breadcrumbs text-sm text-base-content/70">
|
||||
<ul>
|
||||
<li><a href={resolve('/app/dashboard')}>仪表盘</a></li>
|
||||
<li>系统设置</li>
|
||||
<li>认证管理</li>
|
||||
<li><a href={resolve('/app/settings/auth/users')}>用户管理</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between p-4 border-b border-base-200 bg-base-100 relative rounded-t-box">
|
||||
<div class="flex flex-wrap items-center ">
|
||||
<label class="input input-bordered input-sm flex items-center gap-2">
|
||||
<Icon id="search-12" size="16" class="opacity-50" />
|
||||
<input
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="搜索用户..."
|
||||
class="grow"
|
||||
/>
|
||||
<button class="btn btn-xs btn-ghost" onclick={handleSearch}>搜索</button>
|
||||
</label>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-primary btn-sm">
|
||||
<Icon id="person-add" size="16" /> 添加用户
|
||||
</button>
|
||||
|
||||
<div class="dropdown dropdown-bottom dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-sm btn-square btn-ghost">
|
||||
<Icon id="menu" size="20" />
|
||||
</div>
|
||||
<div tabindex="-1" class="dropdown-content w-48 bg-base-100 rounded-box z-[1] mt-2 p-2 shadow-lg border border-base-200 join join-vertical">
|
||||
|
||||
<button
|
||||
onclick={() => handleBatchAction('delete')}
|
||||
class:btn-disabled={selectedIds.length === 0}
|
||||
class=" join-item btn btn-error"
|
||||
>
|
||||
<span>批量删除 ({selectedIds.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => handleBatchAction('ban')}
|
||||
class:btn-disabled={selectedIds.length === 0}
|
||||
class="join-item btn btn-neutral"
|
||||
>
|
||||
<span>批量封禁 ({selectedIds.length})</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 bg-base-100 flex flex-col min-h-0 overflow-hidden ">
|
||||
{#await data.streamed.userList}
|
||||
<TableLoadingState />
|
||||
{:then users}
|
||||
<UsersTable
|
||||
{users}
|
||||
bind:selectedIds
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
{:catch error}
|
||||
<TableLoadingError error={error} />
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
24
src/routes/app/settings/devices/+page.server.ts
Normal file
24
src/routes/app/settings/devices/+page.server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
import { deviceService } from '$lib/api/services/deviceService.ts';
|
||||
import { deviceTypesService } from '$lib/api/services/deviceTypesService.ts';
|
||||
import { log } from '$lib/log.ts';
|
||||
|
||||
export const load:PageServerLoad = async ({ locals }) => {
|
||||
|
||||
|
||||
const result = deviceService.getAllDevices(locals.api,{ page: 1, size: 10 });
|
||||
const options = deviceTypesService.getDeviceTypesOptions(locals.api);
|
||||
|
||||
const handle = () => {
|
||||
return {
|
||||
list: result,
|
||||
options: options
|
||||
}
|
||||
}
|
||||
return {
|
||||
streamed:{
|
||||
result: handle()
|
||||
}
|
||||
};
|
||||
};
|
||||
66
src/routes/app/settings/devices/+page.svelte
Normal file
66
src/routes/app/settings/devices/+page.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script>
|
||||
import { resolve } from '$app/paths';
|
||||
import DevicesTable from '$lib/components/table/DevicesTable.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import AddDevice from '$lib/components/form/AddDevice.svelte';
|
||||
import { log } from '$lib/log.ts';
|
||||
|
||||
const {data} = $props();
|
||||
let isOpen = $state(false)
|
||||
|
||||
let formRef = $state();
|
||||
</script>
|
||||
|
||||
<div class="flex-1 h-full flex flex-col">
|
||||
<div class="flex justify-between items-center ">
|
||||
<p class="font-bold">设备管理</p>
|
||||
<div class="breadcrumbs ">
|
||||
<ul>
|
||||
<li><a href={resolve('/app/dashboard')}>仪表盘</a></li>
|
||||
<li><a href={resolve('/app/settings')}>系统设置</a></li>
|
||||
<li><a href={resolve('/app/settings/devices')}>设备管理</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#await data.streamed.result.list}
|
||||
<div class="">
|
||||
<p class="text-center">正在加载设备列表...</p>
|
||||
<p class="text-center">请稍后...</p>
|
||||
</div>
|
||||
{:then list}
|
||||
{#if list.total > 0 }
|
||||
<div class="overflow-x-auto">
|
||||
<DevicesTable
|
||||
bind:open={isOpen}
|
||||
devices={list} />
|
||||
</div>
|
||||
{:else }
|
||||
<div class="flex-1 w-full flex justify-center items-center 需要占满高度">
|
||||
<div class="select-none text-center">
|
||||
<p class="mb-10">暂无数据</p>
|
||||
<button class="btn btn-primary" onclick={()=>{isOpen = true}}>添加设备</button>
|
||||
<Modal bind:open={isOpen}
|
||||
title="添加设备"
|
||||
width="100%"
|
||||
footer={null}
|
||||
>
|
||||
{#await data.streamed.result.options}
|
||||
<div class="">
|
||||
<p class="text-center">正在加载设备列表...</p>
|
||||
<p class="text-center">请稍后...</p>
|
||||
</div>
|
||||
{:then options}
|
||||
<AddDevice deviceTypeOptions={options} />
|
||||
{:catch error}
|
||||
{log.error(error)}
|
||||
{/await}
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<p class="text-center">{error}</p>
|
||||
<p class="text-center">请稍后...</p>
|
||||
{/await}
|
||||
</div>
|
||||
0
src/routes/app/settings/devices/type/+page.svelte
Normal file
0
src/routes/app/settings/devices/type/+page.svelte
Normal file
1
src/routes/app/statistics/+page.svelte
Normal file
1
src/routes/app/statistics/+page.svelte
Normal file
@@ -0,0 +1 @@
|
||||
这是数据
|
||||
16
src/routes/app/user/+page.server.ts
Normal file
16
src/routes/app/user/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { COOKIE_TOKEN_KEY } from '$lib/components/constants/cookiesConstants.ts';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { userService } from '$lib/api/services/userService.ts';
|
||||
|
||||
export async function load({cookies}) {
|
||||
const token = cookies.get(COOKIE_TOKEN_KEY);
|
||||
if (!token) {
|
||||
throw redirect(302, '/auth/login');
|
||||
}
|
||||
|
||||
const profile = await userService.getUserProfile(token);
|
||||
|
||||
return {
|
||||
profile
|
||||
}
|
||||
}
|
||||
9
src/routes/app/user/+page.svelte
Normal file
9
src/routes/app/user/+page.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
|
||||
export let data;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
这里展示个人信息
|
||||
{JSON.stringify(data.profile)}
|
||||
</div>
|
||||
8
src/routes/app/user/[id]/+page.server.ts
Normal file
8
src/routes/app/user/[id]/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
|
||||
export function load({ params }) {
|
||||
|
||||
console.log('params:', params);
|
||||
|
||||
|
||||
}
|
||||
13
src/routes/app/user/[id]/+page.svelte
Normal file
13
src/routes/app/user/[id]/+page.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
|
||||
import {page} from '$app/state';
|
||||
export let data;
|
||||
|
||||
</script>
|
||||
<div>
|
||||
{JSON.stringify(data)}
|
||||
<div>
|
||||
|
||||
</div>
|
||||
{JSON.stringify(page.params)}
|
||||
</div>
|
||||
7
src/routes/auth/forgetPassword/+page.svelte
Normal file
7
src/routes/auth/forgetPassword/+page.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
|
||||
<div class="h-screen w-screen bg-base-300">
|
||||
123
|
||||
</div>
|
||||
91
src/routes/auth/login/+page.server.ts
Normal file
91
src/routes/auth/login/+page.server.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Actions } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { authService } from '$lib/api/services/authService.ts';
|
||||
import { resolve } from '$app/paths';
|
||||
import { ApiError } from '$lib/types/api.ts';
|
||||
import { COOKIE_TOKEN_KEY } from '$lib/components/constants/cookiesConstants.ts';
|
||||
|
||||
|
||||
|
||||
export const actions:Actions = {
|
||||
default: async ({ request,cookies ,url ,locals}) => {
|
||||
const formData = await request.formData();
|
||||
const username = formData.get('username');
|
||||
const password = formData.get('password');
|
||||
|
||||
if (
|
||||
typeof username !== 'string' ||
|
||||
typeof password !== 'string' ||
|
||||
!username.trim() ||
|
||||
!password.trim()
|
||||
){
|
||||
return fail(400,{
|
||||
missing: true,
|
||||
message: '请填写用户名和密码',
|
||||
username: username?.toString() ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
const regexp = /^[a-zA-Z0-9_-]{5,16}$/
|
||||
|
||||
if (!regexp.test(username)){
|
||||
return fail(400,{
|
||||
username: username.toString(),
|
||||
message: '用户名格式错误,请输入5-16位字符,只能包含字母、数字、下划线、减号'
|
||||
})
|
||||
}
|
||||
|
||||
if (password.length<8 || password.length>16){
|
||||
return fail(400,{
|
||||
message: '密码格式错误,请输入8-16位字符'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
try{
|
||||
const response = await authService.login(locals.api,{username,password});
|
||||
|
||||
cookies.set(COOKIE_TOKEN_KEY,`${response.tokenHead} ${response.token}`,{
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 7
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '登录成功',
|
||||
data: response,// 这个传入的data 在前端页面怎么使用?
|
||||
redirectTo: url.searchParams.get('redirectTo') ?? resolve("/app/dashboard")
|
||||
};
|
||||
|
||||
}catch (error){
|
||||
if (error instanceof ApiError) {
|
||||
return fail(400, {
|
||||
incorrect: true,
|
||||
message: error.message,
|
||||
username
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof TypeError || (error instanceof Error && error.message.includes('fetch'))) {
|
||||
return fail(503, {
|
||||
message: '网络连接失败,无法连接到服务器',
|
||||
username
|
||||
});
|
||||
}
|
||||
// 4. 兜底的未知错误处理
|
||||
console.error('Login unexpected error:', error);
|
||||
return fail(500, {
|
||||
message: '系统内部错误,请稍后再试',
|
||||
username
|
||||
});
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import Icon from '$lib/components/icon/Icon.svelte';
|
||||
|
||||
import { authService } from '$lib/api/services/authService.ts';
|
||||
import type { LoginPayload } from '$lib/types/auth.ts';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { get } from 'svelte/store';
|
||||
import { authStore } from '$lib/stores/authStore.ts';
|
||||
import { enhance } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { AuthResponse } from '$lib/types/auth';
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import type { RouteId } from '$app/types';
|
||||
import { getContext } from 'svelte';
|
||||
import { TOAST_KEY, ToastState } from '$lib/stores/toast.svelte.ts';
|
||||
|
||||
const loginPayload:LoginPayload = {
|
||||
username: '',
|
||||
password: ''
|
||||
}
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
try{
|
||||
await authService.login(loginPayload);
|
||||
if(get(authStore).isAuthenticated){
|
||||
await goto(resolve('/'));
|
||||
}
|
||||
}catch (e){
|
||||
console.error(e);
|
||||
|
||||
const toast = getContext<ToastState>(TOAST_KEY);
|
||||
|
||||
let loading = false;
|
||||
|
||||
const handleLogin:SubmitFunction = () => {
|
||||
loading = true;
|
||||
return async ({ result , update }) => {
|
||||
loading = false;
|
||||
if (result.type === 'failure') {
|
||||
|
||||
if (typeof result.data?.message === 'string' ){
|
||||
toast.error(result.data?.message)
|
||||
}
|
||||
await update();
|
||||
|
||||
}else if (result.type === 'success') {
|
||||
|
||||
console.log('result', result)
|
||||
if (typeof result.data?.message === 'string' ){
|
||||
toast.success(result.data?.message || '登录成功')
|
||||
}
|
||||
|
||||
if (result.data?.redirectTo && typeof result.data?.redirectTo === 'string') {
|
||||
// @ts-expect-error : 设计
|
||||
await goto(resolve(result.data.redirectTo as RouteId ));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
const handleChange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
loginPayload[target.name] = target.value;
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<div class="h-screen w-screen">
|
||||
<form id="loginForm">
|
||||
<div>
|
||||
<label class="" for="username" > username </label>
|
||||
<input type="text" name="username" on:change={handleChange} placeholder="username">
|
||||
<div class="min-h-screen bg-base-200 flex items-center justify-center p-4">
|
||||
|
||||
<div class="card w-full max-w-sm bg-base-100 shadow-2xl">
|
||||
<div class="card-body">
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<h2 class="text-2xl font-bold flex justify-center items-center gap-2">
|
||||
<Icon id="logo" size="40" className="inline-block"></Icon>
|
||||
<span>IT DTMS登录</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<form method="post"
|
||||
use:enhance={handleLogin}
|
||||
class="space-y-4">
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="username">
|
||||
<span class="label-text">用户名</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder="username"
|
||||
class="input input-bordered w-full pl-10"
|
||||
required
|
||||
/>
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"
|
||||
class="w-4 h-4 opacity-70"><path
|
||||
d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12.735 14c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139h9.47Z" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="password">
|
||||
<span class="label-text">密码</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="password"
|
||||
class="input input-bordered w-full pl-10"
|
||||
required
|
||||
/>
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"
|
||||
class="w-4 h-4 opacity-70"><path fill-rule="evenodd"
|
||||
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
|
||||
clip-rule="evenodd" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="form-control mt-6 flex justify-between">
|
||||
<label class="label cursor-pointer justify-start gap-2">
|
||||
<input type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
|
||||
<span class="label-text">记住我</span>
|
||||
</label>
|
||||
<div class="label">
|
||||
<a href={resolve('/auth/forgetPassword')} class="label-text-alt link link-hover">忘记密码?</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control mt-2">
|
||||
<button class="btn btn-primary w-full {loading?'btn-disabled ':''}" >
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner"></span>
|
||||
{/if}
|
||||
<span>登录</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<label class="" for="password" > password </label>
|
||||
<input type="password" name="password" on:change={handleChange} placeholder="password">
|
||||
</div>
|
||||
<button class="" type="submit" on:click="{handleSubmit}" > 登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
17
src/routes/auth/logout/+page.server.ts
Normal file
17
src/routes/auth/logout/+page.server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Actions } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
import { COOKIE_TOKEN_KEY } from '$lib/components/constants/cookiesConstants.ts';
|
||||
|
||||
|
||||
|
||||
export const actions:Actions = {
|
||||
default: async ({ cookies,locals}) => {
|
||||
cookies.delete(COOKIE_TOKEN_KEY,{path:'/'});
|
||||
locals.user = null;
|
||||
|
||||
throw redirect(302, resolve('/'));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
@import 'tailwindcss';
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui"{
|
||||
themes: all;
|
||||
themes: light , dark , cupcake , bumblebee , emerald , corporate
|
||||
, synthwave , retro , cyberpunk , valentine , halloween , garden
|
||||
, forest , aqua , lofi , pastel , fantasy , wireframe --default , black --prefersdark
|
||||
, luxury , dracula , cmyk , autumn , business , acid , lemonade
|
||||
, night , coffee , winter , dim , nord , sunset , caramellatte
|
||||
, abyss , silk;;
|
||||
}
|
||||
@@ -11,10 +11,6 @@
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
@@ -3,5 +3,8 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user