Compare commits
30 Commits
main
...
feat/colle
Author | SHA1 | Date |
---|---|---|
|
e4f366988f | 2 days ago |
|
46d0bbc3a6 | 2 days ago |
|
9feb236a34 | 2 days ago |
|
3245a8eef3 | 2 days ago |
|
43d2f4a765 | 2 weeks ago |
|
ccc9839d17 | 2 weeks ago |
|
76d66cc38f | 2 weeks ago |
|
a302c2e836 | 2 months ago |
|
aeb2b4ea67 | 2 months ago |
|
914b05192f | 2 months ago |
|
07a5b2ff22 | 2 months ago |
|
272664295e | 2 months ago |
|
2c3d6c86b7 | 2 months ago |
|
dcfa188b85 | 2 months ago |
|
b391dcc998 | 2 months ago |
|
d06982da2b | 2 months ago |
|
ed60efbaf8 | 2 months ago |
|
ea8a70c43d | 2 months ago |
|
d079341238 | 2 months ago |
|
e7425ec594 | 2 months ago |
|
fddb11d84f | 2 months ago |
|
07dc21c1f7 | 2 months ago |
|
9611e33b82 | 2 months ago |
|
f7dc33873d | 2 months ago |
|
8aaf9b5cd4 | 2 months ago |
|
1d142c3900 | 2 months ago |
|
c073c46410 | 2 months ago |
|
838dbbd406 | 2 months ago |
|
7d395f02bf | 2 months ago |
|
d2e8df87f3 | 2 months ago |
100 changed files with 9596 additions and 93 deletions
@ -0,0 +1,2 @@ |
|||
# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references |
|||
.specstory/** |
@ -0,0 +1,90 @@ |
|||
# 依赖目录 |
|||
node_modules |
|||
npm-debug.log* |
|||
yarn-debug.log* |
|||
yarn-error.log* |
|||
|
|||
# 运行时文件 |
|||
*.pid |
|||
*.seed |
|||
*.pid.lock |
|||
|
|||
# 覆盖率目录 |
|||
coverage |
|||
*.lcov |
|||
|
|||
# nyc测试覆盖率 |
|||
.nyc_output |
|||
|
|||
# 依赖锁定文件(在Dockerfile中会复制) |
|||
# package-lock.json |
|||
# yarn.lock |
|||
|
|||
# 日志文件 |
|||
logs |
|||
*.log |
|||
|
|||
# 运行时数据 |
|||
pids |
|||
*.pid |
|||
*.seed |
|||
*.pid.lock |
|||
|
|||
# 目录 |
|||
.npm |
|||
.eslintcache |
|||
|
|||
# 可选npm缓存目录 |
|||
.npm |
|||
|
|||
# 可选REPL历史 |
|||
.node_repl_history |
|||
|
|||
# 输出目录 |
|||
dist |
|||
build |
|||
|
|||
# 环境变量文件 |
|||
.env |
|||
.env.local |
|||
.env.development.local |
|||
.env.test.local |
|||
.env.production.local |
|||
|
|||
# IDE文件 |
|||
.vscode |
|||
.idea |
|||
*.swp |
|||
*.swo |
|||
|
|||
# 操作系统文件 |
|||
.DS_Store |
|||
Thumbs.db |
|||
|
|||
# Git文件 |
|||
.git |
|||
.gitignore |
|||
|
|||
# Docker文件 |
|||
Dockerfile* |
|||
docker-compose* |
|||
.dockerignore |
|||
|
|||
# 测试文件 |
|||
test |
|||
tests |
|||
__tests__ |
|||
*.test.js |
|||
*.spec.js |
|||
|
|||
# 文档 |
|||
README.md |
|||
CHANGELOG.md |
|||
docs |
|||
|
|||
# 其他 |
|||
*.md |
|||
.editorconfig |
|||
.eslintrc* |
|||
.prettierrc* |
|||
.babelrc* |
@ -0,0 +1,65 @@ |
|||
|
|||
# SpecStory Artifacts Directory |
|||
|
|||
This directory is automatically created and maintained by the SpecStory extension to preserve your Cursor composer and chat history. |
|||
|
|||
## What's Here? |
|||
|
|||
- `.specstory/history`: Contains markdown files of your AI coding sessions |
|||
- Each file represents a separate chat or composer session |
|||
- Files are automatically updated as you work |
|||
- `.specstory/cursor_rules_backups`: Contains backups of the `.cursor/rules/derived-cursor-rules.mdc` file |
|||
- Backups are automatically created each time the `.cursor/rules/derived-cursor-rules.mdc` file is updated |
|||
- You can enable/disable the Cursor Rules feature in the SpecStory settings, it is disabled by default |
|||
|
|||
## Valuable Uses |
|||
|
|||
- Capture: Keep your context window up-to-date when starting new Chat/Composer sessions via @ references |
|||
- Search: For previous prompts and code snippets |
|||
- Learn: Meta-analyze your patterns and learn from your past experiences |
|||
- Derive: Keep Cursor on course with your past decisions by automatically deriving Cursor rules from your AI interactions |
|||
|
|||
## Version Control |
|||
|
|||
We recommend keeping this directory under version control to maintain a history of your AI interactions. However, if you prefer not to version these files, you can exclude them by adding this to your `.gitignore`: |
|||
|
|||
``` |
|||
.specstory |
|||
``` |
|||
|
|||
We recommend not keeping the `.specstory/cursor_rules_backups` directory under version control if you are already using git to version the `.cursor/rules` directory, and committing regularly. You can exclude it by adding this to your `.gitignore`: |
|||
|
|||
``` |
|||
.specstory/cursor_rules_backups |
|||
``` |
|||
|
|||
## Searching Your Codebase |
|||
|
|||
When searching your codebase in Cursor, search results may include your previous AI coding interactions. To focus solely on your actual code files, you can exclude the AI interaction history from search results. |
|||
|
|||
To exclude AI interaction history: |
|||
|
|||
1. Open the "Find in Files" search in Cursor (Cmd/Ctrl + Shift + F) |
|||
2. Navigate to the "files to exclude" section |
|||
3. Add the following pattern: |
|||
|
|||
``` |
|||
.specstory/* |
|||
``` |
|||
|
|||
This will ensure your searches only return results from your working codebase files. |
|||
|
|||
## Notes |
|||
|
|||
- Auto-save only works when Cursor/sqlite flushes data to disk. This results in a small delay after the AI response is complete before SpecStory can save the history. |
|||
- Auto-save does not yet work on remote WSL workspaces. |
|||
|
|||
## Settings |
|||
|
|||
You can control auto-saving behavior in Cursor: |
|||
|
|||
1. Open Cursor → Settings → VS Code Settings (Cmd/Ctrl + ,) |
|||
2. Search for "SpecStory" |
|||
3. Find "Auto Save" setting to enable/disable |
|||
|
|||
Auto-save occurs when changes are detected in Cursor's sqlite database, or every 2 minutes as a safety net. |
File diff suppressed because it is too large
@ -0,0 +1,3 @@ |
|||
{ |
|||
"CodeFree.index": true |
|||
} |
@ -0,0 +1,64 @@ |
|||
# 多阶段构建 - 构建阶段 |
|||
FROM oven/bun:alpine AS builder |
|||
|
|||
WORKDIR /app |
|||
|
|||
# 复制依赖文件 |
|||
COPY package.json bun.lockb ./ |
|||
|
|||
# 安装所有依赖(包括开发依赖) |
|||
RUN bun install --frozen-lockfile |
|||
|
|||
# 复制源代码 |
|||
COPY . . |
|||
|
|||
# 构建阶段(如果需要) |
|||
RUN bun run build || true |
|||
|
|||
# 生产阶段 |
|||
FROM oven/bun:alpine AS production |
|||
|
|||
# 创建非root用户 |
|||
RUN addgroup -g 1001 -S nodejs && \ |
|||
adduser -S bun -u 1001 |
|||
|
|||
WORKDIR /app |
|||
|
|||
# 从构建阶段复制依赖 |
|||
COPY --from=builder --chown=bun:nodejs /app/node_modules ./node_modules |
|||
COPY --from=builder --chown=bun:nodejs /app/package.json ./ |
|||
COPY --from=builder --chown=bun:nodejs /app/bun.lockb ./ |
|||
COPY --from=builder --chown=bun:nodejs /app/knexfile.mjs ./ |
|||
|
|||
# 复制应用代码 |
|||
COPY --from=builder --chown=bun:nodejs /app/src ./src |
|||
COPY --from=builder --chown=bun:nodejs /app/public ./public |
|||
|
|||
# 复制并设置入口脚本权限 |
|||
COPY --chown=bun:nodejs entrypoint.sh ./entrypoint.sh |
|||
RUN chmod +x ./entrypoint.sh |
|||
|
|||
# 创建必要的目录并设置权限 |
|||
RUN mkdir -p /app/database /app/logs && \ |
|||
chown -R bun:nodejs /app/database /app/logs |
|||
|
|||
# 设置环境变量 |
|||
ENV NODE_ENV=production |
|||
ENV BUN_ENV=production |
|||
ENV PORT=3000 |
|||
|
|||
# 暴露端口 |
|||
EXPOSE 3000 |
|||
|
|||
# 切换到非root用户 |
|||
USER bun |
|||
|
|||
# 健康检查优化 |
|||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ |
|||
CMD bun --version && \ |
|||
(wget --spider -q http://localhost:3000/health || \ |
|||
wget --spider -q http://localhost:3000/ || \ |
|||
exit 1) |
|||
|
|||
# 设置入口点 |
|||
ENTRYPOINT ["./entrypoint.sh"] |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,46 @@ |
|||
version: '3.8' |
|||
|
|||
services: |
|||
app: |
|||
build: |
|||
context: . |
|||
dockerfile: Dockerfile |
|||
target: production |
|||
container_name: koa3-demo-app |
|||
restart: unless-stopped |
|||
ports: |
|||
- "3000:3000" |
|||
environment: |
|||
- NODE_ENV=production |
|||
- BUN_ENV=production |
|||
- PORT=3000 |
|||
volumes: |
|||
- ./database:/app/database |
|||
- ./logs:/app/logs |
|||
networks: |
|||
- app-network |
|||
healthcheck: |
|||
test: ["CMD", "bun", "--version"] |
|||
interval: 30s |
|||
timeout: 10s |
|||
retries: 3 |
|||
start_period: 40s |
|||
depends_on: |
|||
- db |
|||
|
|||
db: |
|||
image: sqlite:latest |
|||
container_name: koa3-demo-db |
|||
restart: unless-stopped |
|||
volumes: |
|||
- ./database:/var/lib/sqlite |
|||
networks: |
|||
- app-network |
|||
|
|||
networks: |
|||
app-network: |
|||
driver: bridge |
|||
|
|||
volumes: |
|||
database: |
|||
logs: |
@ -0,0 +1,28 @@ |
|||
#!/bin/sh |
|||
set -e |
|||
|
|||
# 数据库文件路径(可根据实际环境调整) |
|||
DB_FILE=./database/db.sqlite3 |
|||
ENV=${NODE_ENV:-production} |
|||
|
|||
# 检查 bun 是否存在 |
|||
if command -v bun >/dev/null 2>&1; then |
|||
RUNNER="bun run" |
|||
START="exec bun src/main.js" |
|||
else |
|||
RUNNER="npx" |
|||
START="exec npm run start" |
|||
fi |
|||
|
|||
# 如果数据库文件不存在,先 migrate 再 seed |
|||
if [ ! -f "$DB_FILE" ]; then |
|||
echo "Database not found, running migration and seed..." |
|||
$RUNNER npx knex migrate:latest --env $ENV |
|||
$RUNNER npx knex seed:run --env $ENV |
|||
else |
|||
echo "Database exists, running migration only..." |
|||
$RUNNER npx knex migrate:latest |
|||
fi |
|||
|
|||
# 启动主服务 |
|||
$START |
@ -0,0 +1,29 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"baseUrl": ".", |
|||
"paths": { |
|||
"@/*": [ |
|||
"src/*" |
|||
], |
|||
"db/*": [ |
|||
"src/db/*" |
|||
], |
|||
"config/*": [ |
|||
"src/config/*" |
|||
], |
|||
"utils/*": [ |
|||
"src/utils/*" |
|||
], |
|||
"services/*": [ |
|||
"src/services/*" |
|||
] |
|||
}, |
|||
"module": "commonjs", |
|||
"target": "es6", |
|||
"allowSyntheticDefaultImports": true |
|||
}, |
|||
"include": [ |
|||
"src/**/*", |
|||
"jsconfig.json" |
|||
] |
|||
} |
@ -0,0 +1,727 @@ |
|||
.home-hero { |
|||
margin: 40px 20px 40px; |
|||
/* background: rgba(255, 255, 255, 0.1); |
|||
backdrop-filter: blur(12px); */ |
|||
text-align: center; |
|||
} |
|||
.avatar-container { |
|||
width: 120px; |
|||
height: 120px; |
|||
margin: 0 auto; |
|||
position: relative; |
|||
} |
|||
.avatar-container .author { |
|||
position: absolute; |
|||
top: 50%; |
|||
left: 50%; |
|||
transform: translate(-50%, -50%); |
|||
color: white; |
|||
font-size: 20px; |
|||
font-weight: bold; |
|||
} |
|||
.avatar-container:hover .avatar { |
|||
transform: rotate(360deg); |
|||
left: 100%; |
|||
} |
|||
.avatar { |
|||
position: relative; |
|||
width: 100%; |
|||
height: 100%; |
|||
border-radius: 50%; |
|||
cursor: pointer; |
|||
left: 0; |
|||
transform-origin: center center; |
|||
transition: 0.5s transform ease-in-out, 0.5s left ease-in-out; |
|||
} |
|||
|
|||
.card { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
backdrop-filter: blur(12px); |
|||
border-radius: 8px; |
|||
padding: 20px; |
|||
width: 300px; |
|||
margin: 0 auto; |
|||
margin-bottom: 40px; |
|||
text-align: center; |
|||
} |
|||
|
|||
/* 收藏网站样式 */ |
|||
.bookmarks-container { |
|||
max-width: 1200px; |
|||
margin: 0 auto; |
|||
padding: 20px; |
|||
color: white; |
|||
} |
|||
|
|||
/* 搜索区域 */ |
|||
.search-section { |
|||
margin-bottom: 30px; |
|||
} |
|||
|
|||
.search-box { |
|||
display: flex; |
|||
gap: 15px; |
|||
align-items: center; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
backdrop-filter: blur(12px); |
|||
border-radius: 12px; |
|||
padding: 20px; |
|||
} |
|||
|
|||
.search-input { |
|||
flex: 1; |
|||
background: rgba(255, 255, 255, 0.2); |
|||
border: none; |
|||
border-radius: 8px; |
|||
padding: 12px 16px; |
|||
color: white; |
|||
font-size: 16px; |
|||
outline: none; |
|||
} |
|||
|
|||
.search-input::placeholder { |
|||
color: rgba(255, 255, 255, 0.7); |
|||
} |
|||
|
|||
.search-btn, .add-btn { |
|||
background: rgba(59, 130, 246, 0.8); |
|||
border: none; |
|||
border-radius: 8px; |
|||
padding: 12px 20px; |
|||
color: white; |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.search-btn:hover, .add-btn:hover { |
|||
background: rgba(59, 130, 246, 1); |
|||
transform: translateY(-2px); |
|||
} |
|||
|
|||
.add-btn { |
|||
background: rgba(16, 185, 129, 0.8); |
|||
} |
|||
|
|||
.add-btn:hover { |
|||
background: rgba(16, 185, 129, 1); |
|||
} |
|||
|
|||
/* 导航区域 */ |
|||
.navigation-section { |
|||
display: grid; |
|||
grid-template-columns: 1fr 1fr; |
|||
gap: 20px; |
|||
margin-bottom: 30px; |
|||
} |
|||
|
|||
.categories-nav, .tags-cloud { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
backdrop-filter: blur(12px); |
|||
border-radius: 12px; |
|||
padding: 20px; |
|||
} |
|||
|
|||
.nav-title { |
|||
font-size: 18px; |
|||
font-weight: 600; |
|||
margin-bottom: 15px; |
|||
color: rgba(255, 255, 255, 0.9); |
|||
} |
|||
|
|||
.category-list, .tag-list { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: 10px; |
|||
} |
|||
|
|||
.category-item, .tag-item { |
|||
background: rgba(255, 255, 255, 0.2); |
|||
border-radius: 20px; |
|||
padding: 8px 16px; |
|||
font-size: 14px; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
border: 2px solid transparent; |
|||
} |
|||
|
|||
.category-item:hover, .tag-item:hover { |
|||
background: rgba(255, 255, 255, 0.3); |
|||
transform: translateY(-2px); |
|||
} |
|||
|
|||
.category-item.active, .tag-item.active { |
|||
border-color: rgba(59, 130, 246, 0.8); |
|||
background: rgba(59, 130, 246, 0.3); |
|||
} |
|||
|
|||
/* 收藏区域 */ |
|||
.bookmarks-section { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
backdrop-filter: blur(12px); |
|||
border-radius: 12px; |
|||
padding: 20px; |
|||
margin-bottom: 30px; |
|||
} |
|||
|
|||
.section-header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 20px; |
|||
padding-bottom: 15px; |
|||
border-bottom: 1px solid rgba(255, 255, 255, 0.2); |
|||
} |
|||
|
|||
.section-header h2 { |
|||
margin: 0; |
|||
font-size: 24px; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.view-controls { |
|||
display: flex; |
|||
gap: 10px; |
|||
} |
|||
|
|||
.view-btn { |
|||
background: rgba(255, 255, 255, 0.2); |
|||
border: none; |
|||
border-radius: 6px; |
|||
padding: 8px 16px; |
|||
color: white; |
|||
font-size: 14px; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.view-btn:hover { |
|||
background: rgba(255, 255, 255, 0.3); |
|||
} |
|||
|
|||
.view-btn.active { |
|||
background: rgba(59, 130, 246, 0.8); |
|||
} |
|||
|
|||
/* 网格视图 */ |
|||
.bookmarks-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); |
|||
gap: 20px; |
|||
} |
|||
|
|||
.bookmark-card { |
|||
background: rgba(255, 255, 255, 0.15); |
|||
border-radius: 12px; |
|||
padding: 20px; |
|||
transition: all 0.3s ease; |
|||
border: 1px solid rgba(255, 255, 255, 0.1); |
|||
} |
|||
|
|||
.bookmark-card:hover { |
|||
transform: translateY(-5px); |
|||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); |
|||
border-color: rgba(255, 255, 255, 0.3); |
|||
} |
|||
|
|||
.bookmark-header { |
|||
display: flex; |
|||
align-items: center; |
|||
margin-bottom: 15px; |
|||
} |
|||
|
|||
.bookmark-favicon { |
|||
width: 32px; |
|||
height: 32px; |
|||
border-radius: 6px; |
|||
margin-right: 12px; |
|||
object-fit: cover; |
|||
} |
|||
|
|||
.bookmark-title { |
|||
font-size: 18px; |
|||
font-weight: 600; |
|||
margin: 0; |
|||
color: white; |
|||
flex: 1; |
|||
} |
|||
|
|||
.bookmark-favorite { |
|||
background: none; |
|||
border: none; |
|||
color: #fbbf24; |
|||
font-size: 20px; |
|||
cursor: pointer; |
|||
padding: 5px; |
|||
border-radius: 50%; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.bookmark-favorite:hover { |
|||
background: rgba(251, 191, 36, 0.2); |
|||
} |
|||
|
|||
.bookmark-favorite.active { |
|||
color: #f59e0b; |
|||
} |
|||
|
|||
.bookmark-description { |
|||
color: rgba(255, 255, 255, 0.8); |
|||
margin-bottom: 15px; |
|||
line-height: 1.5; |
|||
} |
|||
|
|||
.bookmark-meta { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 15px; |
|||
} |
|||
|
|||
.bookmark-category { |
|||
background: rgba(59, 130, 246, 0.3); |
|||
color: rgba(59, 130, 246, 1); |
|||
padding: 4px 12px; |
|||
border-radius: 12px; |
|||
font-size: 12px; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.bookmark-stats { |
|||
display: flex; |
|||
gap: 15px; |
|||
color: rgba(255, 255, 255, 0.6); |
|||
font-size: 12px; |
|||
} |
|||
|
|||
.bookmark-tags { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: 8px; |
|||
margin-bottom: 15px; |
|||
} |
|||
|
|||
.bookmark-tag { |
|||
background: rgba(255, 255, 255, 0.2); |
|||
color: white; |
|||
padding: 4px 10px; |
|||
border-radius: 10px; |
|||
font-size: 11px; |
|||
} |
|||
|
|||
.bookmark-actions { |
|||
display: flex; |
|||
gap: 10px; |
|||
} |
|||
|
|||
.bookmark-btn { |
|||
flex: 1; |
|||
background: rgba(255, 255, 255, 0.2); |
|||
border: none; |
|||
border-radius: 6px; |
|||
padding: 8px 12px; |
|||
color: white; |
|||
font-size: 12px; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.bookmark-btn:hover { |
|||
background: rgba(255, 255, 255, 0.3); |
|||
} |
|||
|
|||
.bookmark-btn.primary { |
|||
background: rgba(59, 130, 246, 0.8); |
|||
} |
|||
|
|||
.bookmark-btn.primary:hover { |
|||
background: rgba(59, 130, 246, 1); |
|||
} |
|||
|
|||
/* 列表视图 */ |
|||
.bookmarks-list { |
|||
display: none; |
|||
} |
|||
|
|||
.bookmark-list-item { |
|||
display: flex; |
|||
align-items: center; |
|||
padding: 15px; |
|||
border-bottom: 1px solid rgba(255, 255, 255, 0.1); |
|||
transition: background 0.3s ease; |
|||
} |
|||
|
|||
.bookmark-list-item:hover { |
|||
background: rgba(255, 255, 255, 0.05); |
|||
} |
|||
|
|||
.bookmark-list-item:last-child { |
|||
border-bottom: none; |
|||
} |
|||
|
|||
.bookmark-list-favicon { |
|||
width: 24px; |
|||
height: 24px; |
|||
border-radius: 4px; |
|||
margin-right: 15px; |
|||
} |
|||
|
|||
.bookmark-list-content { |
|||
flex: 1; |
|||
} |
|||
|
|||
.bookmark-list-title { |
|||
font-size: 16px; |
|||
font-weight: 600; |
|||
margin: 0 0 5px 0; |
|||
color: white; |
|||
} |
|||
|
|||
.bookmark-list-description { |
|||
color: rgba(255, 255, 255, 0.7); |
|||
font-size: 14px; |
|||
margin: 0; |
|||
} |
|||
|
|||
.bookmark-list-meta { |
|||
display: flex; |
|||
gap: 15px; |
|||
margin-left: 20px; |
|||
} |
|||
|
|||
/* 分页 */ |
|||
.pagination-section { |
|||
display: flex; |
|||
justify-content: center; |
|||
margin-top: 30px; |
|||
} |
|||
|
|||
.pagination { |
|||
display: flex; |
|||
gap: 10px; |
|||
align-items: center; |
|||
} |
|||
|
|||
.pagination-btn { |
|||
background: rgba(255, 255, 255, 0.2); |
|||
border: none; |
|||
border-radius: 6px; |
|||
padding: 8px 12px; |
|||
color: white; |
|||
font-size: 14px; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.pagination-btn:hover { |
|||
background: rgba(255, 255, 255, 0.3); |
|||
} |
|||
|
|||
.pagination-btn.active { |
|||
background: rgba(59, 130, 246, 0.8); |
|||
} |
|||
|
|||
.pagination-btn:disabled { |
|||
opacity: 0.5; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
/* 模态框 */ |
|||
.modal { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
width: 100%; |
|||
height: 100%; |
|||
z-index: 1000; |
|||
} |
|||
|
|||
.modal-overlay { |
|||
position: absolute; |
|||
top: 0; |
|||
left: 0; |
|||
width: 100%; |
|||
height: 100%; |
|||
background: rgba(0, 0, 0, 0.8); |
|||
backdrop-filter: blur(5px); |
|||
} |
|||
|
|||
.modal-content { |
|||
position: absolute; |
|||
top: 50%; |
|||
left: 50%; |
|||
transform: translate(-50%, -50%); |
|||
background: rgba(30, 41, 59, 0.95); |
|||
backdrop-filter: blur(20px); |
|||
border-radius: 16px; |
|||
padding: 0; |
|||
width: 90%; |
|||
max-width: 600px; |
|||
max-height: 90vh; |
|||
overflow-y: auto; |
|||
border: 1px solid rgba(255, 255, 255, 0.1); |
|||
} |
|||
|
|||
.modal-header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
padding: 20px 25px; |
|||
border-bottom: 1px solid rgba(255, 255, 255, 0.1); |
|||
} |
|||
|
|||
.modal-header h3 { |
|||
margin: 0; |
|||
color: white; |
|||
font-size: 20px; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.modal-close { |
|||
background: none; |
|||
border: none; |
|||
color: rgba(255, 255, 255, 0.7); |
|||
font-size: 24px; |
|||
cursor: pointer; |
|||
padding: 5px; |
|||
border-radius: 50%; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.modal-close:hover { |
|||
color: white; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
} |
|||
|
|||
.modal-body { |
|||
padding: 25px; |
|||
} |
|||
|
|||
.form-group { |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.form-group label { |
|||
display: block; |
|||
margin-bottom: 8px; |
|||
color: white; |
|||
font-weight: 500; |
|||
font-size: 14px; |
|||
} |
|||
|
|||
.form-group input, |
|||
.form-group textarea, |
|||
.form-group select { |
|||
width: 100%; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
border-radius: 8px; |
|||
padding: 12px 16px; |
|||
color: white; |
|||
font-size: 14px; |
|||
outline: none; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.form-group input:focus, |
|||
.form-group textarea:focus, |
|||
.form-group select:focus { |
|||
border-color: rgba(59, 130, 246, 0.8); |
|||
background: rgba(255, 255, 255, 0.15); |
|||
} |
|||
|
|||
.form-group textarea { |
|||
resize: vertical; |
|||
min-height: 80px; |
|||
} |
|||
|
|||
.form-group input::placeholder, |
|||
.form-group textarea::placeholder { |
|||
color: rgba(255, 255, 255, 0.5); |
|||
} |
|||
|
|||
.extra-links { |
|||
margin-bottom: 15px; |
|||
} |
|||
|
|||
.extra-link-item { |
|||
display: flex; |
|||
gap: 10px; |
|||
margin-bottom: 10px; |
|||
align-items: center; |
|||
} |
|||
|
|||
.extra-link-item .link-title { |
|||
flex: 1; |
|||
} |
|||
|
|||
.extra-link-item .link-url { |
|||
flex: 2; |
|||
} |
|||
|
|||
.remove-link { |
|||
background: rgba(239, 68, 68, 0.8); |
|||
border: none; |
|||
border-radius: 6px; |
|||
padding: 8px 12px; |
|||
color: white; |
|||
font-size: 12px; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.remove-link:hover { |
|||
background: rgba(239, 68, 68, 1); |
|||
} |
|||
|
|||
.add-link-btn { |
|||
background: rgba(16, 185, 129, 0.8); |
|||
border: none; |
|||
border-radius: 6px; |
|||
padding: 8px 16px; |
|||
color: white; |
|||
font-size: 14px; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.add-link-btn:hover { |
|||
background: rgba(16, 185, 129, 1); |
|||
} |
|||
|
|||
.form-actions { |
|||
display: flex; |
|||
gap: 15px; |
|||
margin-top: 30px; |
|||
padding-top: 20px; |
|||
border-top: 1px solid rgba(255, 255, 255, 0.1); |
|||
} |
|||
|
|||
.submit-btn, .cancel-btn { |
|||
flex: 1; |
|||
padding: 12px 24px; |
|||
border: none; |
|||
border-radius: 8px; |
|||
font-size: 16px; |
|||
font-weight: 500; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.submit-btn { |
|||
background: rgba(59, 130, 246, 0.8); |
|||
color: white; |
|||
} |
|||
|
|||
.submit-btn:hover { |
|||
background: rgba(59, 130, 246, 1); |
|||
transform: translateY(-2px); |
|||
} |
|||
|
|||
.cancel-btn { |
|||
background: rgba(255, 255, 255, 0.2); |
|||
color: white; |
|||
} |
|||
|
|||
.cancel-btn:hover { |
|||
background: rgba(255, 255, 255, 0.3); |
|||
} |
|||
|
|||
/* 响应式设计 */ |
|||
@media screen and (max-width: 768px) { |
|||
.home-hero { |
|||
margin: 0; |
|||
margin-top: 20px; |
|||
} |
|||
|
|||
.bookmarks-container { |
|||
padding: 15px; |
|||
} |
|||
|
|||
.search-box { |
|||
flex-direction: column; |
|||
align-items: stretch; |
|||
} |
|||
|
|||
.navigation-section { |
|||
grid-template-columns: 1fr; |
|||
gap: 15px; |
|||
} |
|||
|
|||
.section-header { |
|||
flex-direction: column; |
|||
gap: 15px; |
|||
align-items: stretch; |
|||
} |
|||
|
|||
.bookmarks-grid { |
|||
grid-template-columns: 1fr; |
|||
} |
|||
|
|||
.modal-content { |
|||
width: 95%; |
|||
margin: 20px; |
|||
} |
|||
|
|||
.form-actions { |
|||
flex-direction: column; |
|||
} |
|||
} |
|||
|
|||
/* 加载状态 */ |
|||
.loading { |
|||
text-align: center; |
|||
padding: 40px; |
|||
color: rgba(255, 255, 255, 0.7); |
|||
} |
|||
|
|||
.loading::after { |
|||
content: ''; |
|||
display: inline-block; |
|||
width: 20px; |
|||
height: 20px; |
|||
border: 2px solid rgba(255, 255, 255, 0.3); |
|||
border-radius: 50%; |
|||
border-top-color: white; |
|||
animation: spin 1s ease-in-out infinite; |
|||
margin-left: 10px; |
|||
} |
|||
|
|||
@keyframes spin { |
|||
to { transform: rotate(360deg); } |
|||
} |
|||
|
|||
/* 空状态 */ |
|||
.empty-state { |
|||
text-align: center; |
|||
padding: 60px 20px; |
|||
color: rgba(255, 255, 255, 0.7); |
|||
} |
|||
|
|||
.empty-state h3 { |
|||
margin: 0 0 15px 0; |
|||
font-size: 20px; |
|||
color: white; |
|||
} |
|||
|
|||
.empty-state p { |
|||
margin: 0 0 25px 0; |
|||
font-size: 16px; |
|||
} |
|||
|
|||
.empty-state .empty-btn { |
|||
background: rgba(59, 130, 246, 0.8); |
|||
border: none; |
|||
border-radius: 8px; |
|||
padding: 12px 24px; |
|||
color: white; |
|||
font-size: 16px; |
|||
cursor: pointer; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.empty-state .empty-btn:hover { |
|||
background: rgba(59, 130, 246, 1); |
|||
transform: translateY(-2px); |
|||
} |
@ -0,0 +1,804 @@ |
|||
// 收藏网站管理类
|
|||
class BookmarkManager { |
|||
constructor() { |
|||
this.currentView = 'grid'; |
|||
this.currentPage = 1; |
|||
this.itemsPerPage = 12; |
|||
this.currentCategory = null; |
|||
this.currentTag = null; |
|||
this.searchQuery = ''; |
|||
this.bookmarks = []; |
|||
this.categories = []; |
|||
this.tags = []; |
|||
this.editingBookmark = null; |
|||
this.totalBookmarks = 0; |
|||
this.pagination = null; |
|||
|
|||
this.init(); |
|||
} |
|||
|
|||
async init() { |
|||
this.bindEvents(); |
|||
await this.loadInitialData(); |
|||
this.renderUI(); |
|||
} |
|||
|
|||
bindEvents() { |
|||
// 搜索功能
|
|||
document.getElementById('searchBtn').addEventListener('click', () => this.handleSearch()); |
|||
document.getElementById('searchInput').addEventListener('keypress', (e) => { |
|||
if (e.key === 'Enter') this.handleSearch(); |
|||
}); |
|||
|
|||
// 添加收藏
|
|||
document.getElementById('addBookmarkBtn').addEventListener('click', () => this.showAddModal()); |
|||
|
|||
// 视图切换
|
|||
document.getElementById('gridViewBtn').addEventListener('click', () => this.switchView('grid')); |
|||
document.getElementById('listViewBtn').addEventListener('click', () => this.switchView('list')); |
|||
|
|||
// 模态框
|
|||
document.getElementById('closeModal').addEventListener('click', () => this.closeModal()); |
|||
document.getElementById('bookmarkForm').addEventListener('submit', (e) => this.handleFormSubmit(e)); |
|||
document.getElementById('addLinkBtn').addEventListener('click', () => this.addExtraLink()); |
|||
|
|||
// 点击模态框外部关闭
|
|||
document.querySelector('.modal-overlay').addEventListener('click', (e) => { |
|||
if (e.target.classList.contains('modal-overlay')) { |
|||
this.closeModal(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
async loadInitialData() { |
|||
try { |
|||
// 调用后端API加载数据
|
|||
await this.loadCategories(); |
|||
await this.loadTags(); |
|||
await this.loadBookmarks(); |
|||
} catch (error) { |
|||
console.error('加载数据失败:', error); |
|||
this.showError('加载数据失败,请刷新页面重试'); |
|||
} |
|||
} |
|||
|
|||
async loadCategories() { |
|||
try { |
|||
const response = await fetch('/categories', { |
|||
method: 'GET', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
credentials: 'include' |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error('获取分类失败'); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
if (result.success) { |
|||
this.categories = result.data; |
|||
} else { |
|||
throw new Error(result.message || '获取分类失败'); |
|||
} |
|||
} catch (error) { |
|||
console.error('加载分类失败:', error); |
|||
// 如果API调用失败,使用默认分类
|
|||
this.categories = [ |
|||
{ id: 1, name: '技术开发', color: '#3B82F6', icon: 'code' }, |
|||
{ id: 2, name: '学习资源', color: '#10B981', icon: 'graduation-cap' }, |
|||
{ id: 3, name: '设计工具', color: '#F59E0B', icon: 'palette' }, |
|||
{ id: 4, name: '效率工具', color: '#8B5CF6', icon: 'zap' }, |
|||
{ id: 5, name: '娱乐休闲', color: '#EC4899', icon: 'heart' } |
|||
]; |
|||
} |
|||
} |
|||
|
|||
async loadTags() { |
|||
try { |
|||
const response = await fetch('/tags/popular', { |
|||
method: 'GET', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
credentials: 'include' |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error('获取标签失败'); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
if (result.success) { |
|||
this.tags = result.data; |
|||
} else { |
|||
throw new Error(result.message || '获取标签失败'); |
|||
} |
|||
} catch (error) { |
|||
console.error('加载标签失败:', error); |
|||
// 如果API调用失败,使用默认标签
|
|||
this.tags = [ |
|||
{ id: 1, name: 'JavaScript', color: '#F7DF1E' }, |
|||
{ id: 2, name: 'React', color: '#61DAFB' }, |
|||
{ id: 3, name: 'Node.js', color: '#339933' }, |
|||
{ id: 4, name: 'CSS', color: '#1572B6' }, |
|||
{ id: 5, name: '设计灵感', color: '#FF6B6B' }, |
|||
{ id: 6, name: '免费资源', color: '#4ECDC4' }, |
|||
{ id: 7, name: 'API', color: '#45B7D1' }, |
|||
{ id: 8, name: '数据库', color: '#FFA500' } |
|||
]; |
|||
} |
|||
} |
|||
|
|||
async loadBookmarks() { |
|||
try { |
|||
const params = new URLSearchParams({ |
|||
page: this.currentPage, |
|||
limit: this.itemsPerPage |
|||
}); |
|||
|
|||
if (this.currentCategory) { |
|||
params.append('category_id', this.currentCategory); |
|||
} |
|||
if (this.currentTag) { |
|||
params.append('tag_id', this.currentTag); |
|||
} |
|||
if (this.searchQuery) { |
|||
params.append('search', this.searchQuery); |
|||
} |
|||
|
|||
const response = await fetch(`/bookmarks?${params}`, { |
|||
method: 'GET', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
credentials: 'include' |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error('获取收藏失败'); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
if (result.success) { |
|||
this.bookmarks = result.data; |
|||
this.totalBookmarks = result.total; |
|||
this.pagination = result.pagination; |
|||
} else { |
|||
throw new Error(result.message || '获取收藏失败'); |
|||
} |
|||
} catch (error) { |
|||
console.error('加载收藏失败:', error); |
|||
// 如果API调用失败,使用默认数据
|
|||
this.bookmarks = [ |
|||
{ |
|||
id: 1, |
|||
title: 'MDN Web Docs', |
|||
description: 'Mozilla开发者网络,提供Web技术文档和教程', |
|||
url: 'https://developer.mozilla.org/', |
|||
favicon: 'https://developer.mozilla.org/favicon-48x48.cbbd161b.png', |
|||
category_id: 1, |
|||
category_name: '技术开发', |
|||
category_color: '#3B82F6', |
|||
is_favorite: true, |
|||
click_count: 15, |
|||
tags: ['JavaScript', 'Node.js', 'CSS'], |
|||
links: [ |
|||
{ title: 'JavaScript 教程', url: 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript', type: 'tutorial' }, |
|||
{ title: 'CSS 参考', url: 'https://developer.mozilla.org/zh-CN/docs/Web/CSS', type: 'reference' } |
|||
], |
|||
created_at: '2024-01-15T10:30:00Z' |
|||
} |
|||
]; |
|||
} |
|||
} |
|||
|
|||
renderUI() { |
|||
this.renderCategories(); |
|||
this.renderTags(); |
|||
this.renderBookmarks(); |
|||
this.renderPagination(); |
|||
} |
|||
|
|||
renderCategories() { |
|||
const categoryList = document.getElementById('categoryList'); |
|||
const allCategories = [{ id: null, name: '全部', color: '#6B7280', icon: 'grid' }, ...this.categories]; |
|||
|
|||
categoryList.innerHTML = allCategories.map(category => ` |
|||
<div class="category-item ${category.id === this.currentCategory ? 'active' : ''}" |
|||
data-id="${category.id}" |
|||
style="border-color: ${category.color}"> |
|||
${category.name} |
|||
</div> |
|||
`).join('');
|
|||
|
|||
// 绑定分类点击事件
|
|||
categoryList.querySelectorAll('.category-item').forEach(item => { |
|||
item.addEventListener('click', () => { |
|||
const categoryId = item.dataset.id ? parseInt(item.dataset.id) : null; |
|||
this.filterByCategory(categoryId); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
renderTags() { |
|||
const tagList = document.getElementById('tagList'); |
|||
const allTags = [{ id: null, name: '全部', color: '#6B7280' }, ...this.tags]; |
|||
|
|||
tagList.innerHTML = allTags.map(tag => ` |
|||
<div class="tag-item ${tag.id === this.currentTag ? 'active' : ''}" |
|||
data-id="${tag.id}" |
|||
style="border-color: ${tag.color}"> |
|||
${tag.name} |
|||
</div> |
|||
`).join('');
|
|||
|
|||
// 绑定标签点击事件
|
|||
tagList.querySelectorAll('.tag-item').forEach(item => { |
|||
item.addEventListener('click', () => { |
|||
const tagId = item.dataset.id ? parseInt(item.dataset.id) : null; |
|||
this.filterByTag(tagId); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
renderBookmarks() { |
|||
if (this.bookmarks.length === 0) { |
|||
this.showEmptyState(); |
|||
return; |
|||
} |
|||
|
|||
if (this.currentView === 'grid') { |
|||
this.renderGridView(this.bookmarks); |
|||
} else { |
|||
this.renderListView(this.bookmarks); |
|||
} |
|||
} |
|||
|
|||
renderGridView(bookmarks) { |
|||
const gridContainer = document.getElementById('bookmarksGrid'); |
|||
const listContainer = document.getElementById('bookmarksList'); |
|||
|
|||
gridContainer.style.display = 'grid'; |
|||
listContainer.style.display = 'none'; |
|||
|
|||
gridContainer.innerHTML = bookmarks.map(bookmark => this.createBookmarkCard(bookmark)).join(''); |
|||
this.bindBookmarkEvents(); |
|||
} |
|||
|
|||
renderListView(bookmarks) { |
|||
const gridContainer = document.getElementById('bookmarksGrid'); |
|||
const listContainer = document.getElementById('bookmarksList'); |
|||
|
|||
gridContainer.style.display = 'none'; |
|||
listContainer.style.display = 'block'; |
|||
|
|||
listContainer.innerHTML = bookmarks.map(bookmark => this.createBookmarkListItem(bookmark)).join(''); |
|||
this.bindBookmarkEvents(); |
|||
} |
|||
|
|||
createBookmarkCard(bookmark) { |
|||
const tagsHtml = bookmark.tags.map(tag => |
|||
`<span class="bookmark-tag">${tag}</span>` |
|||
).join(''); |
|||
|
|||
const linksHtml = bookmark.links.length > 0 ? |
|||
`<div class="bookmark-links">
|
|||
<small>额外链接: ${bookmark.links.length}个</small> |
|||
</div>` : ''; |
|||
|
|||
return ` |
|||
<div class="bookmark-card" data-id="${bookmark.id}"> |
|||
<div class="bookmark-header"> |
|||
<img src="${bookmark.favicon}" alt="favicon" class="bookmark-favicon" |
|||
onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><rect width=%2232%22 height=%2232%22 fill=%22%23666%22 rx=%224%22/></svg>'"> |
|||
<h3 class="bookmark-title">${bookmark.title}</h3> |
|||
<button class="bookmark-favorite ${bookmark.is_favorite ? 'active' : ''}" |
|||
data-id="${bookmark.id}" title="特别收藏"> |
|||
${bookmark.is_favorite ? '★' : '☆'} |
|||
</button> |
|||
</div> |
|||
|
|||
<p class="bookmark-description">${bookmark.description}</p> |
|||
|
|||
<div class="bookmark-meta"> |
|||
<span class="bookmark-category" style="background: ${bookmark.category_color}20; color: ${bookmark.category_color}"> |
|||
${bookmark.category_name} |
|||
</span> |
|||
<div class="bookmark-stats"> |
|||
<span>👁 ${bookmark.click_count}</span> |
|||
<span>📅 ${this.formatDate(bookmark.created_at)}</span> |
|||
</div> |
|||
</div> |
|||
|
|||
${tagsHtml ? `<div class="bookmark-tags">${tagsHtml}</div>` : ''} |
|||
${linksHtml} |
|||
|
|||
<div class="bookmark-actions"> |
|||
<button class="bookmark-btn primary" onclick="window.open('${bookmark.url}', '_blank')"> |
|||
访问网站 |
|||
</button> |
|||
<button class="bookmark-btn" onclick="bookmarkManager.editBookmark(${bookmark.id})"> |
|||
编辑 |
|||
</button> |
|||
<button class="bookmark-btn" onclick="bookmarkManager.deleteBookmark(${bookmark.id})"> |
|||
删除 |
|||
</button> |
|||
</div> |
|||
</div> |
|||
`;
|
|||
} |
|||
|
|||
createBookmarkListItem(bookmark) { |
|||
const tagsHtml = bookmark.tags.map(tag => |
|||
`<span class="bookmark-tag">${tag}</span>` |
|||
).join(''); |
|||
|
|||
return ` |
|||
<div class="bookmark-list-item" data-id="${bookmark.id}"> |
|||
<img src="${bookmark.favicon}" alt="favicon" class="bookmark-list-favicon" |
|||
onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 fill=%22%23666%22 rx=%223%22/></svg>'"> |
|||
|
|||
<div class="bookmark-list-content"> |
|||
<h4 class="bookmark-list-title">${bookmark.title}</h4> |
|||
<p class="bookmark-list-description">${bookmark.description}</p> |
|||
${tagsHtml ? `<div class="bookmark-tags">${tagsHtml}</div>` : ''} |
|||
</div> |
|||
|
|||
<div class="bookmark-list-meta"> |
|||
<span class="bookmark-category" style="background: ${bookmark.category_color}20; color: ${bookmark.category_color}"> |
|||
${bookmark.category_name} |
|||
</span> |
|||
<span>👁 ${bookmark.click_count}</span> |
|||
<button class="bookmark-btn" onclick="bookmarkManager.editBookmark(${bookmark.id})">编辑</button> |
|||
<button class="bookmark-btn" onclick="bookmarkManager.deleteBookmark(${bookmark.id})">删除</button> |
|||
</div> |
|||
</div> |
|||
`;
|
|||
} |
|||
|
|||
bindBookmarkEvents() { |
|||
// 绑定收藏按钮事件
|
|||
document.querySelectorAll('.bookmark-favorite').forEach(btn => { |
|||
btn.addEventListener('click', (e) => { |
|||
e.stopPropagation(); |
|||
const bookmarkId = parseInt(btn.dataset.id); |
|||
this.toggleFavorite(bookmarkId); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
renderPagination() { |
|||
if (!this.pagination || this.pagination.totalPages <= 1) { |
|||
document.getElementById('pagination').innerHTML = ''; |
|||
return; |
|||
} |
|||
|
|||
let paginationHtml = ''; |
|||
|
|||
// 上一页
|
|||
paginationHtml += ` |
|||
<button class="pagination-btn" ${!this.pagination.hasPrev ? 'disabled' : ''} |
|||
onclick="bookmarkManager.goToPage(${this.currentPage - 1})"> |
|||
上一页 |
|||
</button> |
|||
`;
|
|||
|
|||
// 页码
|
|||
for (let i = 1; i <= this.pagination.totalPages; i++) { |
|||
if (i === 1 || i === this.pagination.totalPages || (i >= this.currentPage - 2 && i <= this.currentPage + 2)) { |
|||
paginationHtml += ` |
|||
<button class="pagination-btn ${i === this.currentPage ? 'active' : ''}" |
|||
onclick="bookmarkManager.goToPage(${i})"> |
|||
${i} |
|||
</button> |
|||
`;
|
|||
} else if (i === this.currentPage - 3 || i === this.currentPage + 3) { |
|||
paginationHtml += '<span class="pagination-btn">...</span>'; |
|||
} |
|||
} |
|||
|
|||
// 下一页
|
|||
paginationHtml += ` |
|||
<button class="pagination-btn" ${!this.pagination.hasNext ? 'disabled' : ''} |
|||
onclick="bookmarkManager.goToPage(${this.currentPage + 1})"> |
|||
下一页 |
|||
</button> |
|||
`;
|
|||
|
|||
document.getElementById('pagination').innerHTML = paginationHtml; |
|||
} |
|||
|
|||
getFilteredBookmarks() { |
|||
// 现在数据直接从后端获取,已经过滤过了
|
|||
return this.bookmarks; |
|||
} |
|||
|
|||
async handleSearch() { |
|||
this.searchQuery = document.getElementById('searchInput').value.trim(); |
|||
this.currentPage = 1; |
|||
await this.loadBookmarks(); |
|||
this.renderUI(); |
|||
} |
|||
|
|||
async filterByCategory(categoryId) { |
|||
this.currentCategory = categoryId; |
|||
this.currentPage = 1; |
|||
await this.loadBookmarks(); |
|||
this.renderUI(); |
|||
} |
|||
|
|||
async filterByTag(tagId) { |
|||
this.currentTag = tagId; |
|||
this.currentPage = 1; |
|||
await this.loadBookmarks(); |
|||
this.renderUI(); |
|||
} |
|||
|
|||
switchView(view) { |
|||
this.currentView = view; |
|||
|
|||
// 更新按钮状态
|
|||
document.querySelectorAll('.view-btn').forEach(btn => { |
|||
btn.classList.toggle('active', btn.dataset.view === view); |
|||
}); |
|||
|
|||
this.renderBookmarks(); |
|||
} |
|||
|
|||
async goToPage(page) { |
|||
this.currentPage = page; |
|||
await this.loadBookmarks(); |
|||
this.renderUI(); |
|||
|
|||
// 滚动到顶部
|
|||
document.querySelector('.bookmarks-section').scrollIntoView({ behavior: 'smooth' }); |
|||
} |
|||
|
|||
showAddModal() { |
|||
this.editingBookmark = null; |
|||
document.getElementById('modalTitle').textContent = '添加收藏'; |
|||
document.getElementById('bookmarkForm').reset(); |
|||
this.populateCategorySelect(); |
|||
this.showModal(); |
|||
} |
|||
|
|||
editBookmark(bookmarkId) { |
|||
const bookmark = this.bookmarks.find(b => b.id === bookmarkId); |
|||
if (!bookmark) return; |
|||
|
|||
this.editingBookmark = bookmark; |
|||
document.getElementById('modalTitle').textContent = '编辑收藏'; |
|||
|
|||
// 填充表单
|
|||
document.getElementById('bookmarkTitle').value = bookmark.title; |
|||
document.getElementById('bookmarkUrl').value = bookmark.url; |
|||
document.getElementById('bookmarkDescription').value = bookmark.description; |
|||
document.getElementById('bookmarkCategory').value = bookmark.category_id; |
|||
document.getElementById('bookmarkTags').value = bookmark.tags.join(', '); |
|||
|
|||
this.populateCategorySelect(); |
|||
this.populateExtraLinks(bookmark.links); |
|||
this.showModal(); |
|||
} |
|||
|
|||
async handleFormSubmit(e) { |
|||
e.preventDefault(); |
|||
|
|||
const formData = new FormData(e.target); |
|||
const bookmarkData = { |
|||
title: formData.get('bookmarkTitle'), |
|||
url: formData.get('bookmarkUrl'), |
|||
description: formData.get('bookmarkDescription'), |
|||
category_id: formData.get('bookmarkCategory') ? parseInt(formData.get('bookmarkCategory')) : null, |
|||
tags: formData.get('bookmarkTags').split(',').map(tag => tag.trim()).filter(tag => tag), |
|||
links: this.getExtraLinksData() |
|||
}; |
|||
|
|||
try { |
|||
if (this.editingBookmark) { |
|||
await this.updateBookmark(this.editingBookmark.id, bookmarkData); |
|||
} else { |
|||
await this.createBookmark(bookmarkData); |
|||
} |
|||
|
|||
this.closeModal(); |
|||
this.renderUI(); |
|||
this.showSuccess(this.editingBookmark ? '收藏更新成功' : '收藏添加成功'); |
|||
} catch (error) { |
|||
this.showError(error.message); |
|||
} |
|||
} |
|||
|
|||
async createBookmark(data) { |
|||
try { |
|||
const response = await fetch('/bookmarks', { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
credentials: 'include', |
|||
body: JSON.stringify(data) |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error('创建收藏失败'); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
if (result.success) { |
|||
const newBookmark = result.data; |
|||
this.bookmarks.unshift(newBookmark); |
|||
return newBookmark; |
|||
} else { |
|||
throw new Error(result.message || '创建收藏失败'); |
|||
} |
|||
} catch (error) { |
|||
console.error('创建收藏失败:', error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async updateBookmark(id, data) { |
|||
try { |
|||
const response = await fetch(`/bookmarks/${id}`, { |
|||
method: 'PUT', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
credentials: 'include', |
|||
body: JSON.stringify(data) |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error('更新收藏失败'); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
if (result.success) { |
|||
const updatedBookmark = result.data; |
|||
const bookmarkIndex = this.bookmarks.findIndex(b => b.id === id); |
|||
if (bookmarkIndex !== -1) { |
|||
this.bookmarks[bookmarkIndex] = updatedBookmark; |
|||
} |
|||
return updatedBookmark; |
|||
} else { |
|||
throw new Error(result.message || '更新收藏失败'); |
|||
} |
|||
} catch (error) { |
|||
console.error('更新收藏失败:', error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async deleteBookmark(id) { |
|||
if (!confirm('确定要删除这个收藏吗?')) return; |
|||
|
|||
try { |
|||
const response = await fetch(`/bookmarks/${id}`, { |
|||
method: 'DELETE', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
credentials: 'include' |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error('删除收藏失败'); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
if (result.success) { |
|||
const bookmarkIndex = this.bookmarks.findIndex(b => b.id === id); |
|||
if (bookmarkIndex !== -1) { |
|||
this.bookmarks.splice(bookmarkIndex, 1); |
|||
} |
|||
this.renderUI(); |
|||
this.showSuccess('收藏删除成功'); |
|||
} else { |
|||
throw new Error(result.message || '删除收藏失败'); |
|||
} |
|||
} catch (error) { |
|||
console.error('删除收藏失败:', error); |
|||
this.showError(error.message); |
|||
} |
|||
} |
|||
|
|||
async toggleFavorite(id) { |
|||
try { |
|||
const response = await fetch(`/bookmarks/${id}/favorite`, { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
credentials: 'include' |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error('切换收藏状态失败'); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
if (result.success) { |
|||
const bookmark = this.bookmarks.find(b => b.id === id); |
|||
if (bookmark) { |
|||
bookmark.is_favorite = result.data.is_favorite; |
|||
} |
|||
this.renderUI(); |
|||
this.showSuccess(result.message); |
|||
} else { |
|||
throw new Error(result.message || '切换收藏状态失败'); |
|||
} |
|||
} catch (error) { |
|||
console.error('切换收藏状态失败:', error); |
|||
this.showError(error.message); |
|||
} |
|||
} |
|||
|
|||
populateCategorySelect() { |
|||
const select = document.getElementById('bookmarkCategory'); |
|||
select.innerHTML = '<option value="">选择分类</option>' + |
|||
this.categories.map(category => |
|||
`<option value="${category.id}">${category.name}</option>` |
|||
).join(''); |
|||
} |
|||
|
|||
populateExtraLinks(links) { |
|||
const container = document.getElementById('extraLinks'); |
|||
container.innerHTML = ''; |
|||
|
|||
if (links && links.length > 0) { |
|||
links.forEach(link => this.addExtraLink(link.title, link.url)); |
|||
} else { |
|||
this.addExtraLink(); |
|||
} |
|||
} |
|||
|
|||
addExtraLink(title = '', url = '') { |
|||
const container = document.getElementById('extraLinks'); |
|||
const linkItem = document.createElement('div'); |
|||
linkItem.className = 'extra-link-item'; |
|||
linkItem.innerHTML = ` |
|||
<input type="text" placeholder="链接标题" class="link-title" value="${title}"> |
|||
<input type="url" placeholder="链接URL" class="link-url" value="${url}"> |
|||
<button type="button" class="remove-link">删除</button> |
|||
`;
|
|||
|
|||
linkItem.querySelector('.remove-link').addEventListener('click', () => { |
|||
if (container.children.length > 1) { |
|||
container.removeChild(linkItem); |
|||
} |
|||
}); |
|||
|
|||
container.appendChild(linkItem); |
|||
} |
|||
|
|||
getExtraLinksData() { |
|||
const links = []; |
|||
document.querySelectorAll('.extra-link-item').forEach(item => { |
|||
const title = item.querySelector('.link-title').value.trim(); |
|||
const url = item.querySelector('.link-url').value.trim(); |
|||
if (title && url) { |
|||
links.push({ title, url, type: 'link' }); |
|||
} |
|||
}); |
|||
return links; |
|||
} |
|||
|
|||
showModal() { |
|||
document.getElementById('bookmarkModal').style.display = 'block'; |
|||
document.body.style.overflow = 'hidden'; |
|||
} |
|||
|
|||
closeModal() { |
|||
document.getElementById('bookmarkModal').style.display = 'none'; |
|||
document.body.style.overflow = ''; |
|||
this.editingBookmark = null; |
|||
} |
|||
|
|||
showEmptyState() { |
|||
const gridContainer = document.getElementById('bookmarksGrid'); |
|||
const listContainer = document.getElementById('bookmarksList'); |
|||
|
|||
gridContainer.style.display = 'none'; |
|||
listContainer.style.display = 'none'; |
|||
|
|||
const emptyHtml = ` |
|||
<div class="empty-state"> |
|||
<h3>暂无收藏</h3> |
|||
<p>开始添加你的第一个收藏吧!</p> |
|||
<button class="empty-btn" onclick="bookmarkManager.showAddModal()"> |
|||
添加收藏 |
|||
</button> |
|||
</div> |
|||
`;
|
|||
|
|||
if (this.currentView === 'grid') { |
|||
gridContainer.innerHTML = emptyHtml; |
|||
gridContainer.style.display = 'block'; |
|||
} else { |
|||
listContainer.innerHTML = emptyHtml; |
|||
listContainer.style.display = 'block'; |
|||
} |
|||
} |
|||
|
|||
showSuccess(message) { |
|||
this.showToast(message, 'success'); |
|||
} |
|||
|
|||
showError(message) { |
|||
this.showToast(message, 'error'); |
|||
} |
|||
|
|||
showToast(message, type = 'info') { |
|||
// 创建toast元素
|
|||
const toast = document.createElement('div'); |
|||
toast.className = `toast toast-${type}`; |
|||
toast.textContent = message; |
|||
toast.style.cssText = ` |
|||
position: fixed; |
|||
top: 20px; |
|||
right: 20px; |
|||
background: ${type === 'success' ? '#10B981' : type === 'error' ? '#EF4444' : '#3B82F6'}; |
|||
color: white; |
|||
padding: 12px 20px; |
|||
border-radius: 8px; |
|||
z-index: 10000; |
|||
animation: slideIn 0.3s ease; |
|||
`;
|
|||
|
|||
document.body.appendChild(toast); |
|||
|
|||
// 3秒后自动移除
|
|||
setTimeout(() => { |
|||
toast.style.animation = 'slideOut 0.3s ease'; |
|||
setTimeout(() => { |
|||
if (toast.parentNode) { |
|||
toast.parentNode.removeChild(toast); |
|||
} |
|||
}, 300); |
|||
}, 3000); |
|||
} |
|||
|
|||
formatDate(dateString) { |
|||
const date = new Date(dateString); |
|||
const now = new Date(); |
|||
const diffTime = Math.abs(now - date); |
|||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); |
|||
|
|||
if (diffDays === 1) return '今天'; |
|||
if (diffDays === 2) return '昨天'; |
|||
if (diffDays <= 7) return `${diffDays - 1}天前`; |
|||
if (diffDays <= 30) return `${Math.floor(diffDays / 7)}周前`; |
|||
if (diffDays <= 365) return `${Math.floor(diffDays / 30)}个月前`; |
|||
return `${Math.floor(diffDays / 365)}年前`; |
|||
} |
|||
} |
|||
|
|||
// 添加CSS动画
|
|||
const style = document.createElement('style'); |
|||
style.textContent = ` |
|||
@keyframes slideIn { |
|||
from { transform: translateX(100%); opacity: 0; } |
|||
to { transform: translateX(0); opacity: 1; } |
|||
} |
|||
@keyframes slideOut { |
|||
from { transform: translateX(0); opacity: 1; } |
|||
to { transform: translateX(100%); opacity: 0; } |
|||
} |
|||
`;
|
|||
document.head.appendChild(style); |
|||
|
|||
// 初始化收藏管理器
|
|||
let bookmarkManager; |
|||
document.addEventListener('DOMContentLoaded', () => { |
|||
bookmarkManager = new BookmarkManager(); |
|||
}); |
|||
|
|||
// 全局函数
|
|||
function closeModal() { |
|||
if (bookmarkManager) { |
|||
bookmarkManager.closeModal(); |
|||
} |
|||
} |
@ -0,0 +1,58 @@ |
|||
let loginToastTimer = null; |
|||
function showLoginToast(msg, isSuccess = false) { |
|||
let toast = document.getElementById("login-toast"); |
|||
if (!toast) { |
|||
toast = document.createElement("div"); |
|||
toast.id = "login-toast"; |
|||
toast.style.position = "fixed"; |
|||
toast.style.top = "20px"; |
|||
toast.style.right = "20px"; |
|||
toast.style.left = "auto"; |
|||
toast.style.transform = "none"; |
|||
toast.style.minWidth = "220px"; |
|||
toast.style.maxWidth = "80vw"; |
|||
toast.style.background = isSuccess ? "linear-gradient(90deg,#7ec6f7,#b2f7ef)" : "#fff"; |
|||
toast.style.color = isSuccess ? "#1976d2" : "#ff4d4f"; |
|||
toast.style.fontSize = "1.08rem"; |
|||
toast.style.fontWeight = "600"; |
|||
toast.style.padding = "1.1em 2.2em"; |
|||
toast.style.borderRadius = "18px"; |
|||
toast.style.boxShadow = "0 4px 24px rgba(30,136,229,0.13),0 1.5px 8px rgba(79,209,255,0.08)"; |
|||
toast.style.zIndex = 9999; |
|||
toast.style.textAlign = "center"; |
|||
toast.style.opacity = "0"; |
|||
toast.style.transition = "opacity 0.2s"; |
|||
document.body.appendChild(toast); |
|||
} |
|||
toast.textContent = msg; |
|||
toast.style.opacity = "1"; |
|||
if (loginToastTimer) clearTimeout(loginToastTimer); |
|||
loginToastTimer = setTimeout(() => { |
|||
toast.style.opacity = "0"; |
|||
setTimeout(() => { |
|||
toast.remove(); |
|||
}, 300); |
|||
loginToastTimer = null; |
|||
}, 2000); |
|||
} |
|||
|
|||
const loginForm = document.getElementById("login-form") |
|||
loginForm.onsubmit = async function (e) { |
|||
e.preventDefault() |
|||
const form = e.target |
|||
const data = Object.fromEntries(new FormData(form)) |
|||
const res = await fetch(form.action, { |
|||
method: "POST", |
|||
headers: { "Content-Type": "application/json" }, |
|||
body: JSON.stringify(data), |
|||
}) |
|||
const result = await res.json() |
|||
if (result.success) { |
|||
showLoginToast("登录成功,2秒后跳转到首页", true) |
|||
setTimeout(() => { |
|||
window.location.href = "/" |
|||
}, 2000) |
|||
} else { |
|||
showLoginToast(result.message || "登录失败") |
|||
} |
|||
} |
@ -0,0 +1,429 @@ |
|||
class BgSwitcher { |
|||
constructor(images, options = {}) { |
|||
this.images = images |
|||
// 从localStorage中读取保存的索引
|
|||
const savedIndex = localStorage.getItem("bgSwitcherIndex") |
|||
if (savedIndex !== null && !isNaN(savedIndex)) { |
|||
this.index = parseInt(savedIndex) |
|||
} else { |
|||
this.index = 0 |
|||
} |
|||
this.container = options.container || document.body |
|||
this.interval = options.interval || 3000 |
|||
this.effect = options.effect || BgSwitcher.fadeEffect |
|||
this.timer = null |
|||
this.apiTimer = null |
|||
this.apiUrl = null |
|||
this.apiInterval = 30000 |
|||
this.startTime = 0 |
|||
// 从localStorage中读取保存的剩余时间
|
|||
const savedRemainingTime = localStorage.getItem("bgSwitcherRemainingTime") |
|||
this.remainingTime = |
|||
savedRemainingTime !== null && !isNaN(savedRemainingTime) && savedRemainingTime >= 0 |
|||
? parseInt(savedRemainingTime) |
|||
: this.interval |
|||
this.bgLayer = document.createElement("div") |
|||
this.isInitialLoad = true |
|||
|
|||
// 从localStorage中读取API模式状态
|
|||
const isApiMode = localStorage.getItem("bgSwitcherIsApiMode") === "true" |
|||
if (isApiMode) { |
|||
this.apiUrl = localStorage.getItem("bgSwitcherApiUrl") || null |
|||
const savedApiInterval = localStorage.getItem("bgSwitcherApiInterval") |
|||
this.apiInterval = savedApiInterval !== null && !isNaN(savedApiInterval) ? parseInt(savedApiInterval) : 30000 |
|||
} |
|||
// 监听页面可见性变化
|
|||
this.handleVisibilityChange = this.handleVisibilityChange.bind(this) |
|||
document.addEventListener("visibilitychange", this.handleVisibilityChange) |
|||
|
|||
// 监听页面卸载事件,确保保存状态
|
|||
this.handleBeforeUnload = this.handleBeforeUnload.bind(this) |
|||
window.addEventListener("beforeunload", this.handleBeforeUnload) |
|||
this.bgLayer.style.position = "fixed" |
|||
this.bgLayer.style.top = 0 |
|||
this.bgLayer.style.left = 0 |
|||
this.bgLayer.style.width = "100vw" |
|||
this.bgLayer.style.height = "100vh" |
|||
this.bgLayer.style.zIndex = "-1" |
|||
this.bgLayer.style.transition = "opacity 1s" |
|||
this.bgLayer.style.opacity = 1 |
|||
this.bgLayer.style.backgroundSize = "cover" |
|||
this.bgLayer.style.backgroundColor = "#000000" |
|||
this.bgLayer.style.backgroundPosition = "center" |
|||
this.bgLayer.style.filter = "brightness(0.68)" |
|||
this.container.style.backgroundColor = "#000000" |
|||
} |
|||
|
|||
setBg(url) { |
|||
// 切换时先预加载目标图片,加载完成后再切换显示
|
|||
const img = new Image() |
|||
img.onload = () => { |
|||
if (this.isInitialLoad) { |
|||
// 初始加载时,先设置背景图再添加到页面
|
|||
this.bgLayer.style.backgroundImage = `url(${url})` |
|||
this.container.appendChild(this.bgLayer) |
|||
this.isInitialLoad = false |
|||
} else { |
|||
this.effect(this.bgLayer, url) |
|||
} |
|||
if (!this.isApiMode) { |
|||
this.scheduleNext() |
|||
} |
|||
} |
|||
img.onerror = () => { |
|||
// 加载失败时处理
|
|||
console.warn("背景图片加载失败:", url) |
|||
if (this.isInitialLoad) { |
|||
// 初始加载失败时,也添加背景层到页面
|
|||
this.container.appendChild(this.bgLayer) |
|||
this.isInitialLoad = false |
|||
} |
|||
} |
|||
img.src = url |
|||
} |
|||
|
|||
next() { |
|||
const nextIndex = (this.index + 1) % this.images.length |
|||
const nextUrl = this.images[nextIndex] |
|||
// 切换前先预加载
|
|||
const img = new Image() |
|||
img.onload = () => { |
|||
this.index = nextIndex |
|||
// 保存索引到localStorage
|
|||
localStorage.setItem("bgSwitcherIndex", this.index) |
|||
this.effect(this.bgLayer, nextUrl) |
|||
this.scheduleNext() |
|||
} |
|||
img.onerror = () => { |
|||
// 加载失败时跳过
|
|||
console.warn("背景图片加载失败:", nextUrl) |
|||
this.scheduleNext() |
|||
} |
|||
img.src = nextUrl |
|||
} |
|||
|
|||
prev() { |
|||
const prevIndex = (this.index - 1 + this.images.length) % this.images.length |
|||
const prevUrl = this.images[prevIndex] |
|||
// 切换前先预加载
|
|||
const img = new Image() |
|||
img.onload = () => { |
|||
this.index = prevIndex |
|||
// 保存索引到localStorage
|
|||
localStorage.setItem("bgSwitcherIndex", this.index) |
|||
this.effect(this.bgLayer, prevUrl) |
|||
this.scheduleNext() |
|||
} |
|||
img.onerror = () => { |
|||
// 加载失败时跳过
|
|||
console.warn("背景图片加载失败:", prevUrl) |
|||
this.scheduleNext() |
|||
} |
|||
img.src = prevUrl |
|||
} |
|||
|
|||
start() { |
|||
if (this.timer || this.apiTimer) return |
|||
|
|||
// 如果处于API模式,启动API请求
|
|||
if (this.apiUrl) { |
|||
this.fetchRandomWallpaper() |
|||
} else { |
|||
// 否则使用默认轮播
|
|||
this.setBg(this.images[this.index]) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 安排下一次背景切换 |
|||
*/ |
|||
scheduleNext() { |
|||
if (this.timer) { |
|||
clearTimeout(this.timer) |
|||
} |
|||
|
|||
// 记录开始时间
|
|||
this.startTime = Date.now() |
|||
|
|||
// 使用剩余时间或默认间隔
|
|||
const timeToWait = this.remainingTime > 0 ? this.remainingTime : this.interval |
|||
|
|||
this.timer = setTimeout(() => { |
|||
this.remainingTime = this.interval // 重置剩余时间
|
|||
// 保存剩余时间到localStorage
|
|||
localStorage.setItem("bgSwitcherRemainingTime", this.remainingTime) |
|||
this.next() |
|||
}, timeToWait) |
|||
} |
|||
|
|||
/** |
|||
* 处理页面可见性变化 |
|||
*/ |
|||
handleVisibilityChange() { |
|||
if (document.hidden) { |
|||
// 页面不可见时,暂停计时器并计算剩余时间
|
|||
if (this.timer) { |
|||
const elapsedTime = Date.now() - this.startTime |
|||
this.remainingTime = Math.max(0, this.remainingTime - elapsedTime) |
|||
// 保存剩余时间到localStorage
|
|||
localStorage.setItem("bgSwitcherRemainingTime", this.remainingTime) |
|||
clearTimeout(this.timer) |
|||
this.timer = null |
|||
} |
|||
// 暂停API计时器
|
|||
if (this.apiTimer) { |
|||
const elapsedTime = Date.now() - this.startTime |
|||
this.remainingTime = Math.max(0, this.remainingTime - elapsedTime) |
|||
// 保存剩余时间到localStorage
|
|||
localStorage.setItem("bgSwitcherRemainingTime", this.remainingTime) |
|||
clearTimeout(this.apiTimer) |
|||
this.apiTimer = null |
|||
} |
|||
} else { |
|||
// 页面可见时,恢复计时器
|
|||
if (!this.timer && !this.apiTimer && !this.apiUrl) { |
|||
// 如果没有活跃的计时器,使用默认的轮播
|
|||
this.scheduleNext() |
|||
} else if (this.apiTimer === null && this.apiUrl) { |
|||
// 如果处于API模式但计时器未运行,恢复API请求
|
|||
this.scheduleNextApiRequest() |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理页面卸载事件,确保保存状态 |
|||
*/ |
|||
handleBeforeUnload() { |
|||
// 保存当前索引
|
|||
localStorage.setItem("bgSwitcherIndex", this.index) |
|||
|
|||
// 保存API模式状态
|
|||
localStorage.setItem("bgSwitcherIsApiMode", !!this.apiUrl) |
|||
if (this.apiUrl) { |
|||
localStorage.setItem("bgSwitcherApiUrl", this.apiUrl) |
|||
localStorage.setItem("bgSwitcherApiInterval", this.apiInterval) |
|||
} |
|||
|
|||
// 如果计时器在运行,计算并保存剩余时间
|
|||
if (this.timer || this.apiTimer) { |
|||
const elapsedTime = Date.now() - this.startTime |
|||
this.remainingTime = Math.max(0, this.remainingTime - elapsedTime) |
|||
localStorage.setItem("bgSwitcherRemainingTime", this.remainingTime) |
|||
} |
|||
} |
|||
|
|||
stop() { |
|||
if (this.timer) { |
|||
clearTimeout(this.timer) |
|||
this.timer = null |
|||
} |
|||
// 重置剩余时间
|
|||
this.remainingTime = this.interval |
|||
// 保存剩余时间到localStorage
|
|||
localStorage.setItem("bgSwitcherRemainingTime", this.remainingTime) |
|||
} |
|||
|
|||
/** |
|||
* 从API获取随机壁纸并定期更新 |
|||
* @param {string} apiUrl - 获取随机壁纸的API地址 |
|||
* @param {number} interval - 请求间隔时间(毫秒) |
|||
*/ |
|||
startRandomApiSwitch(apiUrl, interval = 30000) { |
|||
this.stop() // 停止当前的轮播
|
|||
this.apiInterval = interval |
|||
this.apiUrl = apiUrl |
|||
|
|||
// 创建专用的API计时器
|
|||
this.apiTimer = null |
|||
|
|||
// 立即请求一次
|
|||
this.fetchRandomWallpaper() |
|||
} |
|||
|
|||
/** |
|||
* 从API获取随机壁纸 |
|||
*/ |
|||
fetchRandomWallpaper() { |
|||
// 记录开始时间,用于计算剩余时间
|
|||
this.startTime = Date.now() |
|||
this.remainingTime = this.apiInterval |
|||
|
|||
fetch(this.apiUrl) |
|||
.then(response => { |
|||
console.log(response) |
|||
|
|||
if (!response.ok) { |
|||
throw new Error(`HTTP error! status: ${response.status}`) |
|||
} |
|||
return response.json() |
|||
}) |
|||
.then(data => { |
|||
// 假设API返回的数据格式为 { wallpaperUrl: '图片地址' }
|
|||
const wallpaperUrl = data.wallpaperUrl || data.url || data.image |
|||
if (wallpaperUrl) { |
|||
// 预加载图片
|
|||
const img = new Image() |
|||
img.onload = () => { |
|||
if (this.isInitialLoad) { |
|||
// 初始加载时,先设置背景图再添加到页面
|
|||
this.container.appendChild(this.bgLayer) |
|||
this.isInitialLoad = false |
|||
} |
|||
// 保存当前索引(使用-1标记这是API获取的图片)
|
|||
this.index = -1 |
|||
localStorage.setItem("bgSwitcherIndex", -1) |
|||
this.effect(this.bgLayer, wallpaperUrl) |
|||
this.scheduleNextApiRequest() |
|||
} |
|||
img.onerror = () => { |
|||
console.warn("API返回的壁纸加载失败:", wallpaperUrl) |
|||
this.scheduleNextApiRequest() |
|||
} |
|||
img.src = wallpaperUrl |
|||
} else { |
|||
console.warn("背景图片加载失败:", url) |
|||
if (this.isInitialLoad) { |
|||
console.warn("API返回的数据格式不正确,未找到壁纸地址") |
|||
// 初始加载失败时,也添加背景层到页面
|
|||
this.container.appendChild(this.bgLayer) |
|||
this.isInitialLoad = false |
|||
} |
|||
this.scheduleNextApiRequest() |
|||
} |
|||
}) |
|||
.catch(error => { |
|||
console.error("获取随机壁纸失败:", error) |
|||
this.scheduleNextApiRequest() |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* 安排下一次API请求 |
|||
*/ |
|||
scheduleNextApiRequest() { |
|||
if (this.apiTimer) { |
|||
clearTimeout(this.apiTimer) |
|||
} |
|||
|
|||
// 使用剩余时间或默认间隔
|
|||
const timeToWait = this.remainingTime > 0 ? this.remainingTime : this.apiInterval |
|||
|
|||
this.apiTimer = setTimeout(() => { |
|||
this.remainingTime = this.apiInterval // 重置剩余时间
|
|||
localStorage.setItem("bgSwitcherRemainingTime", this.remainingTime) |
|||
this.fetchRandomWallpaper() |
|||
}, timeToWait) |
|||
} |
|||
|
|||
/** |
|||
* 停止API随机壁纸请求 |
|||
*/ |
|||
stopRandomApiSwitch() { |
|||
if (this.apiTimer) { |
|||
clearTimeout(this.apiTimer) |
|||
this.apiTimer = null |
|||
} |
|||
this.apiUrl = null |
|||
// 重置剩余时间
|
|||
this.remainingTime = this.interval |
|||
localStorage.setItem("bgSwitcherRemainingTime", this.remainingTime) |
|||
} |
|||
|
|||
static fadeEffect(layer, url) { |
|||
layer.style.transition = "opacity 1s" |
|||
layer.style.opacity = 0 |
|||
setTimeout(() => { |
|||
layer.style.backgroundImage = `url(${url})` |
|||
layer.style.opacity = 1 |
|||
}, 500) |
|||
} |
|||
} |
|||
|
|||
// 使用示例
|
|||
// 1. 默认本地图片轮播
|
|||
// const images = [
|
|||
// '/static/bg2.webp',
|
|||
// '/static/bg.jpg',
|
|||
// ];
|
|||
// const bgSwitcher = new BgSwitcher(images, { interval: 5000 });
|
|||
// 启动轮播
|
|||
// bgSwitcher.start();
|
|||
|
|||
// 2. 随机API壁纸示例
|
|||
// 创建一个新的BgSwitcher实例用于API模式
|
|||
let apiBgSwitcher = new BgSwitcher([], { interval: 5000 }) // API模式不需要本地图片列表
|
|||
|
|||
// 模拟API函数,实际使用时替换为真实API地址
|
|||
function createMockWallpaperApi() { |
|||
// 模拟壁纸地址库
|
|||
const mockWallpapers = ["/static/bg2.webp", "/static/bg.jpg"] |
|||
|
|||
// 创建一个简单的服务器端点
|
|||
if (window.mockWallpaperServer) { |
|||
clearInterval(window.mockWallpaperServer) |
|||
} |
|||
|
|||
// 模拟API响应
|
|||
window.fetchRandomWallpaper = function () { |
|||
return new Promise(resolve => { |
|||
setTimeout(() => { |
|||
const randomIndex = Math.floor(Math.random() * mockWallpapers.length) |
|||
resolve({ |
|||
ok: true, |
|||
json() { |
|||
return { |
|||
wallpaperUrl: mockWallpapers[randomIndex], |
|||
} |
|||
}, |
|||
}) |
|||
}, 500) |
|||
}) |
|||
} |
|||
|
|||
// 替换原生fetch以模拟API调用
|
|||
window.originalFetch = window.fetch |
|||
window.fetch = function (url, opts = {}) { |
|||
if (url === "/api/random-wallpaper") { |
|||
return window.fetchRandomWallpaper() |
|||
} |
|||
return window.originalFetch(...arguments) |
|||
} |
|||
|
|||
console.log("模拟壁纸API已启动") |
|||
} |
|||
|
|||
// 初始化模拟API
|
|||
createMockWallpaperApi() |
|||
|
|||
// 启动API模式的随机壁纸切换(每10秒请求一次)
|
|||
apiBgSwitcher.startRandomApiSwitch("/api/random-wallpaper", 10000) |
|||
|
|||
window.addEventListener("pageshow", function (event) { |
|||
if (event.persisted) { |
|||
apiBgSwitcher = new BgSwitcher([], { interval: 5000 }) |
|||
apiBgSwitcher.startRandomApiSwitch("/api/random-wallpaper", 10000) |
|||
} |
|||
}) |
|||
|
|||
// fetch("https://pic.xieyaxin.top/random.php")
|
|||
// .then(response => {
|
|||
// if(response.body instanceof ReadableStream) {
|
|||
// return response.blob()
|
|||
// }
|
|||
// return response.json()
|
|||
// })
|
|||
// .then(data => {
|
|||
// console.log(URL.createObjectURL(data));
|
|||
|
|||
// })
|
|||
|
|||
// 要停止API模式,使用
|
|||
// apiBgSwitcher.stopRandomApiSwitch();
|
|||
|
|||
// 要切换回本地图片轮播,使用
|
|||
// apiBgSwitcher.stopRandomApiSwitch();
|
|||
// apiBgSwitcher.start();
|
|||
|
|||
// 启动默认轮播
|
|||
// bgSwitcher.start();
|
File diff suppressed because one or more lines are too long
@ -0,0 +1,52 @@ |
|||
/* http://meyerweb.com/eric/tools/css/reset/ |
|||
v2.0 | 20110126 |
|||
License: none (public domain) |
|||
*/ |
|||
|
|||
html, body, div, span, applet, object, iframe, |
|||
h1, h2, h3, h4, h5, h6, p, blockquote, pre, |
|||
a, abbr, acronym, address, big, cite, code, |
|||
del, dfn, em, img, ins, kbd, q, s, samp, |
|||
small, strike, strong, sub, sup, tt, var, |
|||
b, u, i, center, |
|||
dl, dt, dd, ol, ul, li, |
|||
fieldset, form, label, legend, |
|||
table, caption, tbody, tfoot, thead, tr, th, td, |
|||
article, aside, canvas, details, embed, |
|||
figure, figcaption, footer, header, hgroup, |
|||
menu, nav, output, ruby, section, summary, |
|||
time, mark, audio, video { |
|||
margin: 0; |
|||
padding: 0; |
|||
border: 0; |
|||
font-size: 100%; |
|||
font: inherit; |
|||
vertical-align: baseline; |
|||
} |
|||
/* HTML5 display-role reset for older browsers */ |
|||
article, aside, details, figcaption, figure, |
|||
footer, header, hgroup, menu, nav, section { |
|||
display: block; |
|||
} |
|||
body { |
|||
line-height: 1; |
|||
} |
|||
ol, ul { |
|||
list-style: none; |
|||
} |
|||
blockquote, q { |
|||
quotes: none; |
|||
} |
|||
blockquote:before, blockquote:after, |
|||
q:before, q:after { |
|||
content: ''; |
|||
content: none; |
|||
} |
|||
table { |
|||
border-collapse: collapse; |
|||
border-spacing: 0; |
|||
} |
|||
|
|||
* { |
|||
box-sizing: border-box |
|||
} |
@ -0,0 +1,21 @@ |
|||
|
|||
.simplebar-content { |
|||
height: 100%; |
|||
} |
|||
|
|||
.simplebar-scrollbar::before { |
|||
background-color: #bdbdbd; |
|||
border-radius: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
top: 0; |
|||
} |
|||
|
|||
.simplebar-hover .simplebar-scrollbar::before { |
|||
background-color: #909090; |
|||
} |
|||
|
|||
.simplebar-wrapper:hover ~ .simplebar-track > .simplebar-scrollbar:before { |
|||
opacity: 0.5 !important; |
|||
} |
@ -0,0 +1 @@ |
|||
asd |
After Width: | Height: | Size: 1.6 MiB |
After Width: | Height: | Size: 105 KiB |
@ -0,0 +1,160 @@ |
|||
html, |
|||
body { |
|||
margin: 0; |
|||
padding: 0; |
|||
height: 100%; |
|||
font-family: Arial, sans-serif; |
|||
color: #333; |
|||
} |
|||
|
|||
body { |
|||
min-height: 100vh; |
|||
display: flex; |
|||
flex-direction: column; |
|||
} |
|||
/* |
|||
body::after { |
|||
content: ""; |
|||
position: fixed; |
|||
pointer-events: none; |
|||
left: 0; |
|||
top: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background-image: var(--bg); |
|||
background-size: cover; |
|||
background-repeat: no-repeat; |
|||
background-color: #000000; |
|||
background-color: #f9f9f9; |
|||
filter: brightness(.75); |
|||
z-index: -2; |
|||
} */ |
|||
|
|||
/* .navbar { |
|||
height: 49px; |
|||
display: flex; |
|||
align-items: center; |
|||
box-shadow: 1px 1px 3px #e4e4e4; |
|||
} */ |
|||
|
|||
.title { |
|||
font-size: 1.5em; |
|||
margin-left: 10px; |
|||
color: #333; |
|||
} |
|||
|
|||
.page-layout { |
|||
flex: 1; |
|||
display: flex; |
|||
flex-direction: column; |
|||
min-height: 0; |
|||
} |
|||
|
|||
.page { |
|||
width: 100%; |
|||
display: flex; |
|||
flex-direction: row; |
|||
flex: 1; |
|||
position: relative; |
|||
|
|||
max-width: 1400px; |
|||
margin: 0 auto; |
|||
} |
|||
|
|||
.content { |
|||
flex: 1; |
|||
width: 0; |
|||
} |
|||
|
|||
.footer {} |
|||
|
|||
.nav { |
|||
padding: 20px; |
|||
} |
|||
|
|||
.flota-nav { |
|||
position: sticky; |
|||
left: 0; |
|||
top: 20px; |
|||
width: 80px; |
|||
padding: 40px 0; |
|||
border-radius: 80px; |
|||
/* background: linear-gradient(135deg, #4fd1ff 0%, #ff6a6a 100%); */ |
|||
/* 更明亮的渐变色 */ |
|||
/* box-shadow: 0 8px 32px 0 rgba(80, 80, 200, 0.18), 0 2px 16px 0 rgba(255, 106, 106, 0.12); */ |
|||
/* 更明显的阴影 */ |
|||
/* transition: background 0.3s, box-shadow 0.3s; */ |
|||
background: rgba(255, 255, 255, 0.1); |
|||
backdrop-filter: blur(12px); |
|||
} |
|||
|
|||
.flota-nav .item { |
|||
display: block; |
|||
width: 100%; |
|||
height: 40px; |
|||
line-height: 40px; |
|||
color: #fff; |
|||
text-align: center; |
|||
cursor: pointer; |
|||
text-decoration: none; |
|||
font-weight: bold; |
|||
font-size: 1.1em; |
|||
letter-spacing: 1px; |
|||
margin-bottom: 8px; |
|||
transition: background 0.2s, color 0.2s, box-shadow 0.2s; |
|||
box-shadow: 0 2px 8px 0 rgba(79, 209, 255, 0.10); |
|||
/* item 也加轻微阴影 */ |
|||
} |
|||
|
|||
.flota-nav .item:hover { |
|||
background: linear-gradient(90deg, #ffb86c 0%, #4fd1ff 100%); |
|||
color: #fff200; |
|||
box-shadow: 0 4px 16px 0 rgba(255, 184, 108, 0.18); |
|||
} |
|||
|
|||
.flota-nav .item.active { |
|||
background: #fff200; |
|||
color: #ff6a6a; |
|||
box-shadow: 0 4px 20px 0 rgba(255, 242, 0, 0.22); |
|||
} |
|||
|
|||
.card { |
|||
padding: 56px 40px 44px 40px; |
|||
border-radius: 28px; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
backdrop-filter: blur(12px); |
|||
color: #fff; |
|||
} |
|||
|
|||
|
|||
@media screen and (max-width: 768px) { |
|||
.nav { |
|||
width: 0; |
|||
height: 0; |
|||
overflow: hidden; |
|||
padding: 0; |
|||
margin: 0; |
|||
} |
|||
.flota-nav { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
width: 100%; |
|||
display: flex; |
|||
z-index: 9999; |
|||
padding: 0 10px; |
|||
height: 40px; |
|||
border-radius: 0; |
|||
} |
|||
|
|||
.flota-nav .item{ |
|||
margin-bottom: 0; |
|||
padding: 0 10px; |
|||
} |
|||
|
|||
.content { |
|||
padding: 0 10px; |
|||
padding-top: 40px; |
|||
} |
|||
} |
@ -0,0 +1,121 @@ |
|||
@echo off |
|||
setlocal enabledelayedexpansion |
|||
|
|||
REM Docker构建脚本 (Windows版本) |
|||
set "SCRIPT_NAME=%~n0" |
|||
|
|||
REM 颜色定义 (Windows CMD不支持ANSI颜色,但保留结构) |
|||
set "RED=[ERROR]" |
|||
set "GREEN=[INFO]" |
|||
set "YELLOW=[WARNING]" |
|||
|
|||
REM 打印带标签的消息 |
|||
:print_message |
|||
echo %GREEN% %~1 |
|||
goto :eof |
|||
|
|||
:print_warning |
|||
echo %YELLOW% %~1 |
|||
goto :eof |
|||
|
|||
:print_error |
|||
echo %RED% %~1 |
|||
goto :eof |
|||
|
|||
REM 检查Docker是否运行 |
|||
:check_docker |
|||
docker info >nul 2>&1 |
|||
if errorlevel 1 ( |
|||
call :print_error "Docker未运行,请启动Docker服务" |
|||
exit /b 1 |
|||
) |
|||
goto :eof |
|||
|
|||
REM 清理旧的镜像和容器 |
|||
:cleanup |
|||
call :print_message "清理旧的Docker资源..." |
|||
docker system prune -f |
|||
goto :eof |
|||
|
|||
REM 构建镜像 |
|||
:build_image |
|||
set "tag=%~1" |
|||
if "%tag%"=="" set "tag=koa3-demo:latest" |
|||
call :print_message "构建Docker镜像: %tag%" |
|||
|
|||
docker build --target production --tag "%tag%" --build-arg BUILDKIT_INLINE_CACHE=1 . |
|||
if errorlevel 1 ( |
|||
call :print_error "镜像构建失败" |
|||
exit /b 1 |
|||
) |
|||
call :print_message "镜像构建成功: %tag%" |
|||
goto :eof |
|||
|
|||
REM 运行容器 |
|||
:run_container |
|||
set "tag=%~1" |
|||
if "%tag%"=="" set "tag=koa3-demo:latest" |
|||
call :print_message "启动容器..." |
|||
|
|||
docker run -d --name koa3-demo --publish 3000:3000 --volume "%cd%\database:/app/database" --volume "%cd%\logs:/app/logs" --env NODE_ENV=production --env BUN_ENV=production --env PORT=3000 "%tag%" |
|||
if errorlevel 1 ( |
|||
call :print_error "容器启动失败" |
|||
exit /b 1 |
|||
) |
|||
call :print_message "容器启动成功" |
|||
call :print_message "应用运行在: http://localhost:3000" |
|||
goto :eof |
|||
|
|||
REM 使用docker-compose |
|||
:use_compose |
|||
call :print_message "使用docker-compose启动服务..." |
|||
docker-compose up -d --build |
|||
if errorlevel 1 ( |
|||
call :print_error "服务启动失败" |
|||
exit /b 1 |
|||
) |
|||
call :print_message "服务启动成功" |
|||
call :print_message "应用运行在: http://localhost:3000" |
|||
goto :eof |
|||
|
|||
REM 显示帮助信息 |
|||
:show_help |
|||
echo 用法: %SCRIPT_NAME% [选项] |
|||
echo. |
|||
echo 选项: |
|||
echo build [tag] 构建Docker镜像 |
|||
echo run [tag] 运行容器 |
|||
echo compose 使用docker-compose启动服务 |
|||
echo cleanup 清理Docker资源 |
|||
echo help 显示此帮助信息 |
|||
echo. |
|||
echo 示例: |
|||
echo %SCRIPT_NAME% build 构建镜像 |
|||
echo %SCRIPT_NAME% build v1.0 构建带标签的镜像 |
|||
echo %SCRIPT_NAME% run 运行容器 |
|||
echo %SCRIPT_NAME% compose 使用docker-compose启动 |
|||
goto :eof |
|||
|
|||
REM 主函数 |
|||
:main |
|||
call :check_docker |
|||
if errorlevel 1 exit /b 1 |
|||
|
|||
set "action=%~1" |
|||
if "%action%"=="" set "action=help" |
|||
|
|||
if "%action%"=="build" ( |
|||
call :build_image "%~2" |
|||
) else if "%action%"=="run" ( |
|||
call :run_container "%~2" |
|||
) else if "%action%"=="compose" ( |
|||
call :use_compose |
|||
) else if "%action%"=="cleanup" ( |
|||
call :cleanup |
|||
) else if "%action%"=="help" ( |
|||
call :show_help |
|||
) else ( |
|||
call :show_help |
|||
) |
|||
|
|||
exit /b 0 |
@ -0,0 +1,138 @@ |
|||
#!/bin/bash |
|||
|
|||
# Docker构建脚本 |
|||
set -e |
|||
|
|||
# 颜色定义 |
|||
RED='\033[0;31m' |
|||
GREEN='\033[0;32m' |
|||
YELLOW='\033[1;33m' |
|||
NC='\033[0m' # No Color |
|||
|
|||
# 打印带颜色的消息 |
|||
print_message() { |
|||
echo -e "${GREEN}[INFO]${NC} $1" |
|||
} |
|||
|
|||
print_warning() { |
|||
echo -e "${YELLOW}[WARNING]${NC} $1" |
|||
} |
|||
|
|||
print_error() { |
|||
echo -e "${RED}[ERROR]${NC} $1" |
|||
} |
|||
|
|||
# 检查Docker是否运行 |
|||
check_docker() { |
|||
if ! docker info > /dev/null 2>&1; then |
|||
print_error "Docker未运行,请启动Docker服务" |
|||
exit 1 |
|||
fi |
|||
} |
|||
|
|||
# 清理旧的镜像和容器 |
|||
cleanup() { |
|||
print_message "清理旧的Docker资源..." |
|||
docker system prune -f |
|||
} |
|||
|
|||
# 构建镜像 |
|||
build_image() { |
|||
local tag=${1:-"koa3-demo:latest"} |
|||
print_message "构建Docker镜像: $tag" |
|||
|
|||
docker build \ |
|||
--target production \ |
|||
--tag "$tag" \ |
|||
--build-arg BUILDKIT_INLINE_CACHE=1 \ |
|||
. |
|||
|
|||
if [ $? -eq 0 ]; then |
|||
print_message "镜像构建成功: $tag" |
|||
else |
|||
print_error "镜像构建失败" |
|||
exit 1 |
|||
fi |
|||
} |
|||
|
|||
# 运行容器 |
|||
run_container() { |
|||
local tag=${1:-"koa3-demo:latest"} |
|||
print_message "启动容器..." |
|||
|
|||
docker run -d \ |
|||
--name koa3-demo \ |
|||
--publish 3000:3000 \ |
|||
--volume "$(pwd)/database:/app/database" \ |
|||
--volume "$(pwd)/logs:/app/logs" \ |
|||
--env NODE_ENV=production \ |
|||
--env BUN_ENV=production \ |
|||
--env PORT=3000 \ |
|||
"$tag" |
|||
|
|||
if [ $? -eq 0 ]; then |
|||
print_message "容器启动成功" |
|||
print_message "应用运行在: http://localhost:3000" |
|||
else |
|||
print_error "容器启动失败" |
|||
exit 1 |
|||
fi |
|||
} |
|||
|
|||
# 使用docker-compose |
|||
use_compose() { |
|||
print_message "使用docker-compose启动服务..." |
|||
docker-compose up -d --build |
|||
|
|||
if [ $? -eq 0 ]; then |
|||
print_message "服务启动成功" |
|||
print_message "应用运行在: http://localhost:3000" |
|||
else |
|||
print_error "服务启动失败" |
|||
exit 1 |
|||
fi |
|||
} |
|||
|
|||
# 显示帮助信息 |
|||
show_help() { |
|||
echo "用法: $0 [选项]" |
|||
echo "" |
|||
echo "选项:" |
|||
echo " build [tag] 构建Docker镜像" |
|||
echo " run [tag] 运行容器" |
|||
echo " compose 使用docker-compose启动服务" |
|||
echo " cleanup 清理Docker资源" |
|||
echo " help 显示此帮助信息" |
|||
echo "" |
|||
echo "示例:" |
|||
echo " $0 build 构建镜像" |
|||
echo " $0 build v1.0 构建带标签的镜像" |
|||
echo " $0 run 运行容器" |
|||
echo " $0 compose 使用docker-compose启动" |
|||
} |
|||
|
|||
# 主函数 |
|||
main() { |
|||
check_docker |
|||
|
|||
case "${1:-help}" in |
|||
"build") |
|||
build_image "$2" |
|||
;; |
|||
"run") |
|||
run_container "$2" |
|||
;; |
|||
"compose") |
|||
use_compose |
|||
;; |
|||
"cleanup") |
|||
cleanup |
|||
;; |
|||
"help"|*) |
|||
show_help |
|||
;; |
|||
esac |
|||
} |
|||
|
|||
# 执行主函数 |
|||
main "$@" |
@ -0,0 +1,23 @@ |
|||
const { execSync } = require('child_process'); |
|||
const readline = require('readline'); |
|||
|
|||
// 写一个执行npm run migrate && npm run seed的脚本,当执行npm run seed时会谈提示是否重置数据
|
|||
function run(command) { |
|||
execSync(command, { stdio: 'inherit' }); |
|||
} |
|||
|
|||
run('npx knex migrate:latest'); |
|||
|
|||
const rl = readline.createInterface({ |
|||
input: process.stdin, |
|||
output: process.stdout |
|||
}); |
|||
|
|||
rl.question('是否重置数据?(y/N): ', (answer) => { |
|||
if (answer.trim().toLowerCase() === 'y') { |
|||
run('npx knex seed:run'); |
|||
} else { |
|||
console.log('已取消数据重置。'); |
|||
} |
|||
rl.close(); |
|||
}); |
@ -0,0 +1,3 @@ |
|||
export default { |
|||
base: "/", |
|||
} |
@ -0,0 +1,45 @@ |
|||
import UserService from "services/UserService.js" |
|||
import { formatResponse } from "utils/helper.js" |
|||
import Router from "utils/router.js" |
|||
|
|||
class AuthController { |
|||
constructor() { |
|||
this.userService = new UserService() |
|||
} |
|||
|
|||
async hello(ctx) { |
|||
ctx.body = formatResponse(true, "Hello World") |
|||
} |
|||
|
|||
async getUser(ctx) { |
|||
const user = await this.userService.getUserById(ctx.params.id) |
|||
ctx.body = formatResponse(true, user) |
|||
} |
|||
|
|||
async register(ctx) { |
|||
const { username, email, password } = ctx.request.body |
|||
const user = await this.userService.register({ username, email, password }) |
|||
ctx.body = formatResponse(true, user) |
|||
} |
|||
|
|||
async login(ctx) { |
|||
const { username, email, password } = ctx.request.body |
|||
const result = await this.userService.login({ username, email, password }) |
|||
ctx.body = formatResponse(true, result) |
|||
} |
|||
|
|||
/** |
|||
* 路由注册 |
|||
*/ |
|||
static createRoutes() { |
|||
const controller = new AuthController() |
|||
const router = new Router({ prefix: "/api" }) |
|||
router.get("/hello", controller.hello.bind(controller), { auth: false }) |
|||
router.get("/user/:id", controller.getUser.bind(controller)) |
|||
router.post("/register", controller.register.bind(controller)) |
|||
router.post("/login", controller.login.bind(controller)) |
|||
return router |
|||
} |
|||
} |
|||
|
|||
export default AuthController |
@ -0,0 +1,46 @@ |
|||
// Job Controller 示例:如何调用 service 层动态控制和查询定时任务
|
|||
import JobService from "services/JobService.js" |
|||
import { formatResponse } from "utils/helper.js" |
|||
import Router from "utils/router.js" |
|||
|
|||
class JobController { |
|||
constructor() { |
|||
this.jobService = new JobService() |
|||
} |
|||
|
|||
async list(ctx) { |
|||
const data = this.jobService.listJobs() |
|||
ctx.body = formatResponse(true, data) |
|||
} |
|||
|
|||
async start(ctx) { |
|||
const { id } = ctx.params |
|||
this.jobService.startJob(id) |
|||
ctx.body = formatResponse(true, null, null, `${id} 任务已启动`) |
|||
} |
|||
|
|||
async stop(ctx) { |
|||
const { id } = ctx.params |
|||
this.jobService.stopJob(id) |
|||
ctx.body = formatResponse(true, null, null, `${id} 任务已停止`) |
|||
} |
|||
|
|||
async updateCron(ctx) { |
|||
const { id } = ctx.params |
|||
const { cronTime } = ctx.request.body |
|||
this.jobService.updateJobCron(id, cronTime) |
|||
ctx.body = formatResponse(true, null, null, `${id} 任务频率已修改`) |
|||
} |
|||
|
|||
static createRoutes() { |
|||
const controller = new JobController() |
|||
const router = new Router({ prefix: "/api/jobs" }) |
|||
router.get("/", controller.list.bind(controller)) |
|||
router.post("/start/:id", controller.start.bind(controller)) |
|||
router.post("/stop/:id", controller.stop.bind(controller)) |
|||
router.post("/update/:id", controller.updateCron.bind(controller)) |
|||
return router |
|||
} |
|||
} |
|||
|
|||
export default JobController |
@ -0,0 +1,20 @@ |
|||
import Router from "utils/router.js" |
|||
|
|||
class StatusController { |
|||
async status(ctx) { |
|||
ctx.body = "OK" |
|||
} |
|||
|
|||
static createRoutes() { |
|||
const controller = new StatusController() |
|||
const v1 = new Router({ prefix: "/api/v1" }) |
|||
v1.use((ctx, next) => { |
|||
ctx.set("X-API-Version", "v1") |
|||
return next() |
|||
}) |
|||
v1.get("/status", controller.status.bind(controller)) |
|||
return v1 |
|||
} |
|||
} |
|||
|
|||
export default StatusController |
@ -0,0 +1,516 @@ |
|||
import Router from "utils/router.js" |
|||
import BookmarkService from "services/BookmarkService.js" |
|||
import CommonError from "@/utils/error/CommonError" |
|||
|
|||
class BookmarkController { |
|||
|
|||
/** |
|||
* 获取收藏列表 |
|||
*/ |
|||
async getBookmarks(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { |
|||
page = 1, |
|||
limit = 12, |
|||
category_id, |
|||
tag_id, |
|||
search, |
|||
orderBy = "created_at", |
|||
orderDirection = "desc" |
|||
} = ctx.query |
|||
|
|||
const options = { |
|||
page: parseInt(page), |
|||
limit: parseInt(limit), |
|||
categoryId: category_id ? parseInt(category_id) : null, |
|||
tagId: tag_id ? parseInt(tag_id) : null, |
|||
search: search || null, |
|||
orderBy, |
|||
orderDirection |
|||
} |
|||
|
|||
const result = await BookmarkService.getBookmarks(userId, options) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: result.bookmarks, |
|||
pagination: result.pagination, |
|||
total: result.total |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取收藏列表失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取单个收藏详情 |
|||
*/ |
|||
async getBookmarkById(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { id } = ctx.params |
|||
const bookmark = await BookmarkService.getBookmarkById(parseInt(id), userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: bookmark |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取收藏详情失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 创建新收藏 |
|||
*/ |
|||
async createBookmark(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const bookmarkData = { |
|||
...ctx.request.body, |
|||
user_id: userId |
|||
} |
|||
|
|||
const bookmark = await BookmarkService.createBookmark(bookmarkData) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
message: "收藏创建成功", |
|||
data: bookmark |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "创建收藏失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 更新收藏 |
|||
*/ |
|||
async updateBookmark(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { id } = ctx.params |
|||
const updateData = ctx.request.body |
|||
|
|||
const bookmark = await BookmarkService.updateBookmark(parseInt(id), updateData, userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
message: "收藏更新成功", |
|||
data: bookmark |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "更新收藏失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 删除收藏 |
|||
*/ |
|||
async deleteBookmark(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { id } = ctx.params |
|||
await BookmarkService.deleteBookmark(parseInt(id), userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
message: "收藏删除成功" |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "删除收藏失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 搜索收藏 |
|||
*/ |
|||
async searchBookmarks(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { |
|||
query, |
|||
limit = 50, |
|||
includeTags = false |
|||
} = ctx.query |
|||
|
|||
if (!query) { |
|||
throw new CommonError("搜索关键词不能为空") |
|||
} |
|||
|
|||
const options = { |
|||
limit: parseInt(limit), |
|||
includeTags: includeTags === 'true' |
|||
} |
|||
|
|||
const bookmarks = await BookmarkService.searchBookmarks(query, userId, options) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: bookmarks, |
|||
total: bookmarks.length |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "搜索收藏失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 按分类获取收藏 |
|||
*/ |
|||
async getBookmarksByCategory(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { categoryId } = ctx.params |
|||
const { limit = 20 } = ctx.query |
|||
|
|||
const options = { limit: parseInt(limit) } |
|||
const result = await BookmarkService.getBookmarksByCategory(parseInt(categoryId), userId, options) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: result.bookmarks, |
|||
category: result.category, |
|||
total: result.bookmarks.length |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取分类收藏失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 按标签获取收藏 |
|||
*/ |
|||
async getBookmarksByTag(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { tagId } = ctx.params |
|||
const { limit = 20 } = ctx.query |
|||
|
|||
const options = { limit: parseInt(limit) } |
|||
const result = await BookmarkService.getBookmarksByTag(parseInt(tagId), userId, options) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: result.bookmarks, |
|||
tag: result.tag, |
|||
total: result.bookmarks.length |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取标签收藏失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取收藏统计信息 |
|||
*/ |
|||
async getBookmarkStats(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const stats = await BookmarkService.getBookmarkStats(userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: stats |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取统计信息失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取快捷访问数据 |
|||
*/ |
|||
async getQuickAccess(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const quickAccess = await BookmarkService.getQuickAccess(userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: quickAccess |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取快捷访问失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 增加点击次数 |
|||
*/ |
|||
async incrementClickCount(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { id } = ctx.params |
|||
await BookmarkService.incrementClickCount(parseInt(id), userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
message: "点击次数已更新" |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "更新点击次数失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 切换收藏状态 |
|||
*/ |
|||
async toggleFavorite(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { id } = ctx.params |
|||
const result = await BookmarkService.toggleFavorite(parseInt(id), userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
message: result.message, |
|||
data: { is_favorite: result.is_favorite } |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "切换收藏状态失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 批量操作 |
|||
*/ |
|||
async batchOperation(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { operation, bookmarkIds, ...data } = ctx.request.body |
|||
|
|||
if (!operation || !bookmarkIds || !Array.isArray(bookmarkIds)) { |
|||
throw new CommonError("操作类型和收藏ID列表是必填项") |
|||
} |
|||
|
|||
const results = await BookmarkService.batchOperation(operation, bookmarkIds, userId, data) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
message: "批量操作完成", |
|||
data: results |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "批量操作失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取所有分类 |
|||
*/ |
|||
async getCategories(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const categories = await BookmarkService.getCategories(userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: categories |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取分类失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取所有标签 |
|||
*/ |
|||
async getTags(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const tags = await BookmarkService.getTags(userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: tags |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取标签失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取热门标签 |
|||
*/ |
|||
async getPopularTags(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { limit = 20 } = ctx.query |
|||
const tags = await BookmarkService.getPopularTags(userId, parseInt(limit)) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: tags |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取热门标签失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
static createRoutes() { |
|||
const controller = new BookmarkController() |
|||
const router = new Router({ auth: "try" }) |
|||
|
|||
// 收藏相关API
|
|||
router.get("/bookmarks", controller.getBookmarks.bind(controller), { auth: true }) |
|||
router.get("/bookmarks/:id", controller.getBookmarkById.bind(controller), { auth: true }) |
|||
router.post("/bookmarks", controller.createBookmark.bind(controller), { auth: true }) |
|||
router.put("/bookmarks/:id", controller.updateBookmark.bind(controller), { auth: true }) |
|||
router.delete("/bookmarks/:id", controller.deleteBookmark.bind(controller), { auth: true }) |
|||
|
|||
// 搜索和筛选
|
|||
router.get("/bookmarks/search", controller.searchBookmarks.bind(controller), { auth: true }) |
|||
router.get("/bookmarks/category/:categoryId", controller.getBookmarksByCategory.bind(controller), { auth: true }) |
|||
router.get("/bookmarks/tag/:tagId", controller.getBookmarksByTag.bind(controller), { auth: true }) |
|||
|
|||
// 统计和快捷访问
|
|||
router.get("/bookmarks/stats", controller.getBookmarkStats.bind(controller), { auth: true }) |
|||
router.get("/bookmarks/quick-access", controller.getQuickAccess.bind(controller), { auth: true }) |
|||
|
|||
// 收藏操作
|
|||
router.post("/bookmarks/:id/click", controller.incrementClickCount.bind(controller), { auth: true }) |
|||
router.post("/bookmarks/:id/favorite", controller.toggleFavorite.bind(controller), { auth: true }) |
|||
|
|||
// 批量操作
|
|||
router.post("/bookmarks/batch", controller.batchOperation.bind(controller), { auth: true }) |
|||
|
|||
// 分类和标签
|
|||
router.get("/categories", controller.getCategories.bind(controller), { auth: true }) |
|||
router.get("/tags", controller.getTags.bind(controller), { auth: true }) |
|||
router.get("/tags/popular", controller.getPopularTags.bind(controller), { auth: true }) |
|||
|
|||
return router |
|||
} |
|||
} |
|||
|
|||
export default BookmarkController |
@ -0,0 +1,63 @@ |
|||
import Router from "utils/router.js" |
|||
|
|||
class HtmxController { |
|||
async index(ctx) { |
|||
return await ctx.render("index", { name: "bluescurry" }) |
|||
} |
|||
|
|||
page(name, data) { |
|||
return async ctx => { |
|||
return await ctx.render(name, data) |
|||
} |
|||
} |
|||
|
|||
static createRoutes() { |
|||
const controller = new HtmxController() |
|||
const router = new Router({ auth: "try" }) |
|||
router.get("/htmx/timeline", async ctx => { |
|||
return await ctx.render("htmx/timeline", { |
|||
timeLine: [ |
|||
{ |
|||
icon: "第一份工作", |
|||
title: "???", |
|||
desc: `做游戏的。`, |
|||
}, |
|||
{ |
|||
icon: "大学毕业", |
|||
title: "2014年09月", |
|||
desc: `我从<a href="https://www.jxnu.edu.cn/" target="_blank">江西师范大学</a>毕业,
|
|||
获得了软件工程(虚拟现实与技术)专业的学士学位。`,
|
|||
}, |
|||
{ |
|||
icon: "高中", |
|||
title: "???", |
|||
desc: `宜春中学`, |
|||
}, |
|||
{ |
|||
icon: "初中", |
|||
title: "???", |
|||
desc: `宜春实验中学`, |
|||
}, |
|||
{ |
|||
icon: "小学(4-6年级)", |
|||
title: "???", |
|||
desc: `宜春二小`, |
|||
}, |
|||
{ |
|||
icon: "小学(1-3年级)", |
|||
title: "???", |
|||
desc: `丰城市泉港镇小学`, |
|||
}, |
|||
{ |
|||
icon: "出生", |
|||
title: "1996年06月", |
|||
desc: `我出生于江西省丰城市泉港镇`, |
|||
}, |
|||
], |
|||
}) |
|||
}) |
|||
return router |
|||
} |
|||
} |
|||
|
|||
export default HtmxController |
@ -0,0 +1,163 @@ |
|||
import Router from "utils/router.js" |
|||
import UserService from "services/UserService.js" |
|||
import SiteConfigService from "services/SiteConfigService.js" |
|||
import svgCaptcha from "svg-captcha" |
|||
import CommonError from "@/utils/error/CommonError" |
|||
|
|||
class PageController { |
|||
constructor() { |
|||
this.userService = new UserService() |
|||
this.siteConfigService = new SiteConfigService() |
|||
} |
|||
async indexGet(ctx) { |
|||
return await ctx.render("page/index/index", {}, { includeSite: true, includeUser: true }) |
|||
} |
|||
async indexNoAuth(ctx) { |
|||
return await ctx.render("page/auth/no-auth", {}) |
|||
} |
|||
|
|||
async loginGet(ctx) { |
|||
if (ctx.state.user) { |
|||
ctx.cookies.set("toast", JSON.stringify({ type: "error", message: encodeURIComponent("用户已登录") }), { |
|||
maxAge: 1, |
|||
httpOnly: false, |
|||
path: "/", |
|||
}) |
|||
return ctx.redirect("/?msg=用户已登录") |
|||
} |
|||
return await ctx.render("page/login/index", { site_title: "登录" }) |
|||
} |
|||
|
|||
async loginPost(ctx) { |
|||
const { username, email, password } = ctx.request.body |
|||
const result = await this.userService.login({ username, email, password }) |
|||
ctx.session.user = result.user |
|||
ctx.body = { success: true, message: "登录成功" } |
|||
} |
|||
|
|||
async captchaGet(ctx) { |
|||
var captcha = svgCaptcha.create({ |
|||
size: 4, // 个数
|
|||
width: 100, // 宽
|
|||
height: 30, // 高
|
|||
fontSize: 38, // 字体大小
|
|||
color: true, // 字体颜色是否多变
|
|||
noise: 2, // 干扰线几条
|
|||
}) |
|||
// 记录验证码信息(文本+过期时间)
|
|||
// 这里设置5分钟后过期
|
|||
const expireTime = Date.now() + 5 * 60 * 1000 |
|||
ctx.session.captcha = { |
|||
text: captcha.text.toLowerCase(), // 转小写,忽略大小写验证
|
|||
expireTime: expireTime, |
|||
} |
|||
ctx.type = "image/svg+xml" |
|||
ctx.body = captcha.data |
|||
} |
|||
|
|||
async registerGet(ctx) { |
|||
if (ctx.state.user) { |
|||
ctx.cookies.set("toast", JSON.stringify({ type: "error", message: encodeURIComponent("用户已登录") }), { |
|||
maxAge: 1, |
|||
httpOnly: false, |
|||
path: "/", |
|||
}) |
|||
return ctx.redirect("/?msg=用户已登录") |
|||
} |
|||
// TODO 多个
|
|||
ctx.session.registerRandomStr = Math.ceil(Math.random() * 100000000000000) |
|||
return await ctx.render("page/register/index", { site_title: "注册", randomStr: ctx.session.registerRandomStr }) |
|||
} |
|||
|
|||
async registerPost(ctx) { |
|||
const { username, password, code, randomStr } = ctx.request.body |
|||
|
|||
if (!ctx.session.registerRandomStr) { |
|||
throw new CommonError("缺少随机数") |
|||
} |
|||
if (ctx.session.registerRandomStr + "" !== randomStr + "") { |
|||
throw new CommonError("随机数不匹配") |
|||
} |
|||
delete ctx.session.registerRandomStr |
|||
|
|||
// 检查Session中是否存在验证码
|
|||
if (!ctx.session.captcha) { |
|||
throw new CommonError("验证码不存在,请重新获取") |
|||
} |
|||
|
|||
const { text, expireTime } = ctx.session.captcha |
|||
|
|||
// 检查是否过期
|
|||
if (Date.now() > expireTime) { |
|||
// 过期后清除Session中的验证码
|
|||
delete ctx.session.captcha |
|||
throw new CommonError("验证码已过期,请重新获取") |
|||
} |
|||
|
|||
if (!code) { |
|||
throw new CommonError("请输入验证码") |
|||
} |
|||
|
|||
if (code.toLowerCase() !== text) { |
|||
throw new CommonError("验证码错误") |
|||
} |
|||
|
|||
delete ctx.session.captcha |
|||
|
|||
// try {
|
|||
await this.userService.register({ username, password, role: "user" }) |
|||
// ctx.cookies.set("toast", JSON.stringify({ type: "success", message: "注册成功" }), {
|
|||
// maxAge: 1,
|
|||
// httpOnly: false,
|
|||
// path: "/",
|
|||
// })
|
|||
// } catch (error) {
|
|||
// ctx.cookies.set('toast', JSON.stringify({type:"error",message:encodeURIComponent(error.message || error)}), {
|
|||
// maxAge: 1,
|
|||
// httpOnly: false,
|
|||
// path: '/',
|
|||
// })
|
|||
// }
|
|||
return ctx.redirect("/login") |
|||
} |
|||
|
|||
logout(ctx) { |
|||
delete ctx.session.user |
|||
return ctx.redirect("/?msg=用户已退出") |
|||
} |
|||
|
|||
pageGet(name, data) { |
|||
return async ctx => { |
|||
return await ctx.render( |
|||
name, |
|||
{ |
|||
...(data || {}), |
|||
}, |
|||
{ includeSite: true, includeUser: true } |
|||
) |
|||
} |
|||
} |
|||
|
|||
static createRoutes() { |
|||
const controller = new PageController() |
|||
const router = new Router({ auth: "try" }) |
|||
// 首页
|
|||
router.get("/", controller.indexGet.bind(controller), { auth: false }) |
|||
// 未授权报错页
|
|||
router.get("/no-auth", controller.indexNoAuth.bind(controller), { auth: false }) |
|||
|
|||
router.get("/article/:id", controller.pageGet("page/articles/index"), { auth: false }) |
|||
router.get("/articles", controller.pageGet("page/articles/index"), { auth: false }) |
|||
|
|||
router.get("/about", controller.pageGet("page/about/index"), { auth: false }) |
|||
router.get("/login", controller.loginGet.bind(controller), { auth: "try" }) |
|||
router.post("/login", controller.loginPost.bind(controller), { auth: false }) |
|||
router.get("/captcha", controller.captchaGet.bind(controller), { auth: false }) |
|||
router.get("/register", controller.registerGet.bind(controller), { auth: "try" }) |
|||
router.post("/register", controller.registerPost.bind(controller), { auth: false }) |
|||
router.post("/logout", controller.logout.bind(controller), { auth: true }) |
|||
return router |
|||
} |
|||
} |
|||
|
|||
export default PageController |
@ -0,0 +1,258 @@ |
|||
# BookmarkController 使用说明 |
|||
|
|||
## 概述 |
|||
|
|||
`BookmarkController` 是收藏网站的后端API控制器,提供了完整的收藏管理功能,包括CRUD操作、搜索、筛选、统计等。 |
|||
|
|||
## API 接口 |
|||
|
|||
### 1. 收藏管理 |
|||
|
|||
#### 获取收藏列表 |
|||
```http |
|||
GET /api/bookmarks?page=1&limit=12&category_id=1&tag_id=2&search=关键词&orderBy=created_at&orderDirection=desc |
|||
``` |
|||
|
|||
**查询参数:** |
|||
- `page`: 页码(默认1) |
|||
- `limit`: 每页数量(默认12) |
|||
- `category_id`: 分类ID(可选) |
|||
- `tag_id`: 标签ID(可选) |
|||
- `search`: 搜索关键词(可选) |
|||
- `orderBy`: 排序字段(默认created_at) |
|||
- `orderDirection`: 排序方向(默认desc) |
|||
|
|||
**响应示例:** |
|||
```json |
|||
{ |
|||
"success": true, |
|||
"data": [...], |
|||
"pagination": { |
|||
"page": 1, |
|||
"limit": 12, |
|||
"total": 50, |
|||
"totalPages": 5, |
|||
"hasNext": true, |
|||
"hasPrev": false |
|||
}, |
|||
"total": 50 |
|||
} |
|||
``` |
|||
|
|||
#### 获取单个收藏 |
|||
```http |
|||
GET /api/bookmarks/:id |
|||
``` |
|||
|
|||
#### 创建收藏 |
|||
```http |
|||
POST /api/bookmarks |
|||
Content-Type: application/json |
|||
|
|||
{ |
|||
"title": "网站标题", |
|||
"url": "https://example.com", |
|||
"description": "网站描述", |
|||
"category_id": 1, |
|||
"tags": ["JavaScript", "React"], |
|||
"links": [ |
|||
{ |
|||
"title": "链接标题", |
|||
"url": "https://example.com/link", |
|||
"type": "tutorial" |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
#### 更新收藏 |
|||
```http |
|||
PUT /api/bookmarks/:id |
|||
Content-Type: application/json |
|||
|
|||
{ |
|||
"title": "更新后的标题", |
|||
"description": "更新后的描述" |
|||
} |
|||
``` |
|||
|
|||
#### 删除收藏 |
|||
```http |
|||
DELETE /api/bookmarks/:id |
|||
``` |
|||
|
|||
### 2. 搜索和筛选 |
|||
|
|||
#### 搜索收藏 |
|||
```http |
|||
GET /api/bookmarks/search?query=关键词&limit=50&includeTags=true |
|||
``` |
|||
|
|||
#### 按分类获取收藏 |
|||
```http |
|||
GET /api/bookmarks/category/:categoryId?limit=20 |
|||
``` |
|||
|
|||
#### 按标签获取收藏 |
|||
```http |
|||
GET /api/bookmarks/tag/:tagId?limit=20 |
|||
``` |
|||
|
|||
### 3. 统计和快捷访问 |
|||
|
|||
#### 获取统计信息 |
|||
```http |
|||
GET /api/bookmarks/stats |
|||
``` |
|||
|
|||
**响应示例:** |
|||
```json |
|||
{ |
|||
"success": true, |
|||
"data": { |
|||
"total": 50, |
|||
"favorites": 10, |
|||
"totalClicks": 150, |
|||
"categories": [...], |
|||
"popularTags": [...] |
|||
} |
|||
} |
|||
``` |
|||
|
|||
#### 获取快捷访问数据 |
|||
```http |
|||
GET /api/bookmarks/quick-access |
|||
``` |
|||
|
|||
**响应示例:** |
|||
```json |
|||
{ |
|||
"success": true, |
|||
"data": { |
|||
"favorites": [...], |
|||
"recent": [...], |
|||
"popular": [...] |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### 4. 收藏操作 |
|||
|
|||
#### 增加点击次数 |
|||
```http |
|||
POST /api/bookmarks/:id/click |
|||
``` |
|||
|
|||
#### 切换收藏状态 |
|||
```http |
|||
POST /api/bookmarks/:id/favorite |
|||
``` |
|||
|
|||
### 5. 批量操作 |
|||
|
|||
#### 批量操作 |
|||
```http |
|||
POST /api/bookmarks/batch |
|||
Content-Type: application/json |
|||
|
|||
{ |
|||
"operation": "delete", |
|||
"bookmarkIds": [1, 2, 3], |
|||
"data": {} |
|||
} |
|||
``` |
|||
|
|||
**支持的操作类型:** |
|||
- `delete`: 批量删除 |
|||
- `move`: 批量移动分类 |
|||
- `tag`: 批量更新标签 |
|||
|
|||
### 6. 分类和标签 |
|||
|
|||
#### 获取所有分类 |
|||
```http |
|||
GET /api/categories |
|||
``` |
|||
|
|||
#### 获取所有标签 |
|||
```http |
|||
GET /api/tags |
|||
``` |
|||
|
|||
#### 获取热门标签 |
|||
```http |
|||
GET /api/tags/popular?limit=20 |
|||
``` |
|||
|
|||
## 认证要求 |
|||
|
|||
所有API接口都需要用户登录认证。系统会自动从session中获取用户ID: |
|||
|
|||
```javascript |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
``` |
|||
|
|||
## 错误处理 |
|||
|
|||
所有API都使用统一的错误处理格式: |
|||
|
|||
```json |
|||
{ |
|||
"success": false, |
|||
"message": "错误描述信息" |
|||
} |
|||
``` |
|||
|
|||
**常见HTTP状态码:** |
|||
- `200`: 请求成功 |
|||
- `400`: 请求参数错误或业务逻辑错误 |
|||
- `401`: 未认证(用户未登录) |
|||
- `500`: 服务器内部错误 |
|||
|
|||
## 前端集成 |
|||
|
|||
前端JavaScript代码已经更新为使用这些API接口。主要变化: |
|||
|
|||
1. **数据加载**: 从模拟数据改为API调用 |
|||
2. **分页处理**: 使用后端返回的分页信息 |
|||
3. **错误处理**: 统一的错误处理和用户提示 |
|||
4. **认证支持**: 自动包含用户认证信息 |
|||
|
|||
## 使用示例 |
|||
|
|||
### 启动服务器 |
|||
```bash |
|||
npm start |
|||
``` |
|||
|
|||
### 测试API |
|||
```bash |
|||
# 获取收藏列表 |
|||
curl -X GET "http://localhost:3000/api/bookmarks" \ |
|||
-H "Cookie: your-session-cookie" |
|||
|
|||
# 创建收藏 |
|||
curl -X POST "http://localhost:3000/api/bookmarks" \ |
|||
-H "Content-Type: application/json" \ |
|||
-H "Cookie: your-session-cookie" \ |
|||
-d '{"title":"测试收藏","url":"https://example.com"}' |
|||
``` |
|||
|
|||
## 注意事项 |
|||
|
|||
1. **用户认证**: 确保用户已登录才能访问API |
|||
2. **数据验证**: 所有输入数据都会进行验证 |
|||
3. **错误处理**: 前端需要处理API调用失败的情况 |
|||
4. **分页**: 大量数据使用分页加载,避免性能问题 |
|||
5. **缓存**: 考虑对分类和标签等静态数据进行缓存 |
|||
|
|||
## 扩展建议 |
|||
|
|||
1. **API版本控制**: 添加版本号支持(如 `/api/v1/bookmarks`) |
|||
2. **速率限制**: 添加API调用频率限制 |
|||
3. **缓存策略**: 实现Redis缓存提升性能 |
|||
4. **日志记录**: 记录API调用日志用于监控 |
|||
5. **API文档**: 使用Swagger等工具生成API文档 |
@ -1,23 +0,0 @@ |
|||
import db from "../db/index.js" |
|||
|
|||
// 创建用户
|
|||
export async function createUser(userData) { |
|||
const [id] = await db("users").insert(userData) |
|||
return id |
|||
} |
|||
|
|||
// 查询所有用户
|
|||
export async function getUsers() { |
|||
return db("users").select("*") |
|||
} |
|||
|
|||
// 更新用户
|
|||
export async function updateUser(id, updates) { |
|||
updates.updated_at = new Date() |
|||
return db("users").where("id", id).update(updates) |
|||
} |
|||
|
|||
// 删除用户
|
|||
export async function deleteUser(id) { |
|||
return db("users").where("id", id).del() |
|||
} |
@ -0,0 +1,217 @@ |
|||
# 收藏网站数据模型 |
|||
|
|||
这是一个完整的收藏网站数据模型实现,支持网站收藏、分类管理、标签系统、多链接等功能。 |
|||
|
|||
## 数据库表结构 |
|||
|
|||
### 1. categories (分类表) |
|||
- `id`: 主键 |
|||
- `name`: 分类名称 (唯一) |
|||
- `description`: 分类描述 |
|||
- `color`: 分类颜色 (十六进制) |
|||
- `icon`: 分类图标 |
|||
- `sort_order`: 排序顺序 |
|||
- `is_active`: 是否激活 |
|||
- `user_id`: 用户ID (外键) |
|||
- `created_at`: 创建时间 |
|||
- `updated_at`: 更新时间 |
|||
|
|||
### 2. tags (标签表) |
|||
- `id`: 主键 |
|||
- `name`: 标签名称 |
|||
- `description`: 标签描述 |
|||
- `color`: 标签颜色 |
|||
- `user_id`: 用户ID (外键) |
|||
- `created_at`: 创建时间 |
|||
- `updated_at`: 更新时间 |
|||
|
|||
### 3. bookmarks (收藏主表) |
|||
- `id`: 主键 |
|||
- `title`: 收藏标题 |
|||
- `description`: 收藏描述 |
|||
- `url`: 网站URL |
|||
- `favicon`: 网站图标 |
|||
- `screenshot`: 截图路径 |
|||
- `category_id`: 分类ID (外键) |
|||
- `user_id`: 用户ID (外键) |
|||
- `is_public`: 是否公开 |
|||
- `is_favorite`: 是否特别收藏 |
|||
- `click_count`: 点击次数 |
|||
- `sort_order`: 排序顺序 |
|||
- `metadata`: 额外元数据 (JSON) |
|||
- `last_visited`: 最后访问时间 |
|||
- `created_at`: 创建时间 |
|||
- `updated_at`: 更新时间 |
|||
|
|||
### 4. bookmark_tags (收藏标签关联表) |
|||
- `id`: 主键 |
|||
- `bookmark_id`: 收藏ID (外键) |
|||
- `tag_id`: 标签ID (外键) |
|||
- `created_at`: 创建时间 |
|||
|
|||
### 5. bookmark_links (收藏多链接表) |
|||
- `id`: 主键 |
|||
- `bookmark_id`: 收藏ID (外键) |
|||
- `title`: 链接标题 |
|||
- `url`: 链接URL |
|||
- `description`: 链接描述 |
|||
- `type`: 链接类型 (link, download, api等) |
|||
- `is_active`: 是否激活 |
|||
- `sort_order`: 排序顺序 |
|||
- `created_at`: 创建时间 |
|||
- `updated_at`: 更新时间 |
|||
|
|||
### 6. bookmark_history (收藏历史表) |
|||
- `id`: 主键 |
|||
- `bookmark_id`: 收藏ID (外键) |
|||
- `user_id`: 用户ID (外键) |
|||
- `action`: 操作类型 (visit, favorite, share等) |
|||
- `context`: 上下文信息 (JSON) |
|||
- `created_at`: 创建时间 |
|||
|
|||
## 使用方法 |
|||
|
|||
### 1. 运行数据库迁移 |
|||
|
|||
```bash |
|||
# 运行迁移 |
|||
npx knex migrate:latest |
|||
|
|||
# 回滚迁移 |
|||
npx knex migrate:rollback |
|||
``` |
|||
|
|||
### 2. 插入种子数据 |
|||
|
|||
```bash |
|||
# 插入种子数据 |
|||
npx knex seed:run |
|||
``` |
|||
|
|||
### 3. 基本操作示例 |
|||
|
|||
#### 创建收藏 |
|||
```javascript |
|||
import BookmarkService from '../services/BookmarkService.js' |
|||
|
|||
const bookmarkData = { |
|||
title: "MDN Web Docs", |
|||
description: "Mozilla开发者网络", |
|||
url: "https://developer.mozilla.org/", |
|||
category_id: 1, |
|||
user_id: 1, |
|||
tags: ["JavaScript", "Web开发"], |
|||
links: [ |
|||
{ |
|||
title: "JavaScript教程", |
|||
url: "https://developer.mozilla.org/zh-CN/docs/Web/JavaScript", |
|||
description: "JavaScript完整教程", |
|||
type: "tutorial" |
|||
} |
|||
] |
|||
} |
|||
|
|||
const bookmark = await BookmarkService.createBookmark(bookmarkData) |
|||
``` |
|||
|
|||
#### 获取收藏列表 |
|||
```javascript |
|||
// 获取所有收藏 |
|||
const bookmarks = await BookmarkService.getBookmarks(userId) |
|||
|
|||
// 按分类获取 |
|||
const categoryBookmarks = await BookmarkService.getBookmarksByCategory(categoryId, userId) |
|||
|
|||
// 按标签获取 |
|||
const tagBookmarks = await BookmarkService.getBookmarksByTag(tagId, userId) |
|||
|
|||
// 搜索收藏 |
|||
const searchResults = await BookmarkService.searchBookmarks("JavaScript", userId) |
|||
``` |
|||
|
|||
#### 更新收藏 |
|||
```javascript |
|||
const updateData = { |
|||
title: "更新后的标题", |
|||
description: "更新后的描述", |
|||
tags: ["JavaScript", "React", "Node.js"] |
|||
} |
|||
|
|||
const updatedBookmark = await BookmarkService.updateBookmark(bookmarkId, updateData, userId) |
|||
``` |
|||
|
|||
#### 删除收藏 |
|||
```javascript |
|||
await BookmarkService.deleteBookmark(bookmarkId, userId) |
|||
``` |
|||
|
|||
### 4. 高级功能 |
|||
|
|||
#### 批量操作 |
|||
```javascript |
|||
// 批量删除 |
|||
const results = await BookmarkService.batchOperation('delete', [1, 2, 3], userId) |
|||
|
|||
// 批量移动分类 |
|||
const results = await BookmarkService.batchOperation('move', [1, 2, 3], userId, { category_id: 2 }) |
|||
|
|||
// 批量更新标签 |
|||
const results = await BookmarkService.batchOperation('tag', [1, 2, 3], userId, { tags: ["新标签"] }) |
|||
``` |
|||
|
|||
#### 统计信息 |
|||
```javascript |
|||
const stats = await BookmarkService.getBookmarkStats(userId) |
|||
console.log(`总收藏数: ${stats.total}`) |
|||
console.log(`特别收藏: ${stats.favorites}`) |
|||
console.log(`总点击数: ${stats.totalClicks}`) |
|||
``` |
|||
|
|||
#### 快捷访问 |
|||
```javascript |
|||
const quickAccess = await BookmarkService.getQuickAccess(userId) |
|||
console.log('特别收藏:', quickAccess.favorites) |
|||
console.log('最近访问:', quickAccess.recent) |
|||
console.log('热门收藏:', quickAccess.popular) |
|||
``` |
|||
|
|||
## 模型特性 |
|||
|
|||
### 1. 数据完整性 |
|||
- 外键约束确保数据一致性 |
|||
- 用户权限验证防止越权访问 |
|||
- 必填字段验证 |
|||
|
|||
### 2. 性能优化 |
|||
- 合理的索引设计 |
|||
- 分页查询支持 |
|||
- 关联查询优化 |
|||
|
|||
### 3. 扩展性 |
|||
- JSON字段支持元数据存储 |
|||
- 灵活的标签系统 |
|||
- 多链接支持 |
|||
- 历史记录追踪 |
|||
|
|||
### 4. 用户体验 |
|||
- 点击统计 |
|||
- 收藏状态管理 |
|||
- 搜索功能 |
|||
- 分类和标签管理 |
|||
|
|||
## 注意事项 |
|||
|
|||
1. 确保在运行迁移前数据库连接正常 |
|||
2. 种子数据需要先有用户数据 (user_id = 1) |
|||
3. 所有操作都需要验证用户权限 |
|||
4. URL格式会自动验证 |
|||
5. 标签和分类名称在同一用户下唯一 |
|||
|
|||
## 扩展建议 |
|||
|
|||
1. 添加收藏导入/导出功能 |
|||
2. 实现收藏分享功能 |
|||
3. 添加收藏推荐算法 |
|||
4. 支持收藏文件夹功能 |
|||
5. 添加收藏同步功能 |
|||
6. 实现收藏备份和恢复 |
@ -0,0 +1,115 @@ |
|||
/** |
|||
* @param { import("knex").Knex } knex |
|||
* @returns { Promise<void> } |
|||
*/ |
|||
export const up = async knex => { |
|||
// 创建分类表
|
|||
await knex.schema.createTable("categories", function (table) { |
|||
table.increments("id").primary() |
|||
table.string("name", 100).notNullable().unique() |
|||
table.string("description", 500) |
|||
table.string("color", 7).defaultTo("#3B82F6") // 十六进制颜色值
|
|||
table.string("icon", 50) // 图标类名或路径
|
|||
table.integer("sort_order").defaultTo(0) |
|||
table.boolean("is_active").defaultTo(true) |
|||
table.integer("user_id").unsigned().references("id").inTable("users").onDelete("CASCADE") |
|||
table.timestamp("created_at").defaultTo(knex.fn.now()) |
|||
table.timestamp("updated_at").defaultTo(knex.fn.now()) |
|||
}) |
|||
|
|||
// 创建标签表
|
|||
await knex.schema.createTable("tags", function (table) { |
|||
table.increments("id").primary() |
|||
table.string("name", 100).notNullable() |
|||
table.string("description", 500) |
|||
table.string("color", 7).defaultTo("#6B7280") |
|||
table.integer("user_id").unsigned().references("id").inTable("users").onDelete("CASCADE") |
|||
table.timestamp("created_at").defaultTo(knex.fn.now()) |
|||
table.timestamp("updated_at").defaultTo(knex.fn.now()) |
|||
|
|||
// 同一用户下标签名唯一
|
|||
table.unique(["name", "user_id"]) |
|||
}) |
|||
|
|||
// 创建收藏主表
|
|||
await knex.schema.createTable("bookmarks", function (table) { |
|||
table.increments("id").primary() |
|||
table.string("title", 200).notNullable() |
|||
table.text("description") |
|||
table.string("url", 1000).notNullable() |
|||
table.string("favicon", 500) // 网站图标
|
|||
table.string("screenshot", 500) // 截图路径
|
|||
table.integer("category_id").unsigned().references("id").inTable("categories").onDelete("SET NULL") |
|||
table.integer("user_id").unsigned().references("id").inTable("users").onDelete("CASCADE") |
|||
table.boolean("is_public").defaultTo(false) // 是否公开
|
|||
table.boolean("is_favorite").defaultTo(false) // 是否特别收藏
|
|||
table.integer("click_count").defaultTo(0) // 点击次数
|
|||
table.integer("sort_order").defaultTo(0) |
|||
table.json("metadata") // 存储额外的元数据,如网站标题、描述等
|
|||
table.timestamp("last_visited").defaultTo(knex.fn.now()) // 最后访问时间
|
|||
table.timestamp("created_at").defaultTo(knex.fn.now()) |
|||
table.timestamp("updated_at").defaultTo(knex.fn.now()) |
|||
|
|||
// 索引
|
|||
table.index(["user_id", "category_id"]) |
|||
table.index(["user_id", "is_favorite"]) |
|||
table.index(["user_id", "created_at"]) |
|||
}) |
|||
|
|||
// 创建收藏与标签关联表
|
|||
await knex.schema.createTable("bookmark_tags", function (table) { |
|||
table.increments("id").primary() |
|||
table.integer("bookmark_id").unsigned().notNullable().references("id").inTable("bookmarks").onDelete("CASCADE") |
|||
table.integer("tag_id").unsigned().notNullable().references("id").inTable("tags").onDelete("CASCADE") |
|||
table.timestamp("created_at").defaultTo(knex.fn.now()) |
|||
|
|||
// 唯一约束,防止重复关联
|
|||
table.unique(["bookmark_id", "tag_id"]) |
|||
table.index(["bookmark_id"]) |
|||
table.index(["tag_id"]) |
|||
}) |
|||
|
|||
// 创建收藏的多链接表
|
|||
await knex.schema.createTable("bookmark_links", function (table) { |
|||
table.increments("id").primary() |
|||
table.integer("bookmark_id").unsigned().notNullable().references("id").inTable("bookmarks").onDelete("CASCADE") |
|||
table.string("title", 200).notNullable() |
|||
table.string("url", 1000).notNullable() |
|||
table.string("description", 500) |
|||
table.string("type", 50).defaultTo("link") // link, download, api, etc.
|
|||
table.boolean("is_active").defaultTo(true) |
|||
table.integer("sort_order").defaultTo(0) |
|||
table.timestamp("created_at").defaultTo(knex.fn.now()) |
|||
table.timestamp("updated_at").defaultTo(knex.fn.now()) |
|||
|
|||
table.index(["bookmark_id"]) |
|||
table.index(["type"]) |
|||
}) |
|||
|
|||
// 创建收藏历史表(可选,用于统计和分析)
|
|||
await knex.schema.createTable("bookmark_history", function (table) { |
|||
table.increments("id").primary() |
|||
table.integer("bookmark_id").unsigned().notNullable().references("id").inTable("bookmarks").onDelete("CASCADE") |
|||
table.integer("user_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE") |
|||
table.string("action", 50).notNullable() // visit, favorite, share, etc.
|
|||
table.json("context") // 存储上下文信息
|
|||
table.timestamp("created_at").defaultTo(knex.fn.now()) |
|||
|
|||
table.index(["bookmark_id"]) |
|||
table.index(["user_id"]) |
|||
table.index(["created_at"]) |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* @param { import("knex").Knex } knex |
|||
* @returns { Promise<void> } |
|||
*/ |
|||
export const down = async knex => { |
|||
await knex.schema.dropTableIfExists("bookmark_history") |
|||
await knex.schema.dropTableIfExists("bookmark_links") |
|||
await knex.schema.dropTableIfExists("bookmark_tags") |
|||
await knex.schema.dropTableIfExists("bookmarks") |
|||
await knex.schema.dropTableIfExists("tags") |
|||
await knex.schema.dropTableIfExists("categories") |
|||
} |
@ -0,0 +1,21 @@ |
|||
/** |
|||
* @param { import("knex").Knex } knex |
|||
* @returns { Promise<void> } |
|||
*/ |
|||
export const up = async knex => { |
|||
return knex.schema.createTable("site_config", function (table) { |
|||
table.increments("id").primary() // 自增主键
|
|||
table.string("key", 100).notNullable().unique() // 配置项key,唯一
|
|||
table.text("value").notNullable() // 配置项value
|
|||
table.timestamp("created_at").defaultTo(knex.fn.now()) // 创建时间
|
|||
table.timestamp("updated_at").defaultTo(knex.fn.now()) // 更新时间
|
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* @param { import("knex").Knex } knex |
|||
* @returns { Promise<void> } |
|||
*/ |
|||
export const down = async knex => { |
|||
return knex.schema.dropTable("site_config") // 回滚时删除表
|
|||
} |
@ -0,0 +1,349 @@ |
|||
import db from "../index.js" |
|||
|
|||
class BookmarkModel { |
|||
static async findAll(userId = null, options = {}) { |
|||
let query = db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.select("categories.color as category_color") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
|
|||
if (userId) { |
|||
query = query.where("bookmarks.user_id", userId) |
|||
} |
|||
|
|||
// 分类过滤
|
|||
if (options.categoryId) { |
|||
query = query.where("bookmarks.category_id", options.categoryId) |
|||
} |
|||
|
|||
// 标签过滤
|
|||
if (options.tagIds && options.tagIds.length > 0) { |
|||
query = query |
|||
.join("bookmark_tags", "bookmarks.id", "bookmark_tags.bookmark_id") |
|||
.whereIn("bookmark_tags.tag_id", options.tagIds) |
|||
} |
|||
|
|||
// 搜索过滤
|
|||
if (options.search) { |
|||
const searchTerm = `%${options.search}%` |
|||
query = query.where(function() { |
|||
this.where("bookmarks.title", "like", searchTerm) |
|||
.orWhere("bookmarks.description", "like", searchTerm) |
|||
.orWhere("bookmarks.url", "like", searchTerm) |
|||
}) |
|||
} |
|||
|
|||
// 排序
|
|||
const orderBy = options.orderBy || "created_at" |
|||
const orderDirection = options.orderDirection || "desc" |
|||
query = query.orderBy(`bookmarks.${orderBy}`, orderDirection) |
|||
|
|||
// 分页
|
|||
if (options.limit) { |
|||
query = query.limit(options.limit) |
|||
} |
|||
if (options.offset) { |
|||
query = query.offset(options.offset) |
|||
} |
|||
|
|||
return query |
|||
} |
|||
|
|||
static async findById(id, userId = null) { |
|||
let query = db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.select("categories.color as category_color") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
.where("bookmarks.id", id) |
|||
|
|||
if (userId) { |
|||
query = query.where("bookmarks.user_id", userId) |
|||
} |
|||
|
|||
return query.first() |
|||
} |
|||
|
|||
static async create(data) { |
|||
// 提取 tags 和 links,避免插入到 bookmarks 表
|
|||
const { tags, links, ...bookmarkData } = data |
|||
|
|||
const bookmark = await db("bookmarks").insert({ |
|||
...bookmarkData, |
|||
created_at: db.fn.now(), |
|||
updated_at: db.fn.now(), |
|||
last_visited: db.fn.now(), |
|||
}).returning("*") |
|||
|
|||
// 如果有标签,创建标签关联
|
|||
if (tags && tags.length > 0) { |
|||
await this.addTags(bookmark[0].id, tags, bookmarkData.user_id) |
|||
} |
|||
|
|||
// 如果有多链接,创建链接记录
|
|||
if (links && links.length > 0) { |
|||
await this.addLinks(bookmark[0].id, links) |
|||
} |
|||
|
|||
return bookmark[0] |
|||
} |
|||
|
|||
static async update(id, data, userId = null) { |
|||
// 提取 tags 和 links,避免更新到 bookmarks 表
|
|||
const { tags, links, ...bookmarkData } = data |
|||
|
|||
let query = db("bookmarks").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
|
|||
const bookmark = await query.update({ |
|||
...bookmarkData, |
|||
updated_at: db.fn.now(), |
|||
}).returning("*") |
|||
|
|||
// 更新标签
|
|||
if (tags !== undefined) { |
|||
await this.updateTags(id, tags, userId) |
|||
} |
|||
|
|||
// 更新链接
|
|||
if (links !== undefined) { |
|||
await this.updateLinks(id, links) |
|||
} |
|||
|
|||
return bookmark[0] |
|||
} |
|||
|
|||
static async delete(id, userId = null) { |
|||
let query = db("bookmarks").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.del() |
|||
} |
|||
|
|||
static async incrementClickCount(id) { |
|||
return db("bookmarks") |
|||
.where("id", id) |
|||
.increment("click_count", 1) |
|||
.update({ |
|||
last_visited: db.fn.now(), |
|||
updated_at: db.fn.now(), |
|||
}) |
|||
} |
|||
|
|||
static async toggleFavorite(id, userId) { |
|||
const bookmark = await this.findById(id, userId) |
|||
if (!bookmark) return null |
|||
|
|||
return db("bookmarks") |
|||
.where("id", id) |
|||
.where("user_id", userId) |
|||
.update({ |
|||
is_favorite: !bookmark.is_favorite, |
|||
updated_at: db.fn.now(), |
|||
}) |
|||
.returning("*") |
|||
} |
|||
|
|||
static async getFavorites(userId, limit = 20) { |
|||
return db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
.where("bookmarks.user_id", userId) |
|||
.where("bookmarks.is_favorite", true) |
|||
.orderBy("bookmarks.updated_at", "desc") |
|||
.limit(limit) |
|||
} |
|||
|
|||
static async getRecentBookmarks(userId, limit = 10) { |
|||
return db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
.where("bookmarks.user_id", userId) |
|||
.orderBy("bookmarks.last_visited", "desc") |
|||
.limit(limit) |
|||
} |
|||
|
|||
static async getPopularBookmarks(userId, limit = 10) { |
|||
return db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
.where("bookmarks.user_id", userId) |
|||
.orderBy("bookmarks.click_count", "desc") |
|||
.limit(limit) |
|||
} |
|||
|
|||
static async getBookmarksByCategory(categoryId, userId, limit = 20) { |
|||
return db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
.where("bookmarks.category_id", categoryId) |
|||
.where("bookmarks.user_id", userId) |
|||
.orderBy("bookmarks.sort_order", "asc") |
|||
.orderBy("bookmarks.created_at", "desc") |
|||
.limit(limit) |
|||
} |
|||
|
|||
static async getBookmarksByTag(tagId, userId, limit = 20) { |
|||
return db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
.join("bookmark_tags", "bookmarks.id", "bookmark_tags.bookmark_id") |
|||
.where("bookmark_tags.tag_id", tagId) |
|||
.where("bookmarks.user_id", userId) |
|||
.orderBy("bookmarks.created_at", "desc") |
|||
.limit(limit) |
|||
} |
|||
|
|||
// 标签相关方法
|
|||
static async addTags(bookmarkId, tagNames, userId) { |
|||
const tags = [] |
|||
for (const tagName of tagNames) { |
|||
// 使用 findOrCreate 自动创建不存在的标签
|
|||
const tag = await db("tags") |
|||
.where("name", tagName.trim()) |
|||
.where("user_id", userId) |
|||
.first() |
|||
|
|||
if (tag) { |
|||
tags.push(tag.id) |
|||
} else { |
|||
// 如果标签不存在,创建新标签
|
|||
const newTag = await db("tags").insert({ |
|||
name: tagName.trim(), |
|||
user_id: userId, |
|||
created_at: db.fn.now(), |
|||
updated_at: db.fn.now(), |
|||
}).returning("*") |
|||
tags.push(newTag[0].id) |
|||
} |
|||
} |
|||
|
|||
if (tags.length > 0) { |
|||
const bookmarkTags = tags.map(tagId => ({ |
|||
bookmark_id: bookmarkId, |
|||
tag_id: tagId, |
|||
created_at: db.fn.now(), |
|||
})) |
|||
await db("bookmark_tags").insert(bookmarkTags) |
|||
} |
|||
} |
|||
|
|||
static async updateTags(bookmarkId, tagNames, userId) { |
|||
// 删除现有标签关联
|
|||
await db("bookmark_tags").where("bookmark_id", bookmarkId).del() |
|||
|
|||
// 添加新标签关联
|
|||
if (tagNames && tagNames.length > 0) { |
|||
await this.addTags(bookmarkId, tagNames, userId) |
|||
} |
|||
} |
|||
|
|||
static async getTags(bookmarkId) { |
|||
const tags = await db("tags") |
|||
.select("tags.name") |
|||
.join("bookmark_tags", "tags.id", "bookmark_tags.tag_id") |
|||
.where("bookmark_tags.bookmark_id", bookmarkId) |
|||
.orderBy("tags.name", "asc") |
|||
|
|||
// 返回标签名称数组
|
|||
return tags.map(tag => tag.name) |
|||
} |
|||
|
|||
// 链接相关方法
|
|||
static async addLinks(bookmarkId, links) { |
|||
if (links && links.length > 0) { |
|||
const bookmarkLinks = links.map((link, index) => ({ |
|||
bookmark_id: bookmarkId, |
|||
title: link.title, |
|||
url: link.url, |
|||
description: link.description, |
|||
type: link.type || "link", |
|||
sort_order: link.sort_order || index, |
|||
created_at: db.fn.now(), |
|||
updated_at: db.fn.now(), |
|||
})) |
|||
await db("bookmark_links").insert(bookmarkLinks) |
|||
} |
|||
} |
|||
|
|||
static async updateLinks(bookmarkId, links) { |
|||
// 删除现有链接
|
|||
await db("bookmark_links").where("bookmark_id", bookmarkId).del() |
|||
|
|||
// 添加新链接
|
|||
if (links && links.length > 0) { |
|||
await this.addLinks(bookmarkId, links) |
|||
} |
|||
} |
|||
|
|||
static async getLinks(bookmarkId) { |
|||
const links = await db("bookmark_links") |
|||
.select("title", "url", "type", "description") |
|||
.where("bookmark_id", bookmarkId) |
|||
.where("is_active", true) |
|||
.orderBy("sort_order", "asc") |
|||
.orderBy("created_at", "asc") |
|||
|
|||
return links |
|||
} |
|||
|
|||
// 统计方法
|
|||
static async getStats(userId) { |
|||
const [totalBookmarks] = await db("bookmarks") |
|||
.where("user_id", userId) |
|||
.count("* as count") |
|||
|
|||
const [favoriteBookmarks] = await db("bookmarks") |
|||
.where("user_id", userId) |
|||
.where("is_favorite", true) |
|||
.count("* as count") |
|||
|
|||
const [totalClicks] = await db("bookmarks") |
|||
.where("user_id", userId) |
|||
.sum("click_count as total") |
|||
|
|||
return { |
|||
total: parseInt(totalBookmarks.count), |
|||
favorites: parseInt(favoriteBookmarks.count), |
|||
totalClicks: parseInt(totalClicks.total) || 0, |
|||
} |
|||
} |
|||
|
|||
static async searchBookmarks(query, userId, options = {}) { |
|||
const searchTerm = `%${query}%` |
|||
let searchQuery = db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
.where("bookmarks.user_id", userId) |
|||
.where(function() { |
|||
this.where("bookmarks.title", "like", searchTerm) |
|||
.orWhere("bookmarks.description", "like", searchTerm) |
|||
.orWhere("bookmarks.url", "like", searchTerm) |
|||
}) |
|||
|
|||
// 标签搜索
|
|||
if (options.includeTags) { |
|||
searchQuery = searchQuery |
|||
.leftJoin("bookmark_tags", "bookmarks.id", "bookmark_tags.bookmark_id") |
|||
.leftJoin("tags", "bookmark_tags.tag_id", "tags.id") |
|||
.orWhere("tags.name", "like", searchTerm) |
|||
} |
|||
|
|||
return searchQuery |
|||
.orderBy("bookmarks.updated_at", "desc") |
|||
.limit(options.limit || 50) |
|||
} |
|||
} |
|||
|
|||
export default BookmarkModel |
|||
export { BookmarkModel } |
@ -0,0 +1,85 @@ |
|||
import db from "../index.js" |
|||
|
|||
class CategoryModel { |
|||
static async findAll(userId = null) { |
|||
let query = db("categories").select("*") |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.orderBy("sort_order", "asc").orderBy("name", "asc") |
|||
} |
|||
|
|||
static async findById(id, userId = null) { |
|||
let query = db("categories").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.first() |
|||
} |
|||
|
|||
static async create(data) { |
|||
return db("categories").insert({ |
|||
...data, |
|||
created_at: db.fn.now(), |
|||
updated_at: db.fn.now(), |
|||
}).returning("*") |
|||
} |
|||
|
|||
static async update(id, data, userId = null) { |
|||
let query = db("categories").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.update({ |
|||
...data, |
|||
updated_at: db.fn.now(), |
|||
}).returning("*") |
|||
} |
|||
|
|||
static async delete(id, userId = null) { |
|||
let query = db("categories").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.del() |
|||
} |
|||
|
|||
static async findByName(name, userId) { |
|||
return db("categories") |
|||
.where("name", name) |
|||
.where("user_id", userId) |
|||
.first() |
|||
} |
|||
|
|||
static async getActiveCategories(userId) { |
|||
return db("categories") |
|||
.where("user_id", userId) |
|||
.where("is_active", true) |
|||
.orderBy("sort_order", "asc") |
|||
.orderBy("name", "asc") |
|||
} |
|||
|
|||
static async updateSortOrder(id, sortOrder, userId) { |
|||
return db("categories") |
|||
.where("id", id) |
|||
.where("user_id", userId) |
|||
.update({ |
|||
sort_order: sortOrder, |
|||
updated_at: db.fn.now(), |
|||
}) |
|||
.returning("*") |
|||
} |
|||
|
|||
static async getCategoryStats(userId) { |
|||
return db("categories") |
|||
.select("categories.*") |
|||
.select(db.raw("COUNT(bookmarks.id) as bookmark_count")) |
|||
.leftJoin("bookmarks", "categories.id", "bookmarks.category_id") |
|||
.where("categories.user_id", userId) |
|||
.groupBy("categories.id") |
|||
.orderBy("categories.sort_order", "asc") |
|||
} |
|||
} |
|||
|
|||
export default CategoryModel |
|||
export { CategoryModel } |
@ -0,0 +1,42 @@ |
|||
import db from "../index.js" |
|||
|
|||
class SiteConfigModel { |
|||
// 获取指定key的配置
|
|||
static async get(key) { |
|||
const row = await db("site_config").where({ key }).first() |
|||
return row ? row.value : null |
|||
} |
|||
|
|||
// 设置指定key的配置(有则更新,无则插入)
|
|||
static async set(key, value) { |
|||
const exists = await db("site_config").where({ key }).first() |
|||
if (exists) { |
|||
await db("site_config").where({ key }).update({ value, updated_at: db.fn.now() }) |
|||
} else { |
|||
await db("site_config").insert({ key, value }) |
|||
} |
|||
} |
|||
|
|||
// 批量获取多个key的配置
|
|||
static async getMany(keys) { |
|||
const rows = await db("site_config").whereIn("key", keys) |
|||
const result = {} |
|||
rows.forEach(row => { |
|||
result[row.key] = row.value |
|||
}) |
|||
return result |
|||
} |
|||
|
|||
// 获取所有配置
|
|||
static async getAll() { |
|||
const rows = await db("site_config").select("key", "value") |
|||
const result = {} |
|||
rows.forEach(row => { |
|||
result[row.key] = row.value |
|||
}) |
|||
return result |
|||
} |
|||
} |
|||
|
|||
export default SiteConfigModel |
|||
export { SiteConfigModel } |
@ -0,0 +1,99 @@ |
|||
import db from "../index.js" |
|||
|
|||
class TagModel { |
|||
static async findAll(userId = null) { |
|||
let query = db("tags").select("*") |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.orderBy("name", "asc") |
|||
} |
|||
|
|||
static async findById(id, userId = null) { |
|||
let query = db("tags").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.first() |
|||
} |
|||
|
|||
static async create(data) { |
|||
return db("tags").insert({ |
|||
...data, |
|||
created_at: db.fn.now(), |
|||
updated_at: db.fn.now(), |
|||
}).returning("*") |
|||
} |
|||
|
|||
static async update(id, data, userId = null) { |
|||
let query = db("tags").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.update({ |
|||
...data, |
|||
updated_at: db.fn.now(), |
|||
}).returning("*") |
|||
} |
|||
|
|||
static async delete(id, userId = null) { |
|||
let query = db("tags").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.del() |
|||
} |
|||
|
|||
static async findByName(name, userId) { |
|||
return db("tags") |
|||
.where("name", name) |
|||
.where("user_id", userId) |
|||
.first() |
|||
} |
|||
|
|||
static async findOrCreate(name, userId, description = null) { |
|||
let tag = await this.findByName(name, userId) |
|||
if (!tag) { |
|||
tag = await this.create({ |
|||
name, |
|||
description, |
|||
user_id: userId, |
|||
}) |
|||
} |
|||
return tag |
|||
} |
|||
|
|||
static async getTagsWithBookmarkCount(userId) { |
|||
return db("tags") |
|||
.select("tags.*") |
|||
.select(db.raw("COUNT(DISTINCT bookmark_tags.bookmark_id) as bookmark_count")) |
|||
.leftJoin("bookmark_tags", "tags.id", "bookmark_tags.tag_id") |
|||
.where("tags.user_id", userId) |
|||
.groupBy("tags.id") |
|||
.orderBy("bookmark_count", "desc") |
|||
.orderBy("tags.name", "asc") |
|||
} |
|||
|
|||
static async searchTags(query, userId, limit = 10) { |
|||
return db("tags") |
|||
.where("user_id", userId) |
|||
.where("name", "like", `%${query}%`) |
|||
.limit(limit) |
|||
.orderBy("name", "asc") |
|||
} |
|||
|
|||
static async getPopularTags(userId, limit = 20) { |
|||
return db("tags") |
|||
.select("tags.*") |
|||
.select(db.raw("COUNT(bookmark_tags.bookmark_id) as usage_count")) |
|||
.leftJoin("bookmark_tags", "tags.id", "bookmark_tags.tag_id") |
|||
.where("tags.user_id", userId) |
|||
.groupBy("tags.id") |
|||
.orderBy("usage_count", "desc") |
|||
.orderBy("tags.name", "asc") |
|||
.limit(limit) |
|||
} |
|||
} |
|||
|
|||
export default TagModel |
|||
export { TagModel } |
@ -0,0 +1,310 @@ |
|||
/** |
|||
* @param { import("knex").Knex } knex |
|||
* @returns { Promise<void> } |
|||
*/ |
|||
export const seed = async knex => { |
|||
// 清空现有数据
|
|||
await knex("bookmark_history").del() |
|||
await knex("bookmark_links").del() |
|||
await knex("bookmark_tags").del() |
|||
await knex("bookmarks").del() |
|||
await knex("tags").del() |
|||
await knex("categories").del() |
|||
|
|||
// 插入分类数据
|
|||
const categories = await knex("categories").insert([ |
|||
{ |
|||
name: "技术开发", |
|||
description: "编程、开发工具、技术文档等", |
|||
color: "#3B82F6", |
|||
icon: "code", |
|||
sort_order: 1, |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "学习资源", |
|||
description: "在线课程、教程、学习平台等", |
|||
color: "#10B981", |
|||
icon: "graduation-cap", |
|||
sort_order: 2, |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "设计工具", |
|||
description: "UI/UX设计、图标、配色等工具", |
|||
color: "#F59E0B", |
|||
icon: "palette", |
|||
sort_order: 3, |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "效率工具", |
|||
description: "生产力工具、时间管理、项目管理等", |
|||
color: "#8B5CF6", |
|||
icon: "zap", |
|||
sort_order: 4, |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "娱乐休闲", |
|||
description: "游戏、视频、音乐等娱乐内容", |
|||
color: "#EC4899", |
|||
icon: "heart", |
|||
sort_order: 5, |
|||
user_id: 1, |
|||
}, |
|||
]).returning("*") |
|||
|
|||
// 插入标签数据
|
|||
const tags = await knex("tags").insert([ |
|||
{ |
|||
name: "JavaScript", |
|||
description: "JavaScript相关资源", |
|||
color: "#F7DF1E", |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "React", |
|||
description: "React框架相关", |
|||
color: "#61DAFB", |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "Node.js", |
|||
description: "Node.js相关资源", |
|||
color: "#339933", |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "CSS", |
|||
description: "CSS样式相关", |
|||
color: "#1572B6", |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "设计灵感", |
|||
description: "设计灵感和参考", |
|||
color: "#FF6B6B", |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "免费资源", |
|||
description: "免费的设计和开发资源", |
|||
color: "#4ECDC4", |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "API", |
|||
description: "各种API接口", |
|||
color: "#45B7D1", |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "数据库", |
|||
description: "数据库相关资源", |
|||
color: "#FFA500", |
|||
user_id: 1, |
|||
}, |
|||
]).returning("*") |
|||
|
|||
// 插入收藏数据
|
|||
const bookmarks = await knex("bookmarks").insert([ |
|||
{ |
|||
title: "MDN Web Docs", |
|||
description: "Mozilla开发者网络,提供Web技术文档和教程", |
|||
url: "https://developer.mozilla.org/", |
|||
favicon: "https://developer.mozilla.org/favicon-48x48.cbbd161b.png", |
|||
category_id: categories[0].id, // 技术开发
|
|||
user_id: 1, |
|||
is_public: true, |
|||
is_favorite: true, |
|||
click_count: 15, |
|||
sort_order: 1, |
|||
metadata: JSON.stringify({ |
|||
siteTitle: "MDN Web Docs", |
|||
siteDescription: "Learn web development", |
|||
keywords: ["web", "development", "documentation"] |
|||
}), |
|||
}, |
|||
{ |
|||
title: "GitHub", |
|||
description: "代码托管平台,全球最大的开源社区", |
|||
url: "https://github.com/", |
|||
favicon: "https://github.com/favicon.ico", |
|||
category_id: categories[0].id, // 技术开发
|
|||
user_id: 1, |
|||
is_public: true, |
|||
is_favorite: true, |
|||
click_count: 42, |
|||
sort_order: 2, |
|||
metadata: JSON.stringify({ |
|||
siteTitle: "GitHub: Let's build from here", |
|||
siteDescription: "GitHub is where over 100 million developers shape the future of software", |
|||
keywords: ["git", "code", "open source"] |
|||
}), |
|||
}, |
|||
{ |
|||
title: "Stack Overflow", |
|||
description: "程序员问答社区,解决编程问题的最佳平台", |
|||
url: "https://stackoverflow.com/", |
|||
favicon: "https://cdn.sstatic.net/Sites/stackoverflow/Img/favicon.ico", |
|||
category_id: categories[0].id, // 技术开发
|
|||
user_id: 1, |
|||
is_public: true, |
|||
is_favorite: false, |
|||
click_count: 28, |
|||
sort_order: 3, |
|||
}, |
|||
{ |
|||
title: "Udemy", |
|||
description: "在线学习平台,提供各种技能课程", |
|||
url: "https://www.udemy.com/", |
|||
favicon: "https://www.udemy.com/favicon-32x32.png", |
|||
category_id: categories[1].id, // 学习资源
|
|||
user_id: 1, |
|||
is_public: true, |
|||
is_favorite: false, |
|||
click_count: 8, |
|||
sort_order: 1, |
|||
}, |
|||
{ |
|||
title: "Figma", |
|||
description: "在线设计工具,支持团队协作的UI/UX设计平台", |
|||
url: "https://www.figma.com/", |
|||
favicon: "https://www.figma.com/favicon.ico", |
|||
category_id: categories[2].id, // 设计工具
|
|||
user_id: 1, |
|||
is_public: true, |
|||
is_favorite: true, |
|||
click_count: 35, |
|||
sort_order: 1, |
|||
}, |
|||
{ |
|||
title: "Notion", |
|||
description: "全能型工作台,笔记、文档、项目管理一体化", |
|||
url: "https://www.notion.so/", |
|||
favicon: "https://www.notion.so/images/favicon.ico", |
|||
category_id: categories[3].id, // 效率工具
|
|||
user_id: 1, |
|||
is_public: true, |
|||
is_favorite: true, |
|||
click_count: 22, |
|||
sort_order: 1, |
|||
}, |
|||
{ |
|||
title: "YouTube", |
|||
description: "全球最大的视频分享平台", |
|||
url: "https://www.youtube.com/", |
|||
favicon: "https://www.youtube.com/s/desktop/0d2c4a3b/img/favicon.ico", |
|||
category_id: categories[4].id, // 娱乐休闲
|
|||
user_id: 1, |
|||
is_public: true, |
|||
is_favorite: false, |
|||
click_count: 67, |
|||
sort_order: 1, |
|||
}, |
|||
]).returning("*") |
|||
|
|||
// 插入标签关联
|
|||
const bookmarkTags = [] |
|||
bookmarks.forEach((bookmark, index) => { |
|||
// 为每个收藏添加一些标签
|
|||
if (index === 0) { // MDN
|
|||
bookmarkTags.push( |
|||
{ bookmark_id: bookmark.id, tag_id: tags[0].id }, // JavaScript
|
|||
{ bookmark_id: bookmark.id, tag_id: tags[2].id }, // Node.js
|
|||
{ bookmark_id: bookmark.id, tag_id: tags[3].id } // CSS
|
|||
) |
|||
} else if (index === 1) { // GitHub
|
|||
bookmarkTags.push( |
|||
{ bookmark_id: bookmark.id, tag_id: tags[2].id }, // Node.js
|
|||
{ bookmark_id: bookmark.id, tag_id: tags[6].id } // API
|
|||
) |
|||
} else if (index === 2) { // Stack Overflow
|
|||
bookmarkTags.push( |
|||
{ bookmark_id: bookmark.id, tag_id: tags[0].id }, // JavaScript
|
|||
{ bookmark_id: bookmark.id, tag_id: tags[2].id } // Node.js
|
|||
) |
|||
} else if (index === 4) { // Figma
|
|||
bookmarkTags.push( |
|||
{ bookmark_id: bookmark.id, tag_id: tags[4].id }, // 设计灵感
|
|||
{ bookmark_id: bookmark.id, tag_id: tags[5].id } // 免费资源
|
|||
) |
|||
} |
|||
}) |
|||
|
|||
if (bookmarkTags.length > 0) { |
|||
await knex("bookmark_tags").insert(bookmarkTags) |
|||
} |
|||
|
|||
// 插入多链接数据
|
|||
const bookmarkLinks = [] |
|||
bookmarks.forEach((bookmark, index) => { |
|||
if (index === 0) { // MDN
|
|||
bookmarkLinks.push( |
|||
{ |
|||
bookmark_id: bookmark.id, |
|||
title: "JavaScript 教程", |
|||
url: "https://developer.mozilla.org/zh-CN/docs/Web/JavaScript", |
|||
description: "JavaScript 完整教程", |
|||
type: "tutorial", |
|||
sort_order: 1, |
|||
}, |
|||
{ |
|||
bookmark_id: bookmark.id, |
|||
title: "CSS 参考", |
|||
url: "https://developer.mozilla.org/zh-CN/docs/Web/CSS", |
|||
description: "CSS 属性参考手册", |
|||
type: "reference", |
|||
sort_order: 2, |
|||
} |
|||
) |
|||
} else if (index === 1) { // GitHub
|
|||
bookmarkLinks.push( |
|||
{ |
|||
bookmark_id: bookmark.id, |
|||
title: "GitHub Pages", |
|||
url: "https://pages.github.com/", |
|||
description: "免费托管静态网站", |
|||
type: "service", |
|||
sort_order: 1, |
|||
}, |
|||
{ |
|||
bookmark_id: bookmark.id, |
|||
title: "GitHub Actions", |
|||
url: "https://github.com/features/actions", |
|||
description: "自动化工作流", |
|||
type: "service", |
|||
sort_order: 2, |
|||
} |
|||
) |
|||
} |
|||
}) |
|||
|
|||
if (bookmarkLinks.length > 0) { |
|||
await knex("bookmark_links").insert(bookmarkLinks) |
|||
} |
|||
|
|||
// 插入一些访问历史
|
|||
const history = [] |
|||
bookmarks.forEach((bookmark) => { |
|||
// 为每个收藏添加一些访问记录
|
|||
for (let i = 0; i < Math.floor(Math.random() * 5) + 1; i++) { |
|||
history.push({ |
|||
bookmark_id: bookmark.id, |
|||
user_id: 1, |
|||
action: "visit", |
|||
context: JSON.stringify({ |
|||
referrer: "direct", |
|||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" |
|||
}), |
|||
}) |
|||
} |
|||
}) |
|||
|
|||
if (history.length > 0) { |
|||
await knex("bookmark_history").insert(history) |
|||
} |
|||
|
|||
console.log("收藏网站种子数据插入完成!") |
|||
} |
@ -1,19 +1,17 @@ |
|||
export const seed = async knex => { |
|||
// 检查表是否存在
|
|||
const tables = await knex.raw(` |
|||
SELECT name FROM sqlite_master WHERE type='table' AND name='users' |
|||
`)
|
|||
// 检查表是否存在
|
|||
const hasUsersTable = await knex.schema.hasTable('users'); |
|||
|
|||
if (tables.length === 0) { |
|||
console.error("表 users 不存在,请先执行迁移") |
|||
return |
|||
} |
|||
if (!hasUsersTable) { |
|||
console.error("表 users 不存在,请先执行迁移") |
|||
return |
|||
} |
|||
// Deletes ALL existing entries
|
|||
await knex("users").del() |
|||
|
|||
// Inserts seed entries
|
|||
await knex("users").insert([ |
|||
{ name: "Alice", email: "alice@example.com" }, |
|||
{ name: "Bob", email: "bob@example.com" }, |
|||
]) |
|||
// await knex("users").insert([
|
|||
// { username: "Alice", email: "alice@example.com" },
|
|||
// { username: "Bob", email: "bob@example.com" },
|
|||
// ])
|
|||
} |
|||
|
@ -0,0 +1,15 @@ |
|||
export const seed = async (knex) => { |
|||
// 删除所有已有配置
|
|||
await knex('site_config').del(); |
|||
|
|||
// 插入常用站点配置项
|
|||
await knex('site_config').insert([ |
|||
{ key: 'site_title', value: '罗非鱼的秘密' }, |
|||
{ key: 'site_author', value: '罗非鱼' }, |
|||
{ key: 'site_author_avatar', value: 'https://alist.xieyaxin.top/p/%E6%B8%B8%E5%AE%A2%E6%96%87%E4%BB%B6/%E5%85%AC%E5%85%B1%E4%BF%A1%E6%81%AF/avatar.jpg' }, |
|||
{ key: 'site_description', value: '一屋很小,却也很大' }, |
|||
{ key: 'site_logo', value: '/static/logo.png' }, |
|||
{ key: 'site_bg', value: '/static/bg.jpg' }, |
|||
{ key: 'keywords', value: 'blog' } |
|||
]); |
|||
}; |
@ -0,0 +1,11 @@ |
|||
import { jobLogger } from "@/logger" |
|||
|
|||
export default { |
|||
id: "example", |
|||
cronTime: "*/10 * * * * *", // 每10秒执行一次
|
|||
task: () => { |
|||
jobLogger.info("Example Job 执行了") |
|||
}, |
|||
options: {}, |
|||
autoStart: false, |
|||
} |
@ -0,0 +1,48 @@ |
|||
import fs from 'fs'; |
|||
import path from 'path'; |
|||
import scheduler from 'utils/scheduler.js'; |
|||
|
|||
const jobsDir = __dirname; |
|||
const jobModules = {}; |
|||
|
|||
fs.readdirSync(jobsDir).forEach(file => { |
|||
if (file === 'index.js' || !file.endsWith('Job.js')) return; |
|||
const jobModule = require(path.join(jobsDir, file)); |
|||
const job = jobModule.default || jobModule; |
|||
if (job && job.id && job.cronTime && typeof job.task === 'function') { |
|||
jobModules[job.id] = job; |
|||
scheduler.add(job.id, job.cronTime, job.task, job.options); |
|||
if (job.autoStart) scheduler.start(job.id); |
|||
} |
|||
}); |
|||
|
|||
function callHook(id, hookName) { |
|||
const job = jobModules[id]; |
|||
if (job && typeof job[hookName] === 'function') { |
|||
try { |
|||
job[hookName](); |
|||
} catch (e) { |
|||
console.error(`[Job:${id}] ${hookName} 执行异常:`, e); |
|||
} |
|||
} |
|||
} |
|||
|
|||
export default { |
|||
start: id => { |
|||
callHook(id, 'beforeStart'); |
|||
scheduler.start(id); |
|||
}, |
|||
stop: id => { |
|||
scheduler.stop(id); |
|||
callHook(id, 'afterStop'); |
|||
}, |
|||
updateCronTime: (id, cronTime) => scheduler.updateCronTime(id, cronTime), |
|||
list: () => scheduler.list(), |
|||
reload: id => { |
|||
const job = jobModules[id]; |
|||
if (job) { |
|||
scheduler.remove(id); |
|||
scheduler.add(job.id, job.cronTime, job.task, job.options); |
|||
} |
|||
} |
|||
}; |
@ -1,39 +1,63 @@ |
|||
import log4js from "log4js" |
|||
|
|||
import log4js from "log4js"; |
|||
|
|||
// 日志目录可通过环境变量 LOG_DIR 配置,默认 logs
|
|||
const LOG_DIR = process.env.LOG_DIR || "logs"; |
|||
|
|||
log4js.configure({ |
|||
appenders: { |
|||
debug: { |
|||
all: { |
|||
type: "file", |
|||
filename: "logs/debug.log", |
|||
filename: `${LOG_DIR}/all.log`, |
|||
maxLogSize: 102400, |
|||
pattern: "-yyyy-MM-dd.log", |
|||
alwaysIncludePattern: true, |
|||
backups: 3, |
|||
layout: { |
|||
type: 'pattern', |
|||
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m', |
|||
}, |
|||
}, |
|||
all: { |
|||
error: { |
|||
type: "file", |
|||
filename: "logs/all.log", |
|||
filename: `${LOG_DIR}/error.log`, |
|||
maxLogSize: 102400, |
|||
pattern: "-yyyy-MM-dd.log", |
|||
alwaysIncludePattern: true, |
|||
backups: 3, |
|||
layout: { |
|||
type: 'pattern', |
|||
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m', |
|||
}, |
|||
}, |
|||
error: { |
|||
jobs: { |
|||
type: "file", |
|||
filename: "logs/error.log", |
|||
filename: `${LOG_DIR}/jobs.log`, |
|||
maxLogSize: 102400, |
|||
pattern: "-yyyy-MM-dd.log", |
|||
alwaysIncludePattern: true, |
|||
backups: 3, |
|||
layout: { |
|||
type: 'pattern', |
|||
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m', |
|||
}, |
|||
}, |
|||
console: { |
|||
type: "console", |
|||
layout: { type: "colored" }, |
|||
layout: { |
|||
type: "pattern", |
|||
pattern: '\x1b[36m[%d{yyyy-MM-dd hh:mm:ss}]\x1b[0m \x1b[1m[%p]\x1b[0m %m', |
|||
}, |
|||
}, |
|||
}, |
|||
categories: { |
|||
jobs: { appenders: ["console", "jobs"], level: "info" }, |
|||
error: { appenders: ["console", "error"], level: "error" }, |
|||
default: { appenders: ["console", "all"], level: "ALL" }, |
|||
debug: { appenders: ["debug"], level: "debug" }, |
|||
default: { appenders: ["console", "all", "error"], level: "all" }, |
|||
}, |
|||
}) |
|||
}); |
|||
|
|||
// 导出常用 logger 实例,便于直接引用
|
|||
export const logger = log4js.getLogger(); // default
|
|||
export const jobLogger = log4js.getLogger('jobs'); |
|||
export const errorLogger = log4js.getLogger('error'); |
|||
|
@ -0,0 +1,73 @@ |
|||
import { logger } from "@/logger" |
|||
import jwt from "./jwt" |
|||
import { minimatch } from "minimatch" |
|||
|
|||
export const JWT_SECRET = process.env.JWT_SECRET || "jwt-demo-secret" |
|||
|
|||
function matchList(list, path) { |
|||
for (const item of list) { |
|||
if (typeof item === "string" && minimatch(path, item)) { |
|||
return { matched: true, auth: false } |
|||
} |
|||
if (typeof item === "object" && minimatch(path, item.pattern)) { |
|||
return { matched: true, auth: item.auth } |
|||
} |
|||
} |
|||
return { matched: false } |
|||
} |
|||
|
|||
function verifyToken(ctx) { |
|||
let token = ctx.headers["authorization"]?.replace(/^Bearer\s/, "") |
|||
if (!token) { |
|||
return { ok: false, status: -1 } |
|||
} |
|||
try { |
|||
ctx.state.user = jwt.verify(token, JWT_SECRET) |
|||
return { ok: true } |
|||
} catch { |
|||
ctx.state.user = undefined |
|||
return { ok: false } |
|||
} |
|||
} |
|||
|
|||
export default function authMiddleware(options = { |
|||
whiteList: [], |
|||
blackList: [] |
|||
}) { |
|||
return async (ctx, next) => { |
|||
if(ctx.session.user) { |
|||
ctx.state.user = ctx.session.user |
|||
} |
|||
// 黑名单优先生效
|
|||
if (matchList(options.blackList, ctx.path).matched) { |
|||
ctx.status = 403 |
|||
ctx.body = { success: false, error: "禁止访问" } |
|||
return |
|||
} |
|||
// 白名单处理
|
|||
const white = matchList(options.whiteList, ctx.path) |
|||
if (white.matched) { |
|||
if (white.auth === false) { |
|||
return await next() |
|||
} |
|||
if (white.auth === "try") { |
|||
verifyToken(ctx) |
|||
return await next() |
|||
} |
|||
// true 或其他情况,必须有token
|
|||
if (!verifyToken(ctx).ok) { |
|||
ctx.status = 401 |
|||
ctx.body = { success: false, error: "未登录或token缺失或无效" } |
|||
return |
|||
} |
|||
return await next() |
|||
} |
|||
// 非白名单,必须有token
|
|||
if (!verifyToken(ctx).ok) { |
|||
ctx.status = 401 |
|||
ctx.body = { success: false, error: "未登录或token缺失或无效" } |
|||
return |
|||
} |
|||
await next() |
|||
} |
|||
} |
@ -0,0 +1,3 @@ |
|||
// 统一导出所有中间件
|
|||
import auth from "./auth.js" |
|||
export { auth } |
@ -0,0 +1,3 @@ |
|||
// 兼容性导出,便于后续扩展
|
|||
import jwt from "jsonwebtoken" |
|||
export default jwt |
@ -0,0 +1,63 @@ |
|||
import { logger } from "@/logger" |
|||
|
|||
// 静态资源扩展名列表
|
|||
const staticExts = [".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".map", ".woff", ".woff2", ".ttf", ".eot"] |
|||
|
|||
function isStaticResource(path) { |
|||
return staticExts.some(ext => path.endsWith(ext)) |
|||
} |
|||
|
|||
/** |
|||
* 响应时间记录中间件 |
|||
* @param {Object} ctx - Koa上下文对象 |
|||
* @param {Function} next - Koa中间件链函数 |
|||
*/ |
|||
export default async (ctx, next) => { |
|||
if (isStaticResource(ctx.path)) { |
|||
await next() |
|||
return |
|||
} |
|||
if (!ctx.path.includes("/api")) { |
|||
const start = Date.now() |
|||
await next() |
|||
const ms = Date.now() - start |
|||
ctx.set("X-Response-Time", `${ms}ms`) |
|||
if (ms > 500) { |
|||
logger.info(`${ctx.path} | ⏱️ ${ms}ms`) |
|||
} |
|||
return |
|||
} |
|||
// API日志记录
|
|||
const start = Date.now() |
|||
await next() |
|||
const ms = Date.now() - start |
|||
ctx.set("X-Response-Time", `${ms}ms`) |
|||
const Threshold = 0 |
|||
if (ms > Threshold) { |
|||
logger.info("====================[➡️REQ]====================") |
|||
// 用户信息(假设ctx.state.user存在)
|
|||
const user = ctx.state && ctx.state.user ? ctx.state.user : null |
|||
// IP
|
|||
const ip = ctx.ip || ctx.request.ip || ctx.headers["x-forwarded-for"] || ctx.req.connection.remoteAddress |
|||
// 请求参数
|
|||
const params = { |
|||
query: ctx.query, |
|||
body: ctx.request.body, |
|||
} |
|||
// 响应状态码
|
|||
const status = ctx.status |
|||
// 组装日志对象
|
|||
const logObj = { |
|||
method: ctx.method, |
|||
path: ctx.path, |
|||
url: ctx.url, |
|||
user: user ? { id: user.id, username: user.username } : null, |
|||
ip, |
|||
params, |
|||
status, |
|||
ms, |
|||
} |
|||
logger.info(JSON.stringify(logObj, null, 2)) |
|||
logger.info("====================[⬅️END]====================\n") |
|||
} |
|||
} |
@ -0,0 +1,185 @@ |
|||
/** |
|||
* koa-send@5.0.1 转换为ES Module版本 |
|||
* 静态资源服务中间件 |
|||
*/ |
|||
import fs from 'fs'; |
|||
import { promisify } from 'util'; |
|||
import logger from 'log4js'; |
|||
import resolvePath from './resolve-path.js'; |
|||
import createError from 'http-errors'; |
|||
import assert from 'assert'; |
|||
import { normalize, basename, extname, resolve, parse, sep } from 'path'; |
|||
import { fileURLToPath } from 'url'; |
|||
import path from "path" |
|||
|
|||
// 转换为ES Module格式
|
|||
const log = logger.getLogger('koa-send'); |
|||
const stat = promisify(fs.stat); |
|||
const access = promisify(fs.access); |
|||
const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
|||
|
|||
/** |
|||
* 检查文件是否存在 |
|||
* @param {string} path - 文件路径 |
|||
* @returns {Promise<boolean>} 文件是否存在 |
|||
*/ |
|||
async function exists(path) { |
|||
try { |
|||
await access(path); |
|||
return true; |
|||
} catch (e) { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送文件给客户端 |
|||
* @param {Context} ctx - Koa上下文对象 |
|||
* @param {String} path - 文件路径 |
|||
* @param {Object} [opts] - 配置选项 |
|||
* @returns {Promise} - 异步Promise |
|||
*/ |
|||
async function send(ctx, path, opts = {}) { |
|||
assert(ctx, 'koa context required'); |
|||
assert(path, 'pathname required'); |
|||
|
|||
// 移除硬编码的public目录,要求必须通过opts.root配置
|
|||
const root = opts.root; |
|||
if (!root) { |
|||
throw new Error('Static root directory must be configured via opts.root'); |
|||
} |
|||
const trailingSlash = path[path.length - 1] === '/'; |
|||
path = path.substr(parse(path).root.length); |
|||
const index = opts.index || 'index.html'; |
|||
const maxage = opts.maxage || opts.maxAge || 0; |
|||
const immutable = opts.immutable || false; |
|||
const hidden = opts.hidden || false; |
|||
const format = opts.format !== false; |
|||
const extensions = Array.isArray(opts.extensions) ? opts.extensions : false; |
|||
const brotli = opts.brotli !== false; |
|||
const gzip = opts.gzip !== false; |
|||
const setHeaders = opts.setHeaders; |
|||
|
|||
if (setHeaders && typeof setHeaders !== 'function') { |
|||
throw new TypeError('option setHeaders must be function'); |
|||
} |
|||
|
|||
// 解码路径
|
|||
path = decode(path); |
|||
if (path === -1) return ctx.throw(400, 'failed to decode'); |
|||
|
|||
// 索引文件支持
|
|||
if (index && trailingSlash) path += index; |
|||
|
|||
path = resolvePath(root, path); |
|||
|
|||
// 隐藏文件支持
|
|||
if (!hidden && isHidden(root, path)) return; |
|||
|
|||
let encodingExt = ''; |
|||
// 尝试提供压缩文件
|
|||
if (ctx.acceptsEncodings('br', 'identity') === 'br' && brotli && (await exists(path + '.br'))) { |
|||
path = path + '.br'; |
|||
ctx.set('Content-Encoding', 'br'); |
|||
ctx.res.removeHeader('Content-Length'); |
|||
encodingExt = '.br'; |
|||
} else if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip' && gzip && (await exists(path + '.gz'))) { |
|||
path = path + '.gz'; |
|||
ctx.set('Content-Encoding', 'gzip'); |
|||
ctx.res.removeHeader('Content-Length'); |
|||
encodingExt = '.gz'; |
|||
} |
|||
|
|||
// 尝试添加文件扩展名
|
|||
if (extensions && !/\./.exec(basename(path))) { |
|||
const list = [].concat(extensions); |
|||
for (let i = 0; i < list.length; i++) { |
|||
let ext = list[i]; |
|||
if (typeof ext !== 'string') { |
|||
throw new TypeError('option extensions must be array of strings or false'); |
|||
} |
|||
if (!/^\./.exec(ext)) ext = `.${ext}`; |
|||
if (await exists(`${path}${ext}`)) { |
|||
path = `${path}${ext}`; |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 获取文件状态
|
|||
let stats; |
|||
try { |
|||
stats = await stat(path); |
|||
|
|||
// 处理目录
|
|||
if (stats.isDirectory()) { |
|||
if (format && index) { |
|||
path += `/${index}`; |
|||
stats = await stat(path); |
|||
} else { |
|||
return; |
|||
} |
|||
} |
|||
} catch (err) { |
|||
const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']; |
|||
if (notfound.includes(err.code)) { |
|||
throw createError(404, err); |
|||
} |
|||
err.status = 500; |
|||
throw err; |
|||
} |
|||
|
|||
if (setHeaders) setHeaders(ctx.res, path, stats); |
|||
|
|||
// 设置响应头
|
|||
ctx.set('Content-Length', stats.size); |
|||
if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString()); |
|||
if (!ctx.response.get('Cache-Control')) { |
|||
const directives = [`max-age=${(maxage / 1000) | 0}`]; |
|||
if (immutable) directives.push('immutable'); |
|||
ctx.set('Cache-Control', directives.join(',')); |
|||
} |
|||
if (!ctx.type) ctx.type = type(path, encodingExt); |
|||
ctx.body = fs.createReadStream(path); |
|||
|
|||
return path; |
|||
} |
|||
|
|||
/** |
|||
* 检查是否为隐藏文件 |
|||
* @param {string} root - 根目录 |
|||
* @param {string} path - 文件路径 |
|||
* @returns {boolean} 是否为隐藏文件 |
|||
*/ |
|||
function isHidden(root, path) { |
|||
path = path.substr(root.length).split(sep); |
|||
for (let i = 0; i < path.length; i++) { |
|||
if (path[i][0] === '.') return true; |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
/** |
|||
* 获取文件类型 |
|||
* @param {string} file - 文件路径 |
|||
* @param {string} ext - 编码扩展名 |
|||
* @returns {string} 文件MIME类型 |
|||
*/ |
|||
function type(file, ext) { |
|||
return ext !== '' ? extname(basename(file, ext)) : extname(file); |
|||
} |
|||
|
|||
/** |
|||
* 解码URL路径 |
|||
* @param {string} path - 需要解码的路径 |
|||
* @returns {string|number} 解码后的路径或错误代码 |
|||
*/ |
|||
function decode(path) { |
|||
try { |
|||
return decodeURIComponent(path); |
|||
} catch (err) { |
|||
return -1; |
|||
} |
|||
} |
|||
|
|||
export default send; |
@ -0,0 +1,74 @@ |
|||
/*! |
|||
* resolve-path |
|||
* Copyright(c) 2014 Jonathan Ong |
|||
* Copyright(c) 2015-2018 Douglas Christopher Wilson |
|||
* MIT Licensed |
|||
*/ |
|||
|
|||
/** |
|||
* ES Module 转换版本 |
|||
* 路径解析工具,防止路径遍历攻击 |
|||
*/ |
|||
import createError from 'http-errors'; |
|||
import { join, normalize, resolve, sep } from 'path'; |
|||
import pathIsAbsolute from 'path-is-absolute'; |
|||
|
|||
/** |
|||
* 模块变量 |
|||
* @private |
|||
*/ |
|||
const UP_PATH_REGEXP = /(?:^|[\/])\.\.(?:[\/]|$)/; |
|||
|
|||
/** |
|||
* 解析相对路径到根路径 |
|||
* @param {string} rootPath - 根目录路径 |
|||
* @param {string} relativePath - 相对路径 |
|||
* @returns {string} 解析后的绝对路径 |
|||
* @public |
|||
*/ |
|||
function resolvePath(rootPath, relativePath) { |
|||
let path = relativePath; |
|||
let root = rootPath; |
|||
|
|||
// root是可选的,类似于root.resolve
|
|||
if (arguments.length === 1) { |
|||
path = rootPath; |
|||
root = process.cwd(); |
|||
} |
|||
|
|||
if (root == null) { |
|||
throw new TypeError('argument rootPath is required'); |
|||
} |
|||
|
|||
if (typeof root !== 'string') { |
|||
throw new TypeError('argument rootPath must be a string'); |
|||
} |
|||
|
|||
if (path == null) { |
|||
throw new TypeError('argument relativePath is required'); |
|||
} |
|||
|
|||
if (typeof path !== 'string') { |
|||
throw new TypeError('argument relativePath must be a string'); |
|||
} |
|||
|
|||
// 包含NULL字节是恶意的
|
|||
if (path.indexOf('\0') !== -1) { |
|||
throw createError(400, 'Malicious Path'); |
|||
} |
|||
|
|||
// 路径绝不能是绝对路径
|
|||
if (pathIsAbsolute.posix(path) || pathIsAbsolute.win32(path)) { |
|||
throw createError(400, 'Malicious Path'); |
|||
} |
|||
|
|||
// 路径超出根目录
|
|||
if (UP_PATH_REGEXP.test(normalize('.' + sep + path))) { |
|||
throw createError(403); |
|||
} |
|||
|
|||
// 拼接相对路径
|
|||
return normalize(join(resolve(root), path)); |
|||
} |
|||
|
|||
export default resolvePath; |
@ -0,0 +1,14 @@ |
|||
import session from 'koa-session'; |
|||
|
|||
export default (app) => { |
|||
const CONFIG = { |
|||
key: 'koa:sess', // cookie key
|
|||
maxAge: 86400000, // 1天
|
|||
httpOnly: true, |
|||
signed: true, |
|||
rolling: false, |
|||
renew: false, |
|||
}; |
|||
app.keys = app.keys || ['koa3-demo-session-secret']; |
|||
return session(CONFIG, app); |
|||
}; |
@ -0,0 +1,14 @@ |
|||
export default function ToastMiddlewares() { |
|||
return function toast(ctx, next) { |
|||
if (ctx.toast) return next() |
|||
// error success info
|
|||
ctx.toast = function (type, message) { |
|||
ctx.cookies.set("toast", JSON.stringify({ type: type, message: encodeURIComponent(message) }), { |
|||
maxAge: 1, |
|||
httpOnly: false, |
|||
path: "/", |
|||
}) |
|||
} |
|||
return next() |
|||
} |
|||
} |
@ -0,0 +1,75 @@ |
|||
import { resolve } from "path" |
|||
import consolidate from "consolidate" |
|||
import send from "../Send" |
|||
import getPaths from "get-paths" |
|||
// import pretty from "pretty"
|
|||
import { logger } from "@/logger" |
|||
import SiteConfigService from "services/SiteConfigService.js" |
|||
import assign from "lodash/assign" |
|||
import config from "config/index.js" |
|||
|
|||
export default viewsMiddleware |
|||
|
|||
function viewsMiddleware(path, { engineSource = consolidate, extension = "html", options = {}, map } = {}) { |
|||
const siteConfigService = new SiteConfigService() |
|||
|
|||
return function views(ctx, next) { |
|||
if (ctx.render) return next() |
|||
|
|||
// 将 render 注入到 context 和 response 对象中
|
|||
ctx.response.render = ctx.render = function (relPath, locals = {}, renderOptions) { |
|||
renderOptions = assign({ includeSite: true, includeUser: false }, renderOptions || {}) |
|||
return getPaths(path, relPath, extension).then(async paths => { |
|||
const suffix = paths.ext |
|||
const site = await siteConfigService.getAll() |
|||
const otherData = { |
|||
currentPath: ctx.path, |
|||
$config: config, |
|||
isLogin: !!ctx.state && !!ctx.state.user, |
|||
} |
|||
if (renderOptions.includeSite) { |
|||
otherData.$site = site |
|||
} |
|||
if (renderOptions.includeUser && ctx.state && ctx.state.user) { |
|||
otherData.$user = ctx.state.user |
|||
} |
|||
const state = assign({}, otherData, locals, options, ctx.state || {}) |
|||
// deep copy partials
|
|||
state.partials = assign({}, options.partials || {}) |
|||
// logger.debug("render `%s` with %j", paths.rel, state)
|
|||
ctx.type = "text/html" |
|||
|
|||
// 如果是 html 文件,不编译直接 send 静态文件
|
|||
if (isHtml(suffix) && !map) { |
|||
return send(ctx, paths.rel, { |
|||
root: path, |
|||
}) |
|||
} else { |
|||
const engineName = map && map[suffix] ? map[suffix] : suffix |
|||
|
|||
// 使用 engineSource 配置的渲染引擎 render
|
|||
const render = engineSource[engineName] |
|||
|
|||
if (!engineName || !render) return Promise.reject(new Error(`Engine not found for the ".${suffix}" file extension`)) |
|||
|
|||
return render(resolve(path, paths.rel), state).then(html => { |
|||
// since pug has deprecated `pretty` option
|
|||
// we'll use the `pretty` package in the meanwhile
|
|||
// if (locals.pretty) {
|
|||
// debug("using `pretty` package to beautify HTML")
|
|||
// html = pretty(html)
|
|||
// }
|
|||
ctx.body = html |
|||
}) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// 中间件执行结束
|
|||
return next() |
|||
} |
|||
} |
|||
|
|||
function isHtml(ext) { |
|||
return ext === "html" |
|||
} |
@ -0,0 +1,53 @@ |
|||
import { logger } from "@/logger" |
|||
import CommonError from "utils/error/CommonError" |
|||
// src/plugins/errorHandler.js
|
|||
// 错误处理中间件插件
|
|||
|
|||
async function formatError(ctx, status, message, stack) { |
|||
const accept = ctx.accepts("json", "html", "text") |
|||
const isDev = process.env.NODE_ENV === "development" |
|||
if (accept === "json") { |
|||
ctx.type = "application/json" |
|||
ctx.body = isDev && stack ? { success: false, error: message, stack } : { success: false, error: message } |
|||
} else if (accept === "html") { |
|||
ctx.type = "html" |
|||
await ctx.render("error/index", { status, message, stack, isDev }) |
|||
} else { |
|||
ctx.type = "text" |
|||
ctx.body = isDev && stack ? `${status} - ${message}\n${stack}` : `${status} - ${message}` |
|||
} |
|||
ctx.status = status |
|||
} |
|||
|
|||
export default function errorHandler() { |
|||
return async (ctx, next) => { |
|||
// 拦截 Chrome DevTools 探测请求,直接返回 204
|
|||
if (ctx.path === "/.well-known/appspecific/com.chrome.devtools.json") { |
|||
ctx.status = 204 |
|||
ctx.body = "" |
|||
return |
|||
} |
|||
try { |
|||
await next() |
|||
if (ctx.status === 404) { |
|||
await formatError(ctx, 404, "Resource not found") |
|||
} |
|||
} catch (err) { |
|||
logger.error(err) |
|||
const isDev = process.env.NODE_ENV === "development" |
|||
if (isDev && err.stack) { |
|||
console.error(err.stack) |
|||
} |
|||
if (err instanceof CommonError) { |
|||
ctx.cookies.set("toast", JSON.stringify({ type: "error", message: encodeURIComponent(err.message) }), { |
|||
maxAge: 1, |
|||
httpOnly: false, |
|||
path: "/", |
|||
}) |
|||
ctx.redirect(ctx.path+"?msg="+err.message) |
|||
return |
|||
} |
|||
await formatError(ctx, err.statusCode || 500, err.message || err || "Internal server error", isDev ? err.stack : undefined) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,55 @@ |
|||
import ResponseTime from "./ResponseTime" |
|||
import Send from "./Send" |
|||
import { resolve } from "path" |
|||
import { fileURLToPath } from "url" |
|||
import path from "path" |
|||
import ErrorHandler from "./ErrorHandler" |
|||
import { auth } from "./Auth" |
|||
import bodyParser from "koa-bodyparser" |
|||
import Views from "./Views" |
|||
import Session from "./Session" |
|||
import Toast from "./Toast" |
|||
import { autoRegisterControllers } from "@/utils/ForRegister.js" |
|||
|
|||
const __dirname = path.dirname(fileURLToPath(import.meta.url)) |
|||
const publicPath = resolve(__dirname, "../../public") |
|||
|
|||
export default app => { |
|||
app.use(Toast()) |
|||
app.use(ErrorHandler()) |
|||
app.use(ResponseTime) |
|||
app.use(Session(app)); |
|||
app.use( |
|||
auth({ |
|||
whiteList: [ |
|||
// 所有请求放行
|
|||
{ pattern: "/", auth: false }, |
|||
{ pattern: "/**/*", auth: false }, |
|||
], |
|||
blackList: [ |
|||
// 禁用api请求
|
|||
"/api", |
|||
"/api/", |
|||
"/api/**/*", |
|||
], |
|||
}) |
|||
) |
|||
app.use(bodyParser()) |
|||
app.use( |
|||
Views(resolve(__dirname, "../views"), { |
|||
extension: "pug", |
|||
options: { |
|||
basedir: resolve(__dirname, "../views"), |
|||
} |
|||
}) |
|||
) |
|||
autoRegisterControllers(app) |
|||
app.use(async (ctx, next) => { |
|||
try { |
|||
await Send(ctx, ctx.path, { root: publicPath }) |
|||
} catch (err) { |
|||
if (err.status !== 404) throw err |
|||
} |
|||
await next() |
|||
}) |
|||
} |
@ -1,13 +0,0 @@ |
|||
import log4js from "log4js" |
|||
|
|||
const logger = log4js.getLogger() |
|||
|
|||
export default async (ctx, next) => { |
|||
logger.debug("::in:: %s %s", ctx.method, ctx.path) |
|||
const start = Date.now() |
|||
await next() |
|||
const ms = Date.now() - start |
|||
ctx.set("X-Response-Time", `${ms}ms`) |
|||
const rt = ctx.response.get("X-Response-Time") |
|||
logger.debug(`::out:: takes ${rt} for ${ctx.method} ${ctx.url}`) |
|||
} |
@ -1,5 +0,0 @@ |
|||
import ResponseTime from "./ResponseTime"; |
|||
|
|||
export default (app)=>{ |
|||
app.use(ResponseTime) |
|||
} |
@ -0,0 +1,415 @@ |
|||
import BookmarkModel from "../db/models/BookmarkModel.js" |
|||
import CategoryModel from "../db/models/CategoryModel.js" |
|||
import TagModel from "../db/models/TagModel.js" |
|||
|
|||
class BookmarkService { |
|||
/** |
|||
* 创建新收藏 |
|||
*/ |
|||
static async createBookmark(data) { |
|||
try { |
|||
// 验证必填字段
|
|||
if (!data.title || !data.url || !data.user_id) { |
|||
throw new Error("标题、URL和用户ID是必填字段") |
|||
} |
|||
|
|||
// 验证URL格式
|
|||
try { |
|||
new URL(data.url) |
|||
} catch (error) { |
|||
throw new Error("无效的URL格式") |
|||
} |
|||
|
|||
// 如果指定了分类,验证分类是否存在
|
|||
if (data.category_id) { |
|||
const category = await CategoryModel.findById(data.category_id, data.user_id) |
|||
if (!category) { |
|||
throw new Error("指定的分类不存在") |
|||
} |
|||
} |
|||
|
|||
// 创建收藏
|
|||
const bookmark = await BookmarkModel.create(data) |
|||
|
|||
// 获取完整的收藏信息(包含分类和标签)
|
|||
const fullBookmark = await BookmarkModel.findById(bookmark.id, data.user_id) |
|||
const tags = await BookmarkModel.getTags(bookmark.id) |
|||
const links = await BookmarkModel.getLinks(bookmark.id) |
|||
|
|||
return { |
|||
...fullBookmark, |
|||
tags, |
|||
links, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`创建收藏失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 更新收藏 |
|||
*/ |
|||
static async updateBookmark(id, data, userId) { |
|||
try { |
|||
// 验证收藏是否存在
|
|||
const existingBookmark = await BookmarkModel.findById(id, userId) |
|||
if (!existingBookmark) { |
|||
throw new Error("收藏不存在或无权限访问") |
|||
} |
|||
|
|||
// 如果更新分类,验证分类是否存在
|
|||
if (data.category_id && data.category_id !== existingBookmark.category_id) { |
|||
const category = await CategoryModel.findById(data.category_id, userId) |
|||
if (!category) { |
|||
throw new Error("指定的分类不存在") |
|||
} |
|||
} |
|||
|
|||
// 更新收藏
|
|||
const updatedBookmark = await BookmarkModel.update(id, data, userId) |
|||
|
|||
// 获取完整的更新后信息
|
|||
const fullBookmark = await BookmarkModel.findById(id, userId) |
|||
const tags = await BookmarkModel.getTags(id) |
|||
const links = await BookmarkModel.getLinks(id) |
|||
|
|||
return { |
|||
...fullBookmark, |
|||
tags, |
|||
links, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`更新收藏失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 删除收藏 |
|||
*/ |
|||
static async deleteBookmark(id, userId) { |
|||
try { |
|||
const bookmark = await BookmarkModel.findById(id, userId) |
|||
if (!bookmark) { |
|||
throw new Error("收藏不存在或无权限访问") |
|||
} |
|||
|
|||
await BookmarkModel.delete(id, userId) |
|||
return { success: true, message: "收藏删除成功" } |
|||
} catch (error) { |
|||
throw new Error(`删除收藏失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取收藏列表 |
|||
*/ |
|||
static async getBookmarks(userId, options = {}) { |
|||
try { |
|||
const { page = 1, limit = 12, ...filterOptions } = options |
|||
|
|||
// 计算分页参数
|
|||
const offset = (page - 1) * limit |
|||
const paginationOptions = { ...filterOptions, limit, offset } |
|||
|
|||
const bookmarks = await BookmarkModel.findAll(userId, paginationOptions) |
|||
|
|||
// 为每个收藏添加标签和链接信息
|
|||
const enrichedBookmarks = await Promise.all( |
|||
bookmarks.map(async (bookmark) => { |
|||
const tags = await BookmarkModel.getTags(bookmark.id) |
|||
const links = await BookmarkModel.getLinks(bookmark.id) |
|||
return { ...bookmark, tags, links } |
|||
}) |
|||
) |
|||
|
|||
// 获取总数用于分页
|
|||
const totalBookmarks = await BookmarkModel.findAll(userId, filterOptions) |
|||
const total = totalBookmarks.length |
|||
|
|||
return { |
|||
bookmarks: enrichedBookmarks, |
|||
pagination: { |
|||
page, |
|||
limit, |
|||
total, |
|||
totalPages: Math.ceil(total / limit), |
|||
hasNext: page * limit < total, |
|||
hasPrev: page > 1 |
|||
}, |
|||
total |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`获取收藏列表失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取单个收藏详情 |
|||
*/ |
|||
static async getBookmarkById(id, userId) { |
|||
try { |
|||
const bookmark = await BookmarkModel.findById(id, userId) |
|||
if (!bookmark) { |
|||
throw new Error("收藏不存在或无权限访问") |
|||
} |
|||
|
|||
const tags = await BookmarkModel.getTags(id) |
|||
const links = await BookmarkModel.getLinks(id) |
|||
|
|||
return { |
|||
...bookmark, |
|||
tags, |
|||
links, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`获取收藏详情失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 搜索收藏 |
|||
*/ |
|||
static async searchBookmarks(query, userId, options = {}) { |
|||
try { |
|||
if (!query || query.trim().length === 0) { |
|||
throw new Error("搜索关键词不能为空") |
|||
} |
|||
|
|||
const bookmarks = await BookmarkModel.searchBookmarks(query, userId, options) |
|||
|
|||
// 为搜索结果添加标签和链接信息
|
|||
const enrichedBookmarks = await Promise.all( |
|||
bookmarks.map(async (bookmark) => { |
|||
const tags = await BookmarkModel.getTags(bookmark.id) |
|||
const links = await BookmarkModel.getLinks(bookmark.id) |
|||
return { ...bookmark, tags, links } |
|||
}) |
|||
) |
|||
|
|||
return enrichedBookmarks |
|||
} catch (error) { |
|||
throw new Error(`搜索收藏失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 按分类获取收藏 |
|||
*/ |
|||
static async getBookmarksByCategory(categoryId, userId, options = {}) { |
|||
try { |
|||
// 验证分类是否存在
|
|||
const category = await CategoryModel.findById(categoryId, userId) |
|||
if (!category) { |
|||
throw new Error("分类不存在") |
|||
} |
|||
|
|||
const bookmarks = await BookmarkModel.getBookmarksByCategory(categoryId, userId, options.limit) |
|||
|
|||
// 为每个收藏添加标签和链接信息
|
|||
const enrichedBookmarks = await Promise.all( |
|||
bookmarks.map(async (bookmark) => { |
|||
const tags = await BookmarkModel.getTags(bookmark.id) |
|||
const links = await BookmarkModel.getLinks(bookmark.id) |
|||
return { ...bookmark, tags, links } |
|||
}) |
|||
) |
|||
|
|||
return { |
|||
category, |
|||
bookmarks: enrichedBookmarks, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`获取分类收藏失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 按标签获取收藏 |
|||
*/ |
|||
static async getBookmarksByTag(tagId, userId, options = {}) { |
|||
try { |
|||
// 验证标签是否存在
|
|||
const tag = await TagModel.findById(tagId, userId) |
|||
if (!tag) { |
|||
throw new Error("标签不存在") |
|||
} |
|||
|
|||
const bookmarks = await BookmarkModel.getBookmarksByTag(tagId, userId, options.limit) |
|||
|
|||
// 为每个收藏添加标签和链接信息
|
|||
const enrichedBookmarks = await Promise.all( |
|||
bookmarks.map(async (bookmark) => { |
|||
const tags = await BookmarkModel.getTags(bookmark.id) |
|||
const links = await BookmarkModel.getLinks(bookmark.id) |
|||
return { ...bookmark, tags, links } |
|||
}) |
|||
) |
|||
|
|||
return { |
|||
tag, |
|||
bookmarks: enrichedBookmarks, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`获取标签收藏失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取收藏统计信息 |
|||
*/ |
|||
static async getBookmarkStats(userId) { |
|||
try { |
|||
const stats = await BookmarkModel.getStats(userId) |
|||
const categories = await CategoryModel.getCategoryStats(userId) |
|||
const popularTags = await TagModel.getPopularTags(userId, 10) |
|||
|
|||
return { |
|||
...stats, |
|||
categories, |
|||
popularTags, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`获取统计信息失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取所有分类 |
|||
*/ |
|||
static async getCategories(userId) { |
|||
try { |
|||
return await CategoryModel.findAll(userId) |
|||
} catch (error) { |
|||
throw new Error(`获取分类失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取所有标签 |
|||
*/ |
|||
static async getTags(userId) { |
|||
try { |
|||
return await TagModel.findAll(userId) |
|||
} catch (error) { |
|||
throw new Error(`获取标签失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取热门标签 |
|||
*/ |
|||
static async getPopularTags(userId, limit = 20) { |
|||
try { |
|||
return await TagModel.getPopularTags(userId, limit) |
|||
} catch (error) { |
|||
throw new Error(`获取热门标签失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取收藏的快捷方式 |
|||
*/ |
|||
static async getQuickAccess(userId) { |
|||
try { |
|||
const [favorites, recent, popular] = await Promise.all([ |
|||
BookmarkModel.getFavorites(userId, 5), |
|||
BookmarkModel.getRecentBookmarks(userId, 5), |
|||
BookmarkModel.getPopularBookmarks(userId, 5), |
|||
]) |
|||
|
|||
return { |
|||
favorites, |
|||
recent, |
|||
popular, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`获取快捷访问失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 增加点击次数 |
|||
*/ |
|||
static async incrementClickCount(id, userId) { |
|||
try { |
|||
const bookmark = await BookmarkModel.findById(id, userId) |
|||
if (!bookmark) { |
|||
throw new Error("收藏不存在或无权限访问") |
|||
} |
|||
|
|||
await BookmarkModel.incrementClickCount(id) |
|||
return { success: true, message: "点击次数已更新" } |
|||
} catch (error) { |
|||
throw new Error(`更新点击次数失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 切换收藏状态 |
|||
*/ |
|||
static async toggleFavorite(id, userId) { |
|||
try { |
|||
const bookmark = await BookmarkModel.findById(id, userId) |
|||
if (!bookmark) { |
|||
throw new Error("收藏不存在或无权限访问") |
|||
} |
|||
|
|||
const updatedBookmark = await BookmarkModel.toggleFavorite(id, userId) |
|||
return { |
|||
success: true, |
|||
message: `已${updatedBookmark.is_favorite ? '添加到' : '从'}特别收藏`, |
|||
is_favorite: updatedBookmark.is_favorite, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`切换收藏状态失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 批量操作 |
|||
*/ |
|||
static async batchOperation(operation, bookmarkIds, userId, data = {}) { |
|||
try { |
|||
if (!Array.isArray(bookmarkIds) || bookmarkIds.length === 0) { |
|||
throw new Error("请选择要操作的收藏") |
|||
} |
|||
|
|||
const results = [] |
|||
for (const id of bookmarkIds) { |
|||
try { |
|||
switch (operation) { |
|||
case 'delete': |
|||
await this.deleteBookmark(id, userId) |
|||
results.push({ id, success: true, message: "删除成功" }) |
|||
break |
|||
case 'move': |
|||
if (!data.category_id) { |
|||
throw new Error("移动操作需要指定目标分类") |
|||
} |
|||
await this.updateBookmark(id, { category_id: data.category_id }, userId) |
|||
results.push({ id, success: true, message: "移动成功" }) |
|||
break |
|||
case 'tag': |
|||
if (!data.tags) { |
|||
throw new Error("标签操作需要指定标签") |
|||
} |
|||
await this.updateBookmark(id, { tags: data.tags }, userId) |
|||
results.push({ id, success: true, message: "标签更新成功" }) |
|||
break |
|||
default: |
|||
throw new Error(`不支持的操作类型: ${operation}`) |
|||
} |
|||
} catch (error) { |
|||
results.push({ id, success: false, message: error.message }) |
|||
} |
|||
} |
|||
|
|||
return results |
|||
} catch (error) { |
|||
throw new Error(`批量操作失败: ${error.message}`) |
|||
} |
|||
} |
|||
} |
|||
|
|||
export default BookmarkService |
|||
export { BookmarkService } |
@ -0,0 +1,18 @@ |
|||
import jobs from "../jobs" |
|||
|
|||
class JobService { |
|||
startJob(id) { |
|||
return jobs.start(id) |
|||
} |
|||
stopJob(id) { |
|||
return jobs.stop(id) |
|||
} |
|||
updateJobCron(id, cronTime) { |
|||
return jobs.updateCronTime(id, cronTime) |
|||
} |
|||
listJobs() { |
|||
return jobs.list() |
|||
} |
|||
} |
|||
|
|||
export default JobService |
@ -0,0 +1,25 @@ |
|||
import SiteConfigModel from "../db/models/SiteConfigModel.js" |
|||
|
|||
class SiteConfigService { |
|||
// 获取单个配置
|
|||
async get(key) { |
|||
return await SiteConfigModel.get(key) |
|||
} |
|||
|
|||
// 设置单个配置
|
|||
async set(key, value) { |
|||
return await SiteConfigModel.set(key, value) |
|||
} |
|||
|
|||
// 批量获取
|
|||
async getMany(keys) { |
|||
return await SiteConfigModel.getMany(keys) |
|||
} |
|||
|
|||
// 获取全部配置
|
|||
async getAll() { |
|||
return await SiteConfigModel.getAll() |
|||
} |
|||
} |
|||
|
|||
export default SiteConfigService |
@ -0,0 +1,73 @@ |
|||
import UserModel from "db/models/UserModel.js" |
|||
import { hashPassword, comparePassword } from "utils/bcrypt.js" |
|||
import CommonError from "utils/error/CommonError" |
|||
import { JWT_SECRET } from "@/middlewares/Auth/auth.js" |
|||
import jwt from "@/middlewares/Auth/jwt.js" |
|||
|
|||
class UserService { |
|||
async getUserById(id) { |
|||
// 这里可以调用数据库模型
|
|||
// 示例返回
|
|||
return { id, name: `User_${id}` } |
|||
} |
|||
|
|||
// 获取所有用户
|
|||
async getAllUsers() { |
|||
return await UserModel.findAll() |
|||
} |
|||
|
|||
// 创建新用户
|
|||
async createUser(data) { |
|||
if (!data.name) throw new Error("用户名不能为空") |
|||
return await UserModel.create(data) |
|||
} |
|||
|
|||
// 更新用户
|
|||
async updateUser(id, data) { |
|||
const user = await UserModel.findById(id) |
|||
if (!user) throw new Error("用户不存在") |
|||
return await UserModel.update(id, data) |
|||
} |
|||
|
|||
// 删除用户
|
|||
async deleteUser(id) { |
|||
const user = await UserModel.findById(id) |
|||
if (!user) throw new Error("用户不存在") |
|||
return await UserModel.delete(id) |
|||
} |
|||
|
|||
// 注册新用户
|
|||
async register(data) { |
|||
if (!data.username || !data.password) throw new CommonError("用户名、邮箱和密码不能为空") |
|||
const existUser = await UserModel.findByUsername(data.username) |
|||
if (existUser) throw new CommonError(`用户名${data.username}已存在`) |
|||
// 密码加密
|
|||
const hashed = await hashPassword(data.password) |
|||
|
|||
const user = await UserModel.create({ ...data, password: hashed }) |
|||
// 返回脱敏信息
|
|||
const { password, ...userInfo } = Array.isArray(user) ? user[0] : user |
|||
return userInfo |
|||
} |
|||
|
|||
// 登录
|
|||
async login({ username, email, password }) { |
|||
let user |
|||
if (username) { |
|||
user = await UserModel.findByUsername(username) |
|||
} else if (email) { |
|||
user = await UserModel.findByEmail(email) |
|||
} |
|||
if (!user) throw new Error("用户不存在") |
|||
// 校验密码
|
|||
const ok = await comparePassword(password, user.password) |
|||
if (!ok) throw new Error("密码错误") |
|||
// 生成token
|
|||
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: "2h" }) |
|||
// 返回token和用户信息
|
|||
const { password: pwd, ...userInfo } = user |
|||
return { token, user: userInfo } |
|||
} |
|||
} |
|||
|
|||
export default UserService |
@ -0,0 +1,37 @@ |
|||
// 抽象基类,使用泛型来正确推导子类类型
|
|||
class BaseSingleton { |
|||
static _instance |
|||
|
|||
constructor() { |
|||
if (this.constructor === BaseSingleton) { |
|||
throw new Error("禁止直接实例化 BaseOne 抽象类") |
|||
} |
|||
|
|||
if (this.constructor._instance) { |
|||
throw new Error("构造函数私有化失败,禁止重复 new") |
|||
} |
|||
|
|||
// this.constructor 是子类,所以这里设为 instance
|
|||
this.constructor._instance = this |
|||
} |
|||
|
|||
static getInstance() { |
|||
const clazz = this |
|||
if (!clazz._instance) { |
|||
const self = new this() |
|||
const handler = { |
|||
get: function (target, prop) { |
|||
const value = Reflect.get(target, prop) |
|||
if (typeof value === "function") { |
|||
return value.bind(target) |
|||
} |
|||
return Reflect.get(target, prop) |
|||
}, |
|||
} |
|||
clazz._instance = new Proxy(self, handler) |
|||
} |
|||
return clazz._instance |
|||
} |
|||
} |
|||
|
|||
export { BaseSingleton } |
@ -0,0 +1,101 @@ |
|||
// 自动扫描 controllers 目录并注册路由
|
|||
// 兼容传统 routes 方式和自动注册 controller 方式
|
|||
import fs from "fs" |
|||
import path from "path" |
|||
import { logger } from "@/logger.js" |
|||
|
|||
// 保证不会被摇树(tree-shaking),即使在生产环境也会被打包
|
|||
if (import.meta.env.PROD) { |
|||
// 通过引用返回值,防止被摇树优化
|
|||
let controllers = import.meta.glob("../controllers/**/*Controller.js", { eager: true }) |
|||
controllers = null |
|||
console.log(controllers); |
|||
} |
|||
|
|||
/** |
|||
* 自动扫描 controllers 目录,注册所有导出的路由 |
|||
* 自动检测 routes 目录下已手动注册的 controller,避免重复注册 |
|||
* @param {Koa} app - Koa 实例 |
|||
* @param {string} controllersDir - controllers 目录路径 |
|||
* @param {string} prefix - 路由前缀 |
|||
* @param {Set<string>} [manualControllers] - 可选,手动传入已注册 controller 文件名集合,优先于自动扫描 |
|||
*/ |
|||
export function autoRegisterControllers(app, controllersDir = path.resolve(__dirname, "../controllers")) { |
|||
let allRouter = [] |
|||
|
|||
async function scan(dir, routePrefix = "") { |
|||
try { |
|||
for (const file of fs.readdirSync(dir)) { |
|||
const fullPath = path.join(dir, file) |
|||
const stat = fs.statSync(fullPath) |
|||
|
|||
if (stat.isDirectory()) { |
|||
await scan(fullPath, routePrefix + "/" + file) |
|||
} else if (file.endsWith("Controller.js")) { |
|||
try { |
|||
// 使用动态import替代require,确保ES模块兼容性
|
|||
const controllerModule = await import(fullPath) |
|||
const controller = controllerModule.default || controllerModule |
|||
|
|||
if (!controller) { |
|||
logger.warn(`Controller ${file} 没有默认导出`) |
|||
continue |
|||
} |
|||
|
|||
const routes = controller.createRoutes || controller.default?.createRoutes || controller.default || controller |
|||
|
|||
if (typeof routes === "function") { |
|||
try { |
|||
const router = routes() |
|||
if (router && typeof router.middleware === "function") { |
|||
allRouter.push(router) |
|||
logger.info(`成功注册控制器: ${file}`) |
|||
} else { |
|||
logger.warn(`Controller ${file} 的 createRoutes 返回的不是有效的路由器`) |
|||
} |
|||
} catch (error) { |
|||
logger.error(`执行 Controller ${file} 的 createRoutes 时出错:`, error.message) |
|||
} |
|||
} else { |
|||
logger.warn(`Controller ${file} 没有 createRoutes 方法`) |
|||
} |
|||
} catch (importError) { |
|||
logger.error(`导入 Controller ${file} 失败:`, importError.message) |
|||
} |
|||
} |
|||
} |
|||
} catch (error) { |
|||
logger.error(`扫描目录 ${dir} 时出错:`, error.message) |
|||
} |
|||
} |
|||
|
|||
// 使用立即执行的异步函数来注册路由
|
|||
(async () => { |
|||
try { |
|||
await scan(controllersDir) |
|||
|
|||
if (allRouter.length === 0) { |
|||
logger.warn("没有找到任何可注册的控制器") |
|||
return |
|||
} |
|||
|
|||
logger.info(`找到 ${allRouter.length} 个控制器,开始注册路由`) |
|||
|
|||
// 按顺序注册路由,确保中间件执行顺序
|
|||
for (let i = 0; i < allRouter.length; i++) { |
|||
const router = allRouter[i] |
|||
try { |
|||
app.use(router.middleware()) |
|||
logger.debug(`路由 ${i + 1}/${allRouter.length} 注册成功`) |
|||
} catch (error) { |
|||
logger.error(`注册路由 ${i + 1}/${allRouter.length} 失败:`, error.message) |
|||
} |
|||
} |
|||
|
|||
logger.info("所有路由注册完成") |
|||
|
|||
} catch (error) { |
|||
logger.error("自动注册控制器过程中发生错误:", error.message) |
|||
} |
|||
})() |
|||
} |
@ -0,0 +1,11 @@ |
|||
// 密码加密与校验工具
|
|||
import bcrypt from "bcryptjs" |
|||
|
|||
export async function hashPassword(password) { |
|||
const salt = await bcrypt.genSalt(10) |
|||
return bcrypt.hash(password, salt) |
|||
} |
|||
|
|||
export async function comparePassword(password, hash) { |
|||
return bcrypt.compare(password, hash) |
|||
} |
@ -0,0 +1,7 @@ |
|||
export default class CommonError extends Error { |
|||
constructor(message, redirect) { |
|||
super(message) |
|||
this.name = "CommonError" |
|||
this.status = 500 |
|||
} |
|||
} |
@ -0,0 +1,4 @@ |
|||
|
|||
export function formatResponse(success, data = null, error = null) { |
|||
return { success, error, data } |
|||
} |
@ -0,0 +1,139 @@ |
|||
import { match } from 'path-to-regexp'; |
|||
import compose from 'koa-compose'; |
|||
import RouteAuth from './router/RouteAuth.js'; |
|||
|
|||
class Router { |
|||
/** |
|||
* 初始化路由实例 |
|||
* @param {Object} options - 路由配置 |
|||
* @param {string} options.prefix - 全局路由前缀 |
|||
* @param {Object} options.auth - 全局默认auth配置(可选,优先级低于路由级) |
|||
*/ |
|||
constructor(options = {}) { |
|||
this.routes = { get: [], post: [], put: [], delete: [] }; |
|||
this.middlewares = []; |
|||
this.options = Object.assign({}, this.options, options); |
|||
} |
|||
|
|||
options = { |
|||
prefix: '', |
|||
auth: true, |
|||
} |
|||
|
|||
/** |
|||
* 注册中间件 |
|||
* @param {Function} middleware - 中间件函数 |
|||
*/ |
|||
use(middleware) { |
|||
this.middlewares.push(middleware); |
|||
} |
|||
|
|||
/** |
|||
* 注册GET路由,支持中间件链 |
|||
* @param {string} path - 路由路径 |
|||
* @param {Function} handler - 中间件和处理函数 |
|||
* @param {Object} others - 其他参数(可选) |
|||
*/ |
|||
get(path, handler, others) { |
|||
this._registerRoute("get", path, handler, others) |
|||
} |
|||
|
|||
/** |
|||
* 注册POST路由,支持中间件链 |
|||
* @param {string} path - 路由路径 |
|||
* @param {Function} handler - 中间件和处理函数 |
|||
* @param {Object} others - 其他参数(可选) |
|||
*/ |
|||
post(path, handler, others) { |
|||
this._registerRoute("post", path, handler, others) |
|||
} |
|||
|
|||
/** |
|||
* 注册PUT路由,支持中间件链 |
|||
*/ |
|||
put(path, handler, others) { |
|||
this._registerRoute("put", path, handler, others) |
|||
} |
|||
|
|||
/** |
|||
* 注册DELETE路由,支持中间件链 |
|||
*/ |
|||
delete(path, handler, others) { |
|||
this._registerRoute("delete", path, handler, others) |
|||
} |
|||
|
|||
/** |
|||
* 创建路由组 |
|||
* @param {string} prefix - 组内路由前缀 |
|||
* @param {Function} callback - 组路由注册回调 |
|||
*/ |
|||
group(prefix, callback) { |
|||
const groupRouter = new Router({ prefix: this.options.prefix + prefix }) |
|||
callback(groupRouter); |
|||
// 合并组路由到当前路由
|
|||
Object.keys(groupRouter.routes).forEach(method => { |
|||
this.routes[method].push(...groupRouter.routes[method]); |
|||
}); |
|||
this.middlewares.push(...groupRouter.middlewares); |
|||
} |
|||
|
|||
/** |
|||
* 生成Koa中间件 |
|||
* @returns {Function} Koa中间件函数 |
|||
*/ |
|||
middleware() { |
|||
return async (ctx, next) => { |
|||
const { method, path } = ctx; |
|||
const route = this._matchRoute(method.toLowerCase(), path); |
|||
|
|||
// 组合全局中间件、路由专属中间件和 handler
|
|||
const middlewares = [...this.middlewares]; |
|||
if (route) { |
|||
// 如果匹配到路由,添加路由专属中间件和处理函数
|
|||
ctx.params = route.params; |
|||
|
|||
let isAuth = this.options.auth; |
|||
if (route.meta && route.meta.auth !== undefined) { |
|||
isAuth = route.meta.auth; |
|||
} |
|||
|
|||
middlewares.push(RouteAuth({ auth: isAuth })); |
|||
middlewares.push(route.handler) |
|||
// 用 koa-compose 组合
|
|||
const composed = compose(middlewares); |
|||
await composed(ctx, next); |
|||
} else { |
|||
// 如果没有匹配到路由,直接调用 next
|
|||
await next(); |
|||
} |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* 内部路由注册方法,支持中间件链 |
|||
* @private |
|||
*/ |
|||
_registerRoute(method, path, handler, others) { |
|||
const fullPath = this.options.prefix + path |
|||
const keys = []; |
|||
const matcher = match(fullPath, { decode: decodeURIComponent }); |
|||
this.routes[method].push({ path: fullPath, matcher, keys, handler, meta: others }) |
|||
} |
|||
|
|||
/** |
|||
* 匹配路由 |
|||
* @private |
|||
*/ |
|||
_matchRoute(method, currentPath) { |
|||
const routes = this.routes[method] || []; |
|||
for (const route of routes) { |
|||
const matchResult = route.matcher(currentPath); |
|||
if (matchResult) { |
|||
return { ...route, params: matchResult.params }; |
|||
} |
|||
} |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
export default Router; |
@ -0,0 +1,49 @@ |
|||
import jwt from "@/middlewares/Auth/jwt.js" |
|||
import { JWT_SECRET } from "@/middlewares/Auth/auth.js" |
|||
|
|||
/** |
|||
* 路由级权限中间件 |
|||
* 支持:auth: false/try/true/roles |
|||
* 用法:router.get('/api/user', RouteAuth({ auth: true }), handler) |
|||
*/ |
|||
export default function RouteAuth(options = {}) { |
|||
const { auth = true } = options |
|||
return async (ctx, next) => { |
|||
if (auth === false) return next() |
|||
|
|||
// 统一用户解析逻辑
|
|||
if (!ctx.state.user) { |
|||
const token = getToken(ctx) |
|||
if (token) { |
|||
try { |
|||
ctx.state.user = jwt.verify(token, JWT_SECRET) |
|||
} catch {} |
|||
} |
|||
} |
|||
|
|||
if (auth === "try") { |
|||
return next() |
|||
} |
|||
|
|||
if (auth === true) { |
|||
if (!ctx.state.user) { |
|||
if (ctx.accepts('html')) { |
|||
ctx.redirect('/no-auth?from=' + ctx.request.url) |
|||
return |
|||
} |
|||
ctx.status = 401 |
|||
ctx.body = { success: false, error: "未登录或Token无效" } |
|||
return |
|||
} |
|||
return next() |
|||
} |
|||
|
|||
// 其他自定义模式
|
|||
return next() |
|||
} |
|||
} |
|||
|
|||
function getToken(ctx) { |
|||
// 只支持 Authorization: Bearer xxx
|
|||
return ctx.headers["authorization"]?.replace(/^Bearer\s/i, "") |
|||
} |
@ -0,0 +1,60 @@ |
|||
import cron from 'node-cron'; |
|||
|
|||
class Scheduler { |
|||
constructor() { |
|||
this.jobs = new Map(); |
|||
} |
|||
|
|||
add(id, cronTime, task, options = {}) { |
|||
if (this.jobs.has(id)) this.remove(id); |
|||
const job = cron.createTask(cronTime, task, { ...options, noOverlap: true }); |
|||
this.jobs.set(id, { job, cronTime, task, options, status: 'stopped' }); |
|||
} |
|||
|
|||
execute(id) { |
|||
const entry = this.jobs.get(id); |
|||
if (entry && entry.status === 'running') { |
|||
entry.job.execute(); |
|||
} |
|||
} |
|||
|
|||
start(id) { |
|||
const entry = this.jobs.get(id); |
|||
if (entry && entry.status !== 'running') { |
|||
entry.job.start(); |
|||
entry.status = 'running'; |
|||
} |
|||
} |
|||
|
|||
stop(id) { |
|||
const entry = this.jobs.get(id); |
|||
if (entry && entry.status === 'running') { |
|||
entry.job.stop(); |
|||
entry.status = 'stopped'; |
|||
} |
|||
} |
|||
|
|||
remove(id) { |
|||
const entry = this.jobs.get(id); |
|||
if (entry) { |
|||
entry.job.destroy(); |
|||
this.jobs.delete(id); |
|||
} |
|||
} |
|||
|
|||
updateCronTime(id, newCronTime) { |
|||
const entry = this.jobs.get(id); |
|||
if (entry) { |
|||
this.remove(id); |
|||
this.add(id, newCronTime, entry.task, entry.options); |
|||
} |
|||
} |
|||
|
|||
list() { |
|||
return Array.from(this.jobs.entries()).map(([id, { cronTime, status }]) => ({ |
|||
id, cronTime, status |
|||
})); |
|||
} |
|||
} |
|||
|
|||
export default new Scheduler(); |
@ -0,0 +1,8 @@ |
|||
html |
|||
head |
|||
title #{status} Error |
|||
body |
|||
h1 #{status} Error |
|||
p #{message} |
|||
if isDev && stack |
|||
pre(style="color:red;") #{stack} |
@ -0,0 +1,51 @@ |
|||
.footer-panel |
|||
.footer-content |
|||
p © 2023-#{new Date().getFullYear()} #{$site.site_author}. 保留所有权利。 |
|||
|
|||
ul.footer-links |
|||
li |
|||
a(href="/") 首页 |
|||
li |
|||
a(href="/about") 关于我们 |
|||
style. |
|||
.footer-panel { |
|||
background: rgba(34,34,34,.25); |
|||
backdrop-filter: blur(12px); |
|||
color: #eee; |
|||
padding: 40px 0 24px 0; |
|||
font-size: 15px; |
|||
margin-top: 40px; |
|||
min-height: 120px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
.footer-content { |
|||
max-width: 900px; |
|||
margin: 0 auto; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
} |
|||
.footer-content p { |
|||
margin: 0 0 10px 0; |
|||
letter-spacing: 1px; |
|||
} |
|||
.footer-links { |
|||
list-style: none; |
|||
padding: 0; |
|||
display: flex; |
|||
gap: 24px; |
|||
} |
|||
.footer-links li { |
|||
display: inline; |
|||
} |
|||
.footer-links a { |
|||
color: #eee; |
|||
text-decoration: none; |
|||
transition: color 0.2s; |
|||
} |
|||
.footer-links a:hover { |
|||
color: #4fc3f7; |
|||
text-decoration: underline; |
|||
} |
@ -0,0 +1,13 @@ |
|||
if edit |
|||
.row.justify-content-center.mt-5 |
|||
.col-md-6 |
|||
form#loginForm(method="post" action="/api/login" hx-post="/api/login" hx-trigger="submit" hx-target="body" hx-swap="none" hx-on:htmx:afterRequest="if(event.detail.xhr.status===200){window.location='/';}") |
|||
.mb-3 |
|||
label.form-label(for="username") 用户名 |
|||
input.form-control(type="text" id="username" name="username" required) |
|||
.mb-3 |
|||
label.form-label(for="password") 密码 |
|||
input.form-control(type="password" id="password" name="password" required) |
|||
button.btn.btn-primary(type="submit") 登录 |
|||
else |
|||
div sad 404 |
@ -0,0 +1,81 @@ |
|||
style. |
|||
.navbar { |
|||
height: 60px; |
|||
border-radius: 12px; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
backdrop-filter: blur(12px); |
|||
color: #fff; |
|||
&::after { |
|||
display: table; |
|||
clear: both; |
|||
content: ''; |
|||
} |
|||
} |
|||
.navbar .site { |
|||
float: left; |
|||
height: 100%; |
|||
display: flex; |
|||
align-items: center; |
|||
padding: 0 20px; |
|||
cursor: pointer; |
|||
font-size: 20px; |
|||
&:hover { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
} |
|||
} |
|||
.menu { |
|||
height: 100%; |
|||
margin-left: 20px; |
|||
.menu-item { |
|||
height: 100%; |
|||
display: flex; |
|||
align-items: center; |
|||
padding: 0 10px; |
|||
cursor: pointer; |
|||
&+.menu-item { |
|||
margin-left: 5px; |
|||
} |
|||
&:hover { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
} |
|||
} |
|||
} |
|||
.menu.left { |
|||
float: left; |
|||
.menu-item { |
|||
float: left; |
|||
} |
|||
} |
|||
.right.menu { |
|||
float: right; |
|||
.menu-item { |
|||
padding: 0 20px; |
|||
float: right; |
|||
} |
|||
} |
|||
script. |
|||
window.addEventListener('pageshow', function(event) { |
|||
// event.persisted 为 true 表示页面从缓存中恢复 |
|||
if (event.persisted) { |
|||
// 执行需要更新的操作,例如: |
|||
console.log('页面从缓存加载,需要更新数据'); |
|||
|
|||
// 1. 刷新页面(简单直接的方式) |
|||
//- window.location.reload(); |
|||
|
|||
// 2. 重新请求数据(更优雅的方式) |
|||
//- fetchData(); // 假设这是你的数据请求函数 |
|||
|
|||
// 3. 更新页面状态 |
|||
//- updatePageState(); // 假设这是你的状态更新函数 |
|||
} |
|||
}); |
|||
|
|||
.navbar |
|||
.site #{$site.site_title} |
|||
.left.menu |
|||
a.menu-item(href="/about") 明月照佳人 |
|||
a.menu-item(href="/about") 岁月催人老 |
|||
.right.menu |
|||
a.menu-item(href="/login") 登录 |
|||
a.menu-item(href="/register") 注册 |
@ -0,0 +1,140 @@ |
|||
- var _dataList = timeLine || [] |
|||
ul.time-line |
|||
each item in _dataList |
|||
li.time-line-item |
|||
.timeline-icon |
|||
div !{item.icon} |
|||
.time-line-item-content |
|||
.time-line-item-title !{item.title} |
|||
.time-line-item-desc !{item.desc} |
|||
style. |
|||
.time-line { |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: center; |
|||
position: relative; |
|||
} |
|||
|
|||
.time-line:before { |
|||
content: ""; |
|||
width: 3px; |
|||
height: 100%; |
|||
background: rgba(255, 255, 255, 0.37); |
|||
backdrop-filter: blur(12px); |
|||
left: 50%; |
|||
top: 0; |
|||
position: absolute; |
|||
transform: translateX(-50%); |
|||
} |
|||
|
|||
.time-line::after { |
|||
content: ""; |
|||
position: absolute; |
|||
left: 50%; |
|||
top: 100%; |
|||
width: 0; |
|||
height: 0; |
|||
border-top: 12px solid rgba(255, 255, 255, 0.37); |
|||
border-right: 7px solid transparent; |
|||
border-left: 7px solid transparent; |
|||
backdrop-filter: blur(12px); |
|||
transform: translateX(-50%); |
|||
} |
|||
|
|||
.time-line a { |
|||
color: rgb(219, 255, 121); |
|||
text-decoration: underline; |
|||
font-weight: 600; |
|||
transition: color 0.2s, background 0.2s; |
|||
border-radius: 8px; |
|||
padding: 1px 4px; |
|||
} |
|||
.time-line a:hover { |
|||
color: #fff; |
|||
background: linear-gradient(90deg, #7ec6f7 0%, #ff8ca8 100%); |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.time-line-item { |
|||
color: white; |
|||
width: 900px; |
|||
margin: 20px auto; |
|||
position: relative; |
|||
} |
|||
|
|||
.time-line-item:first-child { |
|||
margin-top: 0; |
|||
} |
|||
|
|||
.time-line-item:last-child { |
|||
margin-bottom: 50px; |
|||
} |
|||
|
|||
.timeline-icon { |
|||
position: absolute; |
|||
width: 100px; |
|||
height: 50px; |
|||
background-color: #ee4d4d7a; |
|||
backdrop-filter: blur(12px); |
|||
left: 50%; |
|||
top: 0; |
|||
transform: translateX(-50%); |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
font-family: Arial, Helvetica, sans-serif; |
|||
} |
|||
|
|||
.time-line-item-title { |
|||
background-color: #ee4d4d7a; |
|||
backdrop-filter: blur(12px); |
|||
height: 50px; |
|||
line-height: 50px; |
|||
padding: 0 20px; |
|||
} |
|||
|
|||
.time-line-item:nth-child(odd) .time-line-item-content { |
|||
color: white; |
|||
width: 50%; |
|||
padding-right: 80px; |
|||
} |
|||
|
|||
.time-line-item:nth-child(odd) .time-line-item-content::before { |
|||
content: ""; |
|||
position: absolute; |
|||
left: calc(50% - 80px); |
|||
top: 20px; |
|||
width: 0; |
|||
height: 0; |
|||
border-top: 7px solid transparent; |
|||
border-bottom: 7px solid transparent; |
|||
border-left: 7px solid #ee4d4d7a; |
|||
backdrop-filter: blur(12px); |
|||
} |
|||
|
|||
.time-line-item:nth-child(even) .time-line-item-content { |
|||
float: right; |
|||
width: 50%; |
|||
padding-left: 80px; |
|||
} |
|||
|
|||
.time-line-item:nth-child(even) .time-line-item-content::before { |
|||
content: ""; |
|||
position: absolute; |
|||
right: calc(50% - 80px); |
|||
top: 20px; |
|||
width: 0; |
|||
height: 0; |
|||
border-top: 7px solid transparent; |
|||
border-bottom: 7px solid transparent; |
|||
border-right: 7px solid #ee4d4d7a; |
|||
backdrop-filter: blur(12px); |
|||
} |
|||
|
|||
.time-line-item-desc { |
|||
background-color: #ffffff54; |
|||
backdrop-filter: blur(12px); |
|||
color: #fff; |
|||
padding: 20px; |
|||
line-height: 1.4; |
|||
} |
@ -0,0 +1,153 @@ |
|||
mixin include() |
|||
if block |
|||
block |
|||
|
|||
mixin css(url, extranl = false) |
|||
if extranl || url.startsWith('http') || url.startsWith('//') |
|||
link(rel="stylesheet" type="text/css" href=url) |
|||
else |
|||
link(rel="stylesheet", href=($config && $config.base || "") + url) |
|||
|
|||
mixin js(url, extranl = false) |
|||
if extranl || url.startsWith('http') || url.startsWith('//') |
|||
script(type="text/javascript" src=url) |
|||
else |
|||
script(src=($config && $config.base || "") + url) |
|||
|
|||
mixin link(href, name) |
|||
//- attributes == {class: "btn"} |
|||
a(href=href)&attributes(attributes)= name |
|||
|
|||
doctype html |
|||
html(lang="zh-CN") |
|||
head |
|||
block head |
|||
title #{site_title || $site && $site.site_title || ''} |
|||
meta(name="description" content=site_description || $site && $site.site_description || '') |
|||
meta(name="keywords" content=keywords || $site && $site.keywords || '') |
|||
if $site && $site.site_favicon |
|||
link(rel="shortcut icon", href=$site.site_favicon) |
|||
meta(charset="utf-8") |
|||
meta(name="viewport" content="width=device-width, initial-scale=1") |
|||
+css('reset.css') |
|||
+js('lib/htmx.min.js') |
|||
+js('https://cdn.tailwindcss.com') |
|||
+css('https://unpkg.com/simplebar@latest/dist/simplebar.css', true) |
|||
+css('simplebar-shim.css') |
|||
+js('https://unpkg.com/simplebar@latest/dist/simplebar.min.js', true) |
|||
//- body(style="--bg:url("+($site && $site.site_bg || '#fff')+")") |
|||
//- body(style="--bg:url(./static/bg2.webp)") |
|||
body |
|||
noscript |
|||
style. |
|||
.simplebar-content-wrapper { |
|||
scrollbar-width: auto; |
|||
-ms-overflow-style: auto; |
|||
} |
|||
|
|||
.simplebar-content-wrapper::-webkit-scrollbar, |
|||
.simplebar-hide-scrollbar::-webkit-scrollbar { |
|||
display: initial; |
|||
width: initial; |
|||
height: initial; |
|||
} |
|||
div(data-simplebar style="height: 100%") |
|||
div(style="height: 100%; display: flex; flex-direction: column" id="aaaa") |
|||
block content |
|||
block scripts |
|||
+js('lib/bg-change.js') |
|||
script. |
|||
const el = document.querySelector('.simplebar-content-wrapper') |
|||
const scrollTop = sessionStorage.getItem('scrollTop-'+location.pathname) |
|||
el.scrollTop = scrollTop |
|||
el.addEventListener("scroll", function(e) { |
|||
sessionStorage.setItem('scrollTop-'+location.pathname, e.target.scrollTop) |
|||
}) |
|||
function getCookie(cname) { |
|||
var name = cname + "="; |
|||
var decodedCookie = decodeURIComponent(document.cookie); |
|||
var ca = decodedCookie.split(';'); |
|||
for(var i = 0; i <ca.length; i++) { |
|||
var c = ca[i]; |
|||
while (c.charAt(0) == ' ') { |
|||
c = c.substring(1); |
|||
} |
|||
if (c.indexOf(name) == 0) { |
|||
return c.substring(name.length, c.length); |
|||
} |
|||
} |
|||
return ""; |
|||
} |
|||
let have = false |
|||
const queryMsg = (new URLSearchParams(location.search)).get('msg') |
|||
let msg = getCookie('toast') |
|||
if(!msg) { |
|||
msg = JSON.stringify({type:'success',message: queryMsg}) |
|||
have = !!queryMsg |
|||
} else { |
|||
have = true |
|||
} |
|||
if (have) { |
|||
function showToast(message, type = 'info') { |
|||
const containerId = 'toast-container'; |
|||
let container = document.getElementById(containerId); |
|||
if (!container) { |
|||
container = document.createElement('div'); |
|||
container.id = containerId; |
|||
container.style.position = 'fixed'; |
|||
container.style.top = '24px'; |
|||
container.style.right = '24px'; |
|||
container.style.zIndex = '9999'; |
|||
container.style.display = 'flex'; |
|||
container.style.flexDirection = 'column'; |
|||
container.style.gap = '12px'; |
|||
document.body.appendChild(container); |
|||
} |
|||
|
|||
const toast = document.createElement('div'); |
|||
toast.className = 'toast-message'; |
|||
toast.textContent = message; |
|||
|
|||
// Set style based on type |
|||
let bg = '#409eff'; |
|||
if (type === 'error') bg = '#f56c6c'; |
|||
else if (type === 'warning') bg = '#e6a23c'; |
|||
else if (type === 'success') bg = '#67c23a'; |
|||
|
|||
toast.style.background = bg; |
|||
toast.style.color = '#fff'; |
|||
toast.style.padding = '14px 28px'; |
|||
toast.style.borderRadius = '6px'; |
|||
toast.style.boxShadow = '0 2px 8px rgba(0,0,0,0.12)'; |
|||
toast.style.fontSize = '1rem'; |
|||
toast.style.cursor = 'pointer'; |
|||
toast.style.transition = 'opacity 0.3s'; |
|||
toast.style.opacity = '1'; |
|||
|
|||
let timer; |
|||
const removeToast = () => { |
|||
toast.style.opacity = '0'; |
|||
setTimeout(() => { |
|||
if (toast.parentNode) toast.parentNode.removeChild(toast); |
|||
}, 300); |
|||
}; |
|||
|
|||
const resetTimer = () => { |
|||
clearTimeout(timer); |
|||
timer = setTimeout(removeToast, 5000); |
|||
}; |
|||
|
|||
toast.addEventListener('mouseenter', resetTimer); |
|||
toast.addEventListener('mouseleave', resetTimer); |
|||
|
|||
container.appendChild(toast); |
|||
resetTimer(); |
|||
} |
|||
|
|||
try { |
|||
const toastObj = JSON.parse(msg); |
|||
showToast(toastObj.message, toastObj.type); |
|||
} catch (e) { |
|||
showToast(msg); |
|||
} |
|||
} |
@ -0,0 +1,31 @@ |
|||
extends /layouts/base.pug |
|||
|
|||
block head |
|||
+css('styles.css') |
|||
block pageHead |
|||
|
|||
block content |
|||
.page-layout |
|||
.page |
|||
- const navs = []; |
|||
- navs.push({ href: '/', label: '首页' }); |
|||
- navs.push({ href: '/articles', label: '文章' }); |
|||
- navs.push({ href: '/article', label: '收藏' }); |
|||
- navs.push({ href: '/about', label: '关于' }); |
|||
nav.nav |
|||
ul.flota-nav |
|||
each nav in navs |
|||
li |
|||
a.item( |
|||
href=nav.href, |
|||
class=currentPath === nav.href ? 'active' : '' |
|||
) #{nav.label} |
|||
.content |
|||
block pageContent |
|||
footer |
|||
+include() |
|||
- var edit = false |
|||
include /htmx/footer.pug |
|||
|
|||
block scripts |
|||
block pageScripts |
@ -0,0 +1,18 @@ |
|||
extends /layouts/base.pug |
|||
|
|||
block head |
|||
+css('styles.css') |
|||
block pageHead |
|||
|
|||
block content |
|||
.page-layout |
|||
.page |
|||
.content |
|||
block pageContent |
|||
footer |
|||
+include() |
|||
- var edit = false |
|||
include /htmx/footer.pug |
|||
|
|||
block scripts |
|||
block pageScripts |
@ -0,0 +1,100 @@ |
|||
extends /layouts/pure.pug |
|||
|
|||
block pageContent |
|||
.about-container |
|||
h1 关于我们 |
|||
p 我们致力于打造一个基于 Koa3 的现代 Web 示例项目,帮助开发者快速上手高效、可扩展的 Web 应用开发。 |
|||
.about-section |
|||
h2 我们的愿景 |
|||
p 推动 Node.js 生态下的现代 Web 技术发展,降低开发门槛,提升开发体验。 |
|||
.about-section |
|||
h2 技术栈 |
|||
ul |
|||
li Koa3 |
|||
li Pug 模板引擎 |
|||
li 现代前端技术(如 ES6+、CSS3) |
|||
.about-section |
|||
h2 联系我们 |
|||
p 如有建议或合作意向,欢迎通过 |
|||
a(href="mailto:1549469775@qq.com") 联系方式 |
|||
| 与我们取得联系。 |
|||
|
|||
style. |
|||
.about-container { |
|||
margin: 32px auto 0 auto; |
|||
padding: 56px 40px 44px 40px; |
|||
border-radius: 28px; |
|||
background: rgba(255, 255, 255, 0.25); |
|||
backdrop-filter: blur(18px); |
|||
|
|||
//- max-width: 900px; |
|||
//- min-width: 340px; |
|||
} |
|||
.about-container h1 { |
|||
font-size: 2.7em; |
|||
color: rgb(80, 168, 255); |
|||
margin-bottom: 28px; |
|||
text-align: center; |
|||
font-weight: 900; |
|||
letter-spacing: 2.5px; |
|||
text-shadow: 0 2px 16px rgba(33,150,243,0.10); |
|||
background: linear-gradient(90deg,rgb(80, 168, 255) 30%, #7ec6f7 100%); |
|||
-webkit-background-clip: text; |
|||
-webkit-text-fill-color: transparent; |
|||
} |
|||
.about-container p { |
|||
font-size: 1.18em; |
|||
color: #fff; |
|||
margin-bottom: 26px; |
|||
line-height: 1.85; |
|||
} |
|||
.about-section { |
|||
margin-bottom: 38px; |
|||
padding-bottom: 14px; |
|||
border-bottom: 1px dashed #b2ebf2; |
|||
} |
|||
.about-section:last-child { |
|||
border-bottom: none; |
|||
} |
|||
.about-section h2 { |
|||
font-size: 1.22em; |
|||
color:rgb(80, 168, 255); |
|||
margin-bottom: 10px; |
|||
font-weight: 700; |
|||
letter-spacing: 1.2px; |
|||
background: linear-gradient(90deg,rgb(80, 168, 255) 60%, #7ec6f7 100%); |
|||
-webkit-background-clip: text; |
|||
-webkit-text-fill-color: transparent; |
|||
} |
|||
.about-section ul { |
|||
padding-left: 28px; |
|||
margin-bottom: 0; |
|||
} |
|||
.about-section li { |
|||
font-size: 1.08em; |
|||
color: #fff; |
|||
margin-bottom: 8px; |
|||
line-height: 1.7; |
|||
} |
|||
.about-container a { |
|||
color:rgb(80, 168, 255); |
|||
text-decoration: underline; |
|||
font-weight: 600; |
|||
transition: color 0.2s, background 0.2s; |
|||
border-radius: 8px; |
|||
padding: 1px 4px; |
|||
} |
|||
.about-container a:hover { |
|||
color: #fff; |
|||
background: linear-gradient(90deg, #7ec6f7 0%, #ff8ca8 100%); |
|||
text-decoration: none; |
|||
} |
|||
@media (max-width: 900px) { |
|||
.about-container { |
|||
max-width: 98vw; |
|||
padding: 18px 4vw 18px 4vw; |
|||
} |
|||
.about-container h1 { |
|||
font-size: 1.5em; |
|||
} |
|||
} |
@ -0,0 +1,113 @@ |
|||
extends /layouts/page.pug |
|||
|
|||
block pageContent |
|||
.article-list-container-full |
|||
- const articles = [] |
|||
- articles.push({ id: 1, title: '文章标题1', author: '作者1', created_at: '2023-08-01', summary: '这是文章摘要...' }) |
|||
- articles.push({ id: 2, title: '文章标题2', author: '作者2', created_at: '2023-08-02', summary: '这是另一篇文章摘要...' }) |
|||
//- 文章列表 |
|||
if articles && articles.length |
|||
each article in articles |
|||
.article-item-full |
|||
h2.article-title-full |
|||
a(href=`/articles/${article.id}`) #{article.title} |
|||
.article-meta-full |
|||
span 作者:#{article.author} | 发布时间:#{article.created_at} |
|||
p.article-summary-full #{article.summary} |
|||
else |
|||
p.no-articles 暂无文章 |
|||
|
|||
//- 分页控件 |
|||
if totalPages > 1 |
|||
.pagination-full |
|||
if page > 1 |
|||
a.page-btn-full(href=`?page=${page-1}`) 上一页 |
|||
else |
|||
span.page-btn-full.disabled 上一页 |
|||
span.page-info-full 第 #{page} / #{totalPages} 页 |
|||
if page < totalPages |
|||
a.page-btn-full(href=`?page=${page+1}`) 下一页 |
|||
else |
|||
span.page-btn-full.disabled 下一页 |
|||
style. |
|||
.article-list-container-full { |
|||
width: 100%; |
|||
max-width: 100%; |
|||
margin: 40px 0 0 0; |
|||
background: transparent; |
|||
border-radius: 0; |
|||
box-shadow: none; |
|||
padding: 0; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
} |
|||
.article-item-full { |
|||
width: 90vw; |
|||
max-width: 1200px; |
|||
background: #fff; |
|||
border-radius: 14px; |
|||
box-shadow: 0 2px 16px #e0e7ef; |
|||
margin-bottom: 28px; |
|||
padding: 28px 36px 18px 36px; |
|||
border-left: 6px solid #6dd5fa; |
|||
transition: box-shadow 0.2s, border-color 0.2s; |
|||
} |
|||
.article-item-full:hover { |
|||
box-shadow: 0 4px 32px #cbe7ff; |
|||
border-left: 6px solid #ff6a88; |
|||
} |
|||
.article-title-full { |
|||
margin: 0 0 8px 0; |
|||
font-size: 1.6em; |
|||
} |
|||
.article-title-full a { |
|||
color: #2b7cff; |
|||
text-decoration: none; |
|||
transition: color 0.2s; |
|||
} |
|||
.article-title-full a:hover { |
|||
color: #ff6a88; |
|||
} |
|||
.article-meta-full { |
|||
color: #888; |
|||
font-size: 1em; |
|||
margin-bottom: 8px; |
|||
} |
|||
.article-summary-full { |
|||
color: #444; |
|||
font-size: 1.13em; |
|||
} |
|||
.no-articles { |
|||
text-align: center; |
|||
color: #aaa; |
|||
margin: 40px 0; |
|||
} |
|||
.pagination-full { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
margin: 32px 0 0 0; |
|||
gap: 18px; |
|||
} |
|||
.page-btn-full { |
|||
padding: 7px 22px; |
|||
border-radius: 22px; |
|||
background: linear-gradient(90deg, #6dd5fa, #ff6a88); |
|||
color: #fff; |
|||
text-decoration: none; |
|||
font-weight: bold; |
|||
transition: background 0.2s; |
|||
cursor: pointer; |
|||
font-size: 1.08em; |
|||
} |
|||
.page-btn-full.disabled { |
|||
background: #eee; |
|||
color: #bbb; |
|||
cursor: not-allowed; |
|||
pointer-events: none; |
|||
} |
|||
.page-info-full { |
|||
color: #666; |
|||
font-size: 1.12em; |
|||
} |
@ -0,0 +1,54 @@ |
|||
extends /layouts/page.pug |
|||
|
|||
block pageContent |
|||
.no-auth-container |
|||
.no-auth-icon |
|||
i.fa.fa-lock |
|||
h2 访问受限 |
|||
p 您没有权限访问此页面,请先登录或联系管理员。 |
|||
a.btn(href='/login') 去登录 |
|||
|
|||
block pageHead |
|||
style. |
|||
.no-auth-container { |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
justify-content: center; |
|||
min-height: 60vh; |
|||
text-align: center; |
|||
} |
|||
.no-auth-icon { |
|||
font-size: 4em; |
|||
color: #ff6a6a; |
|||
margin-bottom: 20px; |
|||
} |
|||
.no-auth-container h2 { |
|||
margin: 0 0 10px 0; |
|||
color: #333; |
|||
} |
|||
.no-auth-container p { |
|||
color: #888; |
|||
margin-bottom: 24px; |
|||
} |
|||
.no-auth-container .btn { |
|||
display: inline-block; |
|||
padding: 10px 32px; |
|||
background: linear-gradient(90deg, #4fd1ff 0%, #ff6a6a 100%); |
|||
color: #fff; |
|||
border: none; |
|||
border-radius: 24px; |
|||
font-size: 1.1em; |
|||
text-decoration: none; |
|||
transition: background 0.2s; |
|||
cursor: pointer; |
|||
} |
|||
.no-auth-container .btn:hover { |
|||
background: linear-gradient(90deg, #ffb86c 0%, #4fd1ff 100%); |
|||
color: #fff200; |
|||
} |
|||
|
|||
//- block pageScripts |
|||
//- script. |
|||
//- const curUrl = URL.parse(location.href).searchParams.get("from") |
|||
//- fetch(curUrl,{redirect: 'error'}).then(res=>location.href=curUrl).catch(e=>console.log(e)) |
@ -0,0 +1,10 @@ |
|||
extends /layouts/page.pug |
|||
|
|||
block pageHead |
|||
+css("css/page/index.css") |
|||
|
|||
block pageContent |
|||
.card.home-hero |
|||
h1 #{$site.site_title} |
|||
p.subtitle #{$site.site_description} |
|||
|
@ -0,0 +1,17 @@ |
|||
extends /layouts/pure.pug |
|||
|
|||
block pageHead |
|||
+css("css/page/index.css") |
|||
|
|||
block pageContent |
|||
.home-hero |
|||
.avatar-container |
|||
.author #{$site.site_author} |
|||
img.avatar(src=$site.site_author_avatar, alt="") |
|||
.card |
|||
div 人生轨迹 |
|||
+include() |
|||
- var timeLine = [{icon: "第一份工作",title: "???", desc: `做游戏的。`, } ] |
|||
include /htmx/timeline.pug |
|||
//- div(hx-get="/htmx/timeline" hx-trigger="load") |
|||
//- div(style="text-align:center;color:white") Loading |
@ -0,0 +1,96 @@ |
|||
extends /layouts/pure.pug |
|||
|
|||
block pageHead |
|||
+css("css/page/index.css") |
|||
|
|||
block pageContent |
|||
div(class="mt-[50px]") |
|||
+include() |
|||
include /htmx/navbar.pug |
|||
|
|||
//- // 收藏网站主界面 |
|||
//- .bookmarks-container |
|||
//- // 搜索和添加区域 |
|||
//- .search-section |
|||
//- .search-box |
|||
//- input#searchInput(type="text" placeholder="搜索收藏..." class="search-input") |
|||
//- button#searchBtn(class="search-btn") 搜索 |
|||
//- button#addBookmarkBtn(class="add-btn") + 添加收藏 |
|||
|
|||
//- // 分类和标签导航 |
|||
//- .navigation-section |
|||
//- .categories-nav |
|||
//- .nav-title 分类 |
|||
//- .category-list#categoryList |
|||
//- // 分类将通过JavaScript动态加载 |
|||
|
|||
//- .tags-cloud |
|||
//- .nav-title 热门标签 |
|||
//- .tag-list#tagList |
|||
//- // 标签将通过JavaScript动态加载 |
|||
|
|||
//- // 收藏列表区域 |
|||
//- .bookmarks-section |
|||
//- .section-header |
|||
//- h2#sectionTitle 我的收藏 |
|||
//- .view-controls |
|||
//- button#gridViewBtn(class="view-btn active" data-view="grid") 网格 |
|||
//- button#listViewBtn(class="view-btn" data-view="list") 列表 |
|||
|
|||
//- .bookmarks-grid#bookmarksGrid |
|||
//- // 收藏卡片将通过JavaScript动态加载 |
|||
|
|||
//- .bookmarks-list#bookmarksList(style="display: none;") |
|||
//- // 列表视图将通过JavaScript动态加载 |
|||
|
|||
//- // 分页控制 |
|||
//- .pagination-section |
|||
//- .pagination#pagination |
|||
//- // 分页将通过JavaScript动态加载 |
|||
|
|||
//- // 添加/编辑收藏模态框 |
|||
//- .modal#bookmarkModal(style="display: none;") |
|||
//- .modal-overlay |
|||
//- .modal-content |
|||
//- .modal-header |
|||
//- h3#modalTitle 添加收藏 |
|||
//- button.modal-close#closeModal × |
|||
|
|||
//- .modal-body |
|||
//- form#bookmarkForm |
|||
//- .form-group |
|||
//- label(for="bookmarkTitle") 标题 * |
|||
//- input#bookmarkTitle(name="bookmarkTitle" type="text" required placeholder="输入网站标题") |
|||
|
|||
//- .form-group |
|||
//- label(for="bookmarkUrl") URL * |
|||
//- input#bookmarkUrl(name="bookmarkUrl" type="url" required placeholder="https://example.com") |
|||
|
|||
//- .form-group |
|||
//- label(for="bookmarkDescription") 描述 |
|||
//- textarea#bookmarkDescription(name="bookmarkDescription" placeholder="描述这个网站...") |
|||
|
|||
//- .form-group |
|||
//- label(for="bookmarkCategory") 分类 |
|||
//- select#bookmarkCategory(name="bookmarkCategory") |
|||
//- option(value="") 选择分类 |
|||
|
|||
//- .form-group |
|||
//- label(for="bookmarkTags") 标签 |
|||
//- input#bookmarkTags(name="bookmarkTags" type="text" placeholder="用逗号分隔多个标签") |
|||
|
|||
//- .form-group |
|||
//- label 额外链接 |
|||
//- .extra-links#extraLinks |
|||
//- .extra-link-item |
|||
//- input(type="text" placeholder="链接标题" class="link-title") |
|||
//- input(type="url" placeholder="链接URL" class="link-url") |
|||
//- button(type="button" class="remove-link") 删除 |
|||
//- button#addLinkBtn(type="button" class="add-link-btn") + 添加链接 |
|||
|
|||
//- .form-actions |
|||
//- button(type="submit" class="submit-btn") 保存 |
|||
//- button(type="button" class="cancel-btn" onclick="closeModal()") 取消 |
|||
|
|||
//- block pageScripts |
|||
//- script(src="/js/bookmarks.js") |
@ -0,0 +1,9 @@ |
|||
extends /layouts/pure.pug |
|||
|
|||
block pageHead |
|||
+css("css/page/index.css") |
|||
|
|||
block pageContent |
|||
+include() |
|||
- let timeLine = [{icon: '11',title: "aaaa",desc:"asd"}] |
|||
include /htmx/timeline.pug |
@ -0,0 +1,116 @@ |
|||
extends /layouts/pure.pug |
|||
|
|||
block pageScripts |
|||
script(src="js/login.js") |
|||
|
|||
block pageContent |
|||
.login-container |
|||
.login-card |
|||
span.back-home-text |
|||
a(href="/", title="返回首页") 返回首页 |
|||
h2 登录 |
|||
form#login-form(action="/login" method="post") |
|||
.form-group |
|||
label(for="username") 用户名 |
|||
input#username(type="text" name="username" placeholder="请输入用户名" required) |
|||
.form-group |
|||
label(for="password") 密码 |
|||
input#password(type="password" name="password" placeholder="请输入密码" required) |
|||
button.login-btn(type="submit") 登录 |
|||
if error |
|||
.login-error= error |
|||
// 页面内联样式优化 |
|||
style. |
|||
.login-container { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
min-height: 80vh; |
|||
} |
|||
.login-card { |
|||
background: #fff; |
|||
border-radius: 24px; |
|||
box-shadow: 0 4px 24px rgba(0,0,0,0.08); |
|||
padding: 2.5rem 2.5rem 2rem 2.5rem; |
|||
min-width: 380px; |
|||
max-width: 96vw; |
|||
width: 420px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
position: relative; |
|||
} |
|||
.back-home-text { |
|||
position: absolute; |
|||
left: 24px; |
|||
top: 18px; |
|||
font-size: 0.98rem; |
|||
color: #bbb; |
|||
font-weight: 500; |
|||
letter-spacing: 1px; |
|||
} |
|||
.back-home-text a { |
|||
color: #bbb; |
|||
text-decoration: none; |
|||
transition: color 0.2s; |
|||
} |
|||
.back-home-text a:hover { |
|||
color: #7ec6f7; |
|||
text-decoration: underline; |
|||
} |
|||
.login-card h2 { |
|||
margin-bottom: 1.5rem; |
|||
font-weight: 600; |
|||
color: #333; |
|||
letter-spacing: 2px; |
|||
} |
|||
.form-group { |
|||
width: 100%; |
|||
margin-bottom: 1.4rem; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: flex-start; |
|||
} |
|||
.form-group label { |
|||
margin-bottom: 0.5rem; |
|||
color: #444; |
|||
font-size: 1.13rem; |
|||
font-weight: 600; |
|||
letter-spacing: 1px; |
|||
} |
|||
.form-group input { |
|||
width: 100%; |
|||
padding: 0.7rem 1.1rem; |
|||
border: 1px solid #e0e0e0; |
|||
border-radius: 8px; |
|||
font-size: 1.08rem; |
|||
outline: none; |
|||
transition: border 0.2s; |
|||
} |
|||
.form-group input:focus { |
|||
border-color: #7ec6f7; |
|||
} |
|||
.login-btn { |
|||
width: 100%; |
|||
padding: 0.8rem 0; |
|||
border: none; |
|||
border-radius: 20px; |
|||
background: linear-gradient(135deg, #7ec6f7 0%, #ff8ca8 100%); |
|||
color: #fff; |
|||
font-size: 1.13rem; |
|||
font-weight: 600; |
|||
cursor: pointer; |
|||
box-shadow: 0 2px 8px rgba(126,198,247,0.12); |
|||
transition: background 0.2s, box-shadow 0.2s; |
|||
margin-top: 0.7rem; |
|||
} |
|||
.login-btn:hover { |
|||
background: linear-gradient(135deg, #5bb0e6 0%, #ff6f91 100%); |
|||
box-shadow: 0 4px 16px rgba(255,140,168,0.12); |
|||
} |
|||
.login-error { |
|||
color: #ff4d4f; |
|||
margin-top: 1rem; |
|||
font-size: 0.98rem; |
|||
text-align: center; |
|||
} |
@ -0,0 +1,95 @@ |
|||
extends /layouts/pure.pug |
|||
|
|||
block pageHead |
|||
style. |
|||
body { |
|||
background: #f5f7fa; |
|||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
|||
} |
|||
.register-container { |
|||
max-width: 400px; |
|||
margin: 60px auto; |
|||
background: #fff; |
|||
border-radius: 10px; |
|||
box-shadow: 0 2px 16px rgba(0,0,0,0.08); |
|||
padding: 32px 28px 24px 28px; |
|||
} |
|||
.register-title { |
|||
text-align: center; |
|||
font-size: 2rem; |
|||
margin-bottom: 24px; |
|||
color: #333; |
|||
font-weight: 600; |
|||
} |
|||
.form-group { |
|||
margin-bottom: 18px; |
|||
} |
|||
label { |
|||
display: block; |
|||
margin-bottom: 6px; |
|||
color: #555; |
|||
font-size: 1rem; |
|||
} |
|||
input[type="text"], |
|||
input[type="email"], |
|||
input[type="password"] { |
|||
width: 100%; |
|||
padding: 10px 12px; |
|||
border: 1px solid #d1d5db; |
|||
border-radius: 6px; |
|||
font-size: 1rem; |
|||
background: #f9fafb; |
|||
transition: border 0.2s; |
|||
box-sizing: border-box; |
|||
} |
|||
input:focus { |
|||
border-color: #409eff; |
|||
outline: none; |
|||
} |
|||
.register-btn { |
|||
width: 100%; |
|||
padding: 12px 0; |
|||
background: linear-gradient(90deg, #409eff 0%, #66b1ff 100%); |
|||
color: #fff; |
|||
border: none; |
|||
border-radius: 6px; |
|||
font-size: 1.1rem; |
|||
font-weight: 600; |
|||
cursor: pointer; |
|||
margin-top: 10px; |
|||
transition: background 0.2s; |
|||
} |
|||
.register-btn:hover { |
|||
background: linear-gradient(90deg, #66b1ff 0%, #409eff 100%); |
|||
} |
|||
.login-link { |
|||
display: block; |
|||
text-align: right; |
|||
margin-top: 14px; |
|||
color: #409eff; |
|||
text-decoration: none; |
|||
font-size: 0.95rem; |
|||
} |
|||
.login-link:hover { |
|||
text-decoration: underline; |
|||
|
|||
block pageContent |
|||
.register-container |
|||
.register-title 注册账号 |
|||
form(action="/register" method="post") |
|||
input(type="text" name="randomStr" value=randomStr style="display:none") |
|||
.form-group |
|||
label(for="username") 用户名 |
|||
input(type="text" id="username" name="username" required placeholder="请输入用户名") |
|||
.form-group |
|||
label(for="password") 密码 |
|||
input(type="password" id="password" name="password" required placeholder="请输入密码") |
|||
.form-group |
|||
label(for="confirm_password") 确认密码 |
|||
input(type="password" id="confirm_password" name="confirm_password" required placeholder="请再次输入密码") |
|||
img(src="/captcha", alt="") |
|||
.form-group |
|||
label(for="code") 验证码 |
|||
input(type="text" id="code" name="code" required placeholder="请输入验证码") |
|||
button.register-btn(type="submit") 注册 |
|||
a.login-link(href="/login") 已有账号?去登录 |
@ -0,0 +1,83 @@ |
|||
import { dirname, resolve } from "node:path" |
|||
import { fileURLToPath } from "node:url" |
|||
import module from "node:module" |
|||
import { defineConfig } from "vite" |
|||
import pkg from "./package.json" |
|||
import { viteStaticCopy } from "vite-plugin-static-copy" |
|||
|
|||
const __dirname = dirname(fileURLToPath(import.meta.url)) |
|||
|
|||
function getExternal(): string[] { |
|||
return [...Object.keys(pkg.dependencies || {}), ...module.builtinModules] |
|||
} |
|||
|
|||
export default defineConfig({ |
|||
publicDir: false, |
|||
resolve: { |
|||
alias: { |
|||
"@": resolve(__dirname, "src"), |
|||
db: resolve(__dirname, "src/db"), |
|||
config: resolve(__dirname, "src/config"), |
|||
utils: resolve(__dirname, "src/utils"), |
|||
services: resolve(__dirname, "src/services"), |
|||
}, |
|||
}, |
|||
build: { |
|||
lib: { |
|||
entry: resolve(__dirname, "src/main.js"), |
|||
formats: ["es"], |
|||
fileName: () => `[name].js`, |
|||
}, |
|||
outDir: resolve(__dirname, "dist"), |
|||
rollupOptions: { |
|||
external: getExternal(), |
|||
// watch: {
|
|||
// include: "src/**",
|
|||
// exclude: "node_modules/**",
|
|||
// },
|
|||
output: { |
|||
preserveModules: true, |
|||
preserveModulesRoot: "src", |
|||
inlineDynamicImports: false, |
|||
}, |
|||
}, |
|||
}, |
|||
plugins: [ |
|||
viteStaticCopy({ |
|||
targets: [ |
|||
{ |
|||
src: "public", |
|||
dest: "", |
|||
}, |
|||
{ |
|||
src: "src/views", |
|||
dest: "", |
|||
}, |
|||
{ |
|||
src: "src/db/migrations", |
|||
dest: "db", |
|||
}, |
|||
{ |
|||
src: "src/db/seeds", |
|||
dest: "db", |
|||
}, |
|||
{ |
|||
src: "entrypoint.sh", |
|||
dest: "", |
|||
}, |
|||
{ |
|||
src: "package.json", |
|||
dest: "", |
|||
}, |
|||
{ |
|||
src: "knexfile.mjs", |
|||
dest: "", |
|||
}, |
|||
{ |
|||
src: "bun.lockb", |
|||
dest: "", |
|||
}, |
|||
], |
|||
}), |
|||
], |
|||
}) |
Loading…
Reference in new issue