Compare commits

...

30 Commits

Author SHA1 Message Date
Chaos
a4b207df29 fix(auth): 修复登录跳转和图标组件属性问题
- 移除调试日志和冗余类型断言
- 修复图标组件属性名从 Cid 到 id
- 更新登录成功后的页面跳转逻辑
- 优化侧边栏用户卡片渲染逻辑
- 调整侧边栏样式和布局结构
- 添加用户信息下拉菜单占位内容
2025-12-06 20:01:24 +08:00
Chaos
fefa836ee7 feat(icon): 添加新的图标组件并替换错误页面中的图标
- 在Sprite.svelte中新增logo图标
- 在TableLoadingError.svelte中引入Icon组件
- 使用Icon组件替换原有的SVG图标
- 将错误页面的"重试"按钮文案改为"重新加载"
2025-12-03 07:21:20 +08:00
Chaos
50a3022e9d refactor(api): 重构API客户端以支持依赖注入
- 移除全局api实例,改用createApi工厂函数创建客户端
- 在服务层函数中添加api参数,实现依赖注入
- 更新设备、角色、用户等服务调用方式
- 移除请求头中的Authorization字段手动设置
- 在hooks.server.ts中初始化并挂载api到locals
- 修复HttpError类定义位置并完善错误处理逻辑
- 调整页面组件中main容器和表格布局样式
- 更新tailwindcss主题配置和相关CSS类名
- 修改分页大小默认值从10到12
- 删除冗余的COOKIE_TOKEN_KEY导入和重定向逻辑
2025-12-03 07:11:09 +08:00
Chaos
8aeaacac42 style(layout): 调整页面布局和样式细节
- 修改主内容区域的内边距类名,统一使用 px-4 和 pb-4
- 在用户管理页面容器上添加圆角和隐藏溢出效果
- 为侧边栏添加右侧圆角和隐藏溢出,优化整体视觉效果
- 更新侧边栏标题字体为等宽粗体,调整文字大小和对齐方式
- 为侧边栏菜单添加顶部内边距重置,确保菜单项对齐
- 调整用户表格容器高度为全高,改善响应式表现
- 移除用户表格分页区域不必要的背景色类名重复定义
2025-12-02 13:20:18 +08:00
Chaos
4cdf6bade8 feat(app): 实现用户管理页面功能
- 修复用户列表分页参数获取错误的问题
- 添加搜索和角色筛选功能
- 实现批量删除和封禁用户操作
- 优化页面布局和样式
- 添加调试日志输出
- 更新图标资源库
- 修复API客户端请求头传递问题
- 调整侧边栏导航样式和结构
- 减少模拟数据加载时间
- 添加COOKIE常量引用
2025-12-02 11:45:38 +08:00
Chaos
ab43a9a140 feat(users): 实现用户管理页面功能增强
- 添加用户列表分页、搜索和角色筛选功能
- 实现用户批量选择与操作(删除/封禁)
- 引入ofetch库优化API请求处理
- 添加表格加载状态和错误处理组件
- 更新图标组件属性以支持新特性
- 修复页面跳转状态码问题(302改为303)
- 优化用户表格UI展示细节与交互体验
2025-12-01 17:27:02 +08:00
Chaos
bd00e54acd refactor(api): improve http client implementation
- Removed redundant comments and code annotations
- Simplified type definitions and interface declarations
- Streamlined URL construction logic
- Enhanced body handling for FormData and JSON payloads
- Optimized header normalization process
- Cleaned up API method signatures
- Improved content-type handling for form data
- Removed unnecessary blank lines and formatting clutter
2025-12-01 09:24:26 +08:00
Chaos
c1138cd568 feat(api): enhance http client with formdata and sveltekit support
- Add support for FormData in request body handling
- Implement custom fetch option for SvelteKit integration
- Fix content-type header handling for multipart requests
- Improve error handling and logging
- Support absolute URLs in API requests
- Add proper typing for API result responses
- Handle HTTP 204 no-content responses correctly
- Update method signatures to accept FormData bodies
- Normalize headers case-insensitively
- Remove explicit body on GET/HEAD requests
2025-12-01 08:51:13 +08:00
Chaos
87892951f6 feat(device): 实现设备创建功能并优化API调用
- 添加设备创建接口,支持认证检查和重定向
- 更新设备服务以使用新的请求体类型
- 修改表单组件以支持设备创建请求和响应处理
- 扩展HTTP客户端和类型定义以支持对象类型的请求体
- 添加调试日志记录API响应信息
- 更新按钮点击事件处理函数以触发设备创建流程
2025-12-01 07:36:14 +08:00
Chaos
f973284140 feat(devices): 实现设备管理页面与添加设备功能
- 移除设备列表页面的溢出滚动样式
- 引入设备类型服务并获取设备类型选项
- 新增添加设备模态框与表单组件
- 实现设备创建接口与表单数据验证逻辑
- 添加网络接口与IP配置的动态表单管理
- 创建可复用的模态框组件支持表单提交交互
2025-12-01 07:08:20 +08:00
Chaos
e2374571d7 refactor(settings): 重构用户管理和设备管理页面
- 调整用户管理页面角色数据获取方法,使用 getRolesOptions 替代 getAllRoles
- 更新用户表格组件接收的角色数据属性名及类型
- 修改设备管理页面路由路径,从 /device/list 调整为 /devices
- 移除调试用 console.log 输出语句
- 添加选项类型 Options 接口定义
- 优化侧边栏导航结构与交互逻辑,支持父级菜单带链接可点击
- 引入日志模块用于 API 请求与响应记录
- 升级依赖包配置,移除 peer 标记
- 微调样式类名增强布局效果和用户体验
2025-11-30 22:15:53 +08:00
Chaos
0a0e6df66b refactor(settings): 重构用户管理和设备管理页面
- 调整用户管理页面角色数据获取方法,使用 getRolesOptions 替代 getAllRoles
- 更新用户表格组件接收的角色数据属性名及类型
- 修改设备管理页面路由路径,从 /device/list 调整为 /devices
- 移除调试用 console.log 输出语句
- 添加选项类型 Options 接口定义
- 优化侧边栏导航结构与交互逻辑,支持父级菜单带链接可点击
- 引入日志模块用于 API 请求与响应记录
- 升级依赖包配置,移除 peer 标记
- 微调样式类名增强布局效果和用户体验
2025-11-29 09:02:00 +08:00
Chaos
2caa8f26a3 feat(device): add device management feature
- Created device list page with loading states
- Implemented device service with API integration
- Added device response and request types
- Updated sidebar navigation with device management section
- Added laptop-settings icon
- Modified user table component to accept props
- Updated user service to return array of user profiles
- Changed app language to Chinese (zh-CN)
2025-11-27 17:12:03 +08:00
Chaos
2a14389daf feat(users): implement role filtering and pagination in user management
- Add role filtering functionality with radio buttons for each role
- Implement pagination controls at the bottom of the user table
- Update user table to show loading state without text
- Improve avatar display logic with conditional rendering
- Adjust styling for better UI consistency and responsiveness
- Remove unused sidebar state imports and commented-out code
- Add console logs for debugging data flow and role changes
- Modify search button size from sm to xs for better fit
- Enhance role badge styling with margin adjustments
- Refactor sidebar overlay and positioning classes
2025-11-26 09:21:38 +08:00
Chaos
7ce645704e fix(users): 优化用户列表加载失败时的错误展示
- 移除了原有简单的错误提示标签
- 新增了带最小高度容器包裹的错误信息展示区
- 在表格数据加载失败时显示更友好的错误提示
- 统一了组件内两处错误处理的样式结构
2025-11-26 07:26:32 +08:00
Chaos
e09129cab6 feat(users): 实现用户列表页面的异步数据加载与UI优化
- 添加角色服务依赖以获取用户组信息
- 将用户列表和角色信息改为流式加载
- 更新用户列表页面布局与样式
- 增加搜索框和用户组筛选下拉菜单
- 添加加载状态提示与错误处理显示
- 引入sass-embedded支持SCSS样式编写
- 调整表格列宽并增加响应式设计
- 添加面包屑导航与页面标题展示
- 新增添加用户按钮及操作下拉菜单
- 使用Icon组件替换原有图标实现方式
2025-11-26 07:23:20 +08:00
Chaos
7d627a45fb feat(auth): 实现基于令牌的用户认证和访问控制
- 在用户相关页面服务端加载函数中添加令牌检查,防止未授权访问
- 更新用户服务方法以支持携带认证令牌请求API
- 修改用户资料和用户列表组件以适配新的认证流程
- 引入侧边栏状态管理并在布局中注册上下文
- 调整HTTP客户端逻辑以正确传递请求头信息
- 更新用户类型定义以匹配后端返回的角色结构
- 优化应用头部和侧边栏组件的UI细节和交互逻辑
2025-11-25 23:33:32 +08:00
Chaos
81c61f433d feat(auth): implement user logout functionality
- Add logout action to delete auth cookie and reset user state
- Create logout form with enhanced submit handling
- Integrate toast notifications for logout feedback
- Update sidebar to include logout button with form submission
- Remove unnecessary console log in layout server load
- Clean up JWT parsing logic in hooks server file
- Add new LogoutButton component (currently empty)
2025-11-25 17:23:43 +08:00
Chaos
4ec8e88e58 refactor(auth): implement token-based authentication with JWT parsing
- Replace authStore with tokenService for authentication management
- Add JWT parsing utility to extract user info from tokens
- Update login flow to use cookie-based token storage
- Modify logout to properly clear auth state and cookies
- Integrate user data into page context for SSR compatibility
- Remove deprecated authStore and related localStorage logic
- Add cookie constants for consistent token handling
- Implement server-side token validation in hooks
- Update HTTP client to use token from cookies instead of store
- Refactor error handling to use unified ApiError class
- Replace manual redirect logic with resolved paths
- Improve type safety with explicit user and auth interfaces
- Add toast notifications for login/logout feedback
- Remove unused sidebar store and related UI logic
- Migrate theme handling to use cookies and context
- Update icon definitions and component references
- Clean up legacy code and unused imports
2025-11-25 16:53:48 +08:00
Chaos
8f3f2d63a0 refactor(layout): 重构应用布局结构
- 将原有布局中的侧边栏和头部组件拆分为独立的 AppSidebar 和 AppHeader 组件
- 移除内联的导航逻辑和样式,交由专用组件管理
- 更新图标库,优化部分图标的显示效果
- 简化认证存储逻辑,增强状态持久化与安全性
- 优化侧边栏状态管理机制,提高响应式体验
- 改进登录流程错误处理,增加网络异常提示
- 调整路由组件结构,提升代码可维护性
2025-11-25 07:33:58 +08:00
chaos
877a47807c feat(auth): 重构登录流程并增强错误处理
- 改进登录表单验证逻辑,增加非空和类型检查
- 更新认证 Cookie 名称为 Authorization 并调整安全设置
- 登录成功后返回重定向路径和成功消息
- 增强错误处理,区分网络错误、API 错误和未知错误
- 引入 ResponseError 类统一处理 API 响应错误
- 表单提交使用 enhance 函数优化用户体验
- 登录成功时显示 Toast 提示并自动跳转
- 移除独立的用户信息服务,整合至认证服务
- 删除旧的角色检查工具函数
- 新增错误处理工具函数 handleError
2025-11-24 21:39:11 +08:00
Chaos
f9d92e6cc9 fix(auth): handle API error types correctly in login action
- Introduce ApiError interface for better type safety
- Replace generic HttpError handling with specific ApiError casting
- Remove unnecessary console log in error handling block
- Ensure error details are properly extracted and returned to client
2025-11-24 17:26:37 +08:00
Chaos
d4ace86fb3 fix(auth): log http error details on login failure
- Add console logging for HTTP error details
- Improve error handling in login server route
- Ensure error details are properly serialized
2025-11-24 17:21:52 +08:00
Chaos
ed542f108c feat(auth): implement login and user management features
- Added server-side login action with form handling and cookie storage
- Implemented user authentication service with token management
- Created user list page with data fetching from userService
- Developed reusable DataTable component with selection and pagination
- Enhanced AppSidebar with nested navigation and active state tracking
- Updated icon definitions and sprite symbols for UI consistency
- Improved HTTP client to properly handle request bodies for different methods
- Refactored auth store to manage authentication state and cookies
- Added strict typing for navigation items and table columns
- Removed obsolete code and simplified authentication flow
2025-11-24 17:11:41 +08:00
Chaos
3515faa814 refactor(layout): 重构应用布局结构
- 将原有布局中的侧边栏和头部组件拆分为独立的 AppSidebar 和 AppHeader 组件
- 移除内联的导航逻辑和样式,交由专用组件管理
- 更新图标库,优化部分图标的显示效果
- 简化认证存储逻辑,增强状态持久化与安全性
- 优化侧边栏状态管理机制,提高响应式体验
- 改进登录流程错误处理,增加网络异常提示
- 调整路由组件结构,提升代码可维护性
2025-11-24 07:17:12 +08:00
Chaos
71f19b658c feat(toast): 使用SVG图标替换字符图标并优化样式
- 新增四种SVG图标:info、success、warning、error
- 修改Toast组件使用SVG图标替代字符图标
- 更新图标映射逻辑,使用IconId类型定义图标标识
- 调整Toast样式类,使用更具体的Tailwind类名
- 移除旧的alertStyles对象和字符图标映射
- 登录页面增加错误处理和Toast提示
- 布局文件微调菜单样式,增加顶部内边距
2025-11-23 21:59:47 +08:00
Chaos
a71622f797 feat(auth): 重构登录页面并添加忘记密码路由
- 重构登录表单 UI,使用 DaisyUI 组件美化界面
- 添加记住我功能和加载状态提示
- 集成 Toast 提示组件用于显示登录结果
- 新增忘记密码页面路由
- 实现全局 Toast 消息提醒功能
- 在根布局中引入 ToastContainer 组件
2025-11-23 07:43:23 +08:00
Chaos
8c041c1740 style(auth): 优化登录页面样式布局
- 使用 Tailwind CSS 类替换原有基础样式
- 添加背景色和居中对齐容器
- 表单元素增加间距和输入框样式
- 按钮应用宽按钮样式类
- 移除无用的 placeholder 属性
- 调整标签与输入框结构使其更清晰
2025-11-22 22:52:48 +08:00
Chaos
65cf80fb51 feat(layout): 实现应用布局和侧边栏功能
- 添加侧边栏组件,支持展开/收缩和移动端适配
- 实现导航菜单,支持高亮当前路由
- 添加主题选择器组件
- 集成认证状态显示和登出功能
- 优化侧边栏在不同屏幕尺寸下的行为
- 添加多种图标支持,包括logo、菜单、主页等
- 创建NavItem类型定义,用于导航菜单项
- 扩展sidebarStore,增加手动控制状态管理
- 添加数据看板页面占位内容
- 更新全局布局文件以支持主题和侧边栏状态管理
2025-11-22 22:45:49 +08:00
chaos
26fef2fd7a feat(icon): 添加图标组件并更新图标类型定义
- 引入 Icon 组件到 dashboard 页面
- 更新 icon-ids.ts 文件中的图标枚举类型
- 在 Sprite.svelte 中添加新的图标 symbol 定义
- 移除旧的 sprite.svg 文件内容
- 为 panel-right-close 和 panel-right-close-solid 图标添加 SVG 路径定义
2025-11-22 13:50:58 +08:00
77 changed files with 3783 additions and 510 deletions

