This commit is contained in:
2026-04-29 14:59:24 +08:00
parent e2fdaa8fe8
commit 8e96524e0b
81 changed files with 9151 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
*
!target/*-runner
!target/*-runner.jar
!target/lib/*
!target/quarkus-app/*
+45
View File
@@ -0,0 +1,45 @@
#Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
release.properties
.flattened-pom.xml
# Eclipse
.project
.classpath
.settings/
bin/
# IntelliJ
.idea
*.ipr
*.iml
*.iws
# NetBeans
nb-configuration.xml
# Visual Studio Code
.vscode
.factorypath
# OSX
.DS_Store
# Vim
*.swp
*.swo
# patch
*.orig
*.rej
# Local environment
.env
# Plugin directory
/.quarkus/cli/plugins/
# TLS Certificates
.certs/
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=file:///C:/Users/chuyy/.m2/wrapper/dists/apache-maven-3.8.8/apache-maven-3.8.8-bin.zip
+97
View File
@@ -0,0 +1,97 @@
# home4j
This project uses Quarkus, the Supersonic Subatomic Java Framework.
If you want to learn more about Quarkus, please visit its website: <https://quarkus.io/>.
## Running the application in dev mode
You can run your application in dev mode that enables live coding using:
```shell script
./mvnw quarkus:dev
```
> **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at <http://localhost:8080/q/dev/>.
## Packaging and running the application
The application can be packaged using:
```shell script
./mvnw package
```
It produces the `quarkus-run.jar` file in the `target/quarkus-app/` directory.
Be aware that its not an _über-jar_ as the dependencies are copied into the `target/quarkus-app/lib/` directory.
The application is now runnable using `java -jar target/quarkus-app/quarkus-run.jar`.
If you want to build an _über-jar_, execute the following command:
```shell script
./mvnw package -Dquarkus.package.jar.type=uber-jar
```
The application, packaged as an _über-jar_, is now runnable using `java -jar target/*-runner.jar`.
## Creating a native executable
You can create a native executable using:
```shell script
./mvnw package -Dnative
```
Or, if you don't have GraalVM installed, you can run the native executable build in a container using:
```shell script
./mvnw package -Dnative -Dquarkus.native.container-build=true
```
You can then execute your native executable with: `./target/home4j-0.1.0-SNAPSHOT-runner`
If you want to learn more about building native executables, please consult <https://quarkus.io/guides/maven-tooling>.
## Related Guides
- Qute ([guide](https://quarkus.io/guides/qute)): Offer templating support for web, email, etc in a build time, type-safe way
- REST Qute ([guide](https://quarkus.io/guides/qute-reference#rest_integration)): Qute integration for Quarkus REST. This extension is not compatible with the quarkus-resteasy extension, or any of the extensions that depend on it.
- Hibernate ORM with Panache ([guide](https://quarkus.io/guides/hibernate-orm-panache)): Simplify your persistence code for Hibernate ORM via the active record or the repository pattern
- REST ([guide](https://quarkus.io/guides/rest)): A Jakarta REST implementation utilizing build time processing and Vert.x. This extension is not compatible with the quarkus-resteasy extension, or any of the extensions that depend on it.
- REST Jackson ([guide](https://quarkus.io/guides/rest#json-serialisation)): Jackson serialization support for Quarkus REST. This extension is not compatible with the quarkus-resteasy extension, or any of the extensions that depend on it
- SmallRye Health ([guide](https://quarkus.io/guides/smallrye-health)): Monitor service health
- JDBC Driver - MySQL ([guide](https://quarkus.io/guides/datasource)): Connect to the MySQL database via JDBC
- Hibernate Validator ([guide](https://quarkus.io/guides/validation)): Validate object properties (field, getter) and method parameters for your beans (REST, CDI, Jakarta Persistence)
- Security JPA ([guide](https://quarkus.io/guides/security-getting-started)): Secure your applications with username/password stored in a database via Jakarta Persistence
- SmallRye OpenAPI ([guide](https://quarkus.io/guides/openapi-swaggerui)): Document your REST APIs with OpenAPI - comes with Swagger UI
- JDBC Driver - H2 ([guide](https://quarkus.io/guides/datasource)): Connect to the H2 database via JDBC
## Provided Code
### Hibernate ORM
Create your first JPA entity
[Related guide section...](https://quarkus.io/guides/hibernate-orm)
[Related Hibernate with Panache section...](https://quarkus.io/guides/hibernate-orm-panache)
### REST
Easily start your REST Web Services
[Related guide section...](https://quarkus.io/guides/getting-started-reactive#reactive-jax-rs-resources)
### REST Qute
Create your web page using Quarkus REST and Qute
[Related guide section...](https://quarkus.io/guides/qute#type-safe-templates)
### SmallRye Health
Monitor your application's health using SmallRye Health
[Related guide section...](https://quarkus.io/guides/smallrye-health)
+5
View File
@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 home4j-session DG9SzEscC4GiK9vXvTzkv4YjRZvXPJUcIsHeuxZzFDuQZ5c8Qe4BqHQM4Gap0qVA
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=chrome-extension://mcbpblocgmgfnpjjppndjkmgjaogfceg/
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

@@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=chrome-extension://mcbpblocgmgfnpjjppndjkmgjaogfceg/
Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

@@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=chrome-extension://mcbpblocgmgfnpjjppndjkmgjaogfceg/
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

+25
View File
@@ -0,0 +1,25 @@
home
1.综合参考
https://github.com/glanceapp/glance
https://github.com/gethomepage/homepage
https://github.com/Lissy93/dashy
https://github.com/linuxserver/Heimdall
2.优先参考
https://github.com/pawelmalak/flame
https://github.com/bastienwirtz/homer
https://daisyui.com/docs/cdn/
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+76
View File
@@ -0,0 +1,76 @@
<!doctype html>
<html lang="zh" data-theme="light"> <!-- 可改成 dark、cupcake 等主题 -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>daisyUI 5 + Tailwind CSS 4 CDN 示例</title>
<!-- daisyUI 5 的核心 CSS(已优化到 ~34kB 压缩后,很适合 CDN 生产使用) -->
<!-- <script src="
https://cdn.jsdelivr.net/npm/daisyui@5.5.17/index.min.js
"></script> -->
<!-- <link href="
https://cdn.jsdelivr.net/npm/daisyui@5.5.17/daisyui.min.css
" rel="stylesheet"> -->
<script src="./js/browser@4.js"></script>
<link href="./css/daisyui@5.css" rel="stylesheet" type="text/css" />
<!-- <link rel="stylesheet" href="/css/tailwind.min.css"> -->
<!-- <script src="/js/daisyui.index.min.js"></script> -->
<!-- <link href="./css/themes.css" rel="stylesheet" type="text/css" /> -->
<!-- Tailwind CSS 4 的浏览器运行时引擎(必须放在 daisyUI 后面) -->
<!-- <script src="
https://cdn.jsdelivr.net/npm/tailwindcss@4.1.18/dist/lib.min.js
"></script> -->
<!-- <link href="
https://cdn.jsdelivr.net/npm/tailwindcss@4.1.18/index.min.css
" rel="stylesheet"> -->
</head>
<body class="min-h-screen bg-base-200 flex items-center justify-center p-8">
<div class="card w-96 bg-base-100 shadow-xl">
<figure>
<img
src="https://images.unsplash.com/photo-1556745757-8d76bdb6984f?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80"
alt="示例图片"
/>
</figure>
<div class="card-body">
<h2 class="card-title">daisyUI 5 + Tailwind 4</h2>
<p class="text-base-content/80">
这是一个纯 CDN 实现的卡片组件示例。<br>
无需 Node.js、无需构建步骤,<br>
支持所有 daisyUI 组件和 Tailwind 工具类。
</p>
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary">主要按钮</button>
<button class="btn btn-outline btn-secondary">次要按钮</button>
</div>
</div>
</div>
<!-- 一些额外演示元素 -->
<div class="mt-12 flex flex-col items-center gap-6">
<button class="btn btn-lg btn-accent">大按钮 Accent</button>
<div class="badge badge-outline badge-info">Info 徽章</div>
<div class="badge badge-success gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Success
</div>
<div class="alert alert-warning shadow-lg max-w-md">
<span>这是一个警告提示框示例!</span>
</div>
</div>
</body>
</html>
+219
View File
@@ -0,0 +1,219 @@
你现在是一位拥有10年以上经验的高级产品经理 & 产品设计师,特别擅长从0到1设计SaaS、开源工具、开发者平台、基础设施类产品。你写的产品设计文档(PRD / PDD)逻辑清晰、结构完整、语言专业、重点突出、可执行性强。
请根据以下信息,为一个全新的项目撰写一份**完整的产品设计文档**。
### 参考的开源项目(请深度学习它们的优点、设计理念、功能组织方式、用户体验、架构思路等)
1.综合参考
https://github.com/glanceapp/glance
https://github.com/gethomepage/homepage
https://github.com/Lissy93/dashy
https://github.com/linuxserver/Heimdall
2.优先参考
https://github.com/pawelmalak/flame
https://github.com/bastienwirtz/homer
请从这些参考项目中提炼出最值得借鉴的模式、功能组织方式、用户心智模型、扩展性设计等,并应用到新项目中。
### 新项目基本信息
项目中文名称:Home4j
项目英文名称:Home4j
一句话核心目标:【为个人和小型团队打造极简、高性能、可自托管的Home 仪表盘和index导航工具】
目标用户群体:独立开发者、内容创作者、小团队、公司内部
使用场景举例:在flame、dashy、Heimdall、homer之间寻找更轻量替代品的用户,并习惯使用java
核心价值主张(一句话):
因为市面较少有基于java的Home 仪表盘和导航工具,所以Home4j将提供一个轻量级、高度可定制的Home index解决方案,满足用户的特定需求。
主推技术栈(你(AI)也可以补充):
- Java
- Solon
- Thymeleaf
- daisyui(CDN) - (之后可以支持自定义主题更换)
- h2 - (可以支持切换mysql)
- mybatisplus
- Docker
### 重要指令
1. **核心功能由你(AI)来定义**
请你根据项目目标、参考项目、目标用户痛点,**自主推导并决定**最应该包含的MVP核心功能,以及未来1-2年内值得扩展的重要功能。
不要依赖用户事先给出的功能列表,而是从“用户真实需求 + 参考项目最佳实践 + 差异化竞争力”三个维度去思考和设计。
2. 输出必须严格按照以下**中文结构**,使用Markdown格式,语言专业、简洁、有层次感
# 【项目中文名称】产品设计文档(PDD)
## 1. 概述与愿景
- 项目一句话介绍
- 核心价值主张
- 与竞品的差异化定位
- 从参考项目中学到的最关键启发
## 2. 用户与市场分析
- 目标用户画像(1-3个主要Persona,包含典型场景、痛点、期望)
- 竞品分析(至少3个主要竞品 + 参考开源项目的对比)
- 用户核心痛点与机会点总结
## 3. 核心功能设计(MVP
- MVP范围界定说明
- 核心功能列表(建议用表格:功能名称 | 用户价值 | 优先级 | 参考来源)
- 每个核心功能的详细描述(包含用户故事、关键交互流程、验收标准)
## 4. 功能路线图(MVP → v1.0 → 未来方向)
- 分阶段功能规划(可用时间轴或表格表示)
- 每个阶段的关键里程碑
## 5. 用户体验与交互设计原则
- 整体设计语言与调性
- 核心用户旅程(建议画文字流程图或步骤描述)
- 关键页面/模块的交互逻辑说明
## 6. 技术架构与实现约束(产品视角)
- 推荐技术栈(语言、框架、数据库、自托管方式等)
- 部署方式(Docker、一键部署、Helm等)
- 扩展性与插件机制(如果适用)
- 性能与安全底线要求
## 7. 非功能性需求
- 性能目标
- 安全性与隐私保护
- 可观测性(日志、监控、tracing)
- 国际化与可访问性
## 8. 风险、挑战与应对
- 最大的3-5个风险点
- 对应的缓解措施
## 9. 附录
- 名词表 / 术语定义
- 参考资料与灵感来源
- 其他补充说明
请写得**详细但不冗长**(控制在3500-6000字之间),多使用表格、编号列表、加粗重点,让文档易读、易落地。
现在请开始撰写完整的产品设计文档。
# 启动命令
cd /e/test_code/test01/home4j && ./mvnw quarkus:dev
./mvnw quarkus:dev -DskipTests
cd /e/test_code/test01/home4j; ./mvnw quarkus:dev
cd /e/test_code/test01/home4j
./mvnw clean package -DskipTests
./mvnw package -DskipTests -Dquarkus.package.jar.type=uber-jar
JAR 文件位置
e:\test_code\test01\home4j\target\home4j-1.0.0-SNAPSHOT-runner.jar
java -jar home4j-1.0.0-SNAPSHOT-runner.jar
默认配置:
端口:8080
数据库:H2(内存数据库,重启丢失)
启动完成后,您可以访问:
地址 说明
http://localhost:8080 首页(重定向到登录)
http://localhost:8080/login 登录页面
http://localhost:8080/q/health 健康检查
http://localhost:8080/q/swagger-ui API 文档
默认管理员账号:
用户名:admin
密码:admin123
https://www.jsdelivr.com/package/npm/tailwindcss?tab=files&path=dist
https://www.jsdelivr.com/package/npm/daisyui?tab=files
你作为10多年的前端&全栈工程师,优化一下页面样式
第一步、主要参考并仔细理解daisyui 官方docs页面的样式和 组件代码等,地址:
https://github.com/saadeghi/daisyui/tree/master/packages/docs
第二步、并且我也附加了些参考图
Layout-theme-Multilingual.png (参考紧凑布局、右上角的 主题切换和多语言切换)
list.png (书签紧凑列表布局)
card.png (书签卡片布局,并且支持图标或图片的2中显示形式,文字上方居中和卡片背景显示)
第三步、主要需求:
1. 整体样式重构,参考daisyui首页的组件样式,优化Home4j的整体视觉风格,默认色系和daisyui docs相近,不用太高的饱和度和色彩避免过于艳丽
2. 实现daisyui一样的主题切换和多语言切换功能,在右侧用户图标旁边 (daisyui的themes.css已经引入 )
3. 提供书签的紧凑列表和卡片两种展示形式(1.紧凑列表布局 2.卡片布局,并且支持图标或图片的2中显示形式,文字上方居中和卡片背景显示)
4. 发现首页编辑时还是没有更换背景的功能,考虑一下,建议支持自定义背景图片或颜色
5. 确保在不同设备上的良好适配性
6. 关于排列,1.紧凑列表布局 默认5列摆放,每列按列表显示;2.卡片图标布局,不用太大,建议每行5列(5个),每个卡片高度100左右,不用太大
7. 首页布局考虑加 日期时间 和天气显示,(例:左上角显示时间,时间为本机时间;右上角显示天气,地区默认为浏览器本地)
8. 其他优化你补充吧
不着急动代码,先和我确认改动点,我们讨论几次再改
1.搜索框位置:
保留单独的 Hero 区域
2.天气 API
用免费的 wttr.in
3.多语言范围:
只切换 UI 文字
4.背景设置入口:
放在用户菜单下
5.主题持久化:
先只用 localStorage
6.卡片背景图
每个书签单独设置,是书签的设置项之一,可选 默认图标布局 还是 卡片背景布局
7.其他优化 ,全部赞同
你看是否还有其他问题
1.
选择 B. 简洁风格(无渐变,与 DaisyUI 文档类似),这个背景也会跟着主题切换改变颜色吗,还有页面下方的背景颜色会跟随主题改变吗,我的建议是需要改变
2.
选择 B. 放在工具栏中(搜索框下方的 sticky 工具栏)
3.
视图切换是全局切换,还是现在的工具栏位置(紧凑列表和 卡片布局)
卡片图标和卡片背景 只是书签的自定义设置,在书签编辑时设置
选择方案 B:两级设置
全局只切换「列表」和「卡片」
卡片模式下,每个书签自己选择「图标」或「背景图」
4. 书签新增字段 ,可以的
5. 分组标题样式
选择A. 分组标题独占一行(横跨 5 列),和之前一样,带隐藏和展开功能
6. 背景设置的数据存储
选择 B. 后端用户配置表(登录用户同步)
1.搜索框位置:
保留单独的 Hero 区域
搜索框不是应该在 单独的 Hero 区域吗,为什么在顶部导航栏,原来的工具栏也没有了?
不要动代码,先确认
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+535
View File
@@ -0,0 +1,535 @@
# Home4j 功能列表
> **版本**v1.1
> **更新日期**2026年2月3日
> **说明**:本文档定义 Home4j 的完整功能架构,从 MVP 到未来扩展,采用三级结构组织
---
## 核心定位说明
**Home4j 是一个导航工具**,核心竞争力是:
1. **Java 技术栈**:基于 Quarkus,对 Java 开发者友好
2. **轻量部署体验**Docker 一键部署,资源占用低
3. **离线友好**:所有资源本地化,支持内网部署
---
## 功能优先级说明
| 标记 | 含义 | 版本规划 |
|-----|------|---------|
| 🔴 **P0** | 必须有,MVP 核心功能 | v0.1 |
| 🟡 **P1** | 重要,体验优化功能 | v0.5 |
| 🟢 **P2** | 增强,v1.0 完整功能 | v1.0 |
| ⚪ **P3** | 未来,长期规划功能 | v1.x+ |
---
## 一、书签管理模块 🔖
> 核心价值:快速访问和组织用户的常用链接
### 1.1 书签基础操作
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 1.1.1 添加书签 | 支持手动输入 URL、名称、描述创建书签 | 🔴 P0 |
| 1.1.2 编辑书签 | 修改书签的所有属性信息 | 🔴 P0 |
| 1.1.3 删除书签 | 单个删除,需二次确认 | 🔴 P0 |
| 1.1.4 批量删除 | 多选后批量删除书签 | 🟡 P1 |
| 1.1.5 书签排序 | 拖拽调整书签顺序 | 🔴 P0 |
| 1.1.6 书签复制 | 复制现有书签快速创建新书签 | 🟡 P1 |
### 1.2 书签属性配置
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 1.2.1 基础信息 | 名称、URL、描述 | 🔴 P0 |
| 1.2.2 图标设置 | 支持内置图标/URL图标/上传图标/自动获取favicon | 🔴 P0 |
| 1.2.3 分组归属 | 选择书签所属分组 | 🔴 P0 |
| 1.2.4 标签设置 | 为书签添加多个标签用于筛选 | 🟡 P1 |
| 1.2.5 打开方式 | 新标签页/当前页/iframe嵌入 | 🟡 P1 |
| 1.2.6 自定义颜色 | 书签卡片的强调色 | 🟢 P2 |
| 1.2.7 访问密码 | 单个书签的访问保护 | 🟢 P2 |
### 1.3 书签高级功能
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 1.3.1 URL 有效性检测 | 定期检查书签链接是否可访问 | 🟢 P2 |
| 1.3.2 Favicon 自动获取 | 根据 URL 自动抓取网站图标 | 🟡 P1 |
| 1.3.3 书签置顶 | 将重要书签置顶显示 | 🟡 P1 |
| 1.3.4 书签隐藏 | 临时隐藏不常用书签 | 🟢 P2 |
| 1.3.5 快捷访问编号 | 数字键 1-9 快速打开对应书签 | 🟢 P2 |
| 1.3.6 访问统计 | 记录书签点击次数和最近访问时间 | 🟢 P2 |
---
## 二、分组管理模块 📁
> 核心价值:按场景/类型组织书签,提升查找效率
### 2.1 分组基础操作
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 2.1.1 创建分组 | 新建分组并设置名称、图标 | 🔴 P0 |
| 2.1.2 编辑分组 | 修改分组名称、图标、描述 | 🔴 P0 |
| 2.1.3 删除分组 | 删除分组(书签移至默认分组或一并删除) | 🔴 P0 |
| 2.1.4 分组排序 | 拖拽调整分组显示顺序 | 🔴 P0 |
| 2.1.5 分组折叠 | 折叠/展开分组内容 | 🔴 P0 |
| 2.1.6 默认折叠状态 | 设置分组默认是否折叠 | 🟡 P1 |
### 2.2 分组属性配置
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 2.2.1 分组名称 | 显示名称,支持中英文 | 🔴 P0 |
| 2.2.2 分组图标 | 内置图标库选择或自定义 | 🔴 P0 |
| 2.2.3 分组描述 | 可选的分组说明文字 | 🟡 P1 |
| 2.2.4 分组颜色 | 分组标题栏的主题色 | 🟢 P2 |
| 2.2.5 显示模式 | 网格/列表/紧凑三种布局 | 🟢 P2 |
| 2.2.6 列数设置 | 分组内书签每行显示数量 | 🟡 P1 |
### 2.3 分组高级功能
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 2.3.1 分组可见性 | 设置分组仅特定用户可见 | 🟢 P2 |
| 2.3.2 分组标签页 | 多个分组作为 Tab 切换显示 | ⚪ P3 |
| 2.3.3 嵌套分组 | 支持分组内创建子分组 | ⚪ P3 |
| 2.3.4 分组独立页面 | 分组可作为独立页面访问 | ⚪ P3 |
---
## 三、搜索与筛选模块 🔍
> 核心价值:快速定位目标书签,提升使用效率
### 3.1 全局搜索
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 3.1.1 快捷键激活 | `Ctrl+K``/` 打开搜索框 | 🔴 P0 |
| 3.1.2 实时搜索 | 输入即搜索,无需回车 | 🔴 P0 |
| 3.1.3 多字段匹配 | 搜索名称、URL、描述、标签 | 🔴 P0 |
| 3.1.4 模糊搜索 | 支持拼音首字母、模糊匹配 | 🟡 P1 |
| 3.1.5 搜索高亮 | 匹配关键词高亮显示 | 🔴 P0 |
| 3.1.6 键盘导航 | ↑↓ 选择,Enter 打开,Esc 关闭 | 🔴 P0 |
### 3.2 筛选功能
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 3.2.1 按分组筛选 | 只显示特定分组的书签 | 🟡 P1 |
| 3.2.2 按标签筛选 | 根据标签过滤书签 | 🟡 P1 |
| 3.2.3 按访问频率 | 显示最常访问/最近访问的书签 | 🟢 P2 |
| 3.2.4 组合筛选 | 多条件组合筛选 | 🟢 P2 |
### 3.3 搜索增强
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 3.3.1 搜索历史 | 记录最近搜索词 | 🟢 P2 |
| 3.3.2 搜索建议 | 输入时显示匹配建议 | 🟢 P2 |
| 3.3.3 外部搜索引擎 | 未匹配时跳转到搜索引擎 | 🟡 P1 |
| 3.3.4 搜索引擎切换 | 支持 Google/Bing/百度 等 | 🟡 P1 |
---
## 四、主题与外观模块 🎨
> 核心价值:满足个性化需求,提升视觉体验
### 4.1 主题切换
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 4.1.1 预设主题 | 提供 10+ 预设主题(light/dark/nord/dracula等) | 🔴 P0 |
| 4.1.2 一键切换 | 点击即时切换,无需刷新 | 🔴 P0 |
| 4.1.3 跟随系统 | 自动跟随操作系统深色/浅色模式 | 🔴 P0 |
| 4.1.4 主题偏好保存 | 记住用户选择的主题 | 🔴 P0 |
| 4.1.5 定时切换 | 按时间段自动切换主题 | 🟢 P2 |
### 4.2 自定义外观
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 4.2.1 自定义背景 | 支持颜色/图片/渐变背景 | 🟡 P1 |
| 4.2.2 背景模糊 | 背景图片模糊度调节 | 🟡 P1 |
| 4.2.3 背景遮罩 | 背景透明度遮罩 | 🟡 P1 |
| 4.2.4 自定义 CSS | 高级用户可注入自定义样式 | 🟡 P1 |
| 4.2.5 字体设置 | 自定义字体和字号 | 🟢 P2 |
| 4.2.6 圆角设置 | 卡片圆角大小调节 | 🟢 P2 |
### 4.3 布局配置
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 4.3.1 布局模式 | 网格/列表/紧凑三种模式 | 🔴 P0 |
| 4.3.2 列数设置 | 每行显示 2/3/4/5/6 列可选 | 🔴 P0 |
| 4.3.3 卡片尺寸 | 小/中/大三种卡片尺寸 | 🟡 P1 |
| 4.3.4 显示密度 | 紧凑/标准/宽松间距 | 🟡 P1 |
| 4.3.5 响应式断点 | 参考 Tailwind 断点,移动端(<640px)单列 | 🔴 P0 |
---
## 五、配置管理模块 ⚙️
> 核心价值:灵活的配置方式,满足不同用户习惯
### 5.1 Web UI 配置(主要方式)
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 5.1.1 可视化编辑器 | 通过界面修改所有配置项 | P0 |
| 5.1.2 实时预览 | 修改后即时预览效果 | 🟡 P1 |
| 5.1.3 保存到数据库 | 所有配置保存到数据库 | P0 |
| 5.1.4 配置分类 | 常规/外观/数据/账号 Tab 分类 | P0 |
| 5.1.5 配置重置 | 恢复默认配置 | 🟡 P1 |
### 5.2 数据管理
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 5.2.1 数据导出 | 导出所有数据为 JSON/YAML | 🟡 P1 |
| 5.2.2 数据导入 | 从 JSON/YAML 导入数据 | 🟡 P1 |
| 5.2.3 数据备份 | 定时自动备份数据 | 🟢 P2 |
| 5.2.4 备份恢复 | 从备份文件恢复数据 | 🟢 P2 |
| 5.2.5 数据清空 | 清空所有数据(需二次确认) | 🟡 P1 |
| 5.2.6 竞品数据导入 | 支持从 Flame/Homer 导入数据 | ⚪ P3 |
---
## 六、图标系统模块 🖼️
> 核心价值:丰富的图标支持,提升视觉识别效率
### 6.1 内置图标库
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 6.1.1 基础图标集 | 内置 500+ 常用图标(基于 Lucide/Tabler | 🔴 P0 |
| 6.1.2 品牌图标集 | 常见网站/服务的 Logo 图标 | 🟡 P1 |
| 6.1.3 图标搜索 | 在图标库中搜索图标 | 🔴 P0 |
| 6.1.4 图标预览 | 选择前预览图标效果 | 🔴 P0 |
| 6.1.5 最近使用 | 显示最近使用的图标 | 🟡 P1 |
### 6.2 自定义图标
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 6.2.1 URL 图标 | 支持外部图片 URL 作为图标 | 🔴 P0 |
| 6.2.2 URL 图标降级 | URL 不可访问时显示默认图标 | 🔴 P0 |
| 6.2.3 图标上传 | 上传本地图片作为图标 | 🟡 P1 |
| 6.2.4 Emoji 图标 | 支持使用 Emoji 作为图标 | 🟡 P1 |
| 6.2.5 文字图标 | 使用文字首字母作为图标 | 🔴 P0 |
| 6.2.6 SVG 图标 | 支持自定义 SVG 代码 | 🟢 P2 |
### 6.3 图标增强
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 6.3.1 图标颜色 | 自定义图标颜色 | 🟡 P1 |
| 6.3.2 图标大小 | 调整图标显示大小 | 🟡 P1 |
| 6.3.3 图标背景 | 图标背景色/形状设置 | 🟢 P2 |
| 6.3.4 Dashboard Icons | 集成 Dashboard Icons 图标集 | 🟢 P2 |
---
## 七、首页定制模块 🏠
> 核心价值:个性化首页展示,满足品牌和场景需求
### 7.1 页面头部
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 7.1.1 网站标题 | 自定义首页标题 | 🔴 P0 |
| 7.1.2 副标题 | 可选的副标题/欢迎语 | 🔴 P0 |
| 7.1.3 Logo 设置 | 自定义 Logo 图片 | 🟡 P1 |
| 7.1.4 Favicon 设置 | 自定义浏览器标签图标 | 🟡 P1 |
| 7.1.5 问候语 | 根据时间显示不同问候语 | 🟢 P2 |
| 7.1.6 头部隐藏 | 可选择隐藏头部区域 | 🟡 P1 |
### 7.2 页面布局
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 7.2.1 居中布局 | 内容区域居中显示 | 🔴 P0 |
| 7.2.2 最大宽度 | 设置内容区域最大宽度 | 🟡 P1 |
| 7.2.3 侧边栏模式 | 分组作为侧边栏导航 | 🟢 P2 |
| 7.2.4 全屏模式 | 隐藏所有 UI 只显示书签 | 🟢 P2 |
### 7.3 页面底部
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 7.3.1 版权信息 | 自定义底部版权文字 | 🟡 P1 |
| 7.3.2 快捷链接 | 底部显示常用链接 | 🟢 P2 |
| 7.3.3 底部隐藏 | 可选择隐藏底部区域 | 🟡 P1 |
| 7.3.4 页脚自定义 HTML | 高级用户可自定义页脚内容 | ⚪ P3 |
---
## 八、用户与权限模块 👥
> 核心价值:保护编辑权限,支持简单的访问控制
### 8.1 管理员登录(MVP
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 8.1.1 首次设置账号 | 首次启动强制设置管理员账号密码 | P0 |
| 8.1.2 管理员登录 | 用户名密码登录 | P0 |
| 8.1.3 Session 保持 | 登录状态保持,支持记住登录 | P0 |
| 8.1.4 退出登录 | 退出管理员状态 | P0 |
| 8.1.5 修改密码 | 管理员修改自己的密码 | P0 |
### 8.2 访问控制(MVP
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 8.2.1 只读访问 | 未登录用户只能浏览,不能编辑 | 🔴 P0 |
| 8.2.2 编辑权限保护 | 添加/编辑/删除操作需要登录 | 🔴 P0 |
| 8.2.3 设置页面保护 | 设置页面需要登录才能访问 | 🔴 P0 |
### 8.3 多用户支持(v1.0+
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 8.3.1 用户注册 | 管理员邀请注册新用户 | 🟢 P2 |
| 8.3.2 用户列表 | 管理员查看所有用户 | 🟢 P2 |
| 8.3.3 用户编辑 | 修改用户信息和权限 | 🟢 P2 |
| 8.3.4 用户删除 | 删除用户账号 | P2 |
| 8.3.5 角色权限 | 管理员/普通用户角色区分 | 🟢 P2 |
| 8.3.6 密码重置 | 管理员重置用户密码 | 🟢 P2 |
### 8.4 认证集成(未来)
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 8.4.1 OAuth2 登录 | 支持 GitHub/Google OAuth | ⚪ P3 |
| 8.4.2 LDAP 集成 | 企业 LDAP/AD 认证 | ⚪ P3 |
| 8.4.3 SSO 支持 | 单点登录集成 | ⚪ P3 |
| 8.4.4 2FA 支持 | 两步验证(TOTP | ⚪ P3 |
---
## 九、服务状态监控模块 📊
> 核心价值:简单了解服务可用性(非核心功能,保持轻量)
### 9.1 简单状态检测(v1.0
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 9.1.1 HTTP 状态检测 | 检测 URL 返回状态码 | 🟢 P2 |
| 9.1.2 状态图标显示 | 书签上显示红/绿状态点 | 🟢 P2 |
| 9.1.3 自定义检测间隔 | 设置检测频率 | 🟢 P2 |
### 9.2 状态展示
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 9.2.1 响应时间 | 显示服务响应延迟 | 🟢 P2 |
| 9.2.2 状态汇总 | 首页显示服务整体健康度 | 🟢 P2 |
| 9.2.3 故障提示 | 服务异常时醒目提示 | 🟢 P2 |
**说明**
- 不保存历史数据
- 不生成报表
- 不支持告警通知
- 复杂监控需求可使用 Quarkus Dev UI 或专业工具
---
## 十、Widget 组件模块 🧩
> 核心价值:丰富信息展示(非核心差异化,延后实现)
### 10.1 内置 Widgetv1.0 简化版)
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 10.1.1 时钟组件 | 显示当前时间/日期 | 🟢 P2 |
| 10.1.2 搜索框组件 | 独立的搜索引擎入口 | P2 |
### 10.2 Widget 管理(v1.0
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 10.2.1 Widget 启用/禁用 | 选择显示哪些 Widget | 🟢 P2 |
| 10.2.2 Widget 位置 | 设置 Widget 显示位置 | 🟢 P2 |
### 10.3 扩展 Widgetv1.x+
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 10.3.1 天气组件 | 显示当前天气信息 | ⚪ P3 |
| 10.3.2 快捷笔记 | 简单的便签/待办 | ⚪ P3 |
| 10.3.3 系统信息 | 显示服务器 CPU/内存/磁盘 | ⚪ P3 |
| 10.3.4 插件加载机制 | 基于 SPI 的插件加载 | ⚪ P3 |
| 10.3.5 插件开发 SDK | 提供 Widget 开发工具包 | ⚪ P3 |
| 10.3.6 插件市场 | 社区 Widget 分享平台 | ⚪ P3 |
**说明**Widget 系统不是 Home4j 的核心差异化点,v1.0 仅提供简单的时钟和搜索组件,复杂 Widget 延后到 v1.x 作为可选插件。
---
## 十一、快捷操作模块 ⌨️
> 核心价值:键盘友好,提升操作效率
### 11.1 全局快捷键
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 11.1.1 搜索 | `Ctrl+K``/` 打开搜索 | 🔴 P0 |
| 11.1.2 添加书签 | `Ctrl+N` 打开添加弹窗 | 🟡 P1 |
| 11.1.3 设置 | `Ctrl+,` 打开设置 | 🟡 P1 |
| 11.1.4 主题切换 | `Ctrl+T` 切换深色/浅色 | 🟡 P1 |
| 11.1.5 帮助 | `?` 显示快捷键帮助 | 🟡 P1 |
| 11.1.6 全屏 | `F11` 切换全屏模式 | 🟢 P2 |
### 11.2 导航快捷键
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 11.2.1 数字键访问 | `1-9` 打开对应位置书签 | 🟢 P2 |
| 11.2.2 分组跳转 | `G+数字` 跳转到对应分组 | 🟢 P2 |
| 11.2.3 方向键导航 | 方向键在书签间移动 | 🟢 P2 |
| 11.2.4 Tab 导航 | Tab 键在元素间切换 | 🔴 P0 |
---
## 十二、部署与运维模块 🚀
> 核心价值:简化部署,降低运维成本
### 12.1 部署方式
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 12.1.1 Docker 部署 | 官方 Docker 镜像 | 🔴 P0 |
| 12.1.2 Docker Compose | 提供 compose 示例文件 | 🔴 P0 |
| 12.1.3 JAR 直接运行 | 单 JAR 文件直接运行 | 🔴 P0 |
| 12.1.4 Kubernetes | K8s 部署 YAML 和 Helm Chart | ⚪ P3 |
| 12.1.5 一键脚本 | Linux 一键安装脚本 | 🟡 P1 |
### 12.2 数据库支持
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 12.2.1 H2 数据库 | 默认嵌入式数据库,零配置 | 🔴 P0 |
| 12.2.2 MySQL 支持 | 可选切换到 MySQL | 🟡 P1 |
| 12.2.3 PostgreSQL | 可选切换到 PostgreSQL | 🟢 P2 |
| 12.2.4 SQLite | 可选切换到 SQLite | 🟢 P2 |
### 12.3 运维功能
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 12.3.1 健康检查端点 | `/health` 接口 | 🔴 P0 |
| 12.3.2 就绪检查端点 | `/ready` 接口 | 🟡 P1 |
| 12.3.3 Metrics 端点 | Prometheus 格式指标 | 🟢 P2 |
| 12.3.4 日志配置 | 日志级别动态调整 | 🟡 P1 |
| 12.3.5 版本信息 | `/info` 显示版本信息 | 🔴 P0 |
---
## 十三、API 接口模块 🔌
> 核心价值:支持自动化和二次开发
### 13.1 RESTful API
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 13.1.1 书签 CRUD API | 书签的增删改查接口 | 🟢 P2 |
| 13.1.2 分组 CRUD API | 分组的增删改查接口 | 🟢 P2 |
| 13.1.3 配置 API | 读取和修改配置接口 | 🟢 P2 |
| 13.1.4 导入导出 API | 数据导入导出接口 | 🟢 P2 |
| 13.1.5 API 文档 | Swagger/OpenAPI 文档 | 🟢 P2 |
### 13.2 API 安全
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 13.2.1 API Key 认证 | 使用 API Key 访问接口 | 🟢 P2 |
| 13.2.2 速率限制 | API 调用频率限制 | ⚪ P3 |
| 13.2.3 CORS 配置 | 跨域访问配置 | 🟢 P2 |
---
## 十四、国际化模块 🌍
> 核心价值:支持多语言,扩大用户群体
### 14.1 语言支持
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 14.1.1 中文(简体) | 默认语言 | 🔴 P0 |
| 14.1.2 英文 | 完整英文支持 | 🔴 P0 |
| 14.1.3 中文(繁体) | 繁体中文支持 | 🟢 P2 |
| 14.1.4 日文 | 日语支持 | ⚪ P3 |
| 14.1.5 社区翻译 | 支持社区贡献翻译 | ⚪ P3 |
### 14.2 语言切换
| 三级功能 | 描述 | 优先级 |
|---------|------|--------|
| 14.2.1 手动切换 | 设置中选择语言 | 🔴 P0 |
| 14.2.2 浏览器检测 | 自动检测浏览器语言 | 🟡 P1 |
| 14.2.3 语言偏好保存 | 记住用户语言选择 | 🔴 P0 |
---
## 功能统计汇总
| 版本 | P0 功能数 | P1 功能数 | P2 功能数 | P3 功能数 |
|-----|----------|----------|----------|----------|
| MVP (v0.1) | **50** | - | - | - |
| v0.5 | 50 | **35** | - | - |
| v1.0 | 50 | 35 | **35** | - |
| v1.x+ | 50 | 35 | 35 | **25** |
**总计**:约 145 个功能点
---
## 版本交付物清单
### MVP (v0.1) 交付物
- [ ] 完整的书签管理功能
- [ ] 分组管理功能
- [ ] 全局搜索功能
- [ ] 5+ 预设主题(DaisyUI 本地化)
- [ ] 管理员登录与权限保护
- [ ] Web UI 配置管理
- [ ] Docker 镜像发布
- [ ] 基础文档(README、快速开始)
### v0.5 交付物
- [ ] 数据导入导出
- [ ] 图标库增强
- [ ] 自定义背景
- [ ] 快捷键系统
- [ ] MySQL 支持
- [ ] 完整使用文档
### v1.0 交付物
- [ ] 多用户支持
- [ ] 简单服务状态监控
- [ ] RESTful API
- [ ] 基础 Widget(时钟、搜索)
- [ ] 访问统计
- [ ] API 文档
- [ ] 运维监控接口
---
> **文档结束**
> 本功能列表将随项目进展持续更新
+527
View File
@@ -0,0 +1,527 @@
# Home4j 数据库设计文档
> **版本**v1.0
> **更新日期**2026年2月3日
> **数据库**H2(默认)/ MySQL(可选)
---
## 一、设计原则
1. **简洁优先**:MVP 阶段保持表结构简单,避免过度设计
2. **扩展预留**:关键字段预留扩展空间(如 JSON 类型的 extra 字段)
3. **软删除**:重要数据支持软删除,便于恢复
4. **审计字段**:所有表包含创建时间、更新时间
---
## 二、实体关系图(ER Diagram
```
┌─────────────────┐ ┌─────────────────┐
│ t_user │ │ t_config │
├─────────────────┤ ├─────────────────┤
│ id (PK) │ │ id (PK) │
│ username │ │ config_key │
│ password_hash │ │ config_value │
│ role │ │ config_type │
│ ... │ │ ... │
└─────────────────┘ └─────────────────┘
┌─────────────────┐ ┌─────────────────┐
│ t_group │ │ t_bookmark │
├─────────────────┤ ├─────────────────┤
│ id (PK) │◄──────│ id (PK) │
│ name │ 1:N │ group_id (FK) │
│ icon │ │ name │
│ sort_order │ │ url │
│ ... │ │ icon │
└─────────────────┘ │ sort_order │
│ ... │
└─────────────────┘
```
---
## 三、数据表详细设计
### 3.1 用户表(t_user
> 存储管理员账号信息,MVP 阶段仅支持单管理员
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|-------|------|------|--------|------|
| `id` | BIGINT | PK, AUTO_INCREMENT | - | 主键 |
| `username` | VARCHAR(50) | NOT NULL, UNIQUE | - | 用户名 |
| `password_hash` | VARCHAR(255) | NOT NULL | - | 密码哈希(BCrypt |
| `nickname` | VARCHAR(100) | - | NULL | 显示昵称 |
| `email` | VARCHAR(100) | - | NULL | 邮箱(预留) |
| `role` | VARCHAR(20) | NOT NULL | 'ADMIN' | 角色:ADMIN / USER |
| `status` | TINYINT | NOT NULL | 1 | 状态:1-正常 0-禁用 |
| `last_login_time` | TIMESTAMP | - | NULL | 最后登录时间 |
| `last_login_ip` | VARCHAR(50) | - | NULL | 最后登录IP |
| `created_at` | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | 创建时间 |
| `updated_at` | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | 更新时间 |
**索引**
- `uk_username` UNIQUE (`username`)
**SQL**
```sql
CREATE TABLE t_user (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
nickname VARCHAR(100),
email VARCHAR(100),
role VARCHAR(20) NOT NULL DEFAULT 'ADMIN',
status TINYINT NOT NULL DEFAULT 1,
last_login_time TIMESTAMP,
last_login_ip VARCHAR(50),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_username UNIQUE (username)
);
```
---
### 3.2 分组表(t_group
> 书签的分类容器,支持排序和折叠
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|-------|------|------|--------|------|
| `id` | BIGINT | PK, AUTO_INCREMENT | - | 主键 |
| `name` | VARCHAR(100) | NOT NULL | - | 分组名称 |
| `icon` | VARCHAR(255) | - | NULL | 图标(图标名/URL/base64 |
| `description` | VARCHAR(500) | - | NULL | 分组描述 |
| `sort_order` | INT | NOT NULL | 0 | 排序序号(升序) |
| `collapsed` | BOOLEAN | NOT NULL | FALSE | 是否默认折叠 |
| `visible` | BOOLEAN | NOT NULL | TRUE | 是否可见 |
| `color` | VARCHAR(20) | - | NULL | 主题色(预留 v1.0 |
| `created_at` | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | 创建时间 |
| `updated_at` | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | 更新时间 |
**索引**
- `idx_sort_order` (`sort_order`)
**SQL**
```sql
CREATE TABLE t_group (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
icon VARCHAR(255),
description VARCHAR(500),
sort_order INT NOT NULL DEFAULT 0,
collapsed BOOLEAN NOT NULL DEFAULT FALSE,
visible BOOLEAN NOT NULL DEFAULT TRUE,
color VARCHAR(20),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE INDEX idx_group_sort_order ON t_group (sort_order);
```
---
### 3.3 书签表(t_bookmark
> 核心实体,存储书签链接信息
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|-------|------|------|--------|------|
| `id` | BIGINT | PK, AUTO_INCREMENT | - | 主键 |
| `group_id` | BIGINT | FK, NOT NULL | - | 所属分组ID |
| `name` | VARCHAR(100) | NOT NULL | - | 书签名称 |
| `url` | VARCHAR(2000) | NOT NULL | - | 链接地址 |
| `icon` | VARCHAR(500) | - | NULL | 图标(图标名/URL/base64 |
| `icon_type` | VARCHAR(20) | NOT NULL | 'TEXT' | 图标类型:BUILTIN/URL/UPLOAD/TEXT/EMOJI |
| `description` | VARCHAR(500) | - | NULL | 书签描述 |
| `sort_order` | INT | NOT NULL | 0 | 排序序号(升序) |
| `open_in_new_tab` | BOOLEAN | NOT NULL | TRUE | 是否新标签页打开 |
| `pinned` | BOOLEAN | NOT NULL | FALSE | 是否置顶 |
| `visible` | BOOLEAN | NOT NULL | TRUE | 是否可见 |
| `click_count` | INT | NOT NULL | 0 | 点击次数(预留 v1.0 |
| `last_click_time` | TIMESTAMP | - | NULL | 最后点击时间(预留 v1.0) |
| `created_at` | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | 创建时间 |
| `updated_at` | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | 更新时间 |
**索引**
- `idx_group_id` (`group_id`)
- `idx_group_sort` (`group_id`, `sort_order`)
**外键**
- `fk_bookmark_group` FOREIGN KEY (`group_id`) REFERENCES `t_group`(`id`)
**SQL**
```sql
CREATE TABLE t_bookmark (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
group_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
url VARCHAR(2000) NOT NULL,
icon VARCHAR(500),
icon_type VARCHAR(20) NOT NULL DEFAULT 'TEXT',
description VARCHAR(500),
sort_order INT NOT NULL DEFAULT 0,
open_in_new_tab BOOLEAN NOT NULL DEFAULT TRUE,
pinned BOOLEAN NOT NULL DEFAULT FALSE,
visible BOOLEAN NOT NULL DEFAULT TRUE,
click_count INT NOT NULL DEFAULT 0,
last_click_time TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_bookmark_group FOREIGN KEY (group_id) REFERENCES t_group(id) ON DELETE CASCADE
);
CREATE INDEX idx_bookmark_group_id ON t_bookmark (group_id);
CREATE INDEX idx_bookmark_group_sort ON t_bookmark (group_id, sort_order);
```
---
### 3.4 配置表(t_config
> 存储应用级配置,Key-Value 结构
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|-------|------|------|--------|------|
| `id` | BIGINT | PK, AUTO_INCREMENT | - | 主键 |
| `config_key` | VARCHAR(100) | NOT NULL, UNIQUE | - | 配置键 |
| `config_value` | TEXT | - | NULL | 配置值 |
| `config_type` | VARCHAR(20) | NOT NULL | 'STRING' | 值类型:STRING/NUMBER/BOOLEAN/JSON |
| `category` | VARCHAR(50) | NOT NULL | 'GENERAL' | 分类:GENERAL/APPEARANCE/LAYOUT |
| `description` | VARCHAR(255) | - | NULL | 配置说明 |
| `created_at` | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | 创建时间 |
| `updated_at` | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | 更新时间 |
**索引**
- `uk_config_key` UNIQUE (`config_key`)
- `idx_category` (`category`)
**SQL**
```sql
CREATE TABLE t_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
config_key VARCHAR(100) NOT NULL,
config_value TEXT,
config_type VARCHAR(20) NOT NULL DEFAULT 'STRING',
category VARCHAR(50) NOT NULL DEFAULT 'GENERAL',
description VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_config_key UNIQUE (config_key)
);
CREATE INDEX idx_config_category ON t_config (category);
```
**预置配置项**
| config_key | config_value | config_type | category | 说明 |
|-----------|--------------|-------------|----------|------|
| `app.title` | Home4j | STRING | GENERAL | 网站标题 |
| `app.subtitle` | Welcome Home | STRING | GENERAL | 副标题 |
| `app.logo` | NULL | STRING | GENERAL | Logo URL |
| `app.favicon` | NULL | STRING | GENERAL | Favicon URL |
| `app.theme` | light | STRING | APPEARANCE | 当前主题 |
| `app.language` | zh-CN | STRING | GENERAL | 界面语言 |
| `layout.columns` | 4 | NUMBER | LAYOUT | 每行列数 |
| `layout.card_size` | normal | STRING | LAYOUT | 卡片尺寸 |
| `layout.density` | normal | STRING | LAYOUT | 显示密度 |
| `search.enabled` | true | BOOLEAN | GENERAL | 启用搜索 |
| `search.placeholder` | 搜索书签... | STRING | GENERAL | 搜索框占位符 |
| `init.completed` | false | BOOLEAN | GENERAL | 初始化是否完成 |
---
### 3.5 书签标签表(t_bookmark_tag- 预留 v0.5
> 书签的标签,支持多对多关系(预留)
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|-------|------|------|--------|------|
| `id` | BIGINT | PK, AUTO_INCREMENT | - | 主键 |
| `name` | VARCHAR(50) | NOT NULL, UNIQUE | - | 标签名称 |
| `color` | VARCHAR(20) | - | NULL | 标签颜色 |
| `created_at` | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | 创建时间 |
**SQL**(预留):
```sql
CREATE TABLE t_bookmark_tag (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
color VARCHAR(20),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_tag_name UNIQUE (name)
);
-- 书签-标签关联表
CREATE TABLE t_bookmark_tag_rel (
bookmark_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
PRIMARY KEY (bookmark_id, tag_id),
CONSTRAINT fk_rel_bookmark FOREIGN KEY (bookmark_id) REFERENCES t_bookmark(id) ON DELETE CASCADE,
CONSTRAINT fk_rel_tag FOREIGN KEY (tag_id) REFERENCES t_bookmark_tag(id) ON DELETE CASCADE
);
```
---
## 四、枚举值定义
### 4.1 用户角色(UserRole
```java
public enum UserRole {
ADMIN, // 管理员,可编辑
USER // 普通用户(预留 v1.0
}
```
### 4.2 图标类型(IconType
```java
public enum IconType {
BUILTIN, // 内置图标库
URL, // 外部 URL
UPLOAD, // 用户上传
TEXT, // 文字首字母
EMOJI // Emoji 表情
}
```
### 4.3 配置类型(ConfigType
```java
public enum ConfigType {
STRING, // 字符串
NUMBER, // 数字
BOOLEAN, // 布尔
JSON // JSON 对象
}
```
### 4.4 配置分类(ConfigCategory
```java
public enum ConfigCategory {
GENERAL, // 常规设置
APPEARANCE, // 外观设置
LAYOUT // 布局设置
}
```
---
## 五、Quarkus Panache 实体示例
### 5.1 Bookmark 实体
```java
package com.home4j.entity;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "t_bookmark")
public class Bookmark extends PanacheEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "group_id", nullable = false)
public BookmarkGroup group;
@Column(nullable = false, length = 100)
public String name;
@Column(nullable = false, length = 2000)
public String url;
@Column(length = 500)
public String icon;
@Enumerated(EnumType.STRING)
@Column(name = "icon_type", nullable = false, length = 20)
public IconType iconType = IconType.TEXT;
@Column(length = 500)
public String description;
@Column(name = "sort_order", nullable = false)
public Integer sortOrder = 0;
@Column(name = "open_in_new_tab", nullable = false)
public Boolean openInNewTab = true;
@Column(nullable = false)
public Boolean pinned = false;
@Column(nullable = false)
public Boolean visible = true;
@Column(name = "click_count", nullable = false)
public Integer clickCount = 0;
@Column(name = "last_click_time")
public LocalDateTime lastClickTime;
@Column(name = "created_at", nullable = false, updatable = false)
public LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
public LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
public void preUpdate() {
updatedAt = LocalDateTime.now();
}
// 常用查询方法
public static List<Bookmark> findByGroupId(Long groupId) {
return find("group.id = ?1 ORDER BY pinned DESC, sortOrder ASC", groupId).list();
}
public static List<Bookmark> findVisible() {
return find("visible = true ORDER BY pinned DESC, sortOrder ASC").list();
}
}
```
### 5.2 BookmarkGroup 实体
```java
package com.home4j.entity;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.List;
@Entity
@Table(name = "t_group")
public class BookmarkGroup extends PanacheEntity {
@Column(nullable = false, length = 100)
public String name;
@Column(length = 255)
public String icon;
@Column(length = 500)
public String description;
@Column(name = "sort_order", nullable = false)
public Integer sortOrder = 0;
@Column(nullable = false)
public Boolean collapsed = false;
@Column(nullable = false)
public Boolean visible = true;
@Column(length = 20)
public String color;
@OneToMany(mappedBy = "group", cascade = CascadeType.ALL, orphanRemoval = true)
public List<Bookmark> bookmarks;
@Column(name = "created_at", nullable = false, updatable = false)
public LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
public LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
public void preUpdate() {
updatedAt = LocalDateTime.now();
}
// 常用查询方法
public static List<BookmarkGroup> findAllOrdered() {
return find("ORDER BY sortOrder ASC").list();
}
public static List<BookmarkGroup> findVisible() {
return find("visible = true ORDER BY sortOrder ASC").list();
}
}
```
---
## 六、数据初始化
### 6.1 首次启动初始化
```sql
-- 插入默认分组
INSERT INTO t_group (name, icon, sort_order, collapsed, visible)
VALUES ('默认分组', 'folder', 0, FALSE, TRUE);
-- 插入默认配置
INSERT INTO t_config (config_key, config_value, config_type, category, description) VALUES
('app.title', 'Home4j', 'STRING', 'GENERAL', '网站标题'),
('app.subtitle', 'Welcome Home', 'STRING', 'GENERAL', '副标题'),
('app.theme', 'light', 'STRING', 'APPEARANCE', '当前主题'),
('app.language', 'zh-CN', 'STRING', 'GENERAL', '界面语言'),
('layout.columns', '4', 'NUMBER', 'LAYOUT', '每行列数'),
('layout.card_size', 'normal', 'STRING', 'LAYOUT', '卡片尺寸'),
('search.enabled', 'true', 'BOOLEAN', 'GENERAL', '启用搜索'),
('init.completed', 'false', 'BOOLEAN', 'GENERAL', '初始化是否完成');
```
---
## 七、数据库配置
### 7.1 H2 配置(开发/默认)
```properties
# application.properties
quarkus.datasource.db-kind=h2
quarkus.datasource.jdbc.url=jdbc:h2:file:./data/home4j;AUTO_SERVER=TRUE
quarkus.datasource.username=sa
quarkus.datasource.password=
quarkus.hibernate-orm.database.generation=update
```
### 7.2 MySQL 配置(生产可选)
```properties
# application-mysql.properties
quarkus.datasource.db-kind=mysql
quarkus.datasource.jdbc.url=jdbc:mysql://localhost:3306/home4j?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
quarkus.datasource.username=home4j
quarkus.datasource.password=your_password
quarkus.hibernate-orm.database.generation=update
```
---
## 八、表结构总览
| 表名 | 说明 | MVP | v0.5 | v1.0 |
|-----|------|-----|------|------|
| `t_user` | 用户表 | ✅ | ✅ | ✅ |
| `t_group` | 分组表 | ✅ | ✅ | ✅ |
| `t_bookmark` | 书签表 | ✅ | ✅ | ✅ |
| `t_config` | 配置表 | ✅ | ✅ | ✅ |
| `t_bookmark_tag` | 标签表 | - | ✅ | ✅ |
| `t_bookmark_tag_rel` | 书签标签关联 | - | ✅ | ✅ |
---
> **文档结束**
> 本设计将随项目进展持续更新
+594
View File
@@ -0,0 +1,594 @@
# Home4j 产品设计文档(PDD
> **文档版本**v1.1
> **创建日期**2026年1月30日
> **最后更新**2026年2月3日
> **文档状态**:修订版
---
## 1. 概述与愿景
### 1.1 项目一句话介绍
**Home4j** 是一款基于 Java 技术栈的极简、高性能、可自托管的 Home 仪表盘和导航工具,专为独立开发者、内容创作者和小型团队打造。
### 1.2 核心价值主张
> 市面上鲜有基于 Java 的 Home 仪表盘解决方案。Home4j 填补这一空白,为 Java 生态用户提供**轻量级、高度可定制、开箱即用**的首页导航工具,让用户在熟悉的技术栈中获得一流的自托管体验。
### 1.3 与竞品的差异化定位
| 差异化维度 | Home4j 定位 |
|-----------|------------|
| **技术栈** | 基于 **Quarkus** 的轻量级 Java 方案,对 Java 开发者友好 |
| **配置方式** | **Web UI 为主**,数据库存储配置,支持导出备份 |
| **资源占用** | 目标内存 < 128MB,启动时间 < 3秒(JVM 模式) |
| **扩展性** | 预留 Widget 插件机制,支持企业级二次开发 |
| **部署体验** | 单 JAR 文件 / Docker 一键部署,零外部依赖 |
| **离线友好** | 所有资源本地化(图标、CSS),支持内网离线部署 |
### 1.4 从参考项目中学到的最关键启发
| 参考项目 | 核心启发 | 应用到 Home4j |
|---------|---------|--------------|
| **Flame** | 极简至上,专注核心功能;书签+应用分类清晰 | 采用相似的简洁 UI 哲学,MVP 聚焦书签管理 |
| **Homer** | YAML 配置驱动,声明式管理;图标系统完善 | 支持 YAML 配置文件,内置图标库 |
| **Glance** | Widget 化设计,信息聚合能力强 | 预留 Widget 扩展架构 |
| **Homepage** | 服务状态监控集成,实用性强 | v1.0 加入服务健康检查 |
| **Dashy** | 高度可定制,主题丰富 | 基于 DaisyUI 实现多主题切换 |
| **Heimdall** | 应用启动器思维,增强型书签概念 | 支持应用分组和快捷访问 |
---
## 2. 用户与市场分析
### 2.1 目标用户画像
#### Persona 1:独立开发者「小明」
| 属性 | 描述 |
|-----|------|
| **背景** | 全栈开发者,自建 NAS 和多个服务(GitLab、Jenkins、Portainer等) |
| **典型场景** | 需要统一入口管理 10+ 个自托管服务,希望一目了然 |
| **痛点** | 现有工具要么太重(Dashy),要么不熟悉技术栈(Node.js) |
| **期望** | 轻量、快速、能用 Java 二次开发、Docker 部署简单 |
#### Persona 2:小型团队技术负责人「阿杰」
| 属性 | 描述 |
|-----|------|
| **背景** | 5人创业团队 CTO,管理内部工具和文档入口 |
| **典型场景** | 为团队搭建内部 Portal,整合 Notion、飞书、内部系统 |
| **痛点** | 需要简单的权限控制,但不想引入复杂系统 |
| **期望** | 支持多用户、可配置访问权限、界面专业美观 |
#### Persona 3:内容创作者「小美」
| 属性 | 描述 |
|-----|------|
| **背景** | 技术博主,自建博客、图床、评论系统等 |
| **典型场景** | 希望有一个美观的个人主页展示所有项目入口 |
| **痛点** | 设计能力有限,希望开箱即用且美观 |
| **期望** | 多主题可选、支持自定义背景、移动端适配好 |
### 2.2 竞品分析
| 维度 | Flame | Homer | Dashy | Heimdall | **Home4j** |
|-----|-------|-------|-------|----------|------------|
| **技术栈** | Node.js | Vue/静态 | Vue | PHP/Laravel | **Java/Solon** |
| **配置方式** | Web UI | YAML | YAML + UI | Web UI | **YAML + Web UI** |
| **资源占用** | 低 | 极低 | 中等 | 中等 | **低** |
| **学习曲线** | 低 | 中 | 高 | 低 | **低** |
| **Widget 支持** | 有限 | 无 | 丰富 | 无 | **可扩展** |
| **服务状态监控** | ✅ | ❌ | ✅ | ❌ | **✅ (v1.0)** |
| **多用户** | ❌ | ❌ | ✅ | ❌ | **✅ (v1.0)** |
| **中文支持** | 一般 | 一般 | 良好 | 一般 | **原生支持** |
### 2.3 用户核心痛点与机会点
| 痛点 | 机会点 |
|-----|-------|
| Java 生态缺乏优质 Dashboard 工具 | 填补市场空白,吸引 Java 开发者群体 |
| 现有工具配置复杂或资源占用高 | 主打"极简 + 轻量"差异化 |
| 纯 YAML 配置对非技术用户不友好 | 提供 Web UI 可视化配置 |
| 大多数工具不支持中文优先 | 原生中文支持,国际化架构 |
| 企业内部使用缺乏基础权限控制 | 提供简单实用的多用户机制 |
---
## 3. 核心功能设计(MVP
### 3.1 MVP 范围界定说明
MVP 目标:**让用户在 5 分钟内完成部署,10 分钟内配置出可用的个人导航页**。
**MVP 边界原则**
- ✅ 必须有:书签管理、分组分类、主题切换、Docker 部署、管理员登录
- ⏳ 延后:Widget 系统、服务监控、多用户权限
- ❌ 不做:复杂的仪表盘编辑器、第三方集成、竞品数据导入
**MVP 权限模式**
- **只读访问**:普通访客可浏览所有书签
- **管理员编辑**:需登录后才能添加/编辑/删除书签和配置
### 3.2 核心功能列表
| 功能名称 | 用户价值 | 优先级 | 参考来源 |
|---------|---------|--------|---------|
| **书签管理** | 快速访问常用服务和网站 | P0 | Flame, Homer |
| **分组分类** | 按场景/类型组织书签,提升查找效率 | P0 | Flame, Heimdall |
| **搜索功能** | 快速定位目标书签 | P0 | Dashy |
| **主题切换** | 满足个性化需求,适应不同场景 | P0 | Dashy |
| **管理员登录** | 保护编辑权限,防止误操作 | P0 | 通用需求 |
| **Web UI 配置** | 可视化管理所有设置 | P0 | Flame |
| **响应式布局** | 多设备访问体验一致 | P0 | 通用需求 |
| **图标系统** | 提升视觉识别效率(本地化) | P0 | Homer, Dashy |
| **Docker 部署** | 一键启动,环境隔离 | P0 | 所有参考项目 |
### 3.3 核心功能详细描述
#### 3.3.1 书签管理
**用户故事**
> 作为一名独立开发者,我希望能够添加、编辑、删除我的服务书签,以便快速访问我的各种自托管服务。
**关键交互流程**
```
[首页] → [点击添加按钮] → [填写书签信息表单]
→ [选择分组和图标] → [保存] → [首页显示新书签]
```
**书签数据结构**
```yaml
bookmarks:
- name: "GitLab"
url: "https://gitlab.example.com"
icon: "gitlab" # 支持:内置图标名 / URL / base64
group: "开发工具"
description: "代码仓库"
tags: ["开发", "Git"]
```
**验收标准**
- [ ] 支持添加书签(名称、URL、图标、分组、描述)
- [ ] 支持编辑和删除书签
- [ ] 书签点击在新标签页打开
- [ ] 支持拖拽排序
- [ ] 数据持久化到数据库
#### 3.3.2 分组分类
**用户故事**
> 作为用户,我希望将书签按照用途分组(如:开发工具、监控系统、文档资料),以便快速找到目标。
**分组数据结构**
```yaml
groups:
- name: "开发工具"
icon: "code"
order: 1
collapsed: false # 是否默认折叠
- name: "监控系统"
icon: "monitor"
order: 2
```
**验收标准**
- [ ] 支持创建、编辑、删除分组
- [ ] 支持分组排序
- [ ] 支持分组折叠/展开
- [ ] 未分组书签显示在"默认"分组
#### 3.3.3 搜索功能
**用户故事**
> 当我有大量书签时,我希望通过关键词快速搜索定位,而不是逐个查找。
**交互设计**
- 快捷键 `/``Ctrl+K` 激活搜索框
- 支持搜索:书签名称、URL、描述、标签
- 实时过滤,高亮匹配内容
- 支持键盘导航(↑↓选择,Enter打开)
**验收标准**
- [ ] 搜索响应时间 < 100ms
- [ ] 支持模糊匹配
- [ ] 搜索结果高亮显示
- [ ] 空结果友好提示
#### 3.3.4 主题切换
**用户故事**
> 我希望根据个人喜好和使用场景切换界面主题,比如夜间使用深色模式。
**主题方案(基于 DaisyUI**
| 主题名称 | 适用场景 |
|---------|---------|
| light | 日间默认 |
| dark | 夜间模式 |
| cyberpunk | 科技风格 |
| nord | 护眼柔和 |
| dracula | 开发者偏好 |
**验收标准**
- [ ] 支持至少 5 个预设主题
- [ ] 主题切换即时生效,无刷新
- [ ] 记住用户主题偏好
- [ ] 支持跟随系统深色模式
#### 3.3.5 数据存储策略
**设计理念**
- **所有数据统一存储在数据库**:书签、分组、应用配置、用户偏好
- **Web UI 为主要配置入口**:用户通过界面管理所有设置
- **支持数据导出**:可导出为 JSON/YAML 格式用于备份和迁移
**数据分类**
| 数据类型 | 存储位置 | 说明 |
|---------|---------|------|
| 书签数据 | 数据库 | 书签、分组的增删改查 |
| 应用配置 | 数据库 | 标题、副标题、主题、布局等全局设置 |
| 用户账号 | 数据库 | 管理员账号信息 |
| 用户偏好 | 数据库 + LocalStorage | 主题选择、语言偏好(支持游客记忆) |
**配置页面功能**
- 常规设置:网站标题、副标题、Logo
- 外观设置:主题、布局、背景
- 数据管理:导入、导出、备份
- 账号管理:修改密码
---
#### 3.3.6 管理员登录
**用户故事**
> 作为网站管理员,我希望通过登录验证后才能编辑内容,防止他人误操作或恶意修改。
**交互流程**
```
[首页] → [点击设置/编辑按钮] → [跳转登录页]
→ [输入用户名密码] → [登录成功] → [进入管理模式]
```
**首次启动流程**
```
[首次访问] → [初始化向导] → [设置管理员账号密码] → [完成] → [进入首页]
```
**验收标准**
- [ ] 首次启动强制设置管理员账号
- [ ] 未登录用户只能浏览,不能编辑
- [ ] 登录状态支持 Session 保持
- [ ] 支持退出登录
- [ ] 支持修改密码
---
## 4. 功能路线图(MVP → v1.0 → 未来方向)
### 4.1 分阶段功能规划
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
MVP (v0.1) v0.5 v1.0 未来
2个月 2个月 2个月 持续
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[书签管理] [图标库增强] [简单状态监控] [插件市场]
[分组分类] [数据导入导出] [多用户支持] [移动端APP]
[搜索功能] [自定义CSS] [访问统计] [团队协作]
[主题切换] [背景自定义] [API开放] [AI推荐]
[管理员登录] [快捷键系统] [基础Widget] [公开分享]
[Web UI配置] [PWA支持] [数据备份恢复]
[Docker部署]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
### 4.2 关键里程碑
| 版本 | 时间节点 | 关键目标 | 成功指标 |
|-----|---------|---------|---------|
| **MVP (v0.1)** | M+2 | 核心功能可用,Docker 可部署 | 完成冒烟测试,GitHub 发布首个 Release |
| **v0.5** | M+4 | 体验优化,功能完善 | 收集 50+ 用户反馈,GitHub Star > 100 |
| **v1.0** | M+6 | 生产可用,企业特性 | 稳定运行 30 天无重大 BugStar > 500 |
---
## 5. 用户体验与交互设计原则
### 5.1 整体设计语言与调性
| 维度 | 设计原则 |
|-----|---------|
| **视觉风格** | 简洁克制、呼吸感强、信息层次清晰 |
| **交互理念** | 减少点击、键盘友好、即时反馈 |
| **色彩运用** | 主色调由主题决定,强调色用于交互元素 |
| **动效策略** | 轻量过渡动画,提升流畅感但不干扰 |
| **信息密度** | 可配置,支持紧凑/标准/宽松三种模式 |
### 5.2 核心用户旅程
#### 首次使用旅程
```
┌─────────────────────────────────────────────────────────────┐
│ 1. 部署启动 │
│ docker run -p 8080:8080 home4j/home4j │
│ ↓ │
│ 2. 访问首页 │
│ 浏览器打开 http://localhost:8080 │
│ ↓ │
│ 3. 初始化向导(首次,可跳过部分步骤) │
│ [设置管理员账号] → [选择主题] → [添加第一个书签] → [完成] │
│ ↓ │
│ 4. 日常使用 │
│ 游客:查看书签 → 点击访问 / 搜索定位 │
│ 管理员:登录 → 管理编辑 → 退出登录 │
└─────────────────────────────────────────────────────────────┘
```
**说明**:初始化向导由前端驱动,除「设置管理员账号」为必填外,其他步骤可跳过。
### 5.3 关键页面交互逻辑
#### 首页(Dashboard
```
┌────────────────────────────────────────────────────────────┐
│ 🏠 Home4j [搜索框 Ctrl+K] [设置⚙️] │
├────────────────────────────────────────────────────────────┤
│ │
│ 📁 开发工具 [+] │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │GitHub│ │GitLab│ │Jenkins│ │Notion│ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
│ │
│ 📁 监控系统 [+] │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │Grafana│ │Portainer│ │Uptime│ │
│ └──────┘ └──────┘ └──────┘ │
│ │
├────────────────────────────────────────────────────────────┤
│ Powered by Home4j [主题切换🌙] │
└────────────────────────────────────────────────────────────┘
```
#### 设置页面(Settings
- **Tab 式导航**:常规 | 外观 | 数据 | 关于
- **实时预览**:修改即可看到效果
- **导入导出**:支持 JSON/YAML 格式
---
## 6. 技术架构与实现约束(产品视角)
### 6.1 推荐技术栈
| 层级 | 技术选型 | 选型理由 |
|-----|---------|---------|
| **后端框架** | Quarkus | 轻量级 Java 框架,启动快、内存占用低,生态完善 |
| **模板引擎** | Qute | Quarkus 原生模板引擎,类型安全,性能优秀 |
| **前端样式** | DaisyUI + Tailwind(本地) | 开箱即用的组件库,主题切换方便,**本地引用无 CDN 依赖** |
| **数据库** | H2(默认,文件模式)/ MySQL(可选) | H2 零配置,MySQL 满足企业需求 |
| **ORM** | PanacheHibernate | Quarkus 原生 ORM,简化数据库操作 |
| **容器化** | Docker | 标准化部署,环境一致性 |
### 6.2 部署方式
#### Docker 一键部署(推荐)
```bash
docker run -d \
--name home4j \
-p 8080:8080 \
-v /path/to/data:/app/data \
home4j/home4j:latest
```
#### Docker Compose
```yaml
version: '3'
services:
home4j:
image: home4j/home4j:latest
ports:
- "8080:8080"
volumes:
- ./data:/app/data
environment:
- TZ=Asia/Shanghai
restart: unless-stopped
```
#### JAR 直接运行
```bash
java -jar home4j-runner.jar
```
### 6.3 数据目录规划
```
/app/ # 应用根目录(Docker 内)
├── home4j-runner.jar # 应用主程序
├── resources/ # 资源目录
│ ├── static/ # 静态资源
│ │ ├── css/ # DaisyUI/Tailwind 本地文件
│ │ ├── js/ # JavaScript 文件
│ │ └── icons/ # 内置图标库
│ └── templates/ # Qute 模板文件
├── data/ # 数据目录(**需挂载**)
│ ├── home4j.mv.db # H2 数据库文件
│ ├── home4j.trace.db # H2 跟踪文件(可选)
│ └── uploads/ # 用户上传文件
│ └── icons/ # 用户上传的自定义图标
└── logs/ # 日志目录(可选挂载)
└── home4j.log
```
**挂载说明**
- `/app/data` **必须挂载**:包含数据库和用户上传文件,确保数据持久化
- `/app/logs` 可选挂载:便于查看日志
### 6.4 图标系统实现
| 图标类型 | 存储位置 | 说明 |
|---------|---------|------|
| **内置图标** | `/resources/static/icons/` | 打包在 JAR 中,500+ 常用图标 |
| **URL 图标** | 外部 URL | 用户填写的图片 URL |
| **上传图标** | `/data/uploads/icons/` | 用户上传的自定义图标 |
| **默认图标** | `/resources/static/icons/default.svg` | URL 不可访问时的降级显示 |
**URL 图标处理逻辑**
1. 前端尝试加载用户填写的 URL 图标
2. 如果加载失败(超时/404/跨域),自动降级显示默认图标
3. 默认图标为一个灰色的「问号」或「链接断裂」样式
### 6.5 扩展性与插件机制
**Widget 插件架构(v1.0+**
```
┌─────────────────────────────────────────┐
│ Home4j Core │
├─────────────────────────────────────────┤
│ Widget API (SPI) │
├──────────┬──────────┬──────────┬────────┤
│ 时钟组件 │ 搜索组件 │ 待办组件 │ 自定义 │
└──────────┴──────────┴──────────┴────────┘
```
- 基于 Quarkus 扩展机制或 Java SPI 加载插件
- 插件独立 JAR 包,放入 `/plugins` 目录自动加载
- v1.0 提供简单的内置 Widget,复杂插件延后到 v1.x
### 6.6 性能与安全底线要求
| 指标 | 目标值 |
|-----|-------|
| 冷启动时间 | < 3 秒(JVM 模式) |
| 内存占用 | < 128 MB(默认配置) |
| 首页加载时间 | < 500 ms |
| 并发支持 | > 100 QPS(单实例) |
| Docker 镜像大小 | < 200 MB |
---
## 7. 非功能性需求
### 7.1 性能目标
| 场景 | 指标 | 目标 |
|-----|-----|------|
| 首页渲染 | TTFB | < 100ms |
| 书签搜索 | 响应时间 | < 50ms1000条书签内) |
| 配置保存 | 响应时间 | < 200ms |
| 主题切换 | 生效时间 | < 100ms,无闪烁 |
### 7.2 安全性与隐私保护
| 安全措施 | 说明 |
|---------|------|
| **数据本地化** | 所有数据存储在用户自己的服务器,不上传云端 |
| **HTTPS 支持** | 支持反向代理 SSL 终结 |
| **XSS 防护** | 输入输出转义,CSP 策略 |
| **CSRF 防护** | Token 验证机制 |
| **认证机制(v1.0** | 支持基础认证,可选 OAuth2 |
### 7.3 可观测性
| 维度 | 实现方案 |
|-----|---------|
| **日志** | SLF4J + Logback,支持日志级别动态调整 |
| **健康检查** | `/actuator/health` 端点 |
| **指标暴露** | Prometheus 格式 `/metrics`(可选) |
### 7.4 国际化与可访问性
- **语言支持**:中文(默认)、英文
- **i18n 机制**:基于 `messages.properties` 资源文件
- **可访问性**
- 语义化 HTML 结构
- 键盘完整可操作
- 支持屏幕阅读器
- 颜色对比度符合 WCAG 2.1 AA
---
## 8. 风险、挑战与应对
| 风险 | 等级 | 描述 | 缓解措施 |
|-----|------|------|---------|
| **市场认知度低** | 高 | Java Dashboard 类工具用户认知少 | 1. 发布技术博客分享<br>2. 提交到 awesome-selfhosted 列表<br>3. 录制视频教程 |
| **与成熟竞品竞争** | 高 | Flame/Homer 已有稳定用户群 | 1. 差异化定位 Java 用户<br>2. 功能上做精不做多<br>3. 强调离线部署优势 |
| **Quarkus 学习曲线** | 中 | 团队对 Quarkus 熟悉度 | 1. 使用 JVM 模式降低复杂度<br>2. 参考官方示例<br>3. 保持核心依赖简单 |
| **单人维护精力有限** | 中 | 开源项目持续维护挑战 | 1. MVP 功能精简<br>2. 代码质量优先<br>3. 积极培养社区贡献者 |
| **用户需求分散** | 中 | 不同用户对功能期望差异大 | 1. 坚持「导航工具」核心定位<br>2. 通过插件满足扩展需求<br>3. 建立 RFC 机制收集反馈 |
---
## 9. 附录
### 9.1 名词表 / 术语定义
| 术语 | 定义 |
|-----|------|
| **书签(Bookmark** | 用户添加的链接条目,包含名称、URL、图标等属性 |
| **分组(Group** | 书签的逻辑分类容器 |
| **Widget** | 可嵌入仪表盘的小组件,如时钟、天气等 |
| **主题(Theme** | 预定义的视觉风格配置,包含颜色、字体等 |
| **YAML 配置** | 使用 YAML 格式定义应用配置的文件 |
### 9.2 参考资料与灵感来源
| 项目 | 地址 | 主要参考点 |
|-----|------|-----------|
| Flame | https://github.com/pawelmalak/flame | 极简设计、书签管理 |
| Homer | https://github.com/bastienwirtz/homer | YAML 配置、图标系统 |
| Glance | https://github.com/glanceapp/glance | Widget 架构 |
| Homepage | https://github.com/gethomepage/homepage | 服务状态集成 |
| Dashy | https://github.com/Lissy93/dashy | 主题系统、高级功能 |
| Heimdall | https://github.com/linuxserver/Heimdall | 应用启动器概念 |
### 9.3 其他补充说明
#### 项目目录结构(建议)
```
home4j/
├── src/
│ ├── main/
│ │ ├── java/com/home4j/
│ │ │ ├── resource/ # REST 资源(Controller
│ │ │ ├── service/ # 业务逻辑
│ │ │ ├── repository/ # 数据访问(Panache
│ │ │ ├── entity/ # 数据实体
│ │ │ ├── dto/ # 数据传输对象
│ │ │ ├── config/ # 配置类
│ │ │ └── plugin/ # 插件机制
│ │ ├── resources/
│ │ │ ├── templates/ # Qute 模板
│ │ │ ├── META-INF/
│ │ │ │ └── resources/ # 静态资源
│ │ │ │ ├── css/ # DaisyUI/Tailwind 本地文件
│ │ │ │ ├── js/ # JavaScript 文件
│ │ │ │ └── icons/ # 内置图标库
│ │ │ └── application.properties
├── docker/
│ └── Dockerfile
├── docs/ # 文档
└── README.md
```
#### 开源协议建议
推荐使用 **MIT License**,便于企业用户采用和二次开发。
---
> **文档结束**
> 本文档将随项目进展持续更新,欢迎提出反馈和建议。
Vendored
+295
View File
@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
+189
View File
@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+168
View File
@@ -0,0 +1,168 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.home4j</groupId>
<artifactId>home4j</artifactId>
<version>0.1.0-SNAPSHOT</version>
<properties>
<compiler-plugin.version>3.13.0</compiler-plugin.version>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
<quarkus.platform.version>3.17.5</quarkus.platform.version>
<skipITs>true</skipITs>
<surefire-plugin.version>3.5.0</surefire-plugin.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>${quarkus.platform.artifact-id}</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-qute</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-qute</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-mysql</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-security-jpa</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-h2</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.platform.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
<goal>native-image-agent</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>${compiler-plugin.version}</version>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${surefire-plugin.version}</version>
<configuration>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${surefire-plugin.version}</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
<configuration>
<systemPropertyVariables>
<native.image.path>${project.build.directory}/${project.build.finalName}-runner</native.image.path>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>native</id>
<activation>
<property>
<name>native</name>
</property>
</activation>
<properties>
<skipITs>false</skipITs>
<quarkus.native.enabled>true</quarkus.native.enabled>
</properties>
</profile>
</profiles>
</project>
+97
View File
@@ -0,0 +1,97 @@
####
# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode
#
# Before building the container image run:
#
# ./mvnw package
#
# Then, build the image with:
#
# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/home4j-jvm .
#
# Then run the container using:
#
# docker run -i --rm -p 8080:8080 quarkus/home4j-jvm
#
# If you want to include the debug port into your docker image
# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005.
# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005
# when running the container
#
# Then run the container using :
#
# docker run -i --rm -p 8080:8080 quarkus/home4j-jvm
#
# This image uses the `run-java.sh` script to run the application.
# This scripts computes the command line to execute your Java application, and
# includes memory/GC tuning.
# You can configure the behavior using the following environment properties:
# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class")
# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options
# in JAVA_OPTS (example: "-Dsome.property=foo")
# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is
# used to calculate a default maximal heap memory based on a containers restriction.
# If used in a container without any memory constraints for the container then this
# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio
# of the container available memory as set here. The default is `50` which means 50%
# of the available memory is used as an upper boundary. You can skip this mechanism by
# setting this value to `0` in which case no `-Xmx` option is added.
# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This
# is used to calculate a default initial heap memory based on the maximum heap memory.
# If used in a container without any memory constraints for the container then this
# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio
# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx`
# is used as the initial heap size. You can skip this mechanism by setting this value
# to `0` in which case no `-Xms` option is added (example: "25")
# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS.
# This is used to calculate the maximum value of the initial heap memory. If used in
# a container without any memory constraints for the container then this option has
# no effect. If there is a memory constraint then `-Xms` is limited to the value set
# here. The default is 4096MB which means the calculated value of `-Xms` never will
# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096")
# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output
# when things are happening. This option, if set to true, will set
# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true").
# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example:
# true").
# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787").
# - CONTAINER_CORE_LIMIT: A calculated core limit as described in
# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2")
# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024").
# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion.
# (example: "20")
# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking.
# (example: "40")
# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection.
# (example: "4")
# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus
# previous GC times. (example: "90")
# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20")
# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100")
# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should
# contain the necessary JRE command-line options to specify the required GC, which
# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC).
# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080")
# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080")
# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be
# accessed directly. (example: "foo.example.com,bar.example.com")
#
###
FROM registry.access.redhat.com/ubi8/openjdk-17:1.20
ENV LANGUAGE='en_US:en'
# We make four distinct layers so if there are application changes the library layers can be re-used
COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/
COPY --chown=185 target/quarkus-app/*.jar /deployments/
COPY --chown=185 target/quarkus-app/app/ /deployments/app/
COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/
EXPOSE 8080
USER 185
ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ]
+93
View File
@@ -0,0 +1,93 @@
####
# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode
#
# Before building the container image run:
#
# ./mvnw package -Dquarkus.package.jar.type=legacy-jar
#
# Then, build the image with:
#
# docker build -f src/main/docker/Dockerfile.legacy-jar -t quarkus/home4j-legacy-jar .
#
# Then run the container using:
#
# docker run -i --rm -p 8080:8080 quarkus/home4j-legacy-jar
#
# If you want to include the debug port into your docker image
# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005.
# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005
# when running the container
#
# Then run the container using :
#
# docker run -i --rm -p 8080:8080 quarkus/home4j-legacy-jar
#
# This image uses the `run-java.sh` script to run the application.
# This scripts computes the command line to execute your Java application, and
# includes memory/GC tuning.
# You can configure the behavior using the following environment properties:
# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class")
# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options
# in JAVA_OPTS (example: "-Dsome.property=foo")
# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is
# used to calculate a default maximal heap memory based on a containers restriction.
# If used in a container without any memory constraints for the container then this
# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio
# of the container available memory as set here. The default is `50` which means 50%
# of the available memory is used as an upper boundary. You can skip this mechanism by
# setting this value to `0` in which case no `-Xmx` option is added.
# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This
# is used to calculate a default initial heap memory based on the maximum heap memory.
# If used in a container without any memory constraints for the container then this
# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio
# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx`
# is used as the initial heap size. You can skip this mechanism by setting this value
# to `0` in which case no `-Xms` option is added (example: "25")
# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS.
# This is used to calculate the maximum value of the initial heap memory. If used in
# a container without any memory constraints for the container then this option has
# no effect. If there is a memory constraint then `-Xms` is limited to the value set
# here. The default is 4096MB which means the calculated value of `-Xms` never will
# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096")
# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output
# when things are happening. This option, if set to true, will set
# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true").
# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example:
# true").
# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787").
# - CONTAINER_CORE_LIMIT: A calculated core limit as described in
# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2")
# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024").
# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion.
# (example: "20")
# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking.
# (example: "40")
# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection.
# (example: "4")
# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus
# previous GC times. (example: "90")
# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20")
# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100")
# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should
# contain the necessary JRE command-line options to specify the required GC, which
# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC).
# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080")
# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080")
# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be
# accessed directly. (example: "foo.example.com,bar.example.com")
#
###
FROM registry.access.redhat.com/ubi8/openjdk-17:1.20
ENV LANGUAGE='en_US:en'
COPY target/lib/* /deployments/lib/
COPY target/*-runner.jar /deployments/quarkus-run.jar
EXPOSE 8080
USER 185
ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ]
+27
View File
@@ -0,0 +1,27 @@
####
# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
#
# Before building the container image run:
#
# ./mvnw package -Dnative
#
# Then, build the image with:
#
# docker build -f src/main/docker/Dockerfile.native -t quarkus/home4j .
#
# Then run the container using:
#
# docker run -i --rm -p 8080:8080 quarkus/home4j
#
###
FROM registry.access.redhat.com/ubi8/ubi-minimal:8.10
WORKDIR /work/
RUN chown 1001 /work \
&& chmod "g+rwX" /work \
&& chown 1001:root /work
COPY --chown=1001:root target/*-runner /work/application
EXPOSE 8080
USER 1001
ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
+30
View File
@@ -0,0 +1,30 @@
####
# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
# It uses a micro base image, tuned for Quarkus native executables.
# It reduces the size of the resulting container image.
# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image.
#
# Before building the container image run:
#
# ./mvnw package -Dnative
#
# Then, build the image with:
#
# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/home4j .
#
# Then run the container using:
#
# docker run -i --rm -p 8080:8080 quarkus/home4j
#
###
FROM quay.io/quarkus/quarkus-micro-image:2.0
WORKDIR /work/
RUN chown 1001 /work \
&& chmod "g+rwX" /work \
&& chown 1001:root /work
COPY --chown=1001:root target/*-runner /work/application
EXPOSE 8080
USER 1001
ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
@@ -0,0 +1,25 @@
package com.home4j;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Map;
/**
* API 信息端点
*/
@Path("/api")
public class GreetingResource {
@GET
@Path("/info")
@Produces(MediaType.APPLICATION_JSON)
public Map<String, Object> info() {
return Map.of(
"name", "Home4j",
"version", "0.1.0",
"description", "A lightweight home dashboard for developers"
);
}
}
+4
View File
@@ -0,0 +1,4 @@
package com.home4j;
// This file is no longer used - kept for compatibility
// See com.home4j.entity package for actual entity implementations
@@ -0,0 +1,28 @@
package com.home4j;
import com.home4j.service.UserService;
import jakarta.inject.Inject;
import org.eclipse.microprofile.health.HealthCheck;
import org.eclipse.microprofile.health.HealthCheckResponse;
import org.eclipse.microprofile.health.Liveness;
import org.eclipse.microprofile.health.Readiness;
/**
* Home4j Health Check
*/
@Liveness
@Readiness
public class MyLivenessCheck implements HealthCheck {
@Inject
UserService userService;
@Override
public HealthCheckResponse call() {
return HealthCheckResponse.named("home4j")
.up()
.withData("version", "0.1.0")
.withData("users", userService.getUserCount())
.build();
}
}
+4
View File
@@ -0,0 +1,4 @@
package com.home4j;
// This file is no longer used - kept for compatibility
// See com.home4j.resource.IndexResource for the actual implementation
@@ -0,0 +1,139 @@
package com.home4j.config;
import com.home4j.entity.User;
import com.home4j.entity.BookmarkGroup;
import com.home4j.entity.Bookmark;
import com.home4j.entity.Config;
import io.quarkus.elytron.security.common.BcryptUtil;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.transaction.Transactional;
import org.jboss.logging.Logger;
/**
* 应用启动时初始化默认数据
*/
@ApplicationScoped
public class DataInitializer {
private static final Logger LOG = Logger.getLogger(DataInitializer.class);
@Transactional
public void onStart(@Observes StartupEvent event) {
LOG.info("Checking and initializing default data...");
// 初始化默认管理员
initDefaultAdmin();
// 初始化默认配置
initDefaultConfig();
// 初始化默认分组和书签
initDefaultBookmarks();
LOG.info("Data initialization completed.");
}
private void initDefaultAdmin() {
if (User.count() == 0) {
LOG.info("Creating default admin user...");
User admin = new User();
admin.username = "admin";
admin.password = BcryptUtil.bcryptHash("admin123");
admin.role = "ADMIN";
admin.persist();
LOG.info("Default admin user created: admin / admin123");
}
}
private void initDefaultConfig() {
if (Config.count() == 0) {
LOG.info("Creating default configurations...");
// 基础设置
createConfig("site_title", "Home4j", "网站标题");
createConfig("site_subtitle", "我的个人导航页", "网站副标题");
createConfig("footer_text", "Powered by Home4j", "页脚文本");
// 主题设置
createConfig("theme", "light", "主题 (light/dark/cupcake/dracula)");
// 搜索设置
createConfig("show_search", "true", "是否显示搜索框");
createConfig("search_engine", "https://www.google.com/search", "搜索引擎URL");
// 显示设置
createConfig("view_mode", "icon", "视图模式 (icon/list)");
createConfig("columns", "4", "书签列数 (2-6)");
// 背景设置
createConfig("background_image", "", "背景图URL(空则无背景)");
createConfig("background_overlay", "0.5", "背景遮罩透明度 (0-1)");
LOG.info("Default configurations created.");
}
}
private void createConfig(String key, String value, String description) {
Config config = new Config();
config.configKey = key;
config.configValue = value;
config.description = description;
config.persist();
}
private void initDefaultBookmarks() {
if (BookmarkGroup.count() == 0) {
LOG.info("Creating default groups and bookmarks...");
// 常用工具组
BookmarkGroup toolsGroup = new BookmarkGroup();
toolsGroup.name = "常用工具";
toolsGroup.icon = "wrench";
toolsGroup.sortOrder = 0;
toolsGroup.visible = true;
toolsGroup.persist();
createBookmark(toolsGroup, "Google", "https://www.google.com", "全球最大的搜索引擎", "search", 0);
createBookmark(toolsGroup, "Gmail", "https://mail.google.com", "Google邮箱服务", "mail", 1);
// 开发资源组
BookmarkGroup devGroup = new BookmarkGroup();
devGroup.name = "开发资源";
devGroup.icon = "code";
devGroup.sortOrder = 1;
devGroup.visible = true;
devGroup.persist();
createBookmark(devGroup, "GitHub", "https://github.com", "全球最大的代码托管平台", "github", 0);
createBookmark(devGroup, "Stack Overflow", "https://stackoverflow.com", "程序员问答社区", "message-circle", 1);
// 社交媒体组
BookmarkGroup socialGroup = new BookmarkGroup();
socialGroup.name = "社交媒体";
socialGroup.icon = "users";
socialGroup.sortOrder = 2;
socialGroup.visible = true;
socialGroup.persist();
createBookmark(socialGroup, "Twitter", "https://twitter.com", "全球社交平台", "twitter", 0);
createBookmark(socialGroup, "YouTube", "https://youtube.com", "全球最大的视频平台", "youtube", 1);
LOG.info("Default groups and bookmarks created.");
}
}
private void createBookmark(BookmarkGroup group, String name, String url, String description, String icon, int sortOrder) {
Bookmark bookmark = new Bookmark();
bookmark.group = group;
bookmark.name = name;
bookmark.url = url;
bookmark.description = description;
bookmark.icon = icon;
bookmark.sortOrder = sortOrder;
bookmark.visible = true;
bookmark.openInNewTab = true;
bookmark.persist();
}
}
@@ -0,0 +1,36 @@
package com.home4j.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.hibernate.validator.constraints.URL;
/**
* 书签数据传输对象
*/
public class BookmarkDTO {
public Long id;
public Long groupId;
@NotBlank(message = "书签名称不能为空")
@Size(max = 100, message = "书签名称长度不能超过100")
public String name;
@NotBlank(message = "URL不能为空")
@URL(message = "URL格式不正确")
@Size(max = 500, message = "URL长度不能超过500")
public String url;
@Size(max = 200, message = "描述长度不能超过200")
public String description;
@Size(max = 255, message = "图标长度不能超过255")
public String icon;
public Boolean visible;
public Boolean openInNewTab;
public Integer sortOrder;
}
@@ -0,0 +1,23 @@
package com.home4j.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 分组数据传输对象
*/
public class GroupDTO {
public Long id;
@NotBlank(message = "分组名称不能为空")
@Size(max = 100, message = "分组名称长度不能超过100")
public String name;
@Size(max = 50, message = "图标名称长度不能超过50")
public String icon;
public Boolean visible;
public Integer sortOrder;
}
@@ -0,0 +1,153 @@
package com.home4j.entity;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.hibernate.validator.constraints.URL;
import java.time.LocalDateTime;
import java.util.List;
/**
* 书签实体
*/
@Entity
@Table(name = "t_bookmark")
public class Bookmark extends PanacheEntity {
/**
* 所属分组
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "group_id")
public BookmarkGroup group;
/**
* 书签名称
*/
@NotBlank(message = "书签名称不能为空")
@Size(max = 100, message = "书签名称长度不能超过100")
@Column(nullable = false, length = 100)
public String name;
/**
* 书签URL
*/
@NotBlank(message = "URL不能为空")
@URL(message = "URL格式不正确")
@Size(max = 500, message = "URL长度不能超过500")
@Column(nullable = false, length = 500)
public String url;
/**
* 书签描述
*/
@Size(max = 200, message = "描述长度不能超过200")
@Column(length = 200)
public String description;
/**
* 图标(Lucide图标名称或自定义图片URL)
*/
@Size(max = 255, message = "图标长度不能超过255")
@Column(length = 255)
public String icon;
/**
* 排序顺序
*/
@Column(name = "sort_order", nullable = false)
public Integer sortOrder = 0;
/**
* 是否可见
*/
@Column(nullable = false)
public Boolean visible = true;
/**
* 是否在新窗口打开
*/
@Column(name = "open_in_new_tab", nullable = false)
public Boolean openInNewTab = true;
/**
* 卡片显示模式: icon (图标模式), image (背景图模式)
*/
@Size(max = 20, message = "显示模式长度不能超过20")
@Column(name = "display_mode", length = 20)
public String displayMode = "icon";
/**
* 背景图片URL (当 displayMode = image 时使用)
*/
@Size(max = 500, message = "背景图片URL长度不能超过500")
@Column(name = "background_image", length = 500)
public String backgroundImage;
/**
* 创建时间
*/
@Column(name = "created_at", nullable = false, updatable = false)
public LocalDateTime createdAt;
/**
* 更新时间
*/
@Column(name = "updated_at")
public LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
public void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
/**
* 根据分组查找所有可见书签
*/
public static List<Bookmark> findByGroupOrdered(BookmarkGroup group) {
return find("group = ?1 AND visible = true ORDER BY sortOrder ASC", group).list();
}
/**
* 根据分组ID查找所有书签
*/
public static List<Bookmark> findByGroupIdOrdered(Long groupId) {
return find("group.id = ?1 ORDER BY sortOrder ASC", groupId).list();
}
/**
* 查找未分组的书签
*/
public static List<Bookmark> findUngrouped() {
return find("group IS NULL ORDER BY sortOrder ASC").list();
}
/**
* 搜索书签(名称或描述)
*/
public static List<Bookmark> search(String keyword) {
return find("visible = true AND (LOWER(name) LIKE ?1 OR LOWER(description) LIKE ?1) ORDER BY sortOrder ASC",
"%" + keyword.toLowerCase() + "%").list();
}
/**
* 获取分组内下一个排序序号
*/
public static int getNextSortOrder(Long groupId) {
Bookmark last;
if (groupId != null) {
last = find("group.id = ?1 ORDER BY sortOrder DESC", groupId).firstResult();
} else {
last = find("group IS NULL ORDER BY sortOrder DESC").firstResult();
}
return last == null ? 0 : last.sortOrder + 1;
}
}
@@ -0,0 +1,95 @@
package com.home4j.entity;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
/**
* 书签分组实体
*/
@Entity
@Table(name = "t_group")
public class BookmarkGroup extends PanacheEntity {
/**
* 分组名称
*/
@NotBlank(message = "分组名称不能为空")
@Size(max = 100, message = "分组名称长度不能超过100")
@Column(nullable = false, length = 100)
public String name;
/**
* 分组图标(Lucide图标名称)
*/
@Size(max = 50, message = "图标名称长度不能超过50")
@Column(length = 50)
public String icon;
/**
* 排序顺序
*/
@Column(name = "sort_order", nullable = false)
public Integer sortOrder = 0;
/**
* 是否可见
*/
@Column(nullable = false)
public Boolean visible = true;
/**
* 创建时间
*/
@Column(name = "created_at", nullable = false, updatable = false)
public LocalDateTime createdAt;
/**
* 更新时间
*/
@Column(name = "updated_at")
public LocalDateTime updatedAt;
/**
* 关联的书签列表(级联删除)
*/
@OneToMany(mappedBy = "group", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
public List<Bookmark> bookmarks;
@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
public void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
/**
* 查找所有可见分组,按排序顺序
*/
public static List<BookmarkGroup> findAllVisibleOrdered() {
return find("visible = true ORDER BY sortOrder ASC").list();
}
/**
* 查找所有分组,按排序顺序
*/
public static List<BookmarkGroup> findAllOrdered() {
return find("ORDER BY sortOrder ASC").list();
}
/**
* 获取下一个排序序号
*/
public static int getNextSortOrder() {
BookmarkGroup last = find("ORDER BY sortOrder DESC").firstResult();
return last == null ? 0 : last.sortOrder + 1;
}
}
+101
View File
@@ -0,0 +1,101 @@
package com.home4j.entity;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
/**
* 系统配置实体
*/
@Entity
@Table(name = "t_config")
public class Config extends PanacheEntity {
/**
* 配置键(唯一)
*/
@NotBlank(message = "配置键不能为空")
@Size(max = 100, message = "配置键长度不能超过100")
@Column(name = "config_key", nullable = false, unique = true, length = 100)
public String configKey;
/**
* 配置值
*/
@Column(name = "config_value", columnDefinition = "TEXT")
public String configValue;
/**
* 配置描述
*/
@Size(max = 200, message = "描述长度不能超过200")
@Column(length = 200)
public String description;
/**
* 创建时间
*/
@Column(name = "created_at", nullable = false, updatable = false)
public LocalDateTime createdAt;
/**
* 更新时间
*/
@Column(name = "updated_at")
public LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
public void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
/**
* 根据键查找配置
*/
public static Config findByKey(String key) {
return find("configKey", key).firstResult();
}
/**
* 获取配置值,如果不存在返回默认值
*/
public static String getValue(String key, String defaultValue) {
Config config = findByKey(key);
return config != null && config.configValue != null ? config.configValue : defaultValue;
}
/**
* 设置配置值
*/
public static void setValue(String key, String value, String description) {
Config config = findByKey(key);
if (config == null) {
config = new Config();
config.configKey = key;
config.description = description;
}
config.configValue = value;
config.persist();
}
// 常用配置键常量
public static final String KEY_SITE_TITLE = "site_title";
public static final String KEY_SITE_SUBTITLE = "site_subtitle";
public static final String KEY_THEME = "theme";
public static final String KEY_FOOTER_TEXT = "footer_text";
public static final String KEY_SEARCH_ENGINE = "search_engine";
public static final String KEY_SHOW_SEARCH = "show_search";
public static final String KEY_VIEW_MODE = "view_mode";
public static final String KEY_COLUMNS = "columns";
public static final String KEY_BACKGROUND_IMAGE = "background_image";
public static final String KEY_BACKGROUND_OVERLAY = "background_overlay";
}
+77
View File
@@ -0,0 +1,77 @@
package com.home4j.entity;
import io.quarkus.elytron.security.common.BcryptUtil;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import io.quarkus.security.jpa.Password;
import io.quarkus.security.jpa.PasswordType;
import io.quarkus.security.jpa.Roles;
import io.quarkus.security.jpa.UserDefinition;
import io.quarkus.security.jpa.Username;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
/**
* 用户实体 - 管理员账户
*/
@Entity
@Table(name = "t_user")
@UserDefinition
public class User extends PanacheEntity {
/**
* 用户名(唯一)
*/
@Username
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 50, message = "用户名长度必须在3-50之间")
@Column(nullable = false, unique = true, length = 50)
public String username;
/**
* 密码(MCF格式,使用BCrypt
*/
@Password(value = PasswordType.MCF)
@NotBlank(message = "密码不能为空")
@Column(nullable = false, length = 100)
public String password;
/**
* 角色(ADMIN
*/
@Roles
@Column(nullable = false, length = 20)
public String role = "ADMIN";
/**
* 创建时间
*/
@Column(name = "created_at", nullable = false, updatable = false)
public LocalDateTime createdAt;
/**
* 更新时间
*/
@Column(name = "updated_at")
public LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
public void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
/**
* 根据用户名查找用户
*/
public static User findByUsername(String username) {
return find("username", username).firstResult();
}
}
@@ -0,0 +1,108 @@
package com.home4j.entity;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
/**
* 用户设置实体 - 存储用户个性化配置
*/
@Entity
@Table(name = "t_user_settings")
public class UserSettings extends PanacheEntity {
/**
* 关联用户
*/
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", unique = true)
public User user;
/**
* 界面语言: zh-CN, en-US
*/
@Size(max = 10)
@Column(length = 10)
public String language = "zh-CN";
/**
* 背景类型: theme (跟随主题), color (纯色), image (图片)
*/
@Size(max = 20)
@Column(name = "bg_type", length = 20)
public String bgType = "theme";
/**
* 背景颜色 (当 bgType = color 时使用)
*/
@Size(max = 30)
@Column(name = "bg_color", length = 30)
public String bgColor;
/**
* 背景图片URL (当 bgType = image 时使用)
*/
@Size(max = 500)
@Column(name = "bg_image", length = 500)
public String bgImage;
/**
* 默认视图模式: list, card
*/
@Size(max = 20)
@Column(name = "default_view", length = 20)
public String defaultView = "card";
/**
* 默认卡片显示模式: icon, image
*/
@Size(max = 20)
@Column(name = "card_display_mode", length = 20)
public String cardDisplayMode = "icon";
/**
* 天气城市名称
*/
@Size(max = 50)
@Column(name = "weather_city", length = 50)
public String weatherCity;
/**
* 创建时间
*/
@Column(name = "created_at", nullable = false, updatable = false)
public LocalDateTime createdAt;
/**
* 更新时间
*/
@Column(name = "updated_at")
public LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
public void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
/**
* 根据用户ID查找设置
*/
public static UserSettings findByUserId(Long userId) {
return find("user.id", userId).firstResult();
}
/**
* 根据用户名查找设置
*/
public static UserSettings findByUsername(String username) {
return find("user.username", username).firstResult();
}
}
@@ -0,0 +1,382 @@
package com.home4j.resource;
import com.home4j.entity.Bookmark;
import com.home4j.entity.BookmarkGroup;
import com.home4j.service.BookmarkService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.stream.Collectors;
/**
* REST API 控制器 - 用于前端 AJAX 调用
* 接收 JSON 数据,返回 JSON 响应
*/
@Path("/api/admin")
@RolesAllowed("ADMIN")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class AdminApiResource {
@Inject
BookmarkService bookmarkService;
// ==================== 分组 API ====================
/**
* 获取所有分组
*/
@GET
@Path("/groups")
public Response getAllGroups() {
List<BookmarkGroup> groups = bookmarkService.getAllGroups();
List<GroupResponse> response = groups.stream()
.map(GroupResponse::from)
.collect(Collectors.toList());
return Response.ok(response).build();
}
/**
* 获取单个分组
*/
@GET
@Path("/groups/{id}")
public Response getGroup(@PathParam("id") Long id) {
BookmarkGroup group = BookmarkGroup.findById(id);
if (group == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity(new ErrorResponse("分组不存在"))
.build();
}
return Response.ok(GroupResponse.from(group)).build();
}
/**
* 创建分组
*/
@POST
@Path("/groups")
public Response createGroup(GroupRequest request) {
if (request.name == null || request.name.trim().isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ErrorResponse("分组名称不能为空"))
.build();
}
BookmarkGroup group = bookmarkService.createGroup(
request.name.trim(),
request.icon != null ? request.icon.trim() : "folder"
);
// 如果有 sortOrder,更新它
if (request.sortOrder != null) {
group.sortOrder = request.sortOrder;
group.persist();
}
return Response.status(Response.Status.CREATED)
.entity(GroupResponse.from(group))
.build();
}
/**
* 更新分组
*/
@PUT
@Path("/groups/{id}")
public Response updateGroup(@PathParam("id") Long id, GroupRequest request) {
BookmarkGroup group = BookmarkGroup.findById(id);
if (group == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity(new ErrorResponse("分组不存在"))
.build();
}
if (request.name == null || request.name.trim().isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ErrorResponse("分组名称不能为空"))
.build();
}
bookmarkService.updateGroup(
id,
request.name.trim(),
request.icon != null ? request.icon.trim() : group.icon,
request.visible
);
// 更新 sortOrder
if (request.sortOrder != null) {
group.sortOrder = request.sortOrder;
group.persist();
}
return Response.ok(GroupResponse.from(group)).build();
}
/**
* 删除分组
*/
@DELETE
@Path("/groups/{id}")
public Response deleteGroup(@PathParam("id") Long id) {
boolean deleted = bookmarkService.deleteGroup(id);
if (!deleted) {
return Response.status(Response.Status.NOT_FOUND)
.entity(new ErrorResponse("分组不存在"))
.build();
}
return Response.ok(new SuccessResponse("删除成功")).build();
}
// ==================== 书签 API ====================
/**
* 获取所有书签
*/
@GET
@Path("/bookmarks")
public Response getAllBookmarks() {
List<Bookmark> bookmarks = Bookmark.listAll();
List<BookmarkResponse> response = bookmarks.stream()
.map(BookmarkResponse::from)
.collect(Collectors.toList());
return Response.ok(response).build();
}
/**
* 获取单个书签
*/
@GET
@Path("/bookmarks/{id}")
public Response getBookmark(@PathParam("id") Long id) {
Bookmark bookmark = Bookmark.findById(id);
if (bookmark == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity(new ErrorResponse("书签不存在"))
.build();
}
return Response.ok(BookmarkResponse.from(bookmark)).build();
}
/**
* 创建书签
*/
@POST
@Path("/bookmarks")
public Response createBookmark(BookmarkRequest request) {
// 验证必填字段
if (request.name == null || request.name.trim().isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ErrorResponse("书签名称不能为空"))
.build();
}
if (request.url == null || request.url.trim().isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ErrorResponse("URL不能为空"))
.build();
}
// 验证分组是否存在
if (request.groupId != null) {
BookmarkGroup group = BookmarkGroup.findById(request.groupId);
if (group == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ErrorResponse("指定的分组不存在"))
.build();
}
}
Bookmark bookmark = bookmarkService.createBookmark(
request.groupId,
request.name.trim(),
request.url.trim(),
request.description != null ? request.description.trim() : null,
request.icon != null ? request.icon.trim() : "link",
request.openInNewTab != null ? request.openInNewTab : true,
request.displayMode != null ? request.displayMode : "icon",
request.backgroundImage != null ? request.backgroundImage.trim() : null
);
return Response.status(Response.Status.CREATED)
.entity(BookmarkResponse.from(bookmark))
.build();
}
/**
* 更新书签
*/
@PUT
@Path("/bookmarks/{id}")
public Response updateBookmark(@PathParam("id") Long id, BookmarkRequest request) {
Bookmark bookmark = Bookmark.findById(id);
if (bookmark == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity(new ErrorResponse("书签不存在"))
.build();
}
// 验证必填字段
if (request.name == null || request.name.trim().isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ErrorResponse("书签名称不能为空"))
.build();
}
if (request.url == null || request.url.trim().isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ErrorResponse("URL不能为空"))
.build();
}
// 验证分组是否存在
if (request.groupId != null) {
BookmarkGroup group = BookmarkGroup.findById(request.groupId);
if (group == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ErrorResponse("指定的分组不存在"))
.build();
}
}
bookmarkService.updateBookmark(
id,
request.groupId,
request.name.trim(),
request.url.trim(),
request.description != null ? request.description.trim() : null,
request.icon != null ? request.icon.trim() : bookmark.icon,
request.visible,
request.openInNewTab,
request.displayMode,
request.backgroundImage != null ? request.backgroundImage.trim() : null
);
// 重新获取更新后的书签
bookmark = Bookmark.findById(id);
return Response.ok(BookmarkResponse.from(bookmark)).build();
}
/**
* 删除书签
*/
@DELETE
@Path("/bookmarks/{id}")
public Response deleteBookmark(@PathParam("id") Long id) {
boolean deleted = bookmarkService.deleteBookmark(id);
if (!deleted) {
return Response.status(Response.Status.NOT_FOUND)
.entity(new ErrorResponse("书签不存在"))
.build();
}
return Response.ok(new SuccessResponse("删除成功")).build();
}
// ==================== DTO 类 ====================
/**
* 分组请求 DTO
*/
public static class GroupRequest {
public Long id;
public String name;
public String icon;
public Integer sortOrder;
public Boolean visible;
}
/**
* 分组响应 DTO
*/
public static class GroupResponse {
public Long id;
public String name;
public String icon;
public Integer sortOrder;
public Boolean visible;
public static GroupResponse from(BookmarkGroup group) {
GroupResponse response = new GroupResponse();
response.id = group.id;
response.name = group.name;
response.icon = group.icon;
response.sortOrder = group.sortOrder;
response.visible = group.visible;
return response;
}
}
/**
* 书签请求 DTO
*/
public static class BookmarkRequest {
public Long id;
public String name;
public String url;
public String description;
public String icon;
public Long groupId;
public Boolean openInNewTab;
public Boolean visible;
public String displayMode;
public String backgroundImage;
}
/**
* 书签响应 DTO
*/
public static class BookmarkResponse {
public Long id;
public String name;
public String url;
public String description;
public String icon;
public Long groupId;
public Boolean openInNewTab;
public Boolean visible;
public String displayMode;
public String backgroundImage;
public static BookmarkResponse from(Bookmark bookmark) {
BookmarkResponse response = new BookmarkResponse();
response.id = bookmark.id;
response.name = bookmark.name;
response.url = bookmark.url;
response.description = bookmark.description;
response.icon = bookmark.icon;
response.groupId = bookmark.group != null ? bookmark.group.id : null;
response.openInNewTab = bookmark.openInNewTab;
response.visible = bookmark.visible;
response.displayMode = bookmark.displayMode;
response.backgroundImage = bookmark.backgroundImage;
return response;
}
}
/**
* 错误响应 DTO
*/
public static class ErrorResponse {
public boolean success = false;
public String message;
public ErrorResponse(String message) {
this.message = message;
}
}
/**
* 成功响应 DTO
*/
public static class SuccessResponse {
public boolean success = true;
public String message;
public SuccessResponse(String message) {
this.message = message;
}
}
}
@@ -0,0 +1,257 @@
package com.home4j.resource;
import com.home4j.dto.BookmarkDTO;
import com.home4j.dto.GroupDTO;
import com.home4j.entity.Bookmark;
import com.home4j.entity.BookmarkGroup;
import com.home4j.service.BookmarkService;
import com.home4j.service.ConfigService;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.net.URI;
import java.util.List;
import java.util.Map;
/**
* 管理后台控制器 - 需要登录
*/
@Path("/admin")
@RolesAllowed("ADMIN")
public class AdminResource {
@Inject
Template admin;
@Inject
Template adminGroups;
@Inject
Template adminBookmarks;
@Inject
Template adminSettings;
@Inject
BookmarkService bookmarkService;
@Inject
ConfigService configService;
/**
* 管理后台首页
*/
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance adminIndex() {
return admin
.data("groups", bookmarkService.getAllGroups())
.data("siteTitle", configService.getSiteTitle());
}
// ==================== 分组管理 ====================
/**
* 分组管理页面
*/
@GET
@Path("/groups")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance groupsPage() {
return adminGroups
.data("groups", bookmarkService.getAllGroups())
.data("siteTitle", configService.getSiteTitle());
}
/**
* 创建分组
*/
@POST
@Path("/groups")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response createGroup(@FormParam("name") String name, @FormParam("icon") String icon) {
bookmarkService.createGroup(name, icon);
return Response.seeOther(URI.create("/admin/groups")).build();
}
/**
* 更新分组
*/
@POST
@Path("/groups/{id}")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response updateGroup(@PathParam("id") Long id,
@FormParam("name") String name,
@FormParam("icon") String icon,
@FormParam("visible") Boolean visible) {
bookmarkService.updateGroup(id, name, icon, visible);
return Response.seeOther(URI.create("/admin/groups")).build();
}
/**
* 删除分组
*/
@POST
@Path("/groups/{id}/delete")
public Response deleteGroup(@PathParam("id") Long id) {
bookmarkService.deleteGroup(id);
return Response.seeOther(URI.create("/admin/groups")).build();
}
// ==================== 书签管理 ====================
/**
* 书签管理页面
*/
@GET
@Path("/bookmarks")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance bookmarksPage(@QueryParam("groupId") Long groupId) {
List<Bookmark> bookmarks;
if (groupId != null) {
bookmarks = Bookmark.findByGroupIdOrdered(groupId);
} else {
bookmarks = Bookmark.listAll();
}
return adminBookmarks
.data("bookmarks", bookmarks)
.data("groups", bookmarkService.getAllGroups())
.data("selectedGroupId", groupId)
.data("siteTitle", configService.getSiteTitle());
}
/**
* 创建书签
*/
@POST
@Path("/bookmarks")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response createBookmark(@FormParam("groupId") Long groupId,
@FormParam("name") String name,
@FormParam("url") String url,
@FormParam("description") String description,
@FormParam("icon") String icon,
@FormParam("openInNewTab") Boolean openInNewTab) {
bookmarkService.createBookmark(groupId, name, url, description, icon, openInNewTab);
return Response.seeOther(URI.create("/admin/bookmarks")).build();
}
/**
* 更新书签
*/
@POST
@Path("/bookmarks/{id}")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response updateBookmark(@PathParam("id") Long id,
@FormParam("groupId") Long groupId,
@FormParam("name") String name,
@FormParam("url") String url,
@FormParam("description") String description,
@FormParam("icon") String icon,
@FormParam("visible") Boolean visible,
@FormParam("openInNewTab") Boolean openInNewTab) {
bookmarkService.updateBookmark(id, groupId, name, url, description, icon, visible, openInNewTab);
return Response.seeOther(URI.create("/admin/bookmarks")).build();
}
/**
* 删除书签
*/
@POST
@Path("/bookmarks/{id}/delete")
public Response deleteBookmark(@PathParam("id") Long id) {
bookmarkService.deleteBookmark(id);
return Response.seeOther(URI.create("/admin/bookmarks")).build();
}
// ==================== 系统设置 ====================
/**
* 系统设置页面
*/
@GET
@Path("/settings")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance settingsPage() {
return adminSettings
.data("config", configService.getAll())
.data("siteTitle", configService.getSiteTitle());
}
/**
* 保存系统设置
*/
@POST
@Path("/settings")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response saveSettings(@FormParam("site_title") String siteTitle,
@FormParam("site_subtitle") String siteSubtitle,
@FormParam("theme") String theme,
@FormParam("footer_text") String footerText,
@FormParam("search_engine") String searchEngine,
@FormParam("show_search") String showSearch) {
configService.set("site_title", siteTitle);
configService.set("site_subtitle", siteSubtitle);
configService.set("theme", theme);
configService.set("footer_text", footerText);
configService.set("search_engine", searchEngine);
configService.set("show_search", showSearch != null ? "true" : "false");
return Response.seeOther(URI.create("/admin/settings")).build();
}
// ==================== API 接口 ====================
/**
* 获取所有分组 API
*/
@GET
@Path("/api/groups")
@Produces(MediaType.APPLICATION_JSON)
public List<BookmarkGroup> getGroupsApi() {
return bookmarkService.getAllGroups();
}
/**
* 获取所有书签 API
*/
@GET
@Path("/api/bookmarks")
@Produces(MediaType.APPLICATION_JSON)
public List<Bookmark> getBookmarksApi(@QueryParam("groupId") Long groupId) {
if (groupId != null) {
return Bookmark.findByGroupIdOrdered(groupId);
}
return Bookmark.listAll();
}
/**
* 更新分组排序 API
*/
@POST
@Path("/api/groups/order")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response updateGroupOrder(List<Long> groupIds) {
bookmarkService.updateGroupOrder(groupIds);
return Response.ok().entity("{\"success\": true}").build();
}
/**
* 更新书签排序 API
*/
@POST
@Path("/api/bookmarks/order")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response updateBookmarkOrder(List<Long> bookmarkIds) {
bookmarkService.updateBookmarkOrder(bookmarkIds);
return Response.ok().entity("{\"success\": true}").build();
}
}
@@ -0,0 +1,85 @@
package com.home4j.resource;
import com.home4j.entity.BookmarkGroup;
import com.home4j.service.BookmarkService;
import com.home4j.service.ConfigService;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
/**
* 首页控制器 - 公开访问
*/
@Path("/")
public class IndexResource {
@Inject
Template index;
@Inject
BookmarkService bookmarkService;
@Inject
ConfigService configService;
@Inject
SecurityIdentity securityIdentity;
/**
* 首页 - 显示所有可见分组和书签
*/
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance index() {
List<BookmarkGroup> groups = bookmarkService.getAllVisibleGroupsWithBookmarks();
List<BookmarkGroup> allGroups = bookmarkService.getAllGroups(); // 用于编辑模式
// 检查用户是否已登录
boolean isLoggedIn = !securityIdentity.isAnonymous();
String username = isLoggedIn ? securityIdentity.getPrincipal().getName() : null;
boolean isAdmin = securityIdentity.hasRole("ADMIN");
// 构建 config 对象
var config = new java.util.HashMap<String, Object>();
config.put("siteTitle", configService.getSiteTitle());
config.put("siteSubtitle", configService.getSiteSubtitle());
config.put("theme", configService.getTheme());
config.put("footerText", configService.getFooterText());
return index
.data("groups", groups)
.data("allGroups", allGroups)
.data("config", config)
.data("siteTitle", configService.getSiteTitle())
.data("siteSubtitle", configService.getSiteSubtitle())
.data("theme", configService.getTheme())
.data("footerText", configService.getFooterText())
.data("searchEngine", configService.getSearchEngine())
.data("showSearch", configService.isShowSearch())
.data("viewMode", configService.getViewMode())
.data("columns", configService.getColumns())
.data("backgroundImage", configService.getBackgroundImage())
.data("backgroundOverlay", configService.getBackgroundOverlay())
.data("isLoggedIn", isLoggedIn)
.data("isAdmin", isAdmin)
.data("username", username);
}
/**
* 搜索书签 API
*/
@GET
@Path("/api/search")
@Produces(MediaType.APPLICATION_JSON)
public List<?> search(@QueryParam("q") String keyword) {
if (keyword == null || keyword.trim().isEmpty()) {
return List.of();
}
return bookmarkService.searchBookmarks(keyword.trim());
}
}
@@ -0,0 +1,75 @@
package com.home4j.resource;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import io.quarkus.security.Authenticated;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.SecurityContext;
import java.net.URI;
/**
* 登录控制器
*/
@Path("/login")
public class LoginResource {
@Inject
Template login;
/**
* 显示登录页面
*/
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance loginPage(@QueryParam("error") String error) {
return login.data("error", error != null);
}
/**
* 登录失败处理
*/
@GET
@Path("/error")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance loginError() {
return login.data("error", true);
}
/**
* 登出 - 清除 session cookie
*/
@GET
@Path("/logout")
public Response logout() {
// 创建一个过期的 cookie 来清除 session
NewCookie expiredCookie = new NewCookie.Builder("home4j-session")
.path("/")
.maxAge(0)
.httpOnly(true)
.build();
return Response.seeOther(URI.create("/"))
.cookie(expiredCookie)
.build();
}
/**
* 检查登录状态 API
*/
@GET
@Path("/check")
@Produces(MediaType.APPLICATION_JSON)
public Response checkLogin(@Context SecurityContext securityContext) {
if (securityContext.getUserPrincipal() != null) {
return Response.ok().entity("{\"authenticated\": true, \"username\": \"" +
securityContext.getUserPrincipal().getName() + "\"}").build();
}
return Response.ok().entity("{\"authenticated\": false}").build();
}
}
@@ -0,0 +1,142 @@
package com.home4j.resource;
import com.home4j.entity.User;
import com.home4j.entity.UserSettings;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
/**
* 用户设置 REST API
*/
@Path("/api/settings")
@RolesAllowed("ADMIN")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class UserSettingsResource {
@Inject
SecurityIdentity securityIdentity;
/**
* 获取当前用户设置
*/
@GET
public Response getSettings() {
String username = securityIdentity.getPrincipal().getName();
UserSettings settings = UserSettings.findByUsername(username);
if (settings == null) {
// 返回默认设置
return Response.ok(new SettingsResponse()).build();
}
return Response.ok(SettingsResponse.from(settings)).build();
}
/**
* 更新用户设置
*/
@PUT
@Transactional
public Response updateSettings(SettingsRequest request) {
String username = securityIdentity.getPrincipal().getName();
User user = User.findByUsername(username);
if (user == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity(new ErrorResponse("用户不存在"))
.build();
}
UserSettings settings = UserSettings.findByUsername(username);
if (settings == null) {
settings = new UserSettings();
settings.user = user;
}
// 更新设置
if (request.language != null) {
settings.language = request.language;
}
if (request.bgType != null) {
settings.bgType = request.bgType;
}
if (request.bgColor != null) {
settings.bgColor = request.bgColor;
}
if (request.bgImage != null) {
settings.bgImage = request.bgImage;
}
if (request.defaultView != null) {
settings.defaultView = request.defaultView;
}
if (request.cardDisplayMode != null) {
settings.cardDisplayMode = request.cardDisplayMode;
}
if (request.weatherCity != null) {
settings.weatherCity = request.weatherCity;
}
settings.persist();
return Response.ok(SettingsResponse.from(settings)).build();
}
// ==================== DTO 类 ====================
/**
* 设置请求 DTO
*/
public static class SettingsRequest {
public String language;
public String bgType;
public String bgColor;
public String bgImage;
public String defaultView;
public String cardDisplayMode;
public String weatherCity;
}
/**
* 设置响应 DTO
*/
public static class SettingsResponse {
public String language = "zh-CN";
public String bgType = "theme";
public String bgColor;
public String bgImage;
public String defaultView = "card";
public String cardDisplayMode = "icon";
public String weatherCity;
public static SettingsResponse from(UserSettings settings) {
SettingsResponse response = new SettingsResponse();
response.language = settings.language != null ? settings.language : "zh-CN";
response.bgType = settings.bgType != null ? settings.bgType : "theme";
response.bgColor = settings.bgColor;
response.bgImage = settings.bgImage;
response.defaultView = settings.defaultView != null ? settings.defaultView : "card";
response.cardDisplayMode = settings.cardDisplayMode != null ? settings.cardDisplayMode : "icon";
response.weatherCity = settings.weatherCity;
return response;
}
}
/**
* 错误响应 DTO
*/
public static class ErrorResponse {
public boolean success = false;
public String message;
public ErrorResponse(String message) {
this.message = message;
}
}
}
@@ -0,0 +1,192 @@
package com.home4j.service;
import com.home4j.entity.Bookmark;
import com.home4j.entity.BookmarkGroup;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.util.List;
/**
* 书签服务
*/
@ApplicationScoped
public class BookmarkService {
/**
* 获取所有可见分组及其书签
*/
public List<BookmarkGroup> getAllVisibleGroupsWithBookmarks() {
return BookmarkGroup.findAllVisibleOrdered();
}
/**
* 获取所有分组(管理用)
*/
public List<BookmarkGroup> getAllGroups() {
return BookmarkGroup.findAllOrdered();
}
/**
* 创建分组
*/
@Transactional
public BookmarkGroup createGroup(String name, String icon) {
BookmarkGroup group = new BookmarkGroup();
group.name = name;
group.icon = icon;
group.sortOrder = BookmarkGroup.getNextSortOrder();
group.visible = true;
group.persist();
return group;
}
/**
* 更新分组
*/
@Transactional
public BookmarkGroup updateGroup(Long id, String name, String icon, Boolean visible) {
BookmarkGroup group = BookmarkGroup.findById(id);
if (group != null) {
group.name = name;
group.icon = icon;
if (visible != null) {
group.visible = visible;
}
group.persist();
}
return group;
}
/**
* 删除分组(级联删除书签)
*/
@Transactional
public boolean deleteGroup(Long id) {
BookmarkGroup group = BookmarkGroup.findById(id);
if (group != null) {
group.delete();
return true;
}
return false;
}
/**
* 创建书签
*/
@Transactional
public Bookmark createBookmark(Long groupId, String name, String url, String description, String icon, Boolean openInNewTab) {
return createBookmark(groupId, name, url, description, icon, openInNewTab, null, null);
}
/**
* 创建书签(包含显示模式和背景图)
*/
@Transactional
public Bookmark createBookmark(Long groupId, String name, String url, String description, String icon, Boolean openInNewTab, String displayMode, String backgroundImage) {
Bookmark bookmark = new Bookmark();
if (groupId != null) {
bookmark.group = BookmarkGroup.findById(groupId);
}
bookmark.name = name;
bookmark.url = url;
bookmark.description = description;
bookmark.icon = icon;
bookmark.openInNewTab = openInNewTab != null ? openInNewTab : true;
bookmark.displayMode = displayMode != null ? displayMode : "icon";
bookmark.backgroundImage = backgroundImage;
bookmark.sortOrder = Bookmark.getNextSortOrder(groupId);
bookmark.visible = true;
bookmark.persist();
return bookmark;
}
/**
* 更新书签
*/
@Transactional
public Bookmark updateBookmark(Long id, Long groupId, String name, String url, String description, String icon, Boolean visible, Boolean openInNewTab) {
return updateBookmark(id, groupId, name, url, description, icon, visible, openInNewTab, null, null);
}
/**
* 更新书签(包含显示模式和背景图)
*/
@Transactional
public Bookmark updateBookmark(Long id, Long groupId, String name, String url, String description, String icon, Boolean visible, Boolean openInNewTab, String displayMode, String backgroundImage) {
Bookmark bookmark = Bookmark.findById(id);
if (bookmark != null) {
if (groupId != null) {
bookmark.group = BookmarkGroup.findById(groupId);
} else {
bookmark.group = null;
}
bookmark.name = name;
bookmark.url = url;
bookmark.description = description;
bookmark.icon = icon;
if (visible != null) {
bookmark.visible = visible;
}
if (openInNewTab != null) {
bookmark.openInNewTab = openInNewTab;
}
if (displayMode != null) {
bookmark.displayMode = displayMode;
}
if (backgroundImage != null) {
bookmark.backgroundImage = backgroundImage;
}
bookmark.persist();
}
return bookmark;
}
/**
* 删除书签
*/
@Transactional
public boolean deleteBookmark(Long id) {
Bookmark bookmark = Bookmark.findById(id);
if (bookmark != null) {
bookmark.delete();
return true;
}
return false;
}
/**
* 搜索书签
*/
public List<Bookmark> searchBookmarks(String keyword) {
return Bookmark.search(keyword);
}
/**
* 更新分组排序
*/
@Transactional
public void updateGroupOrder(List<Long> groupIds) {
for (int i = 0; i < groupIds.size(); i++) {
BookmarkGroup group = BookmarkGroup.findById(groupIds.get(i));
if (group != null) {
group.sortOrder = i;
group.persist();
}
}
}
/**
* 更新书签排序
*/
@Transactional
public void updateBookmarkOrder(List<Long> bookmarkIds) {
for (int i = 0; i < bookmarkIds.size(); i++) {
Bookmark bookmark = Bookmark.findById(bookmarkIds.get(i));
if (bookmark != null) {
bookmark.sortOrder = i;
bookmark.persist();
}
}
}
}
@@ -0,0 +1,147 @@
package com.home4j.service;
import com.home4j.entity.Config;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 配置服务
*/
@ApplicationScoped
public class ConfigService {
// 默认配置值
private static final Map<String, String> DEFAULTS = new HashMap<>();
static {
DEFAULTS.put(Config.KEY_SITE_TITLE, "Home4j");
DEFAULTS.put(Config.KEY_SITE_SUBTITLE, "我的导航页");
DEFAULTS.put(Config.KEY_THEME, "light");
DEFAULTS.put(Config.KEY_FOOTER_TEXT, "Powered by Home4j");
DEFAULTS.put(Config.KEY_SEARCH_ENGINE, "https://www.google.com/search");
DEFAULTS.put(Config.KEY_SHOW_SEARCH, "true");
DEFAULTS.put(Config.KEY_VIEW_MODE, "icon");
DEFAULTS.put(Config.KEY_COLUMNS, "4");
DEFAULTS.put(Config.KEY_BACKGROUND_IMAGE, "");
DEFAULTS.put(Config.KEY_BACKGROUND_OVERLAY, "0.5");
}
/**
* 获取配置值
*/
public String get(String key) {
return Config.getValue(key, DEFAULTS.get(key));
}
/**
* 获取配置值(带默认值)
*/
public String get(String key, String defaultValue) {
return Config.getValue(key, defaultValue);
}
/**
* 设置配置值
*/
@Transactional
public void set(String key, String value) {
Config.setValue(key, value, null);
}
/**
* 设置配置值(带描述)
*/
@Transactional
public void set(String key, String value, String description) {
Config.setValue(key, value, description);
}
/**
* 获取所有配置
*/
public Map<String, String> getAll() {
Map<String, String> result = new HashMap<>(DEFAULTS);
List<Config> configs = Config.listAll();
for (Config config : configs) {
result.put(config.configKey, config.configValue);
}
return result;
}
/**
* 获取网站标题
*/
public String getSiteTitle() {
return get(Config.KEY_SITE_TITLE);
}
/**
* 获取网站副标题
*/
public String getSiteSubtitle() {
return get(Config.KEY_SITE_SUBTITLE);
}
/**
* 获取当前主题
*/
public String getTheme() {
return get(Config.KEY_THEME);
}
/**
* 获取页脚文本
*/
public String getFooterText() {
return get(Config.KEY_FOOTER_TEXT);
}
/**
* 获取搜索引擎URL
*/
public String getSearchEngine() {
return get(Config.KEY_SEARCH_ENGINE);
}
/**
* 是否显示搜索框
*/
public boolean isShowSearch() {
return "true".equals(get(Config.KEY_SHOW_SEARCH));
}
/**
* 获取视图模式 (icon/list)
*/
public String getViewMode() {
return get(Config.KEY_VIEW_MODE);
}
/**
* 获取列数
*/
public int getColumns() {
try {
return Integer.parseInt(get(Config.KEY_COLUMNS));
} catch (NumberFormatException e) {
return 4;
}
}
/**
* 获取背景图URL
*/
public String getBackgroundImage() {
return get(Config.KEY_BACKGROUND_IMAGE, "");
}
/**
* 获取背景遮罩透明度
*/
public String getBackgroundOverlay() {
return get(Config.KEY_BACKGROUND_OVERLAY, "0.5");
}
}
@@ -0,0 +1,52 @@
package com.home4j.service;
import com.home4j.entity.User;
import io.quarkus.elytron.security.common.BcryptUtil;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
/**
* 用户服务
*/
@ApplicationScoped
public class UserService {
/**
* 创建用户(密码会自动BCrypt加密)
*/
@Transactional
public User createUser(String username, String password) {
User user = new User();
user.username = username;
user.password = BcryptUtil.bcryptHash(password);
user.role = "ADMIN";
user.persist();
return user;
}
/**
* 更新密码
*/
@Transactional
public void updatePassword(Long userId, String newPassword) {
User user = User.findById(userId);
if (user != null) {
user.password = BcryptUtil.bcryptHash(newPassword);
user.persist();
}
}
/**
* 检查是否存在任何用户
*/
public boolean hasAnyUser() {
return User.count() > 0;
}
/**
* 获取用户数量
*/
public long getUserCount() {
return User.count();
}
}
@@ -0,0 +1,444 @@
/**
* Home4j - Custom Styles
* ========================
*/
/* ==========================================
CSS Variables
========================================== */
:root {
--hero-height: 200px;
--card-transition: all 0.2s ease;
--drawer-width: 400px;
}
/* ==========================================
Background Image Support
========================================== */
.background-layer {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.background-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
background-color: rgba(0, 0, 0, 0.5);
pointer-events: none;
}
.hero-background {
position: relative;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.hero-background::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: inherit;
z-index: -1;
}
.hero-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, var(--overlay-opacity, 0.5));
z-index: 0;
}
.hero-content {
position: relative;
z-index: 1;
}
/* ==========================================
Bookmark Card Styles
========================================== */
.bookmark-card {
transition: var(--card-transition);
cursor: pointer;
}
.bookmark-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
}
/* Icon View Card */
.bookmark-card-icon {
min-height: 120px;
}
.bookmark-card-icon .bookmark-icon {
font-size: 2.5rem;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--fallback-b3, oklch(var(--b3)));
}
.bookmark-card-icon .bookmark-icon img,
.bookmark-card-icon .bookmark-icon svg {
width: 32px;
height: 32px;
}
/* List View Card */
.bookmark-card-list {
padding: 0.75rem 1rem;
}
.bookmark-card-list .bookmark-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--fallback-b3, oklch(var(--b3)));
}
.bookmark-card-list .bookmark-icon img,
.bookmark-card-list .bookmark-icon svg {
width: 20px;
height: 20px;
}
/* ==========================================
Group Styles
========================================== */
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
cursor: pointer;
user-select: none;
}
.group-header:hover {
opacity: 0.8;
}
.group-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
}
.group-toggle {
transition: transform 0.2s ease;
}
.group-toggle.collapsed {
transform: rotate(-90deg);
}
.group-content {
overflow: hidden;
transition: max-height 0.3s ease, opacity 0.3s ease;
}
.group-content.collapsed {
max-height: 0 !important;
opacity: 0;
}
.group-actions {
display: flex;
align-items: center;
gap: 0.5rem;
opacity: 0;
transition: opacity 0.2s ease;
}
.group-header:hover .group-actions,
.edit-mode .group-actions {
opacity: 1;
}
/* ==========================================
Edit Mode Styles
========================================== */
.edit-mode .bookmark-card {
position: relative;
}
.edit-mode .bookmark-card::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 2px dashed transparent;
border-radius: inherit;
pointer-events: none;
transition: border-color 0.2s ease;
}
.edit-mode .bookmark-card:hover::after {
border-color: var(--fallback-p, oklch(var(--p)));
}
.bookmark-card.editing {
box-shadow: 0 0 0 3px var(--fallback-p, oklch(var(--p)));
}
.bookmark-edit-actions {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: none;
gap: 0.25rem;
}
.edit-mode .bookmark-edit-actions {
display: flex;
}
/* ==========================================
Drawer Styles
========================================== */
.drawer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 100;
}
.drawer-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: var(--drawer-width);
max-width: 90vw;
background: var(--fallback-b1, oklch(var(--b1)));
z-index: 101;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.3s ease;
}
.drawer-panel.open {
transform: translateX(0);
}
.drawer-side {
z-index: 100;
}
.drawer-content-wrapper {
width: var(--drawer-width);
max-width: 90vw;
background: var(--fallback-b1, oklch(var(--b1)));
height: 100vh;
overflow-y: auto;
}
/* ==========================================
Icon Picker Styles
========================================== */
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 0.5rem;
max-height: 300px;
overflow-y: auto;
}
.icon-picker-item {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.icon-picker-item:hover {
background: var(--fallback-b3, oklch(var(--b3)));
}
.icon-picker-item.selected {
background: var(--fallback-p, oklch(var(--p)));
color: var(--fallback-pc, oklch(var(--pc)));
}
/* ==========================================
Theme Switcher
========================================== */
.theme-switcher {
position: relative;
}
.theme-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
min-width: 150px;
z-index: 50;
}
/* ==========================================
View Mode Toggle
========================================== */
.view-toggle {
display: flex;
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid var(--fallback-b3, oklch(var(--b3)));
}
.view-toggle button {
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.view-toggle button.active {
background: var(--fallback-p, oklch(var(--p)));
color: var(--fallback-pc, oklch(var(--pc)));
}
/* ==========================================
Toast Notifications
========================================== */
.toast-container {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toast {
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* ==========================================
Responsive Adjustments
========================================== */
@media (max-width: 768px) {
:root {
--drawer-width: 100%;
}
.icon-picker-grid {
grid-template-columns: repeat(6, 1fr);
}
}
@media (max-width: 480px) {
.icon-picker-grid {
grid-template-columns: repeat(5, 1fr);
}
}
/* ==========================================
Skeleton Loading
========================================== */
.skeleton-card {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* ==========================================
Empty State
========================================== */
.empty-state {
text-align: center;
padding: 4rem 2rem;
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
/* ==========================================
FAB (Floating Action Button)
========================================== */
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 50;
}
.fab-menu {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: all 0.2s ease;
}
.fab-menu.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=https://cdn.jsdelivr.net/npm/daisyui@5.5.17/daisyui.min.css
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=https://cdn.jsdelivr.net.cn/npm/daisyui@5
@@ -0,0 +1,902 @@
/**
* Failed to minify the file using clean-css v5.3.3. Serving the original version.
* Original file: /npm/tailwindcss@4.1.18/index.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
@layer theme, base, components, utilities;
@layer theme {
@theme default {
--font-sans:
ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--color-red-50: oklch(97.1% 0.013 17.38);
--color-red-100: oklch(93.6% 0.032 17.717);
--color-red-200: oklch(88.5% 0.062 18.334);
--color-red-300: oklch(80.8% 0.114 19.571);
--color-red-400: oklch(70.4% 0.191 22.216);
--color-red-500: oklch(63.7% 0.237 25.331);
--color-red-600: oklch(57.7% 0.245 27.325);
--color-red-700: oklch(50.5% 0.213 27.518);
--color-red-800: oklch(44.4% 0.177 26.899);
--color-red-900: oklch(39.6% 0.141 25.723);
--color-red-950: oklch(25.8% 0.092 26.042);
--color-orange-50: oklch(98% 0.016 73.684);
--color-orange-100: oklch(95.4% 0.038 75.164);
--color-orange-200: oklch(90.1% 0.076 70.697);
--color-orange-300: oklch(83.7% 0.128 66.29);
--color-orange-400: oklch(75% 0.183 55.934);
--color-orange-500: oklch(70.5% 0.213 47.604);
--color-orange-600: oklch(64.6% 0.222 41.116);
--color-orange-700: oklch(55.3% 0.195 38.402);
--color-orange-800: oklch(47% 0.157 37.304);
--color-orange-900: oklch(40.8% 0.123 38.172);
--color-orange-950: oklch(26.6% 0.079 36.259);
--color-amber-50: oklch(98.7% 0.022 95.277);
--color-amber-100: oklch(96.2% 0.059 95.617);
--color-amber-200: oklch(92.4% 0.12 95.746);
--color-amber-300: oklch(87.9% 0.169 91.605);
--color-amber-400: oklch(82.8% 0.189 84.429);
--color-amber-500: oklch(76.9% 0.188 70.08);
--color-amber-600: oklch(66.6% 0.179 58.318);
--color-amber-700: oklch(55.5% 0.163 48.998);
--color-amber-800: oklch(47.3% 0.137 46.201);
--color-amber-900: oklch(41.4% 0.112 45.904);
--color-amber-950: oklch(27.9% 0.077 45.635);
--color-yellow-50: oklch(98.7% 0.026 102.212);
--color-yellow-100: oklch(97.3% 0.071 103.193);
--color-yellow-200: oklch(94.5% 0.129 101.54);
--color-yellow-300: oklch(90.5% 0.182 98.111);
--color-yellow-400: oklch(85.2% 0.199 91.936);
--color-yellow-500: oklch(79.5% 0.184 86.047);
--color-yellow-600: oklch(68.1% 0.162 75.834);
--color-yellow-700: oklch(55.4% 0.135 66.442);
--color-yellow-800: oklch(47.6% 0.114 61.907);
--color-yellow-900: oklch(42.1% 0.095 57.708);
--color-yellow-950: oklch(28.6% 0.066 53.813);
--color-lime-50: oklch(98.6% 0.031 120.757);
--color-lime-100: oklch(96.7% 0.067 122.328);
--color-lime-200: oklch(93.8% 0.127 124.321);
--color-lime-300: oklch(89.7% 0.196 126.665);
--color-lime-400: oklch(84.1% 0.238 128.85);
--color-lime-500: oklch(76.8% 0.233 130.85);
--color-lime-600: oklch(64.8% 0.2 131.684);
--color-lime-700: oklch(53.2% 0.157 131.589);
--color-lime-800: oklch(45.3% 0.124 130.933);
--color-lime-900: oklch(40.5% 0.101 131.063);
--color-lime-950: oklch(27.4% 0.072 132.109);
--color-green-50: oklch(98.2% 0.018 155.826);
--color-green-100: oklch(96.2% 0.044 156.743);
--color-green-200: oklch(92.5% 0.084 155.995);
--color-green-300: oklch(87.1% 0.15 154.449);
--color-green-400: oklch(79.2% 0.209 151.711);
--color-green-500: oklch(72.3% 0.219 149.579);
--color-green-600: oklch(62.7% 0.194 149.214);
--color-green-700: oklch(52.7% 0.154 150.069);
--color-green-800: oklch(44.8% 0.119 151.328);
--color-green-900: oklch(39.3% 0.095 152.535);
--color-green-950: oklch(26.6% 0.065 152.934);
--color-emerald-50: oklch(97.9% 0.021 166.113);
--color-emerald-100: oklch(95% 0.052 163.051);
--color-emerald-200: oklch(90.5% 0.093 164.15);
--color-emerald-300: oklch(84.5% 0.143 164.978);
--color-emerald-400: oklch(76.5% 0.177 163.223);
--color-emerald-500: oklch(69.6% 0.17 162.48);
--color-emerald-600: oklch(59.6% 0.145 163.225);
--color-emerald-700: oklch(50.8% 0.118 165.612);
--color-emerald-800: oklch(43.2% 0.095 166.913);
--color-emerald-900: oklch(37.8% 0.077 168.94);
--color-emerald-950: oklch(26.2% 0.051 172.552);
--color-teal-50: oklch(98.4% 0.014 180.72);
--color-teal-100: oklch(95.3% 0.051 180.801);
--color-teal-200: oklch(91% 0.096 180.426);
--color-teal-300: oklch(85.5% 0.138 181.071);
--color-teal-400: oklch(77.7% 0.152 181.912);
--color-teal-500: oklch(70.4% 0.14 182.503);
--color-teal-600: oklch(60% 0.118 184.704);
--color-teal-700: oklch(51.1% 0.096 186.391);
--color-teal-800: oklch(43.7% 0.078 188.216);
--color-teal-900: oklch(38.6% 0.063 188.416);
--color-teal-950: oklch(27.7% 0.046 192.524);
--color-cyan-50: oklch(98.4% 0.019 200.873);
--color-cyan-100: oklch(95.6% 0.045 203.388);
--color-cyan-200: oklch(91.7% 0.08 205.041);
--color-cyan-300: oklch(86.5% 0.127 207.078);
--color-cyan-400: oklch(78.9% 0.154 211.53);
--color-cyan-500: oklch(71.5% 0.143 215.221);
--color-cyan-600: oklch(60.9% 0.126 221.723);
--color-cyan-700: oklch(52% 0.105 223.128);
--color-cyan-800: oklch(45% 0.085 224.283);
--color-cyan-900: oklch(39.8% 0.07 227.392);
--color-cyan-950: oklch(30.2% 0.056 229.695);
--color-sky-50: oklch(97.7% 0.013 236.62);
--color-sky-100: oklch(95.1% 0.026 236.824);
--color-sky-200: oklch(90.1% 0.058 230.902);
--color-sky-300: oklch(82.8% 0.111 230.318);
--color-sky-400: oklch(74.6% 0.16 232.661);
--color-sky-500: oklch(68.5% 0.169 237.323);
--color-sky-600: oklch(58.8% 0.158 241.966);
--color-sky-700: oklch(50% 0.134 242.749);
--color-sky-800: oklch(44.3% 0.11 240.79);
--color-sky-900: oklch(39.1% 0.09 240.876);
--color-sky-950: oklch(29.3% 0.066 243.157);
--color-blue-50: oklch(97% 0.014 254.604);
--color-blue-100: oklch(93.2% 0.032 255.585);
--color-blue-200: oklch(88.2% 0.059 254.128);
--color-blue-300: oklch(80.9% 0.105 251.813);
--color-blue-400: oklch(70.7% 0.165 254.624);
--color-blue-500: oklch(62.3% 0.214 259.815);
--color-blue-600: oklch(54.6% 0.245 262.881);
--color-blue-700: oklch(48.8% 0.243 264.376);
--color-blue-800: oklch(42.4% 0.199 265.638);
--color-blue-900: oklch(37.9% 0.146 265.522);
--color-blue-950: oklch(28.2% 0.091 267.935);
--color-indigo-50: oklch(96.2% 0.018 272.314);
--color-indigo-100: oklch(93% 0.034 272.788);
--color-indigo-200: oklch(87% 0.065 274.039);
--color-indigo-300: oklch(78.5% 0.115 274.713);
--color-indigo-400: oklch(67.3% 0.182 276.935);
--color-indigo-500: oklch(58.5% 0.233 277.117);
--color-indigo-600: oklch(51.1% 0.262 276.966);
--color-indigo-700: oklch(45.7% 0.24 277.023);
--color-indigo-800: oklch(39.8% 0.195 277.366);
--color-indigo-900: oklch(35.9% 0.144 278.697);
--color-indigo-950: oklch(25.7% 0.09 281.288);
--color-violet-50: oklch(96.9% 0.016 293.756);
--color-violet-100: oklch(94.3% 0.029 294.588);
--color-violet-200: oklch(89.4% 0.057 293.283);
--color-violet-300: oklch(81.1% 0.111 293.571);
--color-violet-400: oklch(70.2% 0.183 293.541);
--color-violet-500: oklch(60.6% 0.25 292.717);
--color-violet-600: oklch(54.1% 0.281 293.009);
--color-violet-700: oklch(49.1% 0.27 292.581);
--color-violet-800: oklch(43.2% 0.232 292.759);
--color-violet-900: oklch(38% 0.189 293.745);
--color-violet-950: oklch(28.3% 0.141 291.089);
--color-purple-50: oklch(97.7% 0.014 308.299);
--color-purple-100: oklch(94.6% 0.033 307.174);
--color-purple-200: oklch(90.2% 0.063 306.703);
--color-purple-300: oklch(82.7% 0.119 306.383);
--color-purple-400: oklch(71.4% 0.203 305.504);
--color-purple-500: oklch(62.7% 0.265 303.9);
--color-purple-600: oklch(55.8% 0.288 302.321);
--color-purple-700: oklch(49.6% 0.265 301.924);
--color-purple-800: oklch(43.8% 0.218 303.724);
--color-purple-900: oklch(38.1% 0.176 304.987);
--color-purple-950: oklch(29.1% 0.149 302.717);
--color-fuchsia-50: oklch(97.7% 0.017 320.058);
--color-fuchsia-100: oklch(95.2% 0.037 318.852);
--color-fuchsia-200: oklch(90.3% 0.076 319.62);
--color-fuchsia-300: oklch(83.3% 0.145 321.434);
--color-fuchsia-400: oklch(74% 0.238 322.16);
--color-fuchsia-500: oklch(66.7% 0.295 322.15);
--color-fuchsia-600: oklch(59.1% 0.293 322.896);
--color-fuchsia-700: oklch(51.8% 0.253 323.949);
--color-fuchsia-800: oklch(45.2% 0.211 324.591);
--color-fuchsia-900: oklch(40.1% 0.17 325.612);
--color-fuchsia-950: oklch(29.3% 0.136 325.661);
--color-pink-50: oklch(97.1% 0.014 343.198);
--color-pink-100: oklch(94.8% 0.028 342.258);
--color-pink-200: oklch(89.9% 0.061 343.231);
--color-pink-300: oklch(82.3% 0.12 346.018);
--color-pink-400: oklch(71.8% 0.202 349.761);
--color-pink-500: oklch(65.6% 0.241 354.308);
--color-pink-600: oklch(59.2% 0.249 0.584);
--color-pink-700: oklch(52.5% 0.223 3.958);
--color-pink-800: oklch(45.9% 0.187 3.815);
--color-pink-900: oklch(40.8% 0.153 2.432);
--color-pink-950: oklch(28.4% 0.109 3.907);
--color-rose-50: oklch(96.9% 0.015 12.422);
--color-rose-100: oklch(94.1% 0.03 12.58);
--color-rose-200: oklch(89.2% 0.058 10.001);
--color-rose-300: oklch(81% 0.117 11.638);
--color-rose-400: oklch(71.2% 0.194 13.428);
--color-rose-500: oklch(64.5% 0.246 16.439);
--color-rose-600: oklch(58.6% 0.253 17.585);
--color-rose-700: oklch(51.4% 0.222 16.935);
--color-rose-800: oklch(45.5% 0.188 13.697);
--color-rose-900: oklch(41% 0.159 10.272);
--color-rose-950: oklch(27.1% 0.105 12.094);
--color-slate-50: oklch(98.4% 0.003 247.858);
--color-slate-100: oklch(96.8% 0.007 247.896);
--color-slate-200: oklch(92.9% 0.013 255.508);
--color-slate-300: oklch(86.9% 0.022 252.894);
--color-slate-400: oklch(70.4% 0.04 256.788);
--color-slate-500: oklch(55.4% 0.046 257.417);
--color-slate-600: oklch(44.6% 0.043 257.281);
--color-slate-700: oklch(37.2% 0.044 257.287);
--color-slate-800: oklch(27.9% 0.041 260.031);
--color-slate-900: oklch(20.8% 0.042 265.755);
--color-slate-950: oklch(12.9% 0.042 264.695);
--color-gray-50: oklch(98.5% 0.002 247.839);
--color-gray-100: oklch(96.7% 0.003 264.542);
--color-gray-200: oklch(92.8% 0.006 264.531);
--color-gray-300: oklch(87.2% 0.01 258.338);
--color-gray-400: oklch(70.7% 0.022 261.325);
--color-gray-500: oklch(55.1% 0.027 264.364);
--color-gray-600: oklch(44.6% 0.03 256.802);
--color-gray-700: oklch(37.3% 0.034 259.733);
--color-gray-800: oklch(27.8% 0.033 256.848);
--color-gray-900: oklch(21% 0.034 264.665);
--color-gray-950: oklch(13% 0.028 261.692);
--color-zinc-50: oklch(98.5% 0 0);
--color-zinc-100: oklch(96.7% 0.001 286.375);
--color-zinc-200: oklch(92% 0.004 286.32);
--color-zinc-300: oklch(87.1% 0.006 286.286);
--color-zinc-400: oklch(70.5% 0.015 286.067);
--color-zinc-500: oklch(55.2% 0.016 285.938);
--color-zinc-600: oklch(44.2% 0.017 285.786);
--color-zinc-700: oklch(37% 0.013 285.805);
--color-zinc-800: oklch(27.4% 0.006 286.033);
--color-zinc-900: oklch(21% 0.006 285.885);
--color-zinc-950: oklch(14.1% 0.005 285.823);
--color-neutral-50: oklch(98.5% 0 0);
--color-neutral-100: oklch(97% 0 0);
--color-neutral-200: oklch(92.2% 0 0);
--color-neutral-300: oklch(87% 0 0);
--color-neutral-400: oklch(70.8% 0 0);
--color-neutral-500: oklch(55.6% 0 0);
--color-neutral-600: oklch(43.9% 0 0);
--color-neutral-700: oklch(37.1% 0 0);
--color-neutral-800: oklch(26.9% 0 0);
--color-neutral-900: oklch(20.5% 0 0);
--color-neutral-950: oklch(14.5% 0 0);
--color-stone-50: oklch(98.5% 0.001 106.423);
--color-stone-100: oklch(97% 0.001 106.424);
--color-stone-200: oklch(92.3% 0.003 48.717);
--color-stone-300: oklch(86.9% 0.005 56.366);
--color-stone-400: oklch(70.9% 0.01 56.259);
--color-stone-500: oklch(55.3% 0.013 58.071);
--color-stone-600: oklch(44.4% 0.011 73.639);
--color-stone-700: oklch(37.4% 0.01 67.558);
--color-stone-800: oklch(26.8% 0.007 34.298);
--color-stone-900: oklch(21.6% 0.006 56.043);
--color-stone-950: oklch(14.7% 0.004 49.25);
--color-black: #000;
--color-white: #fff;
--spacing: 0.25rem;
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
--container-3xs: 16rem;
--container-2xs: 18rem;
--container-xs: 20rem;
--container-sm: 24rem;
--container-md: 28rem;
--container-lg: 32rem;
--container-xl: 36rem;
--container-2xl: 42rem;
--container-3xl: 48rem;
--container-4xl: 56rem;
--container-5xl: 64rem;
--container-6xl: 72rem;
--container-7xl: 80rem;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-base: 1rem;
--text-base--line-height: calc(1.5 / 1);
--text-lg: 1.125rem;
--text-lg--line-height: calc(1.75 / 1.125);
--text-xl: 1.25rem;
--text-xl--line-height: calc(1.75 / 1.25);
--text-2xl: 1.5rem;
--text-2xl--line-height: calc(2 / 1.5);
--text-3xl: 1.875rem;
--text-3xl--line-height: calc(2.25 / 1.875);
--text-4xl: 2.25rem;
--text-4xl--line-height: calc(2.5 / 2.25);
--text-5xl: 3rem;
--text-5xl--line-height: 1;
--text-6xl: 3.75rem;
--text-6xl--line-height: 1;
--text-7xl: 4.5rem;
--text-7xl--line-height: 1;
--text-8xl: 6rem;
--text-8xl--line-height: 1;
--text-9xl: 8rem;
--text-9xl--line-height: 1;
--font-weight-thin: 100;
--font-weight-extralight: 200;
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
--font-weight-black: 900;
--tracking-tighter: -0.05em;
--tracking-tight: -0.025em;
--tracking-normal: 0em;
--tracking-wide: 0.025em;
--tracking-wider: 0.05em;
--tracking-widest: 0.1em;
--leading-tight: 1.25;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--leading-loose: 2;
--radius-xs: 0.125rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-2xl: 1rem;
--radius-3xl: 1.5rem;
--radius-4xl: 2rem;
--shadow-2xs: 0 1px rgb(0 0 0 / 0.05);
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-md:
0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg:
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl:
0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
--inset-shadow-2xs: inset 0 1px rgb(0 0 0 / 0.05);
--inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05);
--inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05);
--drop-shadow-xs: 0 1px 1px rgb(0 0 0 / 0.05);
--drop-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.15);
--drop-shadow-md: 0 3px 3px rgb(0 0 0 / 0.12);
--drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15);
--drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1);
--drop-shadow-2xl: 0 25px 25px rgb(0 0 0 / 0.15);
--text-shadow-2xs: 0px 1px 0px rgb(0 0 0 / 0.15);
--text-shadow-xs: 0px 1px 1px rgb(0 0 0 / 0.2);
--text-shadow-sm:
0px 1px 0px rgb(0 0 0 / 0.075), 0px 1px 1px rgb(0 0 0 / 0.075),
0px 2px 2px rgb(0 0 0 / 0.075);
--text-shadow-md:
0px 1px 1px rgb(0 0 0 / 0.1), 0px 1px 2px rgb(0 0 0 / 0.1),
0px 2px 4px rgb(0 0 0 / 0.1);
--text-shadow-lg:
0px 1px 2px rgb(0 0 0 / 0.1), 0px 3px 2px rgb(0 0 0 / 0.1),
0px 4px 8px rgb(0 0 0 / 0.1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--animate-spin: spin 1s linear infinite;
--animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-bounce: bounce 1s infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes ping {
75%,
100% {
transform: scale(2);
opacity: 0;
}
}
@keyframes pulse {
50% {
opacity: 0.5;
}
}
@keyframes bounce {
0%,
100% {
transform: translateY(-25%);
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
}
50% {
transform: none;
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
}
--blur-xs: 4px;
--blur-sm: 8px;
--blur-md: 12px;
--blur-lg: 16px;
--blur-xl: 24px;
--blur-2xl: 40px;
--blur-3xl: 64px;
--perspective-dramatic: 100px;
--perspective-near: 300px;
--perspective-normal: 500px;
--perspective-midrange: 800px;
--perspective-distant: 1200px;
--aspect-video: 16 / 9;
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: --theme(--font-sans, initial);
--default-font-feature-settings: --theme(
--font-sans--font-feature-settings,
initial
);
--default-font-variation-settings: --theme(
--font-sans--font-variation-settings,
initial
);
--default-mono-font-family: --theme(--font-mono, initial);
--default-mono-font-feature-settings: --theme(
--font-mono--font-feature-settings,
initial
);
--default-mono-font-variation-settings: --theme(
--font-mono--font-variation-settings,
initial
);
}
/* Deprecated */
@theme default inline reference {
--blur: 8px;
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
--drop-shadow: 0 1px 2px rgb(0 0 0 / 0.1), 0 1px 1px rgb(0 0 0 / 0.06);
--radius: 0.25rem;
--max-width-prose: 65ch;
}
}
@layer base {
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Remove default margins and padding
3. Reset all borders.
*/
*,
::after,
::before,
::backdrop,
::file-selector-button {
box-sizing: border-box; /* 1 */
margin: 0; /* 2 */
padding: 0; /* 2 */
border: 0 solid; /* 3 */
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS.
*/
html,
:host {
line-height: 1.5; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
tab-size: 4; /* 3 */
font-family: --theme(
--default-font-family,
ui-sans-serif,
system-ui,
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji"
); /* 4 */
font-feature-settings: --theme(
--default-font-feature-settings,
normal
); /* 5 */
font-variation-settings: --theme(
--default-font-variation-settings,
normal
); /* 6 */
-webkit-tap-highlight-color: transparent; /* 7 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Reset the default border style to a 1px solid border.
*/
hr {
height: 0; /* 1 */
color: inherit; /* 2 */
border-top-width: 1px; /* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font-family by default.
2. Use the user's configured `mono` font-feature-settings by default.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: --theme(
--default-mono-font-family,
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
"Liberation Mono",
"Courier New",
monospace
); /* 1 */
font-feature-settings: --theme(
--default-mono-font-feature-settings,
normal
); /* 2 */
font-variation-settings: --theme(
--default-mono-font-variation-settings,
normal
); /* 3 */
font-size: 1em; /* 4 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0; /* 1 */
border-color: inherit; /* 2 */
border-collapse: collapse; /* 3 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Make lists unstyled by default.
*/
ol,
ul,
menu {
list-style: none;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block; /* 1 */
vertical-align: middle; /* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/*
1. Inherit font styles in all browsers.
2. Remove border radius in all browsers.
3. Remove background color in all browsers.
4. Ensure consistent opacity for disabled states in all browsers.
*/
button,
input,
select,
optgroup,
textarea,
::file-selector-button {
font: inherit; /* 1 */
font-feature-settings: inherit; /* 1 */
font-variation-settings: inherit; /* 1 */
letter-spacing: inherit; /* 1 */
color: inherit; /* 1 */
border-radius: 0; /* 2 */
background-color: transparent; /* 3 */
opacity: 1; /* 4 */
}
/*
Restore default font weight.
*/
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
/*
Restore indentation.
*/
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
/*
Restore space after button.
*/
::file-selector-button {
margin-inline-end: 4px;
}
/*
Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
*/
::placeholder {
opacity: 1;
}
/*
Set the default placeholder color to a semi-transparent version of the current text color in browsers that do not
crash when using `color-mix(…)` with `currentcolor`. (https://github.com/tailwindlabs/tailwindcss/issues/17194)
*/
@supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or
(contain-intrinsic-size: 1px) /* Safari 17+ */ {
::placeholder {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Ensure date/time inputs have the same height when empty in iOS Safari.
2. Ensure text alignment can be changed on date/time inputs in iOS Safari.
*/
::-webkit-date-and-time-value {
min-height: 1lh; /* 1 */
text-align: inherit; /* 2 */
}
/*
Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`.
*/
::-webkit-datetime-edit {
display: inline-flex;
}
/*
Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers.
*/
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit,
::-webkit-datetime-edit-year-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-minute-field,
::-webkit-datetime-edit-second-field,
::-webkit-datetime-edit-millisecond-field,
::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
/*
Center dropdown marker shown on inputs with paired `<datalist>`s in Chrome. (https://github.com/tailwindlabs/tailwindcss/issues/18499)
*/
::-webkit-calendar-picker-indicator {
line-height: 1;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Correct the inability to style the border radius in iOS Safari.
*/
button,
input:where([type="button"], [type="reset"], [type="submit"]),
::file-selector-button {
appearance: button;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
Make elements with the HTML hidden attribute stay hidden by default.
*/
[hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
}
@layer utilities {
@tailwind utilities;
}
@@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=https://cdn.jsdelivr.net/npm/tailwindcss@4.1.18/index.min.css
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=https://cdn.jsdelivr.net.cn/npm/daisyui@5/themes.css
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=https://cdn.jsdelivr.net/npm/alpinejs@3.15.8/+esm
@@ -0,0 +1,807 @@
/**
* Home4j - Alpine.js 应用
* 主题切换、语言切换、天气、日期时间等功能
*/
// ==================== 图标定义 ====================
var ICONS = {
home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 1v2m0 18v2m-9-11h2m18 0h2m-4.2-5.8 1.4-1.4m-12.8 0L6.3 6.3m0 11.4-1.4 1.4m12.8 0 1.4-1.4"/>',
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
edit: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
folder: '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
grid: '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>',
list: '<line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
inbox: '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
database: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/>',
server: '<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>',
cloud: '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>',
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
bookmark: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>',
github: '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>',
youtube: '<path d="M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17"/><path d="m10 15 5-3-5-3z"/>',
tool: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
zap: '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
mail: '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
terminal: '<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>',
music: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
video: '<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5"/><rect x="2" y="6" width="14" height="12" rx="2"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>',
menu: '<line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/>',
chevronDown: '<path d="m6 9 6 6 6-6"/>'
};
/**
* 生成 SVG 图标 HTML
*/
function getIcon(name, size) {
size = size || 24;
var path = ICONS[name] || ICONS['link'];
return '<svg xmlns="http://www.w3.org/2000/svg" width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + path + '</svg>';
}
// ==================== 国际化文本 ====================
var I18N = {
'zh-CN': {
appName: 'Home4j',
search: '搜索书签...',
viewMode: '视图模式',
listView: '列表视图',
cardView: '卡片视图',
cardMode: '卡片样式',
iconMode: '图标模式',
imageMode: '背景图模式',
theme: '主题',
language: '语言',
addBookmark: '添加书签',
addGroup: '添加分组',
background: '背景设置',
bgSettings: '背景设置',
account: '账户',
adminPanel: '管理面板',
logout: '退出登录',
login: '登录',
loginBtn: '登录',
username: '用户名',
password: '密码',
heroSubtitle: '快速访问您的常用网站',
searchBookmark: '搜索书签',
searchEngines: '搜索引擎',
searchGoogle: '使用 Google 搜索...',
searchBing: '使用 Bing 搜索...',
searchBaidu: '使用百度搜索...',
searchDuckDuckGo: '使用 DuckDuckGo 搜索...',
baidu: '百度',
emptyTitle: '还没有书签',
emptyDesc: '点击上方按钮开始添加书签和分组',
edit: '编辑',
delete: '删除',
bookmarkName: '名称',
bookmarkNamePlaceholder: '书签名称',
description: '描述',
descriptionPlaceholder: '书签描述(可选)',
icon: '图标',
group: '分组',
selectGroup: '选择分组',
displayMode: '显示模式',
backgroundImage: '背景图片',
bgImagePlaceholder: 'https://example.com/image.jpg',
openInNewTab: '在新标签页打开',
save: '保存',
add: '添加',
cancel: '取消',
groupName: '分组名称',
groupNamePlaceholder: '分组名称',
sortOrder: '排序',
sortOrderHint: '数字越小越靠前',
bgType: '背景类型',
bgTheme: '跟随主题',
bgColor: '纯色背景',
bgImage: '图片背景',
selectColor: '选择颜色',
imageUrl: '图片URL',
saveSuccess: '保存成功',
saveFailed: '保存失败',
deleteSuccess: '删除成功',
deleteFailed: '删除失败',
confirmDelete: '确定要删除吗?',
confirmDeleteGroup: '确定要删除这个分组及其所有书签吗?'
},
'en-US': {
appName: 'Home4j',
search: 'Search bookmarks...',
viewMode: 'View Mode',
listView: 'List View',
cardView: 'Card View',
cardMode: 'Card Style',
iconMode: 'Icon Mode',
imageMode: 'Image Mode',
theme: 'Theme',
language: 'Language',
addBookmark: 'Add Bookmark',
addGroup: 'Add Group',
background: 'Background',
bgSettings: 'Background Settings',
account: 'Account',
adminPanel: 'Admin Panel',
logout: 'Logout',
login: 'Login',
loginBtn: 'Sign In',
username: 'Username',
password: 'Password',
heroSubtitle: 'Quick access to your favorite sites',
searchBookmark: 'Search Bookmarks',
searchEngines: 'Search Engines',
searchGoogle: 'Search with Google...',
searchBing: 'Search with Bing...',
searchBaidu: 'Search with Baidu...',
searchDuckDuckGo: 'Search with DuckDuckGo...',
baidu: 'Baidu',
emptyTitle: 'No bookmarks yet',
emptyDesc: 'Click the button above to start adding bookmarks and groups',
edit: 'Edit',
delete: 'Delete',
bookmarkName: 'Name',
bookmarkNamePlaceholder: 'Bookmark name',
description: 'Description',
descriptionPlaceholder: 'Bookmark description (optional)',
icon: 'Icon',
group: 'Group',
selectGroup: 'Select group',
displayMode: 'Display Mode',
backgroundImage: 'Background Image',
bgImagePlaceholder: 'https://example.com/image.jpg',
openInNewTab: 'Open in new tab',
save: 'Save',
add: 'Add',
cancel: 'Cancel',
groupName: 'Group Name',
groupNamePlaceholder: 'Group name',
sortOrder: 'Sort Order',
sortOrderHint: 'Lower number comes first',
bgType: 'Background Type',
bgTheme: 'Follow Theme',
bgColor: 'Solid Color',
bgImage: 'Image',
selectColor: 'Select Color',
imageUrl: 'Image URL',
saveSuccess: 'Saved successfully',
saveFailed: 'Failed to save',
deleteSuccess: 'Deleted successfully',
deleteFailed: 'Failed to delete',
confirmDelete: 'Are you sure you want to delete?',
confirmDeleteGroup: 'Delete this group and all its bookmarks?'
}
};
// ==================== 主题配置 ====================
var THEMES = [
{ id: 'light', name: 'Light', colors: { primary: '#570df8', secondary: '#f000b8', accent: '#37cdbe' } },
{ id: 'dark', name: 'Dark', colors: { primary: '#661ae6', secondary: '#d926aa', accent: '#1fb2a5' } },
{ id: 'cupcake', name: 'Cupcake', colors: { primary: '#65c3c8', secondary: '#ef9fbc', accent: '#eeaf3a' } },
{ id: 'bumblebee', name: 'Bumblebee', colors: { primary: '#e0a82e', secondary: '#f9d72f', accent: '#181830' } },
{ id: 'emerald', name: 'Emerald', colors: { primary: '#66cc8a', secondary: '#377cfb', accent: '#ea5234' } },
{ id: 'corporate', name: 'Corporate', colors: { primary: '#4b6bfb', secondary: '#7b92b2', accent: '#67cba0' } },
{ id: 'synthwave', name: 'Synthwave', colors: { primary: '#e779c1', secondary: '#58c7f3', accent: '#f3cc30' } },
{ id: 'retro', name: 'Retro', colors: { primary: '#ef9995', secondary: '#a4cbb4', accent: '#dc8850' } },
{ id: 'cyberpunk', name: 'Cyberpunk', colors: { primary: '#ff7598', secondary: '#75d1f0', accent: '#c07eec' } },
{ id: 'valentine', name: 'Valentine', colors: { primary: '#e96d7b', secondary: '#a991f7', accent: '#88dbdd' } },
{ id: 'halloween', name: 'Halloween', colors: { primary: '#f28c18', secondary: '#6d3a9c', accent: '#51a800' } },
{ id: 'garden', name: 'Garden', colors: { primary: '#5c7f67', secondary: '#ecf4e7', accent: '#fae5e5' } },
{ id: 'forest', name: 'Forest', colors: { primary: '#1eb854', secondary: '#1db990', accent: '#1db9ac' } },
{ id: 'aqua', name: 'Aqua', colors: { primary: '#09ecf3', secondary: '#966fb3', accent: '#ffe999' } },
{ id: 'lofi', name: 'Lo-Fi', colors: { primary: '#0d0d0d', secondary: '#1a1919', accent: '#262626' } },
{ id: 'pastel', name: 'Pastel', colors: { primary: '#d1c1d7', secondary: '#f6cbd1', accent: '#b4e9d6' } },
{ id: 'fantasy', name: 'Fantasy', colors: { primary: '#6e0b75', secondary: '#007ebd', accent: '#f8860d' } },
{ id: 'wireframe', name: 'Wireframe', colors: { primary: '#b8b8b8', secondary: '#b8b8b8', accent: '#b8b8b8' } },
{ id: 'black', name: 'Black', colors: { primary: '#343232', secondary: '#343232', accent: '#343232' } },
{ id: 'luxury', name: 'Luxury', colors: { primary: '#ffffff', secondary: '#152747', accent: '#513448' } },
{ id: 'dracula', name: 'Dracula', colors: { primary: '#ff79c6', secondary: '#bd93f9', accent: '#ffb86c' } },
{ id: 'cmyk', name: 'CMYK', colors: { primary: '#45aeee', secondary: '#e8488a', accent: '#fff232' } },
{ id: 'autumn', name: 'Autumn', colors: { primary: '#8c0327', secondary: '#d85251', accent: '#d59b6a' } },
{ id: 'business', name: 'Business', colors: { primary: '#1c4f82', secondary: '#7c909a', accent: '#eb6b47' } },
{ id: 'acid', name: 'Acid', colors: { primary: '#ff00f4', secondary: '#ff7400', accent: '#cbff00' } },
{ id: 'lemonade', name: 'Lemonade', colors: { primary: '#519903', secondary: '#e9e92e', accent: '#f7f7f7' } },
{ id: 'night', name: 'Night', colors: { primary: '#38bdf8', secondary: '#818cf8', accent: '#f471b5' } },
{ id: 'coffee', name: 'Coffee', colors: { primary: '#db924b', secondary: '#6f4e37', accent: '#263e3f' } },
{ id: 'winter', name: 'Winter', colors: { primary: '#047aff', secondary: '#463aa2', accent: '#c149ad' } },
{ id: 'dim', name: 'Dim', colors: { primary: '#9fe88d', secondary: '#ff7d5c', accent: '#c792e9' } },
{ id: 'nord', name: 'Nord', colors: { primary: '#5e81ac', secondary: '#81a1c1', accent: '#88c0d0' } },
{ id: 'sunset', name: 'Sunset', colors: { primary: '#ff865b', secondary: '#fd6f9c', accent: '#b387fa' } }
];
// ==================== 天气图标映射 ====================
var WEATHER_ICONS = {
'113': '☀️', // Sunny
'116': '⛅', // Partly cloudy
'119': '☁️', // Cloudy
'122': '☁️', // Overcast
'143': '🌫️', // Mist
'176': '🌧️', // Patchy rain
'179': '🌨️', // Patchy snow
'182': '🌨️', // Patchy sleet
'185': '🌨️', // Patchy freezing drizzle
'200': '⛈️', // Thundery outbreaks
'227': '❄️', // Blowing snow
'230': '❄️', // Blizzard
'248': '🌫️', // Fog
'260': '🌫️', // Freezing fog
'263': '🌧️', // Patchy light drizzle
'266': '🌧️', // Light drizzle
'281': '🌧️', // Freezing drizzle
'284': '🌧️', // Heavy freezing drizzle
'293': '🌧️', // Patchy light rain
'296': '🌧️', // Light rain
'299': '🌧️', // Moderate rain at times
'302': '🌧️', // Moderate rain
'305': '🌧️', // Heavy rain at times
'308': '🌧️', // Heavy rain
'311': '🌧️', // Light freezing rain
'314': '🌧️', // Moderate or heavy freezing rain
'317': '🌨️', // Light sleet
'320': '🌨️', // Moderate or heavy sleet
'323': '🌨️', // Patchy light snow
'326': '🌨️', // Light snow
'329': '🌨️', // Patchy moderate snow
'332': '🌨️', // Moderate snow
'335': '🌨️', // Patchy heavy snow
'338': '❄️', // Heavy snow
'350': '🌨️', // Ice pellets
'353': '🌧️', // Light rain shower
'356': '🌧️', // Moderate or heavy rain shower
'359': '🌧️', // Torrential rain shower
'362': '🌨️', // Light sleet showers
'365': '🌨️', // Moderate or heavy sleet showers
'368': '🌨️', // Light snow showers
'371': '🌨️', // Moderate or heavy snow showers
'386': '⛈️', // Patchy light rain with thunder
'389': '⛈️', // Moderate or heavy rain with thunder
'392': '⛈️', // Patchy light snow with thunder
'395': '⛈️' // Moderate or heavy snow with thunder
};
/**
* Home4jApp - Alpine.js 组件
*/
function Home4jApp() {
return {
// ========== 基础状态 ==========
searchQuery: '',
searchMode: localStorage.getItem('home4j_search_mode') || 'bookmark',
viewMode: localStorage.getItem('home4j_view') || 'card',
cardDisplayMode: localStorage.getItem('home4j_card_mode') || 'icon',
currentTheme: localStorage.getItem('home4j_theme') || 'light',
language: localStorage.getItem('home4j_lang') || 'zh-CN',
collapsedGroups: JSON.parse(localStorage.getItem('home4j_collapsed') || '[]'),
// 背景设置
bgType: localStorage.getItem('home4j_bg_type') || 'theme',
bgColor: localStorage.getItem('home4j_bg_color') || '#f5f5f5',
bgImage: localStorage.getItem('home4j_bg_image') || '',
// 日期时间天气
currentDate: '',
currentTime: '',
weather: null,
// 主题列表
themes: THEMES,
// 国际化
i18n: I18N['zh-CN'],
// 抽屉状态
drawerOpen: false,
drawerType: '',
drawerTitle: '',
saving: false,
// 表单
bookmarkForm: {
id: null,
name: '',
url: '',
description: '',
icon: 'link',
groupId: '',
openInNewTab: true,
visible: true,
displayMode: 'icon',
backgroundImage: ''
},
groupForm: {
id: null,
name: '',
icon: 'folder',
sortOrder: 0,
visible: true
},
// Toast
toasts: [],
// ========== 初始化 ==========
init: function() {
var self = this;
// 应用主题
document.documentElement.setAttribute('data-theme', this.currentTheme);
// 应用语言
this.i18n = I18N[this.language] || I18N['zh-CN'];
// 更新日期时间
this.updateDateTime();
setInterval(function() { self.updateDateTime(); }, 1000);
// 获取天气
this.fetchWeather();
// 加载用户设置(如果已登录)
if (window.HOME4J_DATA && window.HOME4J_DATA.isAdmin) {
this.loadUserSettings();
}
},
// ========== 工具方法 ==========
getIcon: function(name, size) {
return getIcon(name, size);
},
// ========== 日期时间 ==========
updateDateTime: function() {
var now = new Date();
var lang = this.language;
// 格式化日期
var dateOptions = { weekday: 'short', month: 'short', day: 'numeric' };
this.currentDate = now.toLocaleDateString(lang === 'zh-CN' ? 'zh-CN' : 'en-US', dateOptions);
// 格式化时间
this.currentTime = now.toLocaleTimeString(lang === 'zh-CN' ? 'zh-CN' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
},
// ========== 天气 ==========
fetchWeather: function() {
var self = this;
var city = localStorage.getItem('home4j_weather_city') || '';
// 使用 wttr.in 免费天气 API
var url = 'https://wttr.in/' + encodeURIComponent(city) + '?format=j1';
fetch(url)
.then(function(res) { return res.json(); })
.then(function(data) {
if (data && data.current_condition && data.current_condition[0]) {
var current = data.current_condition[0];
var code = current.weatherCode;
var icon = WEATHER_ICONS[code] || '🌤️';
self.weather = {
icon: icon,
temp: current.temp_C + '°C',
city: data.nearest_area && data.nearest_area[0] ?
data.nearest_area[0].areaName[0].value : ''
};
}
})
.catch(function(err) {
console.log('Weather fetch failed:', err);
});
},
// ========== 用户设置 ==========
loadUserSettings: function() {
var self = this;
fetch('/api/settings')
.then(function(res) { return res.json(); })
.then(function(data) {
if (data) {
if (data.language) {
self.language = data.language;
self.i18n = I18N[data.language] || I18N['zh-CN'];
localStorage.setItem('home4j_lang', data.language);
}
if (data.bgType) {
self.bgType = data.bgType;
localStorage.setItem('home4j_bg_type', data.bgType);
}
if (data.bgColor) {
self.bgColor = data.bgColor;
localStorage.setItem('home4j_bg_color', data.bgColor);
}
if (data.bgImage) {
self.bgImage = data.bgImage;
localStorage.setItem('home4j_bg_image', data.bgImage);
}
if (data.defaultView) {
self.viewMode = data.defaultView;
localStorage.setItem('home4j_view', data.defaultView);
}
if (data.cardDisplayMode) {
self.cardDisplayMode = data.cardDisplayMode;
localStorage.setItem('home4j_card_mode', data.cardDisplayMode);
}
if (data.weatherCity) {
localStorage.setItem('home4j_weather_city', data.weatherCity);
self.fetchWeather();
}
}
})
.catch(function() {});
},
// ========== 主题 ==========
setTheme: function(theme) {
this.currentTheme = theme;
localStorage.setItem('home4j_theme', theme);
document.documentElement.setAttribute('data-theme', theme);
},
// ========== 语言 ==========
setLanguage: function(lang) {
this.language = lang;
this.i18n = I18N[lang] || I18N['zh-CN'];
localStorage.setItem('home4j_lang', lang);
this.updateDateTime();
// 保存到服务器(如果已登录)
if (window.HOME4J_DATA && window.HOME4J_DATA.isAdmin) {
this.saveUserSetting({ language: lang });
}
},
// ========== 视图模式 ==========
setViewMode: function(mode) {
this.viewMode = mode;
localStorage.setItem('home4j_view', mode);
},
setCardDisplayMode: function(mode) {
this.cardDisplayMode = mode;
localStorage.setItem('home4j_card_mode', mode);
},
// ========== 搜索功能 ==========
getSearchPlaceholder: function() {
var placeholders = {
'bookmark': this.i18n.search,
'google': this.i18n.searchGoogle,
'bing': this.i18n.searchBing,
'baidu': this.i18n.searchBaidu,
'duckduckgo': this.i18n.searchDuckDuckGo
};
return placeholders[this.searchMode] || this.i18n.search;
},
handleSearch: function() {
var query = this.searchQuery.trim();
if (!query) return;
// 保存搜索模式
localStorage.setItem('home4j_search_mode', this.searchMode);
if (this.searchMode === 'bookmark') {
// 书签搜索由 x-model 自动处理,不需要额外操作
return;
}
// 搜索引擎 URL 映射
var searchUrls = {
'google': 'https://www.google.com/search?q=',
'bing': 'https://www.bing.com/search?q=',
'baidu': 'https://www.baidu.com/s?wd=',
'duckduckgo': 'https://duckduckgo.com/?q='
};
var url = searchUrls[this.searchMode];
if (url) {
window.open(url + encodeURIComponent(query), '_blank');
this.searchQuery = ''; // 清空搜索框
}
},
// ========== 分组折叠 ==========
isGroupCollapsed: function(groupId) {
return this.collapsedGroups.indexOf(groupId) !== -1;
},
toggleGroupCollapse: function(groupId) {
var idx = this.collapsedGroups.indexOf(groupId);
if (idx !== -1) {
this.collapsedGroups.splice(idx, 1);
} else {
this.collapsedGroups.push(groupId);
}
localStorage.setItem('home4j_collapsed', JSON.stringify(this.collapsedGroups));
},
// ========== 搜索 ==========
groupHasMatchingBookmarks: function(groupId) {
if (!this.searchQuery) return true;
var query = this.searchQuery.toLowerCase();
var data = window.HOME4J_DATA;
if (!data || !data.groups) return true;
for (var i = 0; i < data.groups.length; i++) {
if (data.groups[i].id == groupId) {
var bookmarks = data.groups[i].bookmarks || [];
for (var j = 0; j < bookmarks.length; j++) {
var b = bookmarks[j];
if ((b.name && b.name.toLowerCase().indexOf(query) !== -1) ||
(b.description && b.description.toLowerCase().indexOf(query) !== -1) ||
(b.url && b.url.toLowerCase().indexOf(query) !== -1)) {
return true;
}
}
return false;
}
}
return true;
},
bookmarkMatchesSearch: function(name, desc, url) {
if (!this.searchQuery) return true;
var query = this.searchQuery.toLowerCase();
name = name || '';
desc = desc || '';
url = url || '';
return name.toLowerCase().indexOf(query) !== -1 ||
desc.toLowerCase().indexOf(query) !== -1 ||
url.toLowerCase().indexOf(query) !== -1;
},
// ========== 抽屉操作 ==========
openDrawer: function(type, title) {
this.drawerType = type;
this.drawerTitle = title;
this.drawerOpen = true;
document.getElementById('app-drawer').checked = true;
},
closeDrawer: function() {
this.drawerOpen = false;
document.getElementById('app-drawer').checked = false;
},
openAddBookmarkDrawer: function() {
this.bookmarkForm = {
id: null,
name: '',
url: '',
description: '',
icon: 'link',
groupId: '',
openInNewTab: true,
visible: true,
displayMode: 'icon',
backgroundImage: ''
};
this.openDrawer('bookmark', this.i18n.addBookmark);
},
openEditBookmarkDrawer: function(id) {
var self = this;
var data = window.HOME4J_DATA;
if (data && data.groups) {
for (var i = 0; i < data.groups.length; i++) {
var bookmarks = data.groups[i].bookmarks || [];
for (var j = 0; j < bookmarks.length; j++) {
if (bookmarks[j].id == id) {
var b = bookmarks[j];
self.bookmarkForm = {
id: b.id,
name: b.name || '',
url: b.url || '',
description: b.description || '',
icon: b.icon || 'link',
groupId: b.groupId || data.groups[i].id,
openInNewTab: b.openInNewTab !== false,
visible: b.visible !== false,
displayMode: b.displayMode || 'icon',
backgroundImage: b.backgroundImage || ''
};
self.openDrawer('bookmark', self.i18n.edit + ' ' + self.i18n.bookmarkName);
return;
}
}
}
}
},
openAddGroupDrawer: function() {
this.groupForm = {
id: null,
name: '',
icon: 'folder',
sortOrder: 0,
visible: true
};
this.openDrawer('group', this.i18n.addGroup);
},
openEditGroupDrawer: function(id) {
var self = this;
var data = window.HOME4J_DATA;
if (data && data.groups) {
for (var i = 0; i < data.groups.length; i++) {
if (data.groups[i].id == id) {
var g = data.groups[i];
self.groupForm = {
id: g.id,
name: g.name || '',
icon: g.icon || 'folder',
sortOrder: g.sortOrder || 0,
visible: g.visible !== false
};
self.openDrawer('group', self.i18n.edit + ' ' + self.i18n.groupName);
return;
}
}
}
},
openBackgroundSettings: function() {
this.openDrawer('background', this.i18n.bgSettings);
},
// ========== 保存操作 ==========
saveBookmark: function() {
var self = this;
self.saving = true;
var method = self.bookmarkForm.id ? 'PUT' : 'POST';
var url = self.bookmarkForm.id
? '/api/admin/bookmarks/' + self.bookmarkForm.id
: '/api/admin/bookmarks';
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(self.bookmarkForm)
})
.then(function(res) {
if (res.ok) {
self.showToast(self.i18n.saveSuccess, 'success');
self.closeDrawer();
setTimeout(function() { location.reload(); }, 500);
} else {
self.showToast(self.i18n.saveFailed, 'error');
}
})
.catch(function() {
self.showToast(self.i18n.saveFailed, 'error');
})
.finally(function() {
self.saving = false;
});
},
saveGroup: function() {
var self = this;
self.saving = true;
var method = self.groupForm.id ? 'PUT' : 'POST';
var url = self.groupForm.id
? '/api/admin/groups/' + self.groupForm.id
: '/api/admin/groups';
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(self.groupForm)
})
.then(function(res) {
if (res.ok) {
self.showToast(self.i18n.saveSuccess, 'success');
self.closeDrawer();
setTimeout(function() { location.reload(); }, 500);
} else {
self.showToast(self.i18n.saveFailed, 'error');
}
})
.catch(function() {
self.showToast(self.i18n.saveFailed, 'error');
})
.finally(function() {
self.saving = false;
});
},
saveBackground: function() {
var self = this;
self.saving = true;
// 保存到本地
localStorage.setItem('home4j_bg_type', this.bgType);
localStorage.setItem('home4j_bg_color', this.bgColor);
localStorage.setItem('home4j_bg_image', this.bgImage);
// 保存到服务器
this.saveUserSetting({
bgType: this.bgType,
bgColor: this.bgColor,
bgImage: this.bgImage
}).finally(function() {
self.saving = false;
self.showToast(self.i18n.saveSuccess, 'success');
self.closeDrawer();
});
},
saveUserSetting: function(settings) {
return fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
}).catch(function() {});
},
// ========== 删除操作 ==========
deleteBookmark: function(id) {
var self = this;
if (!confirm(this.i18n.confirmDelete)) return;
fetch('/api/admin/bookmarks/' + id, { method: 'DELETE' })
.then(function(res) {
if (res.ok) {
self.showToast(self.i18n.deleteSuccess, 'success');
setTimeout(function() { location.reload(); }, 500);
} else {
self.showToast(self.i18n.deleteFailed, 'error');
}
})
.catch(function() {
self.showToast(self.i18n.deleteFailed, 'error');
});
},
deleteGroup: function(id) {
var self = this;
if (!confirm(this.i18n.confirmDeleteGroup)) return;
fetch('/api/admin/groups/' + id, { method: 'DELETE' })
.then(function(res) {
if (res.ok) {
self.showToast(self.i18n.deleteSuccess, 'success');
setTimeout(function() { location.reload(); }, 500);
} else {
self.showToast(self.i18n.deleteFailed, 'error');
}
})
.catch(function() {
self.showToast(self.i18n.deleteFailed, 'error');
});
},
// ========== Toast ==========
showToast: function(message, type) {
var self = this;
var id = Date.now();
self.toasts.push({ id: id, message: message, type: type || 'info' });
setTimeout(function() {
self.toasts = self.toasts.filter(function(t) { return t.id !== id; });
}, 3000);
}
};
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=https://cdn.jsdelivr.net.cn/npm/@tailwindcss/browser@4
@@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using Terser v5.39.0.
* Original file: /npm/daisyui@5.5.17/index.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
const version="5.5.17";import{pluginOptionsHandler}from"./functions/pluginOptionsHandler.js";import{plugin}from"./functions/plugin.js";import variables from"./functions/variables.js";import themesObject from"./theme/object.js";import{base,components,utilities}from"./imports.js";export default plugin.withOptions((e=>({addBase:i,addComponents:r,addUtilities:t,addVariant:s})=>{const{include:n,exclude:d,prefix:o=""}=pluginOptionsHandler(e,i,themesObject,version),a=e=>n&&d?n.includes(e)&&!d.includes(e):n?n.includes(e):!d||!d.includes(e);Object.entries(base).forEach((([e,r])=>{a(e)&&r({addBase:i,prefix:o})})),Object.entries(components).forEach((([e,i])=>{a(e)&&i({addComponents:r,prefix:o})})),Object.entries(utilities).forEach((([e,i])=>{a(e)&&i({addUtilities:t,prefix:o})})),s(`${o}is-drawer-close`,`&:where(.${o}drawer-toggle:not(:checked) ~ .${o}drawer-side, .${o}drawer-toggle:not(:checked) ~ .${o}drawer-side *)`),s(`${o}is-drawer-open`,`&:where(.${o}drawer-toggle:checked ~ .${o}drawer-side, .${o}drawer-toggle:checked ~ .${o}drawer-side *)`)}),(()=>({theme:{extend:variables}})));
//# sourceMappingURL=/sm/eab12cceb9259b9bb043ffb4983e9422e8bffebed87c813f98152900299ba582.map
@@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=https://cdn.jsdelivr.net/npm/daisyui@5.5.17/index.min.js
@@ -0,0 +1,232 @@
/**
* Home4j - Lucide Icons Subset
* ============================
* 常用图标的 SVG 定义
*/
const ICONS = {
// 通用
'home': '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
'search': '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'plus': '<path d="M5 12h14"/><path d="M12 5v14"/>',
'x': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
'edit': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>',
'trash': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
'check': '<polyline points="20 6 9 17 4 12"/>',
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
'eye': '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
'eye-off': '<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>',
'grip-vertical': '<circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/>',
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
'log-in': '<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" y2="12"/>',
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" x2="21" y1="14" y2="3"/>',
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
'folder-open': '<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/>',
'image': '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>',
// 主题
'sun': '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
'moon': '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
'palette': '<circle cx="13.5" cy="6.5" r=".5"/><circle cx="17.5" cy="10.5" r=".5"/><circle cx="8.5" cy="7.5" r=".5"/><circle cx="6.5" cy="12.5" r=".5"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.555C21.965 6.012 17.461 2 12 2z"/>',
// 视图
'grid': '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>',
'list': '<line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/>',
// 社交/品牌
'github': '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>',
'twitter': '<path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z"/>',
'youtube': '<path d="M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17"/><path d="m10 15 5-3-5-3z"/>',
'facebook': '<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/>',
'instagram': '<rect width="20" height="20" x="2" y="2" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" x2="17.51" y1="6.5" y2="6.5"/>',
'linkedin': '<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect width="4" height="12" x="2" y="9"/><circle cx="4" cy="4" r="2"/>',
// 通讯
'mail': '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
'message-circle': '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
'phone': '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>',
// 开发
'code': '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
'terminal': '<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>',
'database': '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>',
'server': '<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>',
'cloud': '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>',
'git-branch': '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" x2="22" y1="12" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
// 工具
'wrench': '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
'tool': '<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z"/><path d="m18 13-1.375-1.375a1 1 0 0 1 0-1.414L18 9"/><path d="m6 6 6-3 6 3v3"/><path d="m9 6v6h6"/><path d="M6 6h.01"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
'file': '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/>',
'file-text': '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" x2="8" y1="13" y2="13"/><line x1="16" x2="8" y1="17" y2="17"/><line x1="10" x2="8" y1="9" y2="9"/>',
'bookmark': '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>',
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
'heart': '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
'coffee': '<path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/>',
// 媒体
'music': '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
'video': '<path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/>',
'camera': '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
'headphones': '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>',
// 购物/金融
'shopping-cart': '<circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/>',
'credit-card': '<rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/>',
'dollar-sign': '<line x1="12" x2="12" y1="2" y2="22"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
// 用户
'user': '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
'users': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
// 其他常用
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'calendar': '<rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/>',
'map-pin': '<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>',
'bell': '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
'lock': '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'unlock': '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/>',
'shield': '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10"/>',
'info': '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
'alert-circle': '<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>',
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
'refresh-cw': '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
'copy': '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
'clipboard': '<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
'archive': '<rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/>',
'box': '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
'package': '<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
'layers': '<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/>',
'layout': '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><line x1="3" x2="21" y1="9" y2="9"/><line x1="9" x2="9" y1="21" y2="9"/>',
'monitor': '<rect width="20" height="14" x="2" y="3" rx="2" ry="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
'smartphone': '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
'tablet': '<rect width="16" height="20" x="4" y="2" rx="2" ry="2"/><line x1="12" x2="12.01" y1="18" y2="18"/>',
'laptop': '<path d="M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9m16 0H4m16 0 1.28 2.55a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45L4 16"/>',
'tv': '<rect width="20" height="15" x="2" y="7" rx="2" ry="2"/><polyline points="17 2 12 7 7 2"/>',
'printer': '<polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect width="12" height="8" x="6" y="14"/>',
'wifi': '<path d="M5 13a10 10 0 0 1 14 0"/><path d="M8.5 16.5a5 5 0 0 1 7 0"/><path d="M2 8.82a15 15 0 0 1 20 0"/><line x1="12" x2="12.01" y1="20" y2="20"/>',
'bluetooth': '<path d="m7 7 10 10-5 5V2l5 5L7 17"/>',
'battery': '<rect width="16" height="10" x="2" y="7" rx="2" ry="2"/><line x1="22" x2="22" y1="11" y2="13"/>',
'power': '<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/>',
'activity': '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
'trending-up': '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
'trending-down': '<polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/>',
'pie-chart': '<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>',
'bar-chart': '<line x1="12" x2="12" y1="20" y2="10"/><line x1="18" x2="18" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="16"/>',
'rss': '<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/>',
'hash': '<line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/>',
'at-sign': '<circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/>',
'send': '<line x1="22" x2="11" y1="2" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>',
'share': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>',
'paperclip': '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
'flag': '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/>',
'award': '<circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/>',
'gift': '<polyline points="20 12 20 22 4 22 4 12"/><rect width="20" height="5" x="2" y="7"/><line x1="12" x2="12" y1="22" y2="7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/>',
'target': '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
'crosshair': '<circle cx="12" cy="12" r="10"/><line x1="22" x2="18" y1="12" y2="12"/><line x1="6" x2="2" y1="12" y2="12"/><line x1="12" x2="12" y1="6" y2="2"/><line x1="12" x2="12" y1="22" y2="18"/>',
'compass': '<circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>',
'navigation': '<polygon points="3 11 22 2 13 21 11 13 3 11"/>',
'anchor': '<circle cx="12" cy="5" r="3"/><line x1="12" x2="12" y1="22" y2="8"/><path d="M5 12H2a10 10 0 0 0 20 0h-3"/>',
'truck': '<path d="M10 17h4V5H2v12h3"/><circle cx="7.5" cy="17.5" r="2.5"/><path d="M20 17h2v-3.34a4 4 0 0 0-1.17-2.83L19 9h-5v8h1"/><circle cx="17.5" cy="17.5" r="2.5"/>',
'plane': '<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/>',
'rocket': '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>',
'umbrella': '<path d="M22 12a10.06 10.06 0 0 0-20 0Z"/><path d="M12 12v8a2 2 0 0 0 4 0"/><path d="M12 2v1"/>',
'key': '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
'thumbs-up': '<path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2h0a3.13 3.13 0 0 1 3 3.88Z"/>',
'thumbs-down': '<path d="M17 14V2"/><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22h0a3.13 3.13 0 0 1-3-3.88Z"/>',
'smile': '<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>',
'meh': '<circle cx="12" cy="12" r="10"/><line x1="8" x2="16" y1="15" y2="15"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>',
'frown': '<circle cx="12" cy="12" r="10"/><path d="M16 16s-1.5-2-4-2-4 2-4 2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>'
};
// 图标分类
const ICON_CATEGORIES = {
'常用': ['home', 'search', 'settings', 'plus', 'edit', 'trash', 'link', 'folder', 'star', 'heart', 'bookmark'],
'社交': ['github', 'twitter', 'youtube', 'facebook', 'instagram', 'linkedin', 'mail', 'message-circle', 'phone'],
'开发': ['code', 'terminal', 'database', 'server', 'cloud', 'git-branch', 'globe', 'wrench', 'tool'],
'媒体': ['image', 'music', 'video', 'camera', 'headphones'],
'设备': ['monitor', 'smartphone', 'tablet', 'laptop', 'tv', 'printer', 'wifi'],
'工具': ['download', 'upload', 'file', 'file-text', 'save', 'copy', 'clipboard', 'archive'],
'图表': ['pie-chart', 'bar-chart', 'activity', 'trending-up', 'trending-down'],
'状态': ['check', 'x', 'info', 'alert-circle', 'alert-triangle', 'help-circle', 'smile', 'meh', 'frown'],
'其他': ['clock', 'calendar', 'map-pin', 'bell', 'lock', 'unlock', 'shield', 'key', 'gift', 'award', 'target', 'rocket', 'zap', 'coffee']
};
/**
* 生成 SVG 图标
* @param {string} name 图标名称
* @param {number} size 图标大小
* @param {string} className 额外的 CSS 类
* @returns {string} SVG HTML
*/
function renderIcon(name, size = 24, className = '') {
const path = ICONS[name] || ICONS['link'];
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="${className}">${path}</svg>`;
}
/**
* 检查是否是 URL
* @param {string} str
* @returns {boolean}
*/
function isUrl(str) {
if (!str) return false;
return str.startsWith('http://') || str.startsWith('https://') || str.startsWith('data:');
}
/**
* 渲染书签图标(支持 URL 和内置图标)
* @param {string} icon 图标名称或 URL
* @param {number} size 图标大小
* @returns {string} HTML
*/
function renderBookmarkIcon(icon, size = 24) {
if (!icon) {
return renderIcon('link', size);
}
if (isUrl(icon)) {
return `<img src="${icon}" alt="icon" width="${size}" height="${size}" style="object-fit: contain;" onerror="this.style.display='none';this.parentElement.innerHTML='${renderIcon('link', size).replace(/'/g, "\\'")}'"/>`;
}
return renderIcon(icon, size);
}
/**
* 获取所有图标名称
* @returns {string[]}
*/
function getAllIconNames() {
return Object.keys(ICONS);
}
/**
* 搜索图标
* @param {string} query 搜索关键词
* @returns {string[]} 匹配的图标名称
*/
function searchIcons(query) {
if (!query) return getAllIconNames();
const q = query.toLowerCase();
return getAllIconNames().filter(name => name.includes(q));
}
// 导出到全局
window.Home4jIcons = {
ICONS,
ICON_CATEGORIES,
renderIcon,
renderBookmarkIcon,
getAllIconNames,
searchIcons,
isUrl
};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=https://cdn.jsdelivr.net/npm/tailwindcss@4.1.18/dist/lib.min.js
+110
View File
@@ -0,0 +1,110 @@
# ==========================================
# Home4j Application Configuration
# ==========================================
# Application Info
quarkus.application.name=home4j
quarkus.application.version=0.1.0
# ==========================================
# HTTP Configuration
# ==========================================
quarkus.http.port=8080
quarkus.http.host=0.0.0.0
# ==========================================
# Database Configuration (H2 - Default)
# ==========================================
quarkus.datasource.db-kind=h2
quarkus.datasource.username=sa
quarkus.datasource.password=
quarkus.datasource.jdbc.url=jdbc:h2:file:./data/home4j;AUTO_SERVER=TRUE
# Hibernate ORM
quarkus.hibernate-orm.database.generation=update
quarkus.hibernate-orm.log.sql=false
quarkus.hibernate-orm.physical-naming-strategy=org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy
# ==========================================
# MySQL Configuration (Optional - Uncomment to use)
# ==========================================
# %mysql.quarkus.datasource.db-kind=mysql
# %mysql.quarkus.datasource.username=home4j
# %mysql.quarkus.datasource.password=your_password
# %mysql.quarkus.datasource.jdbc.url=jdbc:mysql://localhost:3306/home4j?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
# ==========================================
# Security Configuration (Form-based Auth)
# ==========================================
quarkus.http.auth.form.enabled=true
quarkus.http.auth.form.login-page=/login
quarkus.http.auth.form.error-page=/login?error=true
quarkus.http.auth.form.landing-page=/
quarkus.http.auth.form.post-location=/j_security_check
quarkus.http.auth.form.username-parameter=j_username
quarkus.http.auth.form.password-parameter=j_password
quarkus.http.auth.form.cookie-name=home4j-session
quarkus.http.auth.form.timeout=PT24H
quarkus.http.auth.form.new-cookie-interval=PT1H
quarkus.http.auth.form.http-only-cookie=true
quarkus.http.auth.form.cookie-same-site=lax
# Session Cookie Configuration
quarkus.http.auth.session.encryption-key=home4j-secret-key-change-in-production
# Public resources (no authentication required)
quarkus.http.auth.permission.public.paths=/,/api/search,/login,/login/*,/css/*,/js/*,/images/*,/icons/*,/favicon.ico,/q/*
quarkus.http.auth.permission.public.policy=permit
# Admin resources (require ADMIN role)
quarkus.http.auth.permission.admin.paths=/admin,/admin/*
quarkus.http.auth.permission.admin.policy=authenticated
# Admin API (JSON REST API, require ADMIN role)
quarkus.http.auth.permission.api-admin.paths=/api/admin,/api/admin/*
quarkus.http.auth.permission.api-admin.policy=authenticated
# ==========================================
# Qute Template Configuration
# ==========================================
quarkus.qute.content-types.html=text/html;charset=UTF-8
quarkus.qute.suffixes=html,qute.html
# ==========================================
# Static Resources
# ==========================================
quarkus.http.enable-compression=true
# ==========================================
# Health Check
# ==========================================
quarkus.smallrye-health.root-path=/q/health
# ==========================================
# OpenAPI / Swagger
# ==========================================
quarkus.smallrye-openapi.path=/q/openapi
quarkus.swagger-ui.always-include=true
quarkus.swagger-ui.path=/q/swagger-ui
# ==========================================
# Logging
# ==========================================
quarkus.log.level=INFO
quarkus.log.category."com.home4j".level=DEBUG
quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss} %-5p [%c{2.}] (%t) %s%e%n
# ==========================================
# Dev Mode Configuration
# ==========================================
%dev.quarkus.hibernate-orm.database.generation=drop-and-create
%dev.quarkus.hibernate-orm.log.sql=true
%dev.quarkus.log.category."com.home4j".level=DEBUG
# ==========================================
# Production Configuration
# ==========================================
%prod.quarkus.hibernate-orm.database.generation=update
%prod.quarkus.hibernate-orm.log.sql=false
%prod.quarkus.log.level=WARN
%prod.quarkus.log.category."com.home4j".level=INFO
+6
View File
@@ -0,0 +1,6 @@
-- ==========================================
-- Home4j SQL Import File
-- ==========================================
-- Note: Default data is initialized via DataInitializer.java
-- This file is kept empty to avoid conflicts.
-- ==========================================
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台 - {siteTitle}</title>
<script src="/js/browser@4.js"></script>
<link href="/css/daisyui@5.css" rel="stylesheet" type="text/css" />
<link href="/css/themes.css" rel="stylesheet" type="text/css" />
<script defer src="/js/alpine.min.js"></script>
</head>
<body class="min-h-screen bg-base-200">
<!-- Navigation -->
<div class="navbar bg-base-100 shadow-lg">
<div class="flex-1">
<a href="/admin" class="btn btn-ghost text-xl">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-primary"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
管理后台
</a>
</div>
<div class="flex-none gap-2">
<a href="/" class="btn btn-ghost btn-sm" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" x2="21" y1="14" y2="3"/></svg>
查看网站
</a>
<a href="/login/logout" class="btn btn-ghost btn-sm text-error">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></svg>
退出
</a>
</div>
</div>
<div class="container mx-auto px-4 py-8">
<!-- Quick Stats -->
<div class="stats shadow w-full mb-8">
<div class="stat">
<div class="stat-figure text-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</div>
<div class="stat-title">分组数量</div>
<div class="stat-value text-primary">{groups.size()}</div>
</div>
<div class="stat">
<div class="stat-figure text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</div>
<div class="stat-title">书签总数</div>
<div class="stat-value text-secondary">{#each groups}{it.bookmarks.size()}{#else}0{/each}</div>
</div>
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<a href="/admin/groups" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow cursor-pointer">
<div class="card-body items-center text-center">
<div class="text-4xl text-primary mb-4">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</div>
<h2 class="card-title">分组管理</h2>
<p class="text-base-content/60">创建、编辑和删除书签分组</p>
</div>
</a>
<a href="/admin/bookmarks" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow cursor-pointer">
<div class="card-body items-center text-center">
<div class="text-4xl text-secondary mb-4">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</div>
<h2 class="card-title">书签管理</h2>
<p class="text-base-content/60">添加、编辑和删除书签链接</p>
</div>
</a>
<a href="/admin/settings" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow cursor-pointer">
<div class="card-body items-center text-center">
<div class="text-4xl text-accent mb-4">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
</div>
<h2 class="card-title">系统设置</h2>
<p class="text-base-content/60">网站标题、主题和其他配置</p>
</div>
</a>
</div>
</div>
</body>
</html>
@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>书签管理 - {siteTitle}</title>
<script src="/js/browser@4.js"></script>
<link href="/css/daisyui@5.css" rel="stylesheet" type="text/css" />
<link href="/css/themes.css" rel="stylesheet" type="text/css" />
<script defer src="/js/alpine.min.js"></script>
</head>
<body class="min-h-screen bg-base-200">
<!-- Navigation -->
<div class="navbar bg-base-100 shadow-lg">
<div class="flex-1">
<a href="/admin" class="btn btn-ghost">← 返回</a>
<span class="text-xl font-bold ml-2">书签管理</span>
</div>
<div class="flex-none">
<button class="btn btn-primary" onclick="document.getElementById('addModal').showModal()">
+ 添加书签
</button>
</div>
</div>
<div class="container mx-auto px-4 py-8">
<!-- Filter -->
<div class="mb-4">
<form class="flex gap-2">
<select name="groupId" class="select select-bordered" onchange="this.form.submit()">
<option value="">全部分组</option>
{#for group in groups}
<option value="{group.id}" {#if selectedGroupId == group.id}selected{/if}>{group.name}</option>
{/for}
</select>
</form>
</div>
<!-- Bookmarks Table -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>URL</th>
<th>分组</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{#for bookmark in bookmarks}
<tr>
<td>{bookmark.id}</td>
<td class="font-medium">{bookmark.name}</td>
<td class="max-w-xs truncate">
<a href="{bookmark.url}" target="_blank" class="link link-primary">{bookmark.url}</a>
</td>
<td>{bookmark.group?.name ?: '未分组'}</td>
<td>
{#if bookmark.visible}
<span class="badge badge-success">可见</span>
{#else}
<span class="badge badge-ghost">隐藏</span>
{/if}
</td>
<td class="space-x-2">
<button class="btn btn-sm btn-ghost"
onclick="editBookmark({bookmark.id}, {bookmark.group?.id ?: 'null'}, '{bookmark.name}', '{bookmark.url}', '{bookmark.description ?: ''}', '{bookmark.icon ?: ''}', {bookmark.visible}, {bookmark.openInNewTab})">
编辑
</button>
<button class="btn btn-sm btn-ghost text-error" onclick="confirmDelete({bookmark.id}, '{bookmark.name}')">
删除
</button>
</td>
</tr>
{#else}
<tr>
<td colspan="6" class="text-center py-8 text-base-content/60">
暂无书签,点击右上角添加
</td>
</tr>
{/for}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Add Modal -->
<dialog id="addModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">添加书签</h3>
<form action="/admin/bookmarks" method="post">
<div class="form-control mb-4">
<label class="label"><span class="label-text">所属分组</span></label>
<select name="groupId" class="select select-bordered">
<option value="">不分组</option>
{#for group in groups}
<option value="{group.id}">{group.name}</option>
{/for}
</select>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">书签名称 *</span></label>
<input type="text" name="name" class="input input-bordered" required />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">URL *</span></label>
<input type="url" name="url" class="input input-bordered" required placeholder="https://" />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">描述</span></label>
<input type="text" name="description" class="input input-bordered" />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">图标</span></label>
<input type="text" name="icon" class="input input-bordered" placeholder="Lucide图标名称" />
</div>
<div class="form-control mb-4">
<label class="label cursor-pointer">
<span class="label-text">在新标签页打开</span>
<input type="checkbox" name="openInNewTab" value="true" class="checkbox" checked />
</label>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="document.getElementById('addModal').close()">取消</button>
<button type="submit" class="btn btn-primary">添加</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Edit Modal -->
<dialog id="editModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">编辑书签</h3>
<form id="editForm" method="post">
<div class="form-control mb-4">
<label class="label"><span class="label-text">所属分组</span></label>
<select id="editGroupId" name="groupId" class="select select-bordered">
<option value="">不分组</option>
{#for group in groups}
<option value="{group.id}">{group.name}</option>
{/for}
</select>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">书签名称 *</span></label>
<input type="text" id="editName" name="name" class="input input-bordered" required />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">URL *</span></label>
<input type="url" id="editUrl" name="url" class="input input-bordered" required />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">描述</span></label>
<input type="text" id="editDescription" name="description" class="input input-bordered" />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">图标</span></label>
<input type="text" id="editIcon" name="icon" class="input input-bordered" />
</div>
<div class="form-control mb-4">
<label class="label cursor-pointer">
<span class="label-text">可见</span>
<input type="checkbox" id="editVisible" name="visible" value="true" class="checkbox" />
</label>
</div>
<div class="form-control mb-4">
<label class="label cursor-pointer">
<span class="label-text">在新标签页打开</span>
<input type="checkbox" id="editOpenInNewTab" name="openInNewTab" value="true" class="checkbox" />
</label>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="document.getElementById('editModal').close()">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Delete Confirm Modal -->
<dialog id="deleteModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg text-error">确认删除</h3>
<p class="py-4">确定要删除书签 "<span id="deleteName"></span>" 吗?</p>
<div class="modal-action">
<button class="btn" onclick="document.getElementById('deleteModal').close()">取消</button>
<form id="deleteForm" method="post" class="inline">
<button type="submit" class="btn btn-error">删除</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<script>
function editBookmark(id, groupId, name, url, description, icon, visible, openInNewTab) {
document.getElementById('editForm').action = '/admin/bookmarks/' + id;
document.getElementById('editGroupId').value = groupId || '';
document.getElementById('editName').value = name;
document.getElementById('editUrl').value = url;
document.getElementById('editDescription').value = description;
document.getElementById('editIcon').value = icon;
document.getElementById('editVisible').checked = visible;
document.getElementById('editOpenInNewTab').checked = openInNewTab;
document.getElementById('editModal').showModal();
}
function confirmDelete(id, name) {
document.getElementById('deleteForm').action = '/admin/bookmarks/' + id + '/delete';
document.getElementById('deleteName').textContent = name;
document.getElementById('deleteModal').showModal();
}
</script>
</body>
</html>
@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分组管理 - {siteTitle}</title>
<script src="/js/browser@4.js"></script>
<link href="/css/daisyui@5.css" rel="stylesheet" type="text/css" />
<link href="/css/themes.css" rel="stylesheet" type="text/css" />
<script defer src="/js/alpine.min.js"></script>
</head>
<body class="min-h-screen bg-base-200">
<!-- Navigation -->
<div class="navbar bg-base-100 shadow-lg">
<div class="flex-1">
<a href="/admin" class="btn btn-ghost">← 返回</a>
<span class="text-xl font-bold ml-2">分组管理</span>
</div>
<div class="flex-none">
<button class="btn btn-primary" onclick="document.getElementById('addModal').showModal()">
+ 添加分组
</button>
</div>
</div>
<div class="container mx-auto px-4 py-8">
<!-- Groups Table -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>图标</th>
<th>排序</th>
<th>状态</th>
<th>书签数</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{#for group in groups}
<tr>
<td>{group.id}</td>
<td class="font-medium">{group.name}</td>
<td>{group.icon ?: '-'}</td>
<td>{group.sortOrder}</td>
<td>
{#if group.visible}
<span class="badge badge-success">可见</span>
{#else}
<span class="badge badge-ghost">隐藏</span>
{/if}
</td>
<td>{group.bookmarks.size()}</td>
<td class="space-x-2">
<button class="btn btn-sm btn-ghost" onclick="editGroup({group.id}, '{group.name}', '{group.icon ?: ''}', {group.visible})">
编辑
</button>
<button class="btn btn-sm btn-ghost text-error" onclick="confirmDelete({group.id}, '{group.name}')">
删除
</button>
</td>
</tr>
{#else}
<tr>
<td colspan="7" class="text-center py-8 text-base-content/60">
暂无分组,点击右上角添加
</td>
</tr>
{/for}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Add Modal -->
<dialog id="addModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">添加分组</h3>
<form action="/admin/groups" method="post">
<div class="form-control mb-4">
<label class="label"><span class="label-text">分组名称 *</span></label>
<input type="text" name="name" class="input input-bordered" required />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">图标(Lucide图标名称)</span></label>
<input type="text" name="icon" class="input input-bordered" placeholder="如: folder, star, heart" />
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="document.getElementById('addModal').close()">取消</button>
<button type="submit" class="btn btn-primary">添加</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Edit Modal -->
<dialog id="editModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">编辑分组</h3>
<form id="editForm" method="post">
<div class="form-control mb-4">
<label class="label"><span class="label-text">分组名称 *</span></label>
<input type="text" id="editName" name="name" class="input input-bordered" required />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">图标</span></label>
<input type="text" id="editIcon" name="icon" class="input input-bordered" />
</div>
<div class="form-control mb-4">
<label class="label cursor-pointer">
<span class="label-text">可见</span>
<input type="checkbox" id="editVisible" name="visible" value="true" class="checkbox" />
</label>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="document.getElementById('editModal').close()">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Delete Confirm Modal -->
<dialog id="deleteModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg text-error">确认删除</h3>
<p class="py-4">确定要删除分组 "<span id="deleteName"></span>" 吗?<br>该分组下的所有书签也将被删除!</p>
<div class="modal-action">
<button class="btn" onclick="document.getElementById('deleteModal').close()">取消</button>
<form id="deleteForm" method="post" class="inline">
<button type="submit" class="btn btn-error">删除</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<script>
function editGroup(id, name, icon, visible) {
document.getElementById('editForm').action = '/admin/groups/' + id;
document.getElementById('editName').value = name;
document.getElementById('editIcon').value = icon;
document.getElementById('editVisible').checked = visible;
document.getElementById('editModal').showModal();
}
function confirmDelete(id, name) {
document.getElementById('deleteForm').action = '/admin/groups/' + id + '/delete';
document.getElementById('deleteName').textContent = name;
document.getElementById('deleteModal').showModal();
}
</script>
</body>
</html>
@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统设置 - {siteTitle}</title>
<script src="/js/browser@4.js"></script>
<link href="/css/daisyui@5.css" rel="stylesheet" type="text/css" />
<link href="/css/themes.css" rel="stylesheet" type="text/css" />
<script defer src="/js/alpine.min.js"></script>
</head>
<body class="min-h-screen bg-base-200">
<!-- Navigation -->
<div class="navbar bg-base-100 shadow-lg">
<div class="flex-1">
<a href="/admin" class="btn btn-ghost">← 返回</a>
<span class="text-xl font-bold ml-2">系统设置</span>
</div>
</div>
<div class="container mx-auto px-4 py-8">
<div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">
<div class="card-body">
<form action="/admin/settings" method="post">
<!-- 网站设置 -->
<h3 class="text-lg font-semibold mb-4 border-b pb-2">网站设置</h3>
<div class="form-control mb-4">
<label class="label"><span class="label-text">网站标题</span></label>
<input type="text" name="site_title" value="{config.get('site_title')}"
class="input input-bordered" />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">网站副标题</span></label>
<input type="text" name="site_subtitle" value="{config.get('site_subtitle')}"
class="input input-bordered" />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">页脚文本</span></label>
<input type="text" name="footer_text" value="{config.get('footer_text')}"
class="input input-bordered" />
</div>
<!-- 主题设置 -->
<h3 class="text-lg font-semibold mb-4 border-b pb-2 mt-8">主题设置</h3>
<div class="form-control mb-4">
<label class="label"><span class="label-text">主题</span></label>
<select name="theme" class="select select-bordered">
<option value="light" {#if config.get('theme') == 'light'}selected{/if}>浅色 (Light)</option>
<option value="dark" {#if config.get('theme') == 'dark'}selected{/if}>深色 (Dark)</option>
<option value="cupcake" {#if config.get('theme') == 'cupcake'}selected{/if}>Cupcake</option>
<option value="bumblebee" {#if config.get('theme') == 'bumblebee'}selected{/if}>Bumblebee</option>
<option value="emerald" {#if config.get('theme') == 'emerald'}selected{/if}>Emerald</option>
<option value="corporate" {#if config.get('theme') == 'corporate'}selected{/if}>Corporate</option>
<option value="synthwave" {#if config.get('theme') == 'synthwave'}selected{/if}>Synthwave</option>
<option value="retro" {#if config.get('theme') == 'retro'}selected{/if}>Retro</option>
<option value="cyberpunk" {#if config.get('theme') == 'cyberpunk'}selected{/if}>Cyberpunk</option>
<option value="valentine" {#if config.get('theme') == 'valentine'}selected{/if}>Valentine</option>
<option value="halloween" {#if config.get('theme') == 'halloween'}selected{/if}>Halloween</option>
<option value="garden" {#if config.get('theme') == 'garden'}selected{/if}>Garden</option>
<option value="forest" {#if config.get('theme') == 'forest'}selected{/if}>Forest</option>
<option value="aqua" {#if config.get('theme') == 'aqua'}selected{/if}>Aqua</option>
<option value="lofi" {#if config.get('theme') == 'lofi'}selected{/if}>Lo-Fi</option>
<option value="pastel" {#if config.get('theme') == 'pastel'}selected{/if}>Pastel</option>
<option value="fantasy" {#if config.get('theme') == 'fantasy'}selected{/if}>Fantasy</option>
<option value="wireframe" {#if config.get('theme') == 'wireframe'}selected{/if}>Wireframe</option>
<option value="black" {#if config.get('theme') == 'black'}selected{/if}>Black</option>
<option value="luxury" {#if config.get('theme') == 'luxury'}selected{/if}>Luxury</option>
<option value="dracula" {#if config.get('theme') == 'dracula'}selected{/if}>Dracula</option>
<option value="cmyk" {#if config.get('theme') == 'cmyk'}selected{/if}>CMYK</option>
<option value="autumn" {#if config.get('theme') == 'autumn'}selected{/if}>Autumn</option>
<option value="business" {#if config.get('theme') == 'business'}selected{/if}>Business</option>
<option value="acid" {#if config.get('theme') == 'acid'}selected{/if}>Acid</option>
<option value="lemonade" {#if config.get('theme') == 'lemonade'}selected{/if}>Lemonade</option>
<option value="night" {#if config.get('theme') == 'night'}selected{/if}>Night</option>
<option value="coffee" {#if config.get('theme') == 'coffee'}selected{/if}>Coffee</option>
<option value="winter" {#if config.get('theme') == 'winter'}selected{/if}>Winter</option>
</select>
</div>
<!-- 搜索设置 -->
<h3 class="text-lg font-semibold mb-4 border-b pb-2 mt-8">搜索设置</h3>
<div class="form-control mb-4">
<label class="label cursor-pointer">
<span class="label-text">显示搜索框</span>
<input type="checkbox" name="show_search" value="true"
class="checkbox" {#if config.get('show_search') == 'true'}checked{/if} />
</label>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">搜索引擎 URL</span></label>
<input type="text" name="search_engine" value="{config.get('search_engine')}"
class="input input-bordered" placeholder="https://www.google.com/search?q=" />
<label class="label">
<span class="label-text-alt text-base-content/60">搜索关键词会追加到此 URL 后面</span>
</label>
</div>
<!-- 常用搜索引擎 -->
<div class="text-sm text-base-content/60 mb-6">
<p class="font-medium mb-2">常用搜索引擎:</p>
<ul class="list-disc list-inside space-y-1">
<li>Google: https://www.google.com/search?q=</li>
<li>Bing: https://www.bing.com/search?q=</li>
<li>Baidu: https://www.baidu.com/s?wd=</li>
<li>DuckDuckGo: https://duckduckgo.com/?q=</li>
</ul>
</div>
<div class="form-control mt-6">
<button type="submit" class="btn btn-primary">保存设置</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>
+666
View File
@@ -0,0 +1,666 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{siteTitle ?: 'Home4j - 个人导航'}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- Tailwind CSS v4 + DaisyUI 5 -->
<link href="/css/daisyui@5.css" rel="stylesheet">
<link href="/css/themes.css" rel="stylesheet">
<script src="/js/browser@4.js"></script>
<style>
[x-cloak] { display: none !important; }
/* 滚动条美化 */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: oklch(var(--b2)); }
::-webkit-scrollbar-thumb { background: oklch(var(--bc)/0.3); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: oklch(var(--bc)/0.5); }
/* 分组展开/收起动画 */
.group-chevron { transition: transform 0.2s ease; }
.group-collapsed .group-chevron { transform: rotate(-90deg); }
/* 书签卡片悬停效果 */
.bookmark-card { transition: all 0.2s ease; }
.bookmark-card:hover { transform: translateY(-2px); }
/* 图标盒子渐变 */
.icon-box {
background: linear-gradient(135deg, oklch(var(--p)/0.15), oklch(var(--s)/0.15));
}
/* 图片卡片样式 */
.image-card-overlay {
background: linear-gradient(to top, oklch(var(--n)/0.8) 0%, transparent 100%);
}
/* 列表视图网格 */
.list-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
}
@media (max-width: 1280px) { .list-grid { grid-template-columns: repeat(4, 1fr); } }
@media (max-width: 1024px) { .list-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 768px) { .list-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 640px) { .list-grid { grid-template-columns: 1fr; } }
/* 自定义背景 */
.custom-bg-image {
background-size: cover;
background-position: center;
background-attachment: fixed;
}
</style>
</head>
<body class="min-h-screen bg-base-200">
<!-- Alpine.js 主应用 -->
<div x-data="Home4jApp()" x-init="init()" class="drawer">
<input id="app-drawer" type="checkbox" class="drawer-toggle" x-model="drawerOpen">
<!-- 主内容区 -->
<div class="drawer-content flex flex-col min-h-screen"
:class="bgType === 'image' && bgImage ? 'custom-bg-image' : ''"
:style="bgType === 'image' && bgImage ? 'background-image: url(' + bgImage + ')' : (bgType === 'color' && bgColor ? 'background-color: ' + bgColor : '')">
<!-- 导航栏 -->
<nav class="navbar bg-base-100/90 backdrop-blur-sm shadow-sm sticky top-0 z-40">
<div class="navbar-start">
<!-- Logo -->
<a href="/" class="btn btn-ghost text-xl gap-2">
<span x-html="getIcon('home', 24)" class="text-primary"></span>
<span class="font-bold hidden sm:inline" x-text="i18n.appName"></span>
</a>
</div>
<div class="navbar-end gap-1">
<!-- 视图切换 -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" :title="i18n.viewMode">
<span x-html="viewMode === 'list' ? getIcon('list', 18) : getIcon('grid', 18)"></span>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-40 p-2 shadow-lg border border-base-300">
<li><a @click="setViewMode('list')" :class="viewMode === 'list' ? 'active' : ''">
<span x-html="getIcon('list', 16)"></span>
<span x-text="i18n.listView"></span>
</a></li>
<li><a @click="setViewMode('card')" :class="viewMode === 'card' ? 'active' : ''">
<span x-html="getIcon('grid', 16)"></span>
<span x-text="i18n.cardView"></span>
</a></li>
</ul>
</div>
<!-- 主题切换 -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" :title="i18n.theme">
<span x-html="currentTheme === 'dark' ? getIcon('moon', 18) : getIcon('sun', 18)"></span>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-44 p-2 shadow-lg border border-base-300 max-h-80 overflow-y-auto">
<template x-for="theme in themes" :key="theme.id">
<li>
<a @click="setTheme(theme.id)" :class="currentTheme === theme.id ? 'active' : ''" class="gap-3">
<div class="flex gap-1">
<span class="w-2 h-4 rounded-sm" :style="'background-color:' + theme.colors.primary"></span>
<span class="w-2 h-4 rounded-sm" :style="'background-color:' + theme.colors.secondary"></span>
<span class="w-2 h-4 rounded-sm" :style="'background-color:' + theme.colors.accent"></span>
</div>
<span x-text="theme.name" class="flex-1"></span>
</a>
</li>
</template>
</ul>
</div>
<!-- 语言切换 -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" :title="i18n.language">
<span x-html="getIcon('globe', 18)"></span>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-36 p-2 shadow-lg border border-base-300">
<li><a @click="setLanguage('zh-CN')" :class="language === 'zh-CN' ? 'active' : ''">🇨🇳 中文</a></li>
<li><a @click="setLanguage('en-US')" :class="language === 'en-US' ? 'active' : ''">🇺🇸 English</a></li>
</ul>
</div>
{#if isAdmin}
<!-- 管理员菜单 -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle">
<span x-html="getIcon('settings', 18)"></span>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-48 p-2 shadow-lg border border-base-300">
<li><a @click="openAddBookmarkDrawer()">
<span x-html="getIcon('plus', 16)"></span>
<span x-text="i18n.addBookmark"></span>
</a></li>
<li><a @click="openAddGroupDrawer()">
<span x-html="getIcon('folder', 16)"></span>
<span x-text="i18n.addGroup"></span>
</a></li>
<li class="menu-title"><span x-text="i18n.background"></span></li>
<li><a @click="openBackgroundSettings()">
<span x-html="getIcon('image', 16)"></span>
<span x-text="i18n.bgSettings"></span>
</a></li>
<li class="menu-title"><span x-text="i18n.account"></span></li>
<li><a href="/admin">
<span x-html="getIcon('settings', 16)"></span>
<span x-text="i18n.adminPanel"></span>
</a></li>
<li><a href="/login/logout">
<span x-html="getIcon('x', 16)"></span>
<span x-text="i18n.logout"></span>
</a></li>
</ul>
</div>
{#else}
<button onclick="loginModal.showModal()" class="btn btn-ghost btn-sm btn-circle" :title="i18n.login">
<span x-html="getIcon('user', 18)"></span>
</button>
{/if}
</div>
</nav>
<!-- Hero 区域:标题 + 副标题 + 日期时间天气 + 搜索框 -->
<header class="py-8 px-4 bg-base-100/50">
<div class="container mx-auto max-w-4xl">
<!-- 标题和日期时间天气(两列布局,居中对齐) -->
<div class="flex flex-col md:flex-row md:items-center md:justify-center gap-4 md:gap-12 mb-6">
<!-- 左侧:标题 + 副标题 -->
<div class="text-center md:text-right">
<h1 class="text-3xl sm:text-4xl font-bold text-base-content mb-1">{siteTitle ?: 'Welcome Home'}</h1>
<p class="text-base-content/60" x-text="i18n.heroSubtitle"></p>
</div>
<!-- 分隔线 -->
<div class="hidden md:block w-px h-16 bg-base-content/20"></div>
<!-- 右侧:日期时间 + 天气 -->
<div class="text-center md:text-left text-base-content/70">
<div class="text-sm" x-text="currentDate"></div>
<div class="text-2xl font-mono font-semibold" x-text="currentTime"></div>
<template x-if="weather">
<div class="text-sm mt-1">
<span x-text="weather.icon"></span>
<span x-text="weather.temp"></span>
<span class="text-base-content/50" x-text="weather.city"></span>
</div>
</template>
</div>
</div>
<!-- 搜索框(居中) -->
<div class="flex justify-center">
<div class="join w-full max-w-md">
<!-- 搜索模式切换 -->
<div class="dropdown dropdown-bottom">
<div tabindex="0" role="button" class="btn join-item btn-ghost border border-base-300" :title="searchMode === 'bookmark' ? i18n.searchBookmark : i18n.searchWeb">
<span x-html="searchMode === 'bookmark' ? getIcon('bookmark', 18) : getIcon('globe', 18)"></span>
<span x-html="getIcon('chevronDown', 14)" class="opacity-60"></span>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-48 p-2 shadow-lg border border-base-300 mt-1">
<li><a @click="searchMode = 'bookmark'; $refs.searchInput.focus()" :class="searchMode === 'bookmark' ? 'active' : ''">
<span x-html="getIcon('bookmark', 16)"></span>
<span x-text="i18n.searchBookmark"></span>
</a></li>
<li class="menu-title mt-2"><span x-text="i18n.searchEngines"></span></li>
<li><a @click="searchMode = 'google'; $refs.searchInput.focus()" :class="searchMode === 'google' ? 'active' : ''">
<span>🔍</span>
<span>Google</span>
</a></li>
<li><a @click="searchMode = 'bing'; $refs.searchInput.focus()" :class="searchMode === 'bing' ? 'active' : ''">
<span>🔎</span>
<span>Bing</span>
</a></li>
<li><a @click="searchMode = 'baidu'; $refs.searchInput.focus()" :class="searchMode === 'baidu' ? 'active' : ''">
<span>🅱️</span>
<span x-text="i18n.baidu"></span>
</a></li>
<li><a @click="searchMode = 'duckduckgo'; $refs.searchInput.focus()" :class="searchMode === 'duckduckgo' ? 'active' : ''">
<span>🦆</span>
<span>DuckDuckGo</span>
</a></li>
</ul>
</div>
<input type="text"
x-ref="searchInput"
x-model="searchQuery"
@keydown.enter="handleSearch()"
:placeholder="getSearchPlaceholder()"
class="input input-bordered join-item flex-1">
<button class="btn join-item" @click="handleSearch()">
<span x-html="getIcon('search', 20)"></span>
</button>
</div>
</div>
</div>
</header>
<!-- 主要内容 -->
<main class="flex-1 container mx-auto px-4 py-6">
{#if groups.isEmpty()}
<!-- 空状态 -->
<div class="text-center py-20">
<div class="w-20 h-20 mx-auto mb-6 rounded-full bg-base-300 flex items-center justify-center">
<span x-html="getIcon('inbox', 40)" class="text-base-content/30"></span>
</div>
<h3 class="text-xl font-semibold text-base-content/70 mb-2" x-text="i18n.emptyTitle"></h3>
<p class="text-base-content/50 mb-6" x-text="i18n.emptyDesc"></p>
{#if isAdmin}
<div class="flex justify-center gap-3">
<button class="btn btn-primary" @click="openAddGroupDrawer()">
<span x-html="getIcon('folder', 18)"></span>
<span x-text="i18n.addGroup"></span>
</button>
<button class="btn btn-outline" @click="openAddBookmarkDrawer()">
<span x-html="getIcon('plus', 18)"></span>
<span x-text="i18n.addBookmark"></span>
</button>
</div>
{/if}
</div>
{#else}
<!-- 分组列表 -->
<div class="space-y-8">
{#for group in groups}
<div class="group/section"
x-show="groupHasMatchingBookmarks({group.id})"
:class="isGroupCollapsed({group.id}) ? 'group-collapsed' : ''">
<!-- 分组标题 -->
<div class="flex items-center gap-3 mb-4 group/header">
<button class="flex items-center gap-3 flex-1 text-left py-2"
@click="toggleGroupCollapse({group.id})">
<span x-html="getIcon('chevron-down', 18)"
class="group-chevron text-base-content/40"></span>
<div class="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
<span x-html="getIcon('{group.icon ?: 'folder'}', 18)" class="text-primary"></span>
</div>
<span class="text-lg font-semibold text-base-content">{group.name}</span>
<span class="badge badge-ghost">{group.bookmarks.size}</span>
</button>
{#if isAdmin}
<div class="opacity-0 group-hover/header:opacity-100 transition-opacity flex gap-1">
<button class="btn btn-sm btn-ghost btn-circle" @click="openEditGroupDrawer({group.id})" :title="i18n.edit">
<span x-html="getIcon('edit', 14)"></span>
</button>
<button class="btn btn-sm btn-ghost btn-circle text-error" @click="deleteGroup({group.id})" :title="i18n.delete">
<span x-html="getIcon('trash', 14)"></span>
</button>
</div>
{/if}
</div>
<!-- 书签内容 -->
<div x-show="!isGroupCollapsed({group.id})" x-collapse>
<!-- 列表视图 - 5列网格 -->
<div x-show="viewMode === 'list'" class="list-grid">
{#for bookmark in group.bookmarks}
<div x-show="bookmarkMatchesSearch('{bookmark.name}', '{bookmark.description ?: ''}', '{bookmark.url}')"
class="group/item">
<a href="{bookmark.url}"
target="{bookmark.openInNewTab ? '_blank' : '_self'}"
class="flex items-center gap-2 p-2 rounded-lg bg-base-100 hover:bg-base-200 border border-base-300 transition-colors">
<div class="w-8 h-8 rounded-lg icon-box flex items-center justify-center flex-shrink-0">
<span x-html="getIcon('{bookmark.icon ?: 'link'}', 16)" class="text-primary"></span>
</div>
<span class="text-sm font-medium text-base-content truncate flex-1">{bookmark.name}</span>
{#if isAdmin}
<div class="opacity-0 group-hover/item:opacity-100 transition-opacity flex gap-0.5">
<button class="btn btn-xs btn-ghost btn-circle" @click.prevent.stop="openEditBookmarkDrawer({bookmark.id})">
<span x-html="getIcon('edit', 12)"></span>
</button>
<button class="btn btn-xs btn-ghost btn-circle text-error" @click.prevent.stop="deleteBookmark({bookmark.id})">
<span x-html="getIcon('trash', 12)"></span>
</button>
</div>
{/if}
</a>
</div>
{/for}
</div>
<!-- 卡片视图 -->
<div x-show="viewMode === 'card'"
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#for bookmark in group.bookmarks}
<div x-show="bookmarkMatchesSearch('{bookmark.name}', '{bookmark.description ?: ''}', '{bookmark.url}')"
class="group/card relative">
<!-- 图标模式卡片(默认显示,或在图片模式下但没有背景图时显示) -->
<a href="{bookmark.url}"
target="{bookmark.openInNewTab ? '_blank' : '_self'}"
x-show="cardDisplayMode === 'icon' || !'{bookmark.backgroundImage ?: ''}'"
class="bookmark-card block bg-base-100 rounded-xl shadow-sm hover:shadow-lg border border-base-300 overflow-hidden h-full">
<div class="p-4 flex flex-col items-center text-center">
<div class="icon-box w-12 h-12 rounded-xl flex items-center justify-center mb-3">
<span x-html="getIcon('{bookmark.icon ?: 'link'}', 24)" class="text-primary"></span>
</div>
<h3 class="font-medium text-base-content line-clamp-1 mb-1 text-sm">{bookmark.name}</h3>
{#if bookmark.description}
<p class="text-xs text-base-content/50 line-clamp-2">{bookmark.description}</p>
{/if}
</div>
</a>
<!-- 背景图模式卡片(仅在图片模式下且有背景图时显示) -->
{#if bookmark.backgroundImage}
<a href="{bookmark.url}"
target="{bookmark.openInNewTab ? '_blank' : '_self'}"
x-show="cardDisplayMode === 'image'"
class="bookmark-card card image-full h-32 shadow-sm hover:shadow-lg overflow-hidden">
<figure>
<img src="{bookmark.backgroundImage}" alt="{bookmark.name}" class="w-full h-full object-cover">
</figure>
<div class="card-body justify-end p-3 image-card-overlay">
<h3 class="card-title text-white text-sm line-clamp-1">{bookmark.name}</h3>
</div>
</a>
{/if}
<!-- 管理员操作按钮 -->
{#if isAdmin}
<div class="absolute top-2 right-2 opacity-0 group-hover/card:opacity-100 transition-opacity flex gap-1">
<button class="btn btn-xs btn-circle bg-base-100 shadow border-0 hover:bg-primary hover:text-primary-content"
@click.prevent.stop="openEditBookmarkDrawer({bookmark.id})" :title="i18n.edit">
<span x-html="getIcon('edit', 12)"></span>
</button>
<button class="btn btn-xs btn-circle bg-base-100 shadow border-0 hover:bg-error hover:text-error-content"
@click.prevent.stop="deleteBookmark({bookmark.id})" :title="i18n.delete">
<span x-html="getIcon('trash', 12)"></span>
</button>
</div>
{/if}
</div>
{/for}
</div>
</div>
</div>
{/for}
</div>
{/if}
</main>
<!-- Footer -->
<footer class="py-6 text-center border-t border-base-300 bg-base-100/50">
<p class="text-sm text-base-content/50">{footerText ?: 'Powered by Home4j'}</p>
</footer>
</div>
<!-- DaisyUI Drawer 侧边栏 -->
<div class="drawer-side z-50">
<label for="app-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<div class="bg-base-100 min-h-full w-80 sm:w-96 shadow-2xl">
<!-- 抽屉头部 -->
<div class="sticky top-0 bg-primary text-primary-content p-5 flex items-center justify-between">
<h2 class="text-lg font-semibold" x-text="drawerTitle"></h2>
<label for="app-drawer" class="btn btn-sm btn-circle btn-ghost hover:bg-primary-content/20">
<span x-html="getIcon('x', 20)"></span>
</label>
</div>
<!-- 抽屉内容 -->
<div class="p-5">
<!-- 书签表单 -->
<form x-show="drawerType === 'bookmark'" x-cloak @submit.prevent="saveBookmark()" class="space-y-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium" x-text="i18n.bookmarkName + ' *'"></span></label>
<input type="text" x-model="bookmarkForm.name" required
class="input input-bordered w-full" :placeholder="i18n.bookmarkNamePlaceholder">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">URL *</span></label>
<input type="url" x-model="bookmarkForm.url" required
class="input input-bordered w-full" placeholder="https://example.com">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium" x-text="i18n.description"></span></label>
<textarea x-model="bookmarkForm.description"
class="textarea textarea-bordered w-full" rows="2" :placeholder="i18n.descriptionPlaceholder"></textarea>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium" x-text="i18n.icon"></span></label>
<div class="flex gap-3">
<input type="text" x-model="bookmarkForm.icon"
class="input input-bordered flex-1" placeholder="link">
<div class="w-12 h-12 rounded-xl icon-box flex items-center justify-center">
<span x-html="getIcon(bookmarkForm.icon || 'link', 24)" class="text-primary"></span>
</div>
</div>
<label class="label">
<span class="label-text-alt text-base-content/50">home, globe, star, github, youtube, code, heart...</span>
</label>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium" x-text="i18n.group + ' *'"></span></label>
<select x-model="bookmarkForm.groupId" required class="select select-bordered w-full">
<option value="" x-text="i18n.selectGroup"></option>
{#for group in allGroups}
<option value="{group.id}">{group.name}</option>
{/for}
</select>
</div>
<!-- 显示模式 -->
<div class="form-control">
<label class="label"><span class="label-text font-medium" x-text="i18n.displayMode"></span></label>
<select x-model="bookmarkForm.displayMode" class="select select-bordered w-full">
<option value="icon" x-text="i18n.iconMode"></option>
<option value="image" x-text="i18n.imageMode"></option>
</select>
</div>
<!-- 背景图片URL(仅图片模式显示) -->
<div class="form-control" x-show="bookmarkForm.displayMode === 'image'">
<label class="label"><span class="label-text font-medium" x-text="i18n.backgroundImage"></span></label>
<input type="url" x-model="bookmarkForm.backgroundImage"
class="input input-bordered w-full" :placeholder="i18n.bgImagePlaceholder">
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" x-model="bookmarkForm.openInNewTab" class="checkbox checkbox-primary">
<span class="label-text" x-text="i18n.openInNewTab"></span>
</label>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="btn btn-primary flex-1" :disabled="saving">
<span x-show="saving" class="loading loading-spinner loading-sm"></span>
<span x-text="bookmarkForm.id ? i18n.save : i18n.add"></span>
</button>
<label for="app-drawer" class="btn btn-ghost" x-text="i18n.cancel"></label>
</div>
</form>
<!-- 分组表单 -->
<form x-show="drawerType === 'group'" x-cloak @submit.prevent="saveGroup()" class="space-y-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium" x-text="i18n.groupName + ' *'"></span></label>
<input type="text" x-model="groupForm.name" required
class="input input-bordered w-full" :placeholder="i18n.groupNamePlaceholder">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium" x-text="i18n.icon"></span></label>
<div class="flex gap-3">
<input type="text" x-model="groupForm.icon"
class="input input-bordered flex-1" placeholder="folder">
<div class="w-12 h-12 rounded-xl icon-box flex items-center justify-center">
<span x-html="getIcon(groupForm.icon || 'folder', 24)" class="text-primary"></span>
</div>
</div>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium" x-text="i18n.sortOrder"></span></label>
<input type="number" x-model="groupForm.sortOrder"
class="input input-bordered w-full" placeholder="0">
<label class="label">
<span class="label-text-alt text-base-content/50" x-text="i18n.sortOrderHint"></span>
</label>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="btn btn-primary flex-1" :disabled="saving">
<span x-show="saving" class="loading loading-spinner loading-sm"></span>
<span x-text="groupForm.id ? i18n.save : i18n.add"></span>
</button>
<label for="app-drawer" class="btn btn-ghost" x-text="i18n.cancel"></label>
</div>
</form>
<!-- 背景设置表单 -->
<form x-show="drawerType === 'background'" x-cloak @submit.prevent="saveBackground()" class="space-y-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium" x-text="i18n.bgType"></span></label>
<select x-model="bgType" class="select select-bordered w-full">
<option value="theme" x-text="i18n.bgTheme"></option>
<option value="color" x-text="i18n.bgColor"></option>
<option value="image" x-text="i18n.bgImage"></option>
</select>
</div>
<!-- 纯色背景 -->
<div class="form-control" x-show="bgType === 'color'">
<label class="label"><span class="label-text font-medium" x-text="i18n.selectColor"></span></label>
<input type="color" x-model="bgColor" class="w-full h-12 rounded-lg cursor-pointer">
</div>
<!-- 图片背景 -->
<div class="form-control" x-show="bgType === 'image'">
<label class="label"><span class="label-text font-medium" x-text="i18n.imageUrl"></span></label>
<input type="url" x-model="bgImage"
class="input input-bordered w-full" :placeholder="i18n.bgImagePlaceholder">
<template x-if="bgImage">
<div class="mt-2 rounded-lg overflow-hidden h-32">
<img :src="bgImage" class="w-full h-full object-cover" alt="Preview">
</div>
</template>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="btn btn-primary flex-1" :disabled="saving">
<span x-show="saving" class="loading loading-spinner loading-sm"></span>
<span x-text="i18n.save"></span>
</button>
<label for="app-drawer" class="btn btn-ghost" x-text="i18n.cancel"></label>
</div>
</form>
</div>
</div>
</div>
<!-- Toast 通知 -->
<div class="toast toast-end toast-bottom z-50">
<template x-for="toast in toasts" :key="toast.id">
<div class="alert" :class="'alert-' + toast.type">
<span x-text="toast.message"></span>
</div>
</template>
</div>
</div>
<!-- 脚本 -->
<script src="/js/app.js"></script>
<script src="/js/alpine.min.js" defer></script>
<!-- 服务端数据注入 -->
<script>
window.HOME4J_DATA = {
isAdmin: {isAdmin},
groups: [
{#for group in groups}
\{
id: {group.id},
name: "{group.name}",
icon: "{group.icon ?: 'folder'}",
sortOrder: {group.sortOrder},
visible: {group.visible},
bookmarks: [
{#for bookmark in group.bookmarks}
\{
id: {bookmark.id},
name: "{bookmark.name}",
url: "{bookmark.url}",
description: "{bookmark.description ?: ''}",
icon: "{bookmark.icon ?: 'link'}",
groupId: {group.id},
openInNewTab: {bookmark.openInNewTab},
visible: {bookmark.visible},
displayMode: "{bookmark.displayMode ?: 'icon'}",
backgroundImage: "{bookmark.backgroundImage ?: ''}"
\}{#if !bookmark_isLast},{/if}
{/for}
]
\}{#if !group_isLast},{/if}
{/for}
]
};
</script>
<!-- 返回顶部按钮 -->
<button
x-data="{ showBackToTop: false }"
x-init="window.addEventListener('scroll', () => { showBackToTop = window.scrollY > 300 })"
x-show="showBackToTop"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-4"
@click="window.scrollTo({ top: 0, behavior: 'smooth' })"
class="fixed bottom-6 right-6 btn btn-circle btn-primary shadow-lg z-50"
title="返回顶部">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m18 15-6-6-6 6"/>
</svg>
</button>
<!-- 登录模态框 -->
<dialog id="loginModal" class="modal modal-bottom sm:modal-middle">
<div class="modal-box">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg mb-4" x-data x-text="window.homeApp ? homeApp.i18n.login : '登录'">登录</h3>
<form action="/j_security_check" method="post" class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text" x-data x-text="window.homeApp ? homeApp.i18n.username : '用户名'">用户名</span>
</label>
<input type="text" name="j_username" required
class="input input-bordered w-full"
placeholder="请输入用户名">
</div>
<div class="form-control">
<label class="label">
<span class="label-text" x-data x-text="window.homeApp ? homeApp.i18n.password : '密码'">密码</span>
</label>
<input type="password" name="j_password" required
class="input input-bordered w-full"
placeholder="请输入密码">
</div>
<div class="form-control mt-6">
<button type="submit" class="btn btn-primary w-full">
<span x-data x-text="window.homeApp ? homeApp.i18n.loginBtn : '登录'">登录</span>
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - Home4j</title>
<script src="/js/browser@4.js"></script>
<link href="/css/daisyui@5.css" rel="stylesheet" type="text/css" />
<link href="/css/themes.css" rel="stylesheet" type="text/css" />
</head>
<body class="min-h-screen bg-base-200 flex items-center justify-center">
<div class="card w-96 bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl font-bold text-center justify-center mb-4">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-primary"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Home4j
</h2>
{#if error}
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>用户名或密码错误</span>
</div>
{/if}
<form action="/j_security_check" method="post">
<div class="form-control mb-4">
<label class="label">
<span class="label-text">用户名</span>
</label>
<input type="text" name="j_username" placeholder="请输入用户名"
class="input input-bordered w-full" required autofocus />
</div>
<div class="form-control mb-6">
<label class="label">
<span class="label-text">密码</span>
</label>
<input type="password" name="j_password" placeholder="请输入密码"
class="input input-bordered w-full" required />
</div>
<button type="submit" class="btn btn-primary w-full">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" y2="12"/></svg>
登录
</button>
</form>
<div class="divider"></div>
<a href="/" class="btn btn-ghost btn-sm w-full">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
返回首页
</a>
</div>
</div>
</body>
</html>
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=/">
<title>Redirecting...</title>
</head>
<body>
<p>Redirecting to <a href="/">Home</a>...</p>
</body>
</html>
@@ -0,0 +1,8 @@
package com.home4j;
import io.quarkus.test.junit.QuarkusIntegrationTest;
@QuarkusIntegrationTest
class GreetingResourceIT extends GreetingResourceTest {
// Execute the same tests but in packaged mode.
}
@@ -0,0 +1,20 @@
package com.home4j;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
@QuarkusTest
class GreetingResourceTest {
@Test
void testHelloEndpoint() {
given()
.when().get("/hello")
.then()
.statusCode(200)
.body(is("Hello from Quarkus REST"));
}
}