init
@@ -0,0 +1,5 @@
|
|||||||
|
*
|
||||||
|
!target/*-runner
|
||||||
|
!target/*-runner.jar
|
||||||
|
!target/lib/*
|
||||||
|
!target/quarkus-app/*
|
||||||
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 it’s 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
[ZoneTransfer]
|
||||||
|
ZoneId=3
|
||||||
|
HostUrl=chrome-extension://mcbpblocgmgfnpjjppndjkmgjaogfceg/
|
||||||
|
After Width: | Height: | Size: 166 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
[ZoneTransfer]
|
||||||
|
ZoneId=3
|
||||||
|
HostUrl=chrome-extension://mcbpblocgmgfnpjjppndjkmgjaogfceg/
|
||||||
|
After Width: | Height: | Size: 688 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
[ZoneTransfer]
|
||||||
|
ZoneId=3
|
||||||
|
HostUrl=chrome-extension://mcbpblocgmgfnpjjppndjkmgjaogfceg/
|
||||||
|
After Width: | Height: | Size: 170 KiB |
@@ -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/
|
||||||
|
After Width: | Height: | Size: 96 KiB |
@@ -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>
|
||||||
@@ -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 区域吗,为什么在顶部导航栏,原来的工具栏也没有了?
|
||||||
|
|
||||||
|
不要动代码,先确认
|
||||||
|
After Width: | Height: | Size: 81 KiB |
@@ -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 内置 Widget(v1.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 扩展 Widget(v1.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 文档
|
||||||
|
- [ ] 运维监控接口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **文档结束**
|
||||||
|
> 本功能列表将随项目进展持续更新
|
||||||
@@ -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` | 书签标签关联 | - | ✅ | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **文档结束**
|
||||||
|
> 本设计将随项目进展持续更新
|
||||||
@@ -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 天无重大 Bug,Star > 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** | Panache(Hibernate) | 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 |
|
||||||
|
| 书签搜索 | 响应时间 | < 50ms(1000条书签内) |
|
||||||
|
| 配置保存 | 响应时间 | < 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**,便于企业用户采用和二次开发。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **文档结束**
|
||||||
|
> 本文档将随项目进展持续更新,欢迎提出反馈和建议。
|
||||||
@@ -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 "$@"
|
||||||
@@ -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"
|
||||||
@@ -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>
|
||||||
@@ -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" ]
|
||||||
|
|
||||||
@@ -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" ]
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[ZoneTransfer]
|
||||||
|
ZoneId=3
|
||||||
|
HostUrl=https://cdn.jsdelivr.net/npm/daisyui@5.5.17/daisyui.min.css
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[ZoneTransfer]
|
||||||
|
ZoneId=3
|
||||||
|
HostUrl=https://cdn.jsdelivr.net.cn/npm/daisyui@5/themes.css
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[ZoneTransfer]
|
||||||
|
ZoneId=3
|
||||||
|
HostUrl=https://cdn.jsdelivr.net/npm/tailwindcss@4.1.18/dist/lib.min.js
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Home4j SQL Import File
|
||||||
|
-- ==========================================
|
||||||
|
-- Note: Default data is initialized via DataInitializer.java
|
||||||
|
-- This file is kept empty to avoid conflicts.
|
||||||
|
-- ==========================================
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||