6
.idea/compiler.xml generated Normal file
View 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
View 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>

View File

@@ -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>

827
package-lock.json generated
View File

@@ -8,7 +8,8 @@
"name": "chaos-it",
"version": "0.0.1",
"dependencies": {
"daisyui": "^5.5.5"
"daisyui": "^5.5.5",
"ofetch": "^1.5.1"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
@@ -25,6 +26,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",
@@ -33,6 +35,13 @@
"vite": "^7.1.10"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.10.1",
"resolved": "https://registry.npmmirror.com/@bufbuild/protobuf/-/protobuf-2.10.1.tgz",
"integrity": "sha512-ckS3+vyJb5qGpEYv/s1OebUHDi/xSNtfgw1wqKZo7MR9F2z+qXr0q5XagafAG/9O0QPVIUfST0smluYSTpYFkg==",
"dev": true,
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -793,6 +802,330 @@
"node": ">= 8"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher/node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -1888,6 +2221,13 @@
"node": ">=8"
}
},
"node_modules/buffer-builder": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/buffer-builder/-/buffer-builder-0.2.0.tgz",
"integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==",
"dev": true,
"license": "MIT/X11"
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz",
@@ -1961,6 +2301,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/colorjs.io": {
"version": "0.5.2",
"resolved": "https://registry.npmmirror.com/colorjs.io/-/colorjs.io-0.5.2.tgz",
"integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
@@ -2050,6 +2397,12 @@
"node": ">=0.10.0"
}
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2575,6 +2928,13 @@
"node": ">= 4"
}
},
"node_modules/immutable": {
"version": "5.1.4",
"resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.4.tgz",
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
"dev": true,
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3151,6 +3511,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmmirror.com/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"license": "MIT"
},
"node_modules/ofetch": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/ofetch/-/ofetch-1.5.1.tgz",
"integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==",
"license": "MIT",
"dependencies": {
"destr": "^2.0.5",
"node-fetch-native": "^1.6.7",
"ufo": "^1.6.1"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
@@ -3639,6 +4024,16 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmmirror.com/sade/-/sade-1.8.1.tgz",
@@ -3652,6 +4047,393 @@
"node": ">=6"
}
},
"node_modules/sass": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass/-/sass-1.93.3.tgz",
"integrity": "sha512-elOcIZRTM76dvxNAjqYrucTSI0teAF/L2Lv0s6f6b7FOwcwIuA357bIE871580AjHJuSvLIRUosgV+lIWx6Rgg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/sass-embedded": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded/-/sass-embedded-1.93.3.tgz",
"integrity": "sha512-+VUy01yfDqNmIVMd/LLKl2TTtY0ovZN0rTonh+FhKr65mFwIYgU9WzgIZKS7U9/SPCQvWTsTGx9jyt+qRm/XFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bufbuild/protobuf": "^2.5.0",
"buffer-builder": "^0.2.0",
"colorjs.io": "^0.5.0",
"immutable": "^5.0.2",
"rxjs": "^7.4.0",
"supports-color": "^8.1.1",
"sync-child-process": "^1.0.2",
"varint": "^6.0.0"
},
"bin": {
"sass": "dist/bin/sass.js"
},
"engines": {
"node": ">=16.0.0"
},
"optionalDependencies": {
"sass-embedded-all-unknown": "1.93.3",
"sass-embedded-android-arm": "1.93.3",
"sass-embedded-android-arm64": "1.93.3",
"sass-embedded-android-riscv64": "1.93.3",
"sass-embedded-android-x64": "1.93.3",
"sass-embedded-darwin-arm64": "1.93.3",
"sass-embedded-darwin-x64": "1.93.3",
"sass-embedded-linux-arm": "1.93.3",
"sass-embedded-linux-arm64": "1.93.3",
"sass-embedded-linux-musl-arm": "1.93.3",
"sass-embedded-linux-musl-arm64": "1.93.3",
"sass-embedded-linux-musl-riscv64": "1.93.3",
"sass-embedded-linux-musl-x64": "1.93.3",
"sass-embedded-linux-riscv64": "1.93.3",
"sass-embedded-linux-x64": "1.93.3",
"sass-embedded-unknown-all": "1.93.3",
"sass-embedded-win32-arm64": "1.93.3",
"sass-embedded-win32-x64": "1.93.3"
}
},
"node_modules/sass-embedded-all-unknown": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.3.tgz",
"integrity": "sha512-3okGgnE41eg+CPLtAPletu6nQ4N0ij7AeW+Sl5Km4j29XcmqZQeFwYjHe1AlKTEgLi/UAONk1O8i8/lupeKMbw==",
"cpu": [
"!arm",
"!arm64",
"!riscv64",
"!x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"sass": "1.93.3"
}
},
"node_modules/sass-embedded-android-arm": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.3.tgz",
"integrity": "sha512-8xOw9bywfOD6Wv24BgCmgjkk6tMrsOTTHcb28KDxeJtFtoxiUyMbxo0vChpPAfp2Hyg2tFFKS60s0s4JYk+Raw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-android-arm64": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.3.tgz",
"integrity": "sha512-uqUl3Kt1IqdGVAcAdbmC+NwuUJy8tM+2ZnB7/zrt6WxWVShVCRdFnWR9LT8HJr7eJN7AU8kSXxaVX/gedanPsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-android-riscv64": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.3.tgz",
"integrity": "sha512-2jNJDmo+3qLocjWqYbXiBDnfgwrUeZgZFHJIwAefU7Fn66Ot7rsXl+XPwlokaCbTpj7eMFIqsRAZ/uDueXNCJg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-android-x64": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.3.tgz",
"integrity": "sha512-y0RoAU6ZenQFcjM9PjQd3cRqRTjqwSbtWLL/p68y2oFyh0QGN0+LQ826fc0ZvU/AbqCsAizkqjzOn6cRZJxTTQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-darwin-arm64": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.3.tgz",
"integrity": "sha512-7zb/hpdMOdKteK17BOyyypemglVURd1Hdz6QGsggy60aUFfptTLQftLRg8r/xh1RbQAUKWFbYTNaM47J9yPxYg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-darwin-x64": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.3.tgz",
"integrity": "sha512-Ek1Vp8ZDQEe327Lz0b7h3hjvWH3u9XjJiQzveq74RPpJQ2q6d9LfWpjiRRohM4qK6o4XOHw1X10OMWPXJtdtWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-arm": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.3.tgz",
"integrity": "sha512-yeiv2y+dp8B4wNpd3+JsHYD0mvpXSfov7IGyQ1tMIR40qv+ROkRqYiqQvAOXf76Qwh4Y9OaYZtLpnsPjfeq6mA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-arm64": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.3.tgz",
"integrity": "sha512-RBrHWgfd8Dd8w4fbmdRVXRrhh8oBAPyeWDTKAWw8ZEmuXfVl4ytjDuyxaVilh6rR1xTRTNpbaA/YWApBlLrrNw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-arm": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.3.tgz",
"integrity": "sha512-fU0fwAwbp7sBE3h5DVU5UPzvaLg7a4yONfFWkkcCp6ZrOiPuGRHXXYriWQ0TUnWy4wE+svsVuWhwWgvlb/tkKg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-arm64": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.3.tgz",
"integrity": "sha512-PS829l+eUng+9W4PFclXGb4uA2+965NHV3/Sa5U7qTywjeeUUYTZg70dJHSqvhrBEfCc2XJABeW3adLJbyQYkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-riscv64": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.3.tgz",
"integrity": "sha512-cK1oBY+FWQquaIGEeQ5H74KTO8cWsSWwXb/WaildOO9U6wmUypTgUYKQ0o5o/29nZbWWlM1PHuwVYTSnT23Jjg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-x64": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.3.tgz",
"integrity": "sha512-A7wkrsHu2/I4Zpa0NMuPGkWDVV7QGGytxGyUq3opSXgAexHo/vBPlGoDXoRlSdex0cV+aTMRPjoGIfdmNlHwyg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-riscv64": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.3.tgz",
"integrity": "sha512-vWkW1+HTF5qcaHa6hO80gx/QfB6GGjJUP0xLbnAoY4pwEnw5ulGv6RM8qYr8IDhWfVt/KH+lhJ2ZFxnJareisQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-x64": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.3.tgz",
"integrity": "sha512-k6uFxs+e5jSuk1Y0niCwuq42F9ZC5UEP7P+RIOurIm8w/5QFa0+YqeW+BPWEW5M1FqVOsNZH3qGn4ahqvAEjPA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-unknown-all": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.3.tgz",
"integrity": "sha512-o5wj2rLpXH0C+GJKt/VpWp6AnMsCCbfFmnMAttcrsa+U3yrs/guhZ3x55KAqqUsE8F47e3frbsDL+1OuQM5DAA==",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"!android",
"!darwin",
"!linux",
"!win32"
],
"dependencies": {
"sass": "1.93.3"
}
},
"node_modules/sass-embedded-win32-arm64": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.3.tgz",
"integrity": "sha512-0dOfT9moy9YmBolodwYYXtLwNr4jL4HQC9rBfv6mVrD7ud8ue2kDbn+GVzj1hEJxvEexVSmDCf7MHUTLcGs9xQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-win32-x64": {
"version": "1.93.3",
"resolved": "https://registry.npmmirror.com/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.3.tgz",
"integrity": "sha512-wHFVfxiS9hU/sNk7KReD+lJWRp3R0SLQEX4zfOnRP2zlvI2X4IQR5aZr9GNcuMP6TmNpX0nQPZTegS8+h9RrEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz",
@@ -3826,6 +4608,29 @@
}
}
},
"node_modules/sync-child-process": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/sync-child-process/-/sync-child-process-1.0.2.tgz",
"integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"sync-message-port": "^1.0.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/sync-message-port": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/sync-message-port/-/sync-message-port-1.1.3.tgz",
"integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.1.17",
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.17.tgz",
@@ -3900,6 +4705,13 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz",
@@ -3951,6 +4763,12 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/ufo": {
"version": "1.6.1",
"resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.1.tgz",
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
@@ -3975,6 +4793,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/varint": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/varint/-/varint-6.0.0.tgz",
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "7.2.2",
"resolved": "https://registry.npmmirror.com/vite/-/vite-7.2.2.tgz",

View File

@@ -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
View File

@@ -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;
}
}
}

View File

@@ -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
View 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);
}

View File

@@ -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 })
};
};

View File

@@ -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);
}
}
};

View 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;
}
}

View 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;
}
}

View 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;
},
}

View 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;
}
}
};

View File

@@ -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;
},
}

View File

@@ -1,8 +0,0 @@
<svg>
<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>
</svg>

Before

Width:  |  Height:  |  Size: 580 B

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,2 @@
export const COOKIE_TOKEN_KEY = 'authorization';
export const COOKIE_THEME_KEY = 'theme';

View 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>

View 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>

View File

@@ -1,9 +1,101 @@
<script lang="ts">
import SpriteSvg from '$lib/assets/sprite.svg'
</script>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;" >
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html SpriteSvg}
<!-- &lt;!&ndash; eslint-disable-next-line svelte/no-at-html-tags &ndash;&gt;-->
<!-- {@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>

View File

@@ -1,7 +0,0 @@
<script lang="ts">
</script>
<div>
header
</div>

View 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>

View 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}

View 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>

View 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>

View 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>

View 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
View 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
View 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),
};

View File

@@ -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);
},
};

View 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');

View File

@@ -1,33 +0,0 @@
import { writable } from 'svelte/store';
interface SidebarState {
isOpen: boolean;
isExpanded: boolean;
}
export const sidebarStore = writable<SidebarState>({
isOpen: false,
isExpanded: false,
})
/**
* 切换侧边栏打开、隐藏(偏移隐藏)状态
*/
export const toggleSidebar = () => {
sidebarStore.update(state => ({
...state,
isOpen: !state.isOpen,
}));
}
/**
* 切换侧边栏展开状态
*/
export const toggleSidebarOpen = () => {
sidebarStore.update(state => ({
...state,
isExpanded: !state.isExpanded,
}));
}

View 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');

View File

@@ -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);
},
};

View 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');

View File

@@ -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
};

View File

@@ -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[];
}

View File

@@ -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
}

View 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;
}

View File

@@ -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;

View File

@@ -1,3 +1,25 @@
export type IconId = {
[key: string]: string;
};
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
View 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[];
}

View File

@@ -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;
}

View File

@@ -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))
};

View 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');
};

View 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: '无法连接服务器,请检查网络' };
}
};

View 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;
}
}

View File

@@ -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-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={$themeStore} />
<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-primary 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>

View File

@@ -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
}
};

View File

@@ -1,55 +1,38 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg?url';
import { themeStore } from '$lib/stores/themeStore.ts';
let { children } = $props();
import { onMount } from 'svelte';
import { sidebarStore } from '$lib/stores/sidebarStore.ts';
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();
const MD_BREAKPOINT = '(min-width: 768px)';
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);
const handleMediaQueryChange = (event: MediaQueryListEvent) => {
const isCurrentlyDesktop = event.matches;
console.log(isCurrentlyDesktop);
sidebarStore.update((store) => ({
...store,
isOpen: isCurrentlyDesktop
}));
}
let isMounted = $state(false); // 客户端渲染标志
onMount(()=>{
isMounted = true;
const isDesktop = window.matchMedia(MD_BREAKPOINT).matches;
console.log(isDesktop);
sidebarStore.update((store) => ({
...store,
isOpen: isDesktop,
}));
const mediaQuery = window.matchMedia(MD_BREAKPOINT);
mediaQuery.addEventListener('change', handleMediaQueryChange);
return () => {
mediaQuery.removeEventListener('change', handleMediaQueryChange);
}
$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} />
{#if isMounted}
<Sprite />
{/if}
</svelte:head>
<div data-theme={$themeStore} class="text-base-content">
<Sprite />
<div class="text-base-content">
<ToastContainer />
{@render children()}
</div>

View File

@@ -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>

View 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 }),
);
}

View File

@@ -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>

View File

@@ -1,64 +1,11 @@
<script>
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { authStore } from '$lib/stores/authStore.ts';
import { authService } from '$lib/api/services/authService.ts';
import ThemeSelector from '$lib/widget/ThemeSelector.svelte';
import { sidebarStore, toggleSidebarOpen } from '$lib/stores/sidebarStore';
<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="flex h-screen bg-base-300 font-sans overflow-hidden">
<aside class=" opacity-0 md:opacity-100 flex-shrink-0 flex flex-col bg-base-200 border-r border-gray-700/30
transition-all duration-500 ease-in-out relative
{$sidebarStore.isOpen ? 'translate-x-0' : '-translate-x-full'}
{$sidebarStore.isExpanded ? 'w-[280px]' : 'w-[72px]'}
">
<div class="h-16 flex items-center px-4 justify-start">
<button
on:click={toggleSidebarOpen}
class="p-2 hover:bg-gray-700/50 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>
</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>

View File

@@ -0,0 +1,3 @@
<div>
123
</div>

View File

@@ -0,0 +1,3 @@
<div>
234
</div>

View 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(),
}
};
};

View 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>

View 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()
}
};
};

View 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>

View File

@@ -0,0 +1 @@
这是数据

View 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
}
}

View File

@@ -0,0 +1,9 @@
<script lang="ts">
export let data;
</script>
<div>
这里展示个人信息
{JSON.stringify(data.profile)}
</div>

View File

@@ -0,0 +1,8 @@
export function load({ params }) {
console.log('params:', params);
}

View 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>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
</script>
<div class="h-screen w-screen bg-base-300">
123
</div>

View 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
});
}
}
}

View File

@@ -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>

View 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('/'));
}
}

View File

@@ -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;;
}