Repository: liangliangyy/DjangoBlog
Branch: master
Commit: 866a07b72ce6
Files: 268
Total size: 1.0 MB
Directory structure:
gitextract_z9fgi4z9/
├── .dockerignore
├── .gitattributes
├── .github/
│ ├── ISSUE_TEMPLATE.md
│ ├── dependabot.yml
│ └── workflows/
│ ├── codeql-analysis.yml
│ ├── deploy-master.yml
│ ├── django.yml
│ ├── docker.yml
│ ├── frontend.yml
│ └── publish-release.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── accounts/
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── forms.py
│ ├── migrations/
│ │ ├── 0001_initial.py
│ │ ├── 0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py
│ │ └── __init__.py
│ ├── models.py
│ ├── templatetags/
│ │ └── __init__.py
│ ├── test_admin.py
│ ├── test_user_business_logic.py
│ ├── tests.py
│ ├── urls.py
│ ├── user_login_backend.py
│ ├── utils.py
│ └── views.py
├── blog/
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── context_processors.py
│ ├── documents.py
│ ├── forms.py
│ ├── management/
│ │ ├── __init__.py
│ │ └── commands/
│ │ ├── __init__.py
│ │ ├── build_index.py
│ │ ├── build_search_words.py
│ │ ├── clear_cache.py
│ │ ├── create_testdata.py
│ │ ├── ping_baidu.py
│ │ └── sync_user_avatar.py
│ ├── middleware.py
│ ├── migrations/
│ │ ├── 0001_initial.py
│ │ ├── 0002_blogsettings_global_footer_and_more.py
│ │ ├── 0003_blogsettings_comment_need_review.py
│ │ ├── 0004_rename_analyticscode_blogsettings_analytics_code_and_more.py
│ │ ├── 0005_alter_article_options_alter_category_options_and_more.py
│ │ ├── 0006_alter_blogsettings_options.py
│ │ ├── 0007_article_idx_type_status_pub_article_idx_status_views_and_more.py
│ │ ├── 0008_blogsettings_color_scheme.py
│ │ └── __init__.py
│ ├── models.py
│ ├── search_indexes.py
│ ├── static/
│ │ ├── account/
│ │ │ ├── css/
│ │ │ │ └── account.css
│ │ │ └── js/
│ │ │ └── account.js
│ │ ├── assets/
│ │ │ └── css/
│ │ │ └── signin.css
│ │ ├── blog/
│ │ │ ├── css/
│ │ │ │ └── oauth_style.css
│ │ │ ├── fonts/
│ │ │ │ └── open-sans.css
│ │ │ └── js/
│ │ │ └── mathjax-loader.js
│ │ └── pygments/
│ │ └── default.css
│ ├── templatetags/
│ │ ├── __init__.py
│ │ ├── blog_tags.py
│ │ └── vite_tags.py
│ ├── test_admin.py
│ ├── test_article_business_logic.py
│ ├── test_context_processors.py
│ ├── test_middleware.py
│ ├── test_templatetags.py
│ ├── test_views.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py
├── codecov.yml
├── comments/
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── forms.py
│ ├── migrations/
│ │ ├── 0001_initial.py
│ │ ├── 0002_alter_comment_is_enable.py
│ │ ├── 0003_alter_comment_options_remove_comment_created_time_and_more.py
│ │ ├── 0004_comment_idx_art_parent_enable_comment_idx_enable_id.py
│ │ ├── 0005_commentreaction.py
│ │ └── __init__.py
│ ├── models.py
│ ├── templatetags/
│ │ ├── __init__.py
│ │ └── comments_tags.py
│ ├── test_comment_business_logic.py
│ ├── test_views.py
│ ├── tests.py
│ ├── urls.py
│ ├── utils.py
│ └── views.py
├── deploy/
│ ├── docker-compose/
│ │ ├── docker-compose.es.yml
│ │ └── docker-compose.yml
│ ├── entrypoint.sh
│ ├── k8s/
│ │ ├── configmap.yaml
│ │ ├── deployment.yaml
│ │ ├── gateway.yaml
│ │ ├── pv.yaml
│ │ ├── pvc.yaml
│ │ ├── service.yaml
│ │ └── storageclass.yaml
│ └── nginx.conf
├── djangoblog/
│ ├── __init__.py
│ ├── admin_site.py
│ ├── apps.py
│ ├── base_views.py
│ ├── blog_signals.py
│ ├── constants.py
│ ├── elasticsearch_backend.py
│ ├── error_views.py
│ ├── feeds.py
│ ├── logentryadmin.py
│ ├── mixins.py
│ ├── plugin_manage/
│ │ ├── base_plugin.py
│ │ ├── hook_constants.py
│ │ ├── hooks.py
│ │ └── loader.py
│ ├── settings.py
│ ├── sitemap.py
│ ├── spider_notify.py
│ ├── test_base.py
│ ├── test_email_integration.py
│ ├── test_email_integration_complete.py
│ ├── test_plugins.py
│ ├── tests.py
│ ├── urls.py
│ ├── utils.py
│ ├── whoosh_cn_backend.py
│ └── wsgi.py
├── docs/
│ ├── README-en.md
│ ├── config-en.md
│ ├── config.md
│ ├── docker-en.md
│ ├── docker.md
│ ├── es.md
│ ├── k8s-en.md
│ ├── k8s.md
│ └── search-engine-config.md
├── frontend/
│ ├── .gitignore
│ ├── package.json
│ ├── postcss.config.js
│ ├── src/
│ │ ├── components/
│ │ │ ├── backToTop.js
│ │ │ ├── commentSystem.js
│ │ │ ├── imageLightbox.js
│ │ │ ├── navigation.js
│ │ │ └── reactionPicker.js
│ │ ├── features/
│ │ │ └── darkMode.js
│ │ ├── main.js
│ │ ├── styles/
│ │ │ └── main.css
│ │ └── utils/
│ │ └── nprogress.js
│ ├── tailwind.config.js
│ └── vite.config.js
├── locale/
│ ├── en/
│ │ └── LC_MESSAGES/
│ │ ├── django.mo
│ │ └── django.po
│ ├── zh_Hans/
│ │ └── LC_MESSAGES/
│ │ ├── django.mo
│ │ └── django.po
│ └── zh_Hant/
│ └── LC_MESSAGES/
│ ├── django.mo
│ └── django.po
├── manage.py
├── oauth/
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── forms.py
│ ├── migrations/
│ │ ├── 0001_initial.py
│ │ ├── 0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py
│ │ ├── 0003_alter_oauthuser_nickname.py
│ │ └── __init__.py
│ ├── models.py
│ ├── oauthmanager.py
│ ├── templatetags/
│ │ ├── __init__.py
│ │ └── oauth_tags.py
│ ├── test_oauth_business_logic.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py
├── owntracks/
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── migrations/
│ │ ├── 0001_initial.py
│ │ ├── 0002_alter_owntracklog_options_and_more.py
│ │ └── __init__.py
│ ├── models.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py
├── plugins/
│ ├── __init__.py
│ ├── article_copyright/
│ │ ├── __init__.py
│ │ └── plugin.py
│ ├── article_recommendation/
│ │ ├── __init__.py
│ │ ├── plugin.py
│ │ └── static/
│ │ └── article_recommendation/
│ │ ├── css/
│ │ │ └── recommendation.css
│ │ └── js/
│ │ └── recommendation.js
│ ├── cloudflare_cache/
│ │ ├── __init__.py
│ │ ├── api.py
│ │ ├── handlers.py
│ │ └── plugin.py
│ ├── external_links/
│ │ ├── __init__.py
│ │ └── plugin.py
│ ├── image_lazy_loading/
│ │ ├── __init__.py
│ │ └── plugin.py
│ ├── reading_time/
│ │ ├── __init__.py
│ │ └── plugin.py
│ ├── seo_optimizer/
│ │ ├── __init__.py
│ │ └── plugin.py
│ └── view_count/
│ ├── __init__.py
│ └── plugin.py
├── requirements.txt
├── servermanager/
│ ├── MemcacheStorage.py
│ ├── __init__.py
│ ├── admin.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── blogapi.py
│ │ └── commonapi.py
│ ├── apps.py
│ ├── migrations/
│ │ ├── 0001_initial.py
│ │ ├── 0002_alter_emailsendlog_options_and_more.py
│ │ └── __init__.py
│ ├── models.py
│ ├── robot.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py
└── templates/
├── account/
│ ├── forget_password.html
│ ├── login.html
│ ├── registration_form.html
│ └── result.html
├── blog/
│ ├── article_archives.html
│ ├── article_detail.html
│ ├── article_index.html
│ ├── error_page.html
│ ├── links_list.html
│ └── tags/
│ ├── article_info.html
│ ├── article_info_highlight.html
│ ├── article_meta_info.html
│ ├── article_pagination.html
│ ├── article_tag_list.html
│ ├── breadcrumb.html
│ └── sidebar.html
├── comments/
│ └── tags/
│ ├── comment_item.html
│ ├── comment_item_modern.html
│ ├── comment_item_tree.html
│ ├── comment_list.html
│ ├── comment_list_modern.html
│ ├── post_comment.html
│ └── post_comment_modern.html
├── oauth/
│ ├── bindsuccess.html
│ ├── oauth_applications.html
│ └── require_email.html
├── owntracks/
│ ├── show_log_dates.html
│ └── show_maps.html
├── plugins/
│ ├── article_recommendation/
│ │ ├── __init__.py
│ │ ├── bottom_widget.html
│ │ └── sidebar_widget.html
│ ├── css_includes.html
│ └── js_includes.html
├── search/
│ ├── indexes/
│ │ └── blog/
│ │ └── article_text.txt
│ └── search.html
└── share_layout/
├── adsense.html
├── base.html
├── base_account.html
├── footer.html
├── nav.html
├── nav_node.html
└── nav_node_mobile.html
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
bin/data/
# virtualenv
venv/
collectedstatic/
djangoblog/whoosh_index/
uploads/
settings_production.py
*.md
docs/
logs/
static/
.github/
# Frontend build artifacts (will be built in Docker)
frontend/node_modules/
frontend/dist/
blog/static/blog/dist/
# Development files
.git/
.gitignore
.vscode/
.idea/
*.pyc
__pycache__/
*.log
.env
================================================
FILE: .gitattributes
================================================
* text=auto
*.sh text eol=lf
*.conf text eol=lf
*.mo binary
*.po text eol=lf
================================================
FILE: .github/ISSUE_TEMPLATE.md
================================================
**我确定我已经查看了** (标注`[ ]`为`[x]`)
- [ ] [DjangoBlog的readme](https://github.com/liangliangyy/DjangoBlog/blob/master/README.md)
- [ ] [配置说明](https://github.com/liangliangyy/DjangoBlog/blob/master/bin/config.md)
- [ ] [其他 Issues](https://github.com/liangliangyy/DjangoBlog/issues)
----
**我要申请** (标注`[ ]`为`[x]`)
- [ ] BUG 反馈
- [ ] 添加新的特性或者功能
- [ ] 请求技术支持
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
# Python 依赖管理
- package-ecosystem: "pip"
directory: "/"
target-branch: "dev"
schedule:
interval: "weekly"
open-pull-requests-limit: 3 # 从 5 减少到 3
# 将依赖更新分组,减少 PR 数量
groups:
production-dependencies:
dependency-type: "production"
update-types:
- "minor"
- "patch"
development-dependencies:
dependency-type: "development"
update-types:
- "minor"
- "patch"
labels:
- "dependencies"
- "python"
commit-message:
prefix: "chore"
prefix-development: "chore"
include: "scope"
# 前端 npm 依赖管理
- package-ecosystem: "npm"
directory: "/frontend"
target-branch: "dev"
schedule:
interval: "weekly"
open-pull-requests-limit: 3 # 从 5 减少到 3
# 将依赖更新分组
groups:
frontend-production:
dependency-type: "production"
update-types:
- "minor"
- "patch"
frontend-development:
dependency-type: "development"
update-types:
- "minor"
- "patch"
labels:
- "dependencies"
- "frontend"
commit-message:
prefix: "chore"
prefix-development: "chore"
include: "scope"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
# GitHub Actions 依赖管理
- package-ecosystem: "github-actions"
directory: "/"
target-branch: "dev"
schedule:
interval: "weekly"
open-pull-requests-limit: 3 # 从 5 减少到 3
# 将 GitHub Actions 更新分组
groups:
github-actions:
patterns:
- "*"
update-types:
- "minor"
- "patch"
labels:
- "dependencies"
- "ci"
commit-message:
prefix: "ci"
include: "scope"
================================================
FILE: .github/workflows/codeql-analysis.yml
================================================
name: "CodeQL"
on:
push:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
- '**/*.yml'
- '**/*.txt'
pull_request:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
- '**/*.yml'
- '**/*.txt'
schedule:
- cron: '30 1 * * 0'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
CodeQL-Build:
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: python
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
================================================
FILE: .github/workflows/deploy-master.yml
================================================
name: 自动部署到生产环境
on:
workflow_run:
workflows: ["Django CI", "Frontend CI"]
types:
- completed
branches:
- master
workflow_dispatch:
inputs:
environment:
description: '部署环境'
required: true
default: 'production'
type: choice
options:
- production
- staging
image_tag:
description: '镜像标签 (默认: latest)'
required: false
default: 'latest'
type: string
skip_tests:
description: '跳过测试直接部署 (包括Django和Frontend CI)'
required: false
default: false
type: boolean
concurrency:
group: deploy-${{ github.event.workflow_run.head_sha || github.sha }}
cancel-in-progress: true
env:
REGISTRY: registry.cn-shenzhen.aliyuncs.com
IMAGE_NAME: liangliangyy/djangoblog
NAMESPACE: djangoblog
jobs:
# Job 1: 检查 CI 状态(自动触发时)
check-ci:
name: 检查 CI 状态
runs-on: ubuntu-latest
# 只在 workflow_run 触发且不跳过测试时运行
if: ${{ github.event_name == 'workflow_run' }}
outputs:
ci_passed: ${{ steps.check.outputs.ci_passed }}
steps:
- name: 检查所有 CI 是否完成
id: check
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "🔍 检查 CI 状态 (触发者: ${{ github.event.workflow_run.name }})"
COMMIT_SHA="${{ github.event.workflow_run.head_sha }}"
# 检查 Django CI(必须完成且成功)
DJANGO_STATUS=$(gh api "repos/${{ github.repository }}/actions/runs?head_sha=$COMMIT_SHA&event=push&status=completed" \
| jq -r '.workflow_runs[] | select(.name == "Django CI") | .conclusion' | head -1)
# 检查 Frontend CI 是否在运行
FRONTEND_RUNNING=$(gh api "repos/${{ github.repository }}/actions/runs?head_sha=$COMMIT_SHA&event=push&status=in_progress" \
| jq -r '.workflow_runs[] | select(.name == "Frontend CI") | .id' | head -1)
# 检查 Frontend CI 完成状态
FRONTEND_STATUS=$(gh api "repos/${{ github.repository }}/actions/runs?head_sha=$COMMIT_SHA&event=push&status=completed" \
| jq -r '.workflow_runs[] | select(.name == "Frontend CI") | .conclusion' | head -1)
# Django CI 必须完成且成功
if [ -z "$DJANGO_STATUS" ]; then
echo "⏸️ Django CI 未完成,等待..."
echo "ci_passed=false" >> $GITHUB_OUTPUT
exit 0 # 成功退出,但不触发部署
elif [ "$DJANGO_STATUS" != "success" ]; then
echo "❌ Django CI 失败"
echo "ci_passed=false" >> $GITHUB_OUTPUT
exit 1
fi
# Frontend CI 如果在运行,等待
if [ -n "$FRONTEND_RUNNING" ]; then
echo "⏸️ Frontend CI 运行中,等待..."
echo "ci_passed=false" >> $GITHUB_OUTPUT
exit 0
fi
# Frontend CI 如果运行了必须成功,未运行则跳过
if [ -n "$FRONTEND_STATUS" ] && [ "$FRONTEND_STATUS" != "success" ]; then
echo "❌ Frontend CI 失败"
echo "ci_passed=false" >> $GITHUB_OUTPUT
exit 1
fi
echo "✅ 所有 CI 通过"
echo "ci_passed=true" >> $GITHUB_OUTPUT
# Job 2: 构建和部署
deploy:
name: 构建镜像并部署到生产环境
runs-on: ubuntu-latest
# 手动触发 或 CI检查通过后触发
needs: [check-ci]
if: |
always() &&
(github.event_name == 'workflow_dispatch' ||
(needs.check-ci.result == 'success' && needs.check-ci.outputs.ci_passed == 'true'))
steps:
- name: 检出代码
uses: actions/checkout@v6
- name: 设置部署参数
id: deploy-params
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "trigger_type=手动触发" >> $GITHUB_OUTPUT
echo "environment=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT
echo "image_tag=${{ github.event.inputs.image_tag }}" >> $GITHUB_OUTPUT
else
echo "trigger_type=CI自动触发" >> $GITHUB_OUTPUT
echo "environment=production" >> $GITHUB_OUTPUT
echo "image_tag=latest" >> $GITHUB_OUTPUT
fi
- name: 显示部署信息
run: |
echo "🚀 部署信息:"
echo " 触发方式: ${{ steps.deploy-params.outputs.trigger_type }}"
echo " 部署环境: ${{ steps.deploy-params.outputs.environment }}"
echo " 镜像标签: ${{ steps.deploy-params.outputs.image_tag }}"
- name: 设置Docker Buildx
uses: docker/setup-buildx-action@v4
- name: 登录私有镜像仓库
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: 提取镜像元数据
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix={{branch}}-
type=raw,value=${{ steps.deploy-params.outputs.image_tag }}
- name: 构建并推送Docker镜像
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
- name: 部署到生产服务器
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }}
key: ${{ secrets.PRODUCTION_SSH_KEY }}
port: ${{ secrets.PRODUCTION_PORT || 22 }}
script: |
echo "🚀 开始部署 DjangoBlog..."
# 检查kubectl是否可用
if ! command -v kubectl &> /dev/null; then
echo "❌ 错误: kubectl 未安装或不在PATH中"
exit 1
fi
# 检查命名空间是否存在
if ! kubectl get namespace ${{ env.NAMESPACE }} &> /dev/null; then
echo "❌ 错误: 命名空间 ${{ env.NAMESPACE }} 不存在"
exit 1
fi
# 更新deployment镜像
echo "📦 更新deployment镜像为: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.deploy-params.outputs.image_tag }}"
kubectl set image deployment/djangoblog \
djangoblog=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.deploy-params.outputs.image_tag }} \
-n ${{ env.NAMESPACE }}
# 重启deployment
echo "🔄 重启deployment..."
kubectl -n ${{ env.NAMESPACE }} rollout restart deployment djangoblog
# 等待deployment完成
echo "⏳ 等待deployment完成..."
kubectl rollout status deployment/djangoblog -n ${{ env.NAMESPACE }} --timeout=300s
# 检查deployment状态
echo "✅ 检查deployment状态..."
kubectl get deployment djangoblog -n ${{ env.NAMESPACE }}
kubectl get pods -l app=djangoblog -n ${{ env.NAMESPACE }}
echo "🎉 部署完成!"
- name: 发送部署通知
if: always()
run: |
# 设置通知内容
if [ "${{ job.status }}" = "success" ]; then
TITLE="✅ DjangoBlog部署成功"
STATUS="成功"
else
TITLE="❌ DjangoBlog部署失败"
STATUS="失败"
fi
MESSAGE="部署状态: ${STATUS}
触发方式: ${{ steps.deploy-params.outputs.trigger_type }}
部署环境: ${{ steps.deploy-params.outputs.environment }}
镜像标签: ${{ steps.deploy-params.outputs.image_tag }}
提交者: ${{ github.actor }}
时间: $(date '+%Y-%m-%d %H:%M:%S')
查看详情: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
# 发送Server酱通知
if [ -n "${{ secrets.SERVERCHAN_KEY }}" ]; then
echo "{\"title\": \"${TITLE}\", \"desp\": \"${MESSAGE}\"}" > /tmp/serverchan.json
curl --location "https://sctapi.ftqq.com/${{ secrets.SERVERCHAN_KEY }}.send" \
--header "Content-Type: application/json" \
--data @/tmp/serverchan.json \
--silent > /dev/null
rm -f /tmp/serverchan.json
echo "📱 部署通知已发送"
fi
================================================
FILE: .github/workflows/django.yml
================================================
name: Django CI
on:
push:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
pull_request:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
duplicate-check:
runs-on: ubuntu-latest
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip }}
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
skip_after_successful_duplicate: 'false'
paths_ignore: '["**/*.md", "docs/**"]'
do_not_skip: '["workflow_dispatch", "schedule"]'
concurrent_skipping: 'outdated_runs'
cancel_others: 'true'
test:
needs: duplicate-check
if: needs.duplicate-check.outputs.should_skip != 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# 标准测试 - Python 3.10
- python-version: "3.10"
test-type: "standard"
database: "mysql"
elasticsearch: false
coverage: false
# 标准测试 - Python 3.11
- python-version: "3.11"
test-type: "standard"
database: "mysql"
elasticsearch: false
coverage: false
# 完整测试 - 包含ES和覆盖率
- python-version: "3.11"
test-type: "full"
database: "mysql"
elasticsearch: true
coverage: true
# Docker构建测试
- python-version: "3.11"
test-type: "docker"
database: "none"
elasticsearch: false
coverage: false
name: Test (${{ matrix.test-type }}, Python ${{ matrix.python-version }})
steps:
- name: Checkout代码
uses: actions/checkout@v6
- name: 设置测试信息
id: test-info
run: |
echo "test_name=${{ matrix.test-type }}-py${{ matrix.python-version }}" >> $GITHUB_OUTPUT
if [ "${{ matrix.test-type }}" = "docker" ]; then
echo "skip_python_setup=true" >> $GITHUB_OUTPUT
else
echo "skip_python_setup=false" >> $GITHUB_OUTPUT
fi
# MySQL数据库设置 (只有需要数据库的测试才执行)
- name: 启动MySQL数据库
if: matrix.database == 'mysql'
uses: samin/mysql-action@v1.3
with:
host port: 3306
container port: 3306
character set server: utf8mb4
collation server: utf8mb4_general_ci
mysql version: latest
mysql root password: root
mysql database: djangoblog
mysql user: root
mysql password: root
# Elasticsearch设置 (只有完整测试才执行)
- name: 配置系统参数 (ES)
if: matrix.elasticsearch == true
run: |
sudo swapoff -a
sudo sysctl -w vm.swappiness=1
sudo sysctl -w fs.file-max=262144
sudo sysctl -w vm.max_map_count=262144
- name: 启动Elasticsearch
if: matrix.elasticsearch == true
run: |
echo "🚀 启动 Elasticsearch 8.6.1 容器"
# 启动 Elasticsearch 容器
docker run -d \
--name elasticsearch \
-p 9200:9200 \
-p 9300:9300 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
elasticsearch:8.6.1
# 等待 Elasticsearch 启动
echo "⏳ 等待 Elasticsearch 启动..."
for i in {1..60}; do
if curl -s http://localhost:9200 > /dev/null; then
echo "✅ Elasticsearch 启动成功"
curl -s http://localhost:9200
# 安装 IK 分词器
echo "📦 安装 IK 分词器插件..."
docker exec elasticsearch elasticsearch-plugin install --batch \
https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.6.1.zip
# 重启 Elasticsearch
echo "🔄 重启 Elasticsearch 以加载插件..."
docker restart elasticsearch
sleep 10
# 等待重启完成
for j in {1..30}; do
if curl -s http://localhost:9200 > /dev/null; then
echo "✅ Elasticsearch 重启成功"
break
fi
echo "🔄 等待 Elasticsearch 重启... ($j/30)"
sleep 2
done
break
fi
echo "🔄 等待 Elasticsearch 启动... ($i/60)"
sleep 2
done
# Python环境设置 (Docker测试跳过)
- name: 设置Python ${{ matrix.python-version }}
if: steps.test-info.outputs.skip_python_setup == 'false'
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: 'requirements.txt'
# 多层缓存策略优化
- name: 缓存Python依赖
if: steps.test-info.outputs.skip_python_setup == 'false'
uses: actions/cache@v5
with:
path: |
~/.cache/pip
.pytest_cache
key: ${{ runner.os }}-python-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-python-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}-
${{ runner.os }}-python-${{ matrix.python-version }}-
${{ runner.os }}-python-
# Django缓存优化 (测试数据库等)
- name: 缓存Django资源
if: matrix.test-type != 'docker'
uses: actions/cache@v5
with:
path: |
.coverage*
htmlcov/
.django_cache/
key: ${{ runner.os }}-django-${{ matrix.test-type }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-django-${{ matrix.test-type }}-
${{ runner.os }}-django-
- name: 安装Python依赖
if: steps.test-info.outputs.skip_python_setup == 'false'
run: |
echo "📦 安装Python依赖 (Python ${{ matrix.python-version }})"
python -m pip install --upgrade pip setuptools wheel
# 安装基础依赖
pip install -r requirements.txt
# 根据测试类型安装额外依赖
if [ "${{ matrix.coverage }}" = "true" ]; then
echo "📊 安装覆盖率工具"
pip install coverage[toml]
fi
# 验证关键依赖
echo "🔍 验证关键依赖安装"
python -c "import django; print(f'Django version: {django.get_version()}')"
python -c "import MySQLdb; print('MySQL client: OK')" || python -c "import pymysql; print('PyMySQL client: OK')"
if [ "${{ matrix.elasticsearch }}" = "true" ]; then
python -c "import elasticsearch; print('Elasticsearch client: OK')"
fi
# Django环境准备
- name: 准备Django环境
if: matrix.test-type != 'docker'
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && 'http://127.0.0.1:9200' || '' }}
run: |
echo "🔧 准备Django测试环境"
# 等待数据库就绪
echo "⏳ 等待MySQL数据库启动..."
for i in {1..30}; do
if python -c "import MySQLdb; MySQLdb.connect(host='127.0.0.1', user='root', passwd='root', db='djangoblog')" 2>/dev/null; then
echo "✅ MySQL数据库连接成功"
break
fi
echo "🔄 等待数据库启动... ($i/30)"
sleep 2
done
# 等待Elasticsearch就绪 (如果启用)
if [ "${{ matrix.elasticsearch }}" = "true" ]; then
echo "⏳ 等待Elasticsearch启动..."
for i in {1..30}; do
if curl -s http://127.0.0.1:9200/_cluster/health | grep -q '"status":"green"\|"status":"yellow"'; then
echo "✅ Elasticsearch连接成功"
break
fi
echo "🔄 等待Elasticsearch启动... ($i/30)"
sleep 2
done
fi
# Django测试执行
- name: 执行数据库迁移
if: matrix.test-type != 'docker'
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && 'http://127.0.0.1:9200' || '' }}
run: |
echo "🗄️ 执行数据库迁移"
# 检查迁移文件
echo "📋 检查待应用的迁移..."
python manage.py showmigrations
# 检查是否有未创建的迁移
python manage.py makemigrations --check --verbosity 2
# 执行迁移
python manage.py migrate --verbosity 2
echo "✅ 数据库迁移完成"
- name: 运行Django测试
if: matrix.test-type != 'docker'
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && 'http://127.0.0.1:9200' || '' }}
run: |
echo "🧪 开始执行 ${{ matrix.test-type }} 测试 (Python ${{ matrix.python-version }})"
# 显示Django配置信息
python manage.py diffsettings | head -20
# 运行测试
if [ "${{ matrix.coverage }}" = "true" ]; then
echo "📊 运行测试并生成覆盖率报告"
coverage run --source='.' --omit='*/venv/*,*/migrations/*,*/tests/*,manage.py' manage.py test --verbosity=2
echo "📈 生成覆盖率报告"
coverage xml
coverage report --show-missing
coverage html
echo "📋 覆盖率统计:"
coverage report | tail -1
else
echo "🧪 运行标准测试"
python manage.py test --verbosity=2 --failfast
fi
echo "✅ 测试执行完成"
# 覆盖率报告上传 (只有完整测试才执行)
- name: 上传覆盖率到Codecov
if: matrix.coverage == true && success()
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage.xml
flags: unittests,${{ github.ref_name }}
name: codecov-${{ github.ref_name }}-${{ steps.test-info.outputs.test_name }}
fail_ci_if_error: false
verbose: true
- name: 上传覆盖率到Codecov (备用)
if: matrix.coverage == true && failure()
uses: codecov/codecov-action@v5
with:
file: ./coverage.xml
flags: unittests,${{ github.ref_name }}
name: codecov-${{ github.ref_name }}-${{ steps.test-info.outputs.test_name }}-fallback
fail_ci_if_error: false
verbose: true
# Docker构建测试
- name: 设置QEMU
if: matrix.test-type == 'docker'
uses: docker/setup-qemu-action@v4
- name: 设置Docker Buildx
if: matrix.test-type == 'docker'
uses: docker/setup-buildx-action@v4
- name: Docker构建测试
if: matrix.test-type == 'docker'
uses: docker/build-push-action@v7
with:
context: .
push: false
tags: djangoblog/djangoblog:test-${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
# 收集测试工件 (失败时收集调试信息)
- name: 收集测试工件
if: failure() && matrix.test-type != 'docker'
run: |
echo "🔍 收集测试失败的调试信息"
# 收集Django日志
if [ -d "logs" ]; then
echo "📄 Django日志文件:"
ls -la logs/
if [ -f "logs/djangoblog.log" ]; then
echo "🔍 最新日志内容:"
tail -100 logs/djangoblog.log
fi
fi
# 显示数据库状态
echo "🗄️ 数据库连接状态:"
python -c "
try:
from django.db import connection
cursor = connection.cursor()
cursor.execute('SELECT VERSION()')
print(f'MySQL版本: {cursor.fetchone()[0]}')
cursor.execute('SHOW TABLES')
tables = cursor.fetchall()
print(f'数据库表数量: {len(tables)}')
except Exception as e:
print(f'数据库连接错误: {e}')
" || true
# Elasticsearch状态 (如果启用)
if [ "${{ matrix.elasticsearch }}" = "true" ]; then
echo "🔍 Elasticsearch状态:"
curl -s http://127.0.0.1:9200/_cluster/health?pretty || true
fi
# 上传测试工件
- name: 上传覆盖率HTML报告
if: matrix.coverage == true && always()
uses: actions/upload-artifact@v7
with:
name: coverage-report-${{ steps.test-info.outputs.test_name }}
path: htmlcov/
retention-days: 30
# 性能统计
- name: 测试性能统计
if: always() && matrix.test-type != 'docker'
run: |
echo "⚡ 测试性能统计:"
echo " 开始时间: $(date -d '@${{ job.started_at }}' '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo '未知')"
echo " 当前时间: $(date '+%Y-%m-%d %H:%M:%S')"
# 系统资源使用情况
echo "💻 系统资源:"
echo " CPU使用: $(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1)%"
echo " 内存使用: $(free -h | awk '/^Mem:/ {printf "%.1f%%", $3/$2 * 100}')"
echo " 磁盘使用: $(df -h / | awk 'NR==2{printf "%s", $5}')"
# 测试结果汇总
- name: 测试完成总结
if: always()
run: |
echo "📋 ============ 测试执行总结 ============"
echo " 🏷️ 测试类型: ${{ matrix.test-type }}"
echo " 🐍 Python版本: ${{ matrix.python-version }}"
echo " 🗄️ 数据库: ${{ matrix.database }}"
echo " 🔍 Elasticsearch: ${{ matrix.elasticsearch }}"
echo " 📊 覆盖率: ${{ matrix.coverage }}"
echo " ⚡ 状态: ${{ job.status }}"
echo " 📅 完成时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "============================================"
# 根据测试结果显示不同消息
if [ "${{ job.status }}" = "success" ]; then
echo "🎉 测试执行成功!"
else
echo "❌ 测试执行失败,请检查上面的日志"
fi
================================================
FILE: .github/workflows/docker.yml
================================================
name: docker
on:
push:
paths-ignore:
- '**/*.md'
- '**/*.yml'
branches:
- 'master'
- 'dev'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Set env to docker dev tag
if: endsWith(github.ref, '/dev')
run: |
echo "DOCKER_TAG=test" >> $GITHUB_ENV
- name: Set env to docker latest tag
if: endsWith(github.ref, '/master')
run: |
echo "DOCKER_TAG=latest" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{env.DOCKER_TAG}}
ghcr.io/${{ github.repository_owner }}/djangoblog:${{env.DOCKER_TAG}}
================================================
FILE: .github/workflows/frontend.yml
================================================
name: Frontend CI
on:
push:
branches:
- master
- dev
paths:
- 'frontend/**'
- '.github/workflows/frontend.yml'
pull_request:
branches:
- master
- dev
paths:
- 'frontend/**'
- '.github/workflows/frontend.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
duplicate-check:
runs-on: ubuntu-latest
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip }}
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
skip_after_successful_duplicate: 'false'
paths_ignore: '["**/*.md", "**/*.txt", "docs/**"]'
do_not_skip: '["workflow_dispatch", "schedule"]'
concurrent_skipping: 'outdated_runs'
cancel_others: 'true'
build-and-validate:
needs: duplicate-check
if: needs.duplicate-check.outputs.should_skip != 'true'
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20]
name: Frontend Build & Validation (Node ${{ matrix.node-version }})
steps:
- name: Checkout代码
uses: actions/checkout@v6
- name: 设置Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: 'frontend/package-lock.json'
# 缓存node_modules
- name: 缓存Node模块
uses: actions/cache@v5
with:
path: |
frontend/node_modules
~/.npm
key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node-version }}-
${{ runner.os }}-node-
- name: 安装依赖
working-directory: ./frontend
run: |
echo "📦 安装前端依赖 (Node ${{ matrix.node-version }})"
npm ci
# 验证关键依赖
echo "🔍 验证关键依赖"
npm list alpinejs
npm list htmx.org
npm list tailwindcss
npm list vite
- name: 构建前端资源
working-directory: ./frontend
run: |
echo "🔨 构建前端资源"
npm run build
# 验证构建产物
echo "✅ 验证构建产物"
ls -lh ../blog/static/blog/dist/
# 检查关键文件是否存在
if [ ! -f "../blog/static/blog/dist/.vite/manifest.json" ]; then
echo "❌ manifest.json 不存在"
exit 1
fi
# 检查是否有CSS文件
css_files=$(find ../blog/static/blog/dist/css -name "*.css" | wc -l)
if [ "$css_files" -eq 0 ]; then
echo "❌ 没有找到CSS文件"
exit 1
fi
echo "✅ 找到 $css_files 个CSS文件"
# 检查是否有JS文件
js_files=$(find ../blog/static/blog/dist/js -name "*.js" | wc -l)
if [ "$js_files" -eq 0 ]; then
echo "❌ 没有找到JS文件"
exit 1
fi
echo "✅ 找到 $js_files 个JS文件"
- name: 检查文件大小
working-directory: ./frontend
run: |
echo "📊 检查构建产物大小"
# 获取CSS文件大小
css_file=$(find ../blog/static/blog/dist/css -name "main-*.css" | head -1)
if [ -f "$css_file" ]; then
css_size=$(stat -c%s "$css_file")
css_size_kb=$((css_size / 1024))
echo " CSS大小: ${css_size_kb}KB"
# CSS文件大小警告阈值 (200KB)
if [ "$css_size_kb" -gt 200 ]; then
echo "⚠️ 警告: CSS文件较大 (${css_size_kb}KB)"
fi
fi
# 获取JS文件总大小
js_total_size=$(find ../blog/static/blog/dist/js -name "*.js" -exec stat -c%s {} \; | awk '{sum+=$1} END {print sum}')
js_total_kb=$((js_total_size / 1024))
echo " JS总大小: ${js_total_kb}KB"
# JS文件大小警告阈值 (200KB)
if [ "$js_total_kb" -gt 200 ]; then
echo "⚠️ 警告: JS文件较大 (${js_total_kb}KB)"
fi
- name: 验证CSS语法
working-directory: ./frontend
run: |
echo "🎨 验证CSS文件"
# 检查CSS文件是否包含关键选择器
css_file=$(find ../blog/static/blog/dist/css -name "main-*.css" | head -1)
if [ -f "$css_file" ]; then
echo "✅ 检查CSS关键选择器"
# 检查深色模式支持
if grep -q "data-theme=dark" "$css_file"; then
echo " ✅ 包含深色模式支持"
else
echo " ❌ 缺少深色模式支持"
exit 1
fi
# 检查Tailwind基础类
if grep -q "@tailwind" "$css_file" || grep -q "tailwind" "$css_file" || grep -q ".container" "$css_file"; then
echo " ✅ 包含Tailwind CSS"
else
echo " ⚠️ 可能缺少Tailwind CSS"
fi
# 检查代码块样式
if grep -q "codehilite" "$css_file" || grep -q "code" "$css_file"; then
echo " ✅ 包含代码块样式"
else
echo " ⚠️ 可能缺少代码块样式"
fi
fi
- name: 验证JS语法
working-directory: ./frontend
run: |
echo "🔍 验证JS文件"
# 检查主JS文件
main_js=$(find ../blog/static/blog/dist/js -name "main-*.js" | head -1)
if [ -f "$main_js" ]; then
echo "✅ 检查JS模块"
# 检查Alpine.js
if grep -q "Alpine" "$main_js" || [ -f "$(find ../blog/static/blog/dist/js -name "alpine-*.js" | head -1)" ]; then
echo " ✅ 包含Alpine.js"
else
echo " ❌ 缺少Alpine.js"
exit 1
fi
# 检查HTMX
if grep -q "htmx" "$main_js" || [ -f "$(find ../blog/static/blog/dist/js -name "htmx-*.js" | head -1)" ]; then
echo " ✅ 包含HTMX"
else
echo " ❌ 缺少HTMX"
exit 1
fi
fi
- name: 检查manifest.json
working-directory: ./frontend
run: |
echo "📋 验证Vite manifest"
manifest="../blog/static/blog/dist/.vite/manifest.json"
if [ -f "$manifest" ]; then
echo "✅ manifest.json 存在"
# 验证JSON格式
if jq empty "$manifest" 2>/dev/null; then
echo " ✅ JSON格式正确"
# 显示入口点
echo " 📝 入口点:"
jq -r 'keys[]' "$manifest" | sed 's/^/ - /'
else
echo " ❌ JSON格式错误"
exit 1
fi
else
echo "❌ manifest.json 不存在"
exit 1
fi
- name: 构建统计
if: always()
working-directory: ./frontend
run: |
echo "📊 ============ 构建统计 ============"
echo " 🏷️ Node版本: ${{ matrix.node-version }}"
echo " 📦 构建状态: ${{ job.status }}"
# 文件统计
if [ -d "../blog/static/blog/dist/" ]; then
total_size=$(du -sh ../blog/static/blog/dist/ | cut -f1)
file_count=$(find ../blog/static/blog/dist/ -type f | wc -l)
echo " 📁 总文件数: $file_count"
echo " 💾 总大小: $total_size"
fi
echo " 📅 完成时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "======================================"
if [ "${{ job.status }}" = "success" ]; then
echo "🎉 前端构建成功!"
else
echo "❌ 前端构建失败,请检查上面的日志"
fi
================================================
FILE: .github/workflows/publish-release.yml
================================================
name: publish release
on:
release:
types: [ published ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: name/app
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
platforms: |
linux/amd64
linux/arm64
tags: ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{ github.event.release.tag_name }}
================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
# Translations
*.pot
# Django stuff:
*.log
logs/
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# PyCharm
# http://www.jetbrains.com/pycharm/webhelp/project.html
.idea
.iml
# virtualenv
venv/
collectedstatic/
djangoblog/whoosh_index/
whoosh_index/
google93fd32dbd906620a.html
baidu_verify_FlHL7cUyC9.html
BingSiteAuth.xml
cb9339dbe2ff86a5aa169d28dba5f615.txt
werobot_session.*
django.jpg
uploads/
settings_production.py
werobot_session.db
bin/datas/
.claude/
nul
deploy/docker-compose/bin/datas
static/avatar
img/
.specify/
specs/
================================================
FILE: Dockerfile
================================================
# Stage 1: Build frontend assets
FROM node:20-alpine AS frontend-builder
WORKDIR /app
# Copy frontend package files
COPY frontend/package*.json ./frontend/
# Set npm registry to official registry and install dependencies (including devDependencies for build)
RUN cd frontend && \
npm config set registry https://registry.npmjs.org/ && \
npm ci
# Copy frontend source files
COPY frontend/ ./frontend/
# Copy templates for Tailwind CSS content scanning
COPY templates/ ./templates/
# Build frontend assets (output goes to ../blog/static/blog/dist)
# Vite will create the output directory structure automatically
RUN cd frontend && npm run build
# Stage 2: Build final image
FROM python:3.11
ENV PYTHONUNBUFFERED=1
WORKDIR /code/djangoblog/
# Install system dependencies
RUN apt-get update && \
apt-get install default-libmysqlclient-dev gettext -y && \
rm -rf /var/lib/apt/lists/*
# Copy and install Python dependencies
COPY requirements.txt requirements.txt
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt && \
pip install --no-cache-dir gunicorn[gevent] && \
pip cache purge
# Copy application code (excluding old build artifacts)
COPY . .
# Remove any old build artifacts that might have been copied
RUN rm -rf /code/djangoblog/blog/static/blog/dist
# Copy built frontend assets from frontend-builder stage
COPY --from=frontend-builder /app/blog/static/blog/dist /code/djangoblog/blog/static/blog/dist
# Verify the frontend assets were copied correctly
RUN ls -la /code/djangoblog/blog/static/blog/dist/css/ && \
cat /code/djangoblog/blog/static/blog/dist/.vite/manifest.json
# Set execute permission for entrypoint
RUN chmod +x /code/djangoblog/deploy/entrypoint.sh
ENTRYPOINT ["/code/djangoblog/deploy/entrypoint.sh"]
================================================
FILE: LICENSE
================================================
The MIT License (MIT)
Copyright (c) 2025 车亮亮
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================
# DjangoBlog
"), 2)
# 测试多个关键词
text = "Django web framework with Python"
result = highlight_search_term(text, "django python")
self.assertIn("Django ", result)
self.assertIn("Python ", result)
# 测试空查询
text = "Some text"
result = highlight_search_term(text, "")
self.assertEqual(result, text)
# 测试空文本
result = highlight_search_term("", "query")
self.assertEqual(result, "")
def test_highlight_content_filter(self):
"""测试正文高亮filter"""
# 测试HTML内容高亮
html = "Django is a great web framework. Django makes development easy.
"
result = highlight_content(html, "django")
# 应该包含高亮标记
self.assertIn("", result)
self.assertIn(" ", result)
# HTML标签应该被去除
self.assertNotIn("", result)
# 应该包含Django关键词(不区分大小写)
self.assertIn("django", result.lower())
# 测试空查询
result = highlight_content(html, "")
self.assertEqual(result, html)
def test_search_page_access(self):
"""测试搜索页面访问"""
# 测试搜索页面可以正常访问
response = self.client.get('/search', {'q': 'django'})
self.assertEqual(response.status_code, 200)
# 检查响应中是否有查询参数
self.assertIn('query', response.context)
self.assertEqual(response.context['query'], 'django')
def test_chinese_keyword_highlight(self):
"""测试中文关键词高亮"""
text = "这是一个Django博客系统"
result = highlight_search_term(text, "Django")
self.assertIn("Django ", result)
# 测试中文关键词
text = "欢迎使用DjangoBlog系统"
result = highlight_search_term(text, "Django")
self.assertIn("Django ", result)
================================================
FILE: blog/urls.py
================================================
from django.urls import path
from django.views.decorators.cache import cache_page
from . import views
app_name = "blog"
urlpatterns = [
path(
r'',
views.IndexView.as_view(),
name='index'),
path(
r'page//',
views.IndexView.as_view(),
name='index_page'),
path(
r'article////.html',
views.ArticleDetailView.as_view(),
name='detailbyid'),
path(
r'category/.html',
views.CategoryDetailView.as_view(),
name='category_detail'),
path(
r'category//.html',
views.CategoryDetailView.as_view(),
name='category_detail_page'),
path(
r'author/.html',
views.AuthorDetailView.as_view(),
name='author_detail'),
path(
r'author//.html',
views.AuthorDetailView.as_view(),
name='author_detail_page'),
path(
r'tag/.html',
views.TagDetailView.as_view(),
name='tag_detail'),
path(
r'tag//.html',
views.TagDetailView.as_view(),
name='tag_detail_page'),
path(
'archives.html',
cache_page(
60 * 60)(
views.ArchivesView.as_view()),
name='archives'),
path(
'links.html',
views.LinkListView.as_view(),
name='links'),
path(
r'upload',
views.fileupload,
name='upload'),
path(
r'clean',
views.clean_cache_view,
name='clean'),
]
================================================
FILE: blog/views.py
================================================
import logging
import os
import uuid
from django.conf import settings
from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.templatetags.static import static
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from haystack.views import SearchView
from blog.models import Article, Category, LinkShowType, Links, Tag
from comments.forms import CommentForm
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
from djangoblog.utils import cache, get_blog_setting, get_sha256
from djangoblog.mixins import (
SlugCachedMixin,
ArticleListMixin,
OptimizedArticleQueryMixin,
CachedListViewMixin,
PageNumberMixin
)
logger = logging.getLogger(__name__)
class ArticleListView(CachedListViewMixin, PageNumberMixin, ListView):
"""
文章列表视图基类(重构版)
使用 Mixin 简化代码,消除重复逻辑
子类只需实现 get_queryset_data() 和 get_queryset_cache_key() 方法
"""
# template_name属性用于指定使用哪个模板进行渲染
template_name = 'blog/article_index.html'
# context_object_name属性用于给上下文变量取名(在模板中使用该名字)
context_object_name = 'article_list'
# 页面类型,分类目录或标签列表等
page_type = ''
paginate_by = settings.PAGINATE_BY
page_kwarg = 'page'
link_type = LinkShowType.L
def get_view_cache_key(self):
return self.request.get['pages']
def get_context_data(self, **kwargs):
kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs)
class IndexView(OptimizedArticleQueryMixin, ArticleListView):
"""
首页视图(重构版)
继承 OptimizedArticleQueryMixin 获得优化的查询方法
"""
# 友情链接类型
link_type = LinkShowType.I
def get_queryset_data(self):
# 使用 Mixin 提供的优化查询方法
return self.get_optimized_article_queryset().filter(
type='a', status='p'
)
def get_queryset_cache_key(self):
return f'index_{self.page_number}'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
blog_setting = get_blog_setting()
# 提供基础SEO数据
context['seo_title'] = f"{blog_setting.site_name} | {blog_setting.site_description}"
context['seo_description'] = blog_setting.site_seo_description
context['seo_keywords'] = blog_setting.site_keywords
return context
class ArticleDetailView(DetailView):
'''
文章详情页面
'''
template_name = 'blog/article_detail.html'
model = Article
pk_url_kwarg = 'article_id'
context_object_name = "article"
def get_context_data(self, **kwargs):
comment_form = CommentForm()
# 优化:直接查询父评论,减少数据库查询
from comments.models import Comment
parent_comments = Comment.objects.filter(
article=self.object,
parent_comment=None,
is_enable=True
).select_related('author').prefetch_related(
'comment_set__author' # 预加载子评论及其作者
).order_by('-id')
# 获取所有评论用于总数显示
article_comments = self.object.comment_list()
blog_setting = get_blog_setting()
paginator = Paginator(parent_comments, blog_setting.article_comment_count)
page = self.request.GET.get('comment_page', '1')
if not page.isnumeric():
page = 1
else:
page = int(page)
if page < 1:
page = 1
if page > paginator.num_pages:
page = paginator.num_pages
p_comments = paginator.page(page)
next_page = p_comments.next_page_number() if p_comments.has_next() else None
prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
if next_page:
kwargs[
'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
if prev_page:
kwargs[
'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
kwargs['form'] = comment_form
kwargs['article_comments'] = article_comments
kwargs['p_comments'] = p_comments
kwargs['comment_count'] = len(
article_comments) if article_comments else 0
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object
# 添加基础SEO数据
blog_setting = get_blog_setting()
from django.utils.html import strip_tags
from django.utils.text import Truncator
from djangoblog.utils import CommonMarkdown
# 处理description:markdown -> HTML -> 纯文本,彻底去除格式
html_content = CommonMarkdown.get_markdown(article.body)
description = strip_tags(html_content)
description = ' '.join(description.split()) # 规范化空白字符
description = Truncator(description).chars(150, truncate='...')
# 处理keywords:去除空格,用逗号分隔
tags = [tag.name.strip() for tag in article.tags.all()]
keywords = ", ".join(tags) if tags else blog_setting.site_keywords
context['seo_title'] = f"{article.title} | {blog_setting.site_name}"
context['seo_description'] = description
context['seo_keywords'] = keywords
# 触发文章详情加载钩子,让插件可以添加额外的上下文数据
from djangoblog.plugin_manage.hook_constants import ARTICLE_DETAIL_LOAD
hooks.run_action(ARTICLE_DETAIL_LOAD, article=article, context=context, request=self.request)
# Action Hook, 通知插件"文章详情已获取"
hooks.run_action('after_article_body_get', article=article, request=self.request)
return context
class CategoryDetailView(SlugCachedMixin, OptimizedArticleQueryMixin, ArticleListView):
"""
分类目录列表(重构版)
使用 SlugCachedMixin 避免重复查询 Category
使用 OptimizedArticleQueryMixin 优化文章查询
"""
page_type = "分类目录归档"
slug_url_kwarg = 'category_name'
slug_model = Category
def get_queryset_data(self):
# 使用 Mixin 缓存的对象,只查询一次
category = self.get_slug_object()
categorynames = [c.name for c in category.get_sub_categorys()]
return self.get_optimized_article_queryset().filter(
category__name__in=categorynames, status='p'
)
def get_queryset_cache_key(self):
# 复用缓存的对象,不再重复查询数据库
category = self.get_slug_object()
return f'category_list_{category.name}_{self.page_number}'
def get_context_data(self, **kwargs):
category = self.get_slug_object()
categoryname = category.name
try:
categoryname = categoryname.split('/')[-1]
except BaseException:
pass
kwargs['page_type'] = CategoryDetailView.page_type
kwargs['tag_name'] = categoryname
# 添加基础SEO数据
blog_setting = get_blog_setting()
article_count = self.get_queryset().count()
kwargs['seo_title'] = f"{categoryname} | {blog_setting.site_name}"
kwargs['seo_description'] = f"浏览 {categoryname} 分类下的所有文章,共 {article_count} 篇文章。"
kwargs['seo_keywords'] = f"{categoryname}, {blog_setting.site_keywords}"
return super(CategoryDetailView, self).get_context_data(**kwargs)
class AuthorDetailView(OptimizedArticleQueryMixin, ArticleListView):
"""
作者详情页(重构版)
使用 OptimizedArticleQueryMixin 优化文章查询
"""
page_type = '作者文章归档'
def get_queryset_cache_key(self):
from uuslug import slugify
author_name = slugify(self.kwargs['author_name'])
return f'author_{author_name}_{self.page_number}'
def get_queryset_data(self):
author_name = self.kwargs['author_name']
return self.get_optimized_article_queryset().filter(
author__username=author_name, type='a', status='p'
)
def get_context_data(self, **kwargs):
author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type
kwargs['tag_name'] = author_name
# 添加基础SEO数据
blog_setting = get_blog_setting()
article_count = self.get_queryset().count()
kwargs['seo_title'] = f"{author_name} 的文章 | {blog_setting.site_name}"
kwargs['seo_description'] = f"浏览 {author_name} 发表的所有文章,共 {article_count} 篇。"
kwargs['seo_keywords'] = f"{author_name}, {blog_setting.site_keywords}"
return super(AuthorDetailView, self).get_context_data(**kwargs)
class TagDetailView(SlugCachedMixin, OptimizedArticleQueryMixin, ArticleListView):
"""
标签列表页面(重构版)
使用 SlugCachedMixin 避免重复查询 Tag
使用 OptimizedArticleQueryMixin 优化文章查询
"""
page_type = '分类标签归档'
slug_url_kwarg = 'tag_name'
slug_model = Tag
def get_queryset_data(self):
# 使用 Mixin 缓存的对象,只查询一次
tag = self.get_slug_object()
return self.get_optimized_article_queryset().filter(
tags__name=tag.name, type='a', status='p'
)
def get_queryset_cache_key(self):
# 复用缓存的对象,不再重复查询数据库
tag = self.get_slug_object()
return f'tag_{tag.name}_{self.page_number}'
def get_context_data(self, **kwargs):
tag = self.get_slug_object()
kwargs['page_type'] = TagDetailView.page_type
kwargs['tag_name'] = tag.name
# 添加基础SEO数据
blog_setting = get_blog_setting()
article_count = self.get_queryset().count()
kwargs['seo_title'] = f"{tag.name} | {blog_setting.site_name}"
kwargs['seo_description'] = f"浏览所有关于 {tag.name} 的文章,共 {article_count} 篇内容。"
kwargs['seo_keywords'] = f"{tag.name}, {blog_setting.site_keywords}"
return super(TagDetailView, self).get_context_data(**kwargs)
class ArchivesView(OptimizedArticleQueryMixin, ArticleListView):
"""
文章归档页面(重构版)
使用 OptimizedArticleQueryMixin 优化文章查询
"""
page_type = '文章归档'
paginate_by = None
page_kwarg = None
template_name = 'blog/article_archives.html'
def get_queryset_data(self):
return self.get_optimized_article_queryset().filter(status='p')
def get_queryset_cache_key(self):
return 'archives'
class LinkListView(ListView):
model = Links
template_name = 'blog/links_list.html'
def get_queryset(self):
return Links.objects.filter(is_enable=True)
class EsSearchView(SearchView):
def build_form(self, form_kwargs=None):
"""Override to enable highlighting"""
if form_kwargs is None:
form_kwargs = {}
# Enable highlighting for search results
from haystack.query import SearchQuerySet
if self.searchqueryset is None:
sqs = SearchQuerySet().highlight()
else:
sqs = self.searchqueryset.highlight()
form_kwargs['searchqueryset'] = sqs
return super().build_form(form_kwargs=form_kwargs)
def get_context(self):
paginator, page = self.build_page()
context = {
"query": self.query,
"form": self.form,
"page": page,
"paginator": paginator,
"suggestion": None,
}
if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
context["suggestion"] = self.results.query.get_spelling_suggestion()
context.update(self.extra_context())
return context
@csrf_exempt
def fileupload(request):
"""
该方法需自己写调用端来上传图片,该方法仅提供图床功能
:param request:
:return:
"""
if request.method == 'POST':
sign = request.GET.get('sign', None)
if not sign:
return HttpResponseForbidden()
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden()
response = []
for filename in request.FILES:
timestr = timezone.now().strftime('%Y/%m/%d')
imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename))
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir):
os.makedirs(base_dir)
savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
if not savepath.startswith(base_dir):
return HttpResponse("only for post")
with open(savepath, 'wb+') as wfile:
for chunk in request.FILES[filename].chunks():
wfile.write(chunk)
if isimage:
from PIL import Image
image = Image.open(savepath)
image.save(savepath, quality=20, optimize=True)
url = static(savepath)
response.append(url)
return HttpResponse(response)
else:
return HttpResponse("only for post")
# ===== 错误处理视图 =====
# 注意:这些函数保留是为了向后兼容
# 实际实现已经移动到 djangoblog.error_views
# 可以在 urls.py 中直接引用新的实现
from djangoblog.error_views import (
page_not_found_view,
server_error_view,
permission_denied_view
)
def clean_cache_view(request):
cache.clear()
return HttpResponse('ok')
================================================
FILE: codecov.yml
================================================
codecov:
require_ci_to_pass: yes
coverage:
precision: 2
round: down
range: "70...100"
status:
project:
default:
target: auto
threshold: 1%
informational: true
branches:
- master
- dev
patch:
default:
target: auto
threshold: 1%
informational: true
parsers:
gcov:
branch_detection:
conditional: yes
loop: yes
method: no
macro: no
comment:
layout: "reach,diff,flags,tree"
behavior: default
require_changes: no
ignore:
# Django 相关
- "*/migrations/*"
- "manage.py"
- "*/settings.py"
- "*/wsgi.py"
- "*/asgi.py"
# 测试相关
- "*/tests/*"
- "*/test_*.py"
- "*/*test*.py"
# 静态文件和模板
- "*/static/*"
- "*/templates/*"
- "*/collectedstatic/*"
# 国际化文件
- "*/locale/*"
- "**/*.po"
- "**/*.mo"
# 文档和部署
- "*/docs/*"
- "*/deploy/*"
- "README*.md"
- "LICENSE"
- "Dockerfile"
- "docker-compose*.yml"
- "*.yaml"
- "*.yml"
# 开发环境
- "*/venv/*"
- "*/__pycache__/*"
- "*.pyc"
- ".coverage"
- "coverage.xml"
# 日志文件
- "*/logs/*"
- "*.log"
# 特定文件
- "*/whoosh_cn_backend.py" # 搜索后端
- "*/elasticsearch_backend.py" # 搜索后端
- "*/MemcacheStorage.py" # 缓存存储
- "*/robot.py" # 机器人相关
# 配置文件
- "codecov.yml"
- ".coveragerc"
- "requirements*.txt"
================================================
FILE: comments/__init__.py
================================================
================================================
FILE: comments/admin.py
================================================
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from .models import Comment, CommentReaction
def disable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=False)
def enable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=True)
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
class CommentAdmin(admin.ModelAdmin):
list_per_page = 20
list_display = (
'id',
'body',
'link_to_userinfo',
'link_to_article',
'is_enable',
'creation_time')
list_display_links = ('id', 'body', 'is_enable')
list_filter = ('is_enable',)
exclude = ('creation_time', 'last_modify_time')
actions = [disable_commentstatus, enable_commentstatus]
raw_id_fields = ('author', 'article')
search_fields = ('body',)
def link_to_userinfo(self, obj):
info = (obj.author._meta.app_label, obj.author._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
return format_html(
u'%s ' %
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
def link_to_article(self, obj):
info = (obj.article._meta.app_label, obj.article._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
return format_html(
u'%s ' % (link, obj.article.title))
link_to_userinfo.short_description = _('User')
link_to_article.short_description = _('Article')
class CommentReactionAdmin(admin.ModelAdmin):
list_display = ('id', 'reaction_type', 'link_to_comment', 'link_to_user', 'created_at')
list_display_links = ('id', 'reaction_type')
list_filter = ('reaction_type', 'created_at')
raw_id_fields = ('comment', 'user')
search_fields = ('comment__body', 'user__username')
date_hierarchy = 'created_at'
def link_to_comment(self, obj):
info = (obj.comment._meta.app_label, obj.comment._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.comment.id,))
return format_html(
u'Comment #%s ' % (link, obj.comment.id))
def link_to_user(self, obj):
info = (obj.user._meta.app_label, obj.user._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.user.id,))
return format_html(
u'%s ' %
(link, obj.user.nickname if obj.user.nickname else obj.user.username))
link_to_comment.short_description = _('Comment')
link_to_user.short_description = _('User')
admin.site.register(Comment, CommentAdmin)
admin.site.register(CommentReaction, CommentReactionAdmin)
================================================
FILE: comments/apps.py
================================================
from django.apps import AppConfig
class CommentsConfig(AppConfig):
name = 'comments'
================================================
FILE: comments/forms.py
================================================
from django import forms
from django.forms import ModelForm
from .models import Comment
class CommentForm(ModelForm):
parent_comment_id = forms.IntegerField(
widget=forms.HiddenInput, required=False)
class Meta:
model = Comment
fields = ['body']
================================================
FILE: comments/migrations/0001_initial.py
================================================
# Generated by Django 4.1.7 on 2023-03-02 07:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('blog', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Comment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('body', models.TextField(max_length=300, verbose_name='正文')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')),
],
options={
'verbose_name': '评论',
'verbose_name_plural': '评论',
'ordering': ['-id'],
'get_latest_by': 'id',
},
),
]
================================================
FILE: comments/migrations/0002_alter_comment_is_enable.py
================================================
# Generated by Django 4.1.7 on 2023-04-24 13:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comments', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='comment',
name='is_enable',
field=models.BooleanField(default=False, verbose_name='是否显示'),
),
]
================================================
FILE: comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py
================================================
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0005_alter_article_options_alter_category_options_and_more'),
('comments', '0002_alter_comment_is_enable'),
]
operations = [
migrations.AlterModelOptions(
name='comment',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'},
),
migrations.RemoveField(
model_name='comment',
name='created_time',
),
migrations.RemoveField(
model_name='comment',
name='last_mod_time',
),
migrations.AddField(
model_name='comment',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='comment',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
migrations.AlterField(
model_name='comment',
name='article',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'),
),
migrations.AlterField(
model_name='comment',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
migrations.AlterField(
model_name='comment',
name='is_enable',
field=models.BooleanField(default=False, verbose_name='enable'),
),
migrations.AlterField(
model_name='comment',
name='parent_comment',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'),
),
]
================================================
FILE: comments/migrations/0004_comment_idx_art_parent_enable_comment_idx_enable_id.py
================================================
# Generated by Django 5.2.9 on 2025-12-25 14:36
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0007_article_idx_type_status_pub_article_idx_status_views_and_more'),
('comments', '0003_alter_comment_options_remove_comment_created_time_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='comment',
index=models.Index(fields=['article', 'parent_comment', 'is_enable'], name='idx_art_parent_enable'),
),
migrations.AddIndex(
model_name='comment',
index=models.Index(fields=['is_enable', '-id'], name='idx_enable_id'),
),
]
================================================
FILE: comments/migrations/0005_commentreaction.py
================================================
# Generated by Django 5.2.9 on 2026-01-22 14:13
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comments', '0004_comment_idx_art_parent_enable_comment_idx_enable_id'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CommentReaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reaction_type', models.CharField(choices=[('👍', 'thumbs_up'), ('👎', 'thumbs_down'), ('❤️', 'heart'), ('😄', 'laugh'), ('🎉', 'hooray'), ('😕', 'confused'), ('🚀', 'rocket'), ('👀', 'eyes')], max_length=10, verbose_name='reaction type')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='comments.comment', verbose_name='comment')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'comment reaction',
'verbose_name_plural': 'comment reactions',
'indexes': [models.Index(fields=['comment', 'reaction_type'], name='idx_comment_reaction')],
'unique_together': {('comment', 'user', 'reaction_type')},
},
),
]
================================================
FILE: comments/migrations/__init__.py
================================================
================================================
FILE: comments/models.py
================================================
from django.conf import settings
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from blog.models import Article
# Create your models here.
class Comment(models.Model):
body = models.TextField('正文', max_length=300)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
on_delete=models.CASCADE)
article = models.ForeignKey(
Article,
verbose_name=_('article'),
on_delete=models.CASCADE)
parent_comment = models.ForeignKey(
'self',
verbose_name=_('parent comment'),
blank=True,
null=True,
on_delete=models.CASCADE)
is_enable = models.BooleanField(_('enable'),
default=False, blank=False, null=False)
class Meta:
ordering = ['-id']
verbose_name = _('comment')
verbose_name_plural = verbose_name
get_latest_by = 'id'
indexes = [
# 优化评论列表查询:article + parent_comment + is_enable组合索引
models.Index(fields=['article', 'parent_comment', 'is_enable'], name='idx_art_parent_enable'),
# 优化侧边栏评论查询:is_enable + id组合索引
models.Index(fields=['is_enable', '-id'], name='idx_enable_id'),
]
def __str__(self):
return self.body
def get_reactions_summary(self, user=None):
"""
获取评论的 reactions 统计信息
返回格式: {
'👍': {
'count': 5,
'has_reacted': True,
'users': ['Alice', 'Bob', 'Charlie']
},
'❤️': {'count': 3, 'has_reacted': False, 'users': [...]},
...
}
"""
from django.db.models import Count
reactions = CommentReaction.objects.filter(
comment=self
).values('reaction_type').annotate(count=Count('id'))
result = {}
for reaction in reactions:
emoji = reaction['reaction_type']
# 获取该 emoji 的所有点赞用户
reaction_users = CommentReaction.objects.filter(
comment=self,
reaction_type=emoji
).select_related('user')[:10] # 最多显示10个用户
user_names = [r.user.nickname or r.user.username for r in reaction_users]
result[emoji] = {
'count': reaction['count'],
'has_reacted': False,
'users': user_names
}
if user and user.is_authenticated:
result[emoji]['has_reacted'] = CommentReaction.objects.filter(
comment=self,
user=user,
reaction_type=emoji
).exists()
return result
class CommentReaction(models.Model):
"""
评论的 Emoji 反应/点赞
"""
REACTION_CHOICES = [
('👍', 'thumbs_up'),
('👎', 'thumbs_down'),
('❤️', 'heart'),
('😄', 'laugh'),
('🎉', 'hooray'),
('😕', 'confused'),
('🚀', 'rocket'),
('👀', 'eyes'),
]
comment = models.ForeignKey(
Comment,
verbose_name=_('comment'),
on_delete=models.CASCADE,
related_name='reactions'
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('user'),
on_delete=models.CASCADE
)
reaction_type = models.CharField(
_('reaction type'),
max_length=10,
choices=REACTION_CHOICES
)
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
class Meta:
verbose_name = _('comment reaction')
verbose_name_plural = _('comment reactions')
# 每个用户对同一评论的同一种 emoji 只能点一次
unique_together = ['comment', 'user', 'reaction_type']
indexes = [
models.Index(fields=['comment', 'reaction_type'], name='idx_comment_reaction'),
]
def __str__(self):
return f'{self.user.username} - {self.reaction_type} on comment {self.comment.id}'
================================================
FILE: comments/templatetags/__init__.py
================================================
================================================
FILE: comments/templatetags/comments_tags.py
================================================
from django import template
register = template.Library()
@register.simple_tag
def parse_commenttree(commentlist, comment):
"""获得当前评论子评论的列表
用法: {% parse_commenttree article_comments comment as childcomments %}
"""
datas = []
def parse(c):
childs = commentlist.filter(parent_comment=c, is_enable=True)
for child in childs:
datas.append(child)
parse(child)
parse(comment)
return datas
@register.inclusion_tag('comments/tags/comment_item.html')
def show_comment_item(comment, ischild):
"""评论"""
depth = 1 if ischild else 2
return {
'comment_item': comment,
'depth': depth
}
================================================
FILE: comments/test_comment_business_logic.py
================================================
"""
Test cases for comment business logic
包括评论审核工作流、嵌套回复、权限控制等核心业务逻辑
"""
from django.test import TestCase, Client
from django.utils import timezone
from accounts.models import BlogUser
from blog.models import Article, Category, BlogSettings
from comments.models import Comment
class CommentCreationTest(TestCase):
"""测试评论创建业务逻辑"""
def setUp(self):
"""设置测试环境"""
self.category = Category.objects.create(
name='Test Category',
slug='test-category'
)
self.author = BlogUser.objects.create_user(
username='author',
email='author@example.com',
password='password'
)
self.commenter = BlogUser.objects.create_user(
username='commenter',
email='commenter@example.com',
password='password'
)
self.article = Article.objects.create(
title='Test Article',
body='Test content',
author=self.author,
category=self.category,
status='p',
type='a',
comment_status='o'
)
def test_comment_created_with_required_fields(self):
"""测试评论创建包含必需字段"""
comment = Comment.objects.create(
body='Test comment',
author=self.commenter,
article=self.article
)
self.assertIsNotNone(comment.id)
self.assertEqual(comment.body, 'Test comment')
self.assertEqual(comment.author, self.commenter)
self.assertEqual(comment.article, self.article)
def test_comment_has_creation_time(self):
"""测试评论有创建时间"""
comment = Comment.objects.create(
body='Test comment',
author=self.commenter,
article=self.article
)
self.assertIsNotNone(comment.creation_time)
# 验证创建时间是最近的
time_diff = timezone.now() - comment.creation_time
self.assertLess(time_diff.total_seconds(), 10)
def test_comment_author_is_correct(self):
"""测试评论作者正确"""
comment = Comment.objects.create(
body='Test comment',
author=self.commenter,
article=self.article
)
self.assertEqual(comment.author, self.commenter)
def test_comment_article_relationship(self):
"""测试评论与文章的关系"""
comment = Comment.objects.create(
body='Test comment',
author=self.commenter,
article=self.article
)
self.assertEqual(comment.article, self.article)
# 验证可以通过文章查询到评论
article_comments = Comment.objects.filter(article=self.article)
self.assertIn(comment, article_comments)
class CommentModerationTest(TestCase):
"""测试评论审核工作流"""
def setUp(self):
"""设置测试环境"""
self.category = Category.objects.create(
name='Test Category',
slug='test-category'
)
self.author = BlogUser.objects.create_user(
username='author',
email='author@example.com',
password='password'
)
self.commenter = BlogUser.objects.create_user(
username='commenter',
email='commenter@example.com',
password='password'
)
self.article = Article.objects.create(
title='Test Article',
body='Test content',
author=self.author,
category=self.category,
status='p',
type='a',
comment_status='o'
)
# 获取或创建博客设置
self.blog_settings, _ = BlogSettings.objects.get_or_create(
id=1,
defaults={'site_name': 'Test Blog'}
)
def test_comment_pending_by_default_when_review_required(self):
"""测试需要审核时评论默认为待审状态"""
# 启用评论审核
self.blog_settings.comment_need_review = True
self.blog_settings.save()
comment = Comment.objects.create(
body='Test comment',
author=self.commenter,
article=self.article,
is_enable=False # 待审状态
)
self.assertFalse(comment.is_enable)
def test_comment_approved_directly_when_no_review_required(self):
"""测试不需要审核时评论直接通过"""
# 禁用评论审核
self.blog_settings.comment_need_review = False
self.blog_settings.save()
comment = Comment.objects.create(
body='Test comment',
author=self.commenter,
article=self.article,
is_enable=True # 直接启用
)
self.assertTrue(comment.is_enable)
def test_comment_can_be_approved(self):
"""测试评论可以被批准"""
comment = Comment.objects.create(
body='Test comment',
author=self.commenter,
article=self.article,
is_enable=False
)
# 审核通过
comment.is_enable = True
comment.save()
comment.refresh_from_db()
self.assertTrue(comment.is_enable)
def test_comment_can_be_rejected(self):
"""测试评论可以被拒绝"""
comment = Comment.objects.create(
body='Test comment',
author=self.commenter,
article=self.article,
is_enable=True
)
# 拒绝评论
comment.is_enable = False
comment.save()
comment.refresh_from_db()
self.assertFalse(comment.is_enable)
def test_only_approved_comments_in_public_list(self):
"""测试只有已批准的评论在公开列表中"""
# 创建已批准的评论
approved_comment = Comment.objects.create(
body='Approved comment',
author=self.commenter,
article=self.article,
is_enable=True
)
# 创建待审的评论
pending_comment = Comment.objects.create(
body='Pending comment',
author=self.commenter,
article=self.article,
is_enable=False
)
# 查询已批准的评论
approved_comments = Comment.objects.filter(
article=self.article,
is_enable=True
)
self.assertIn(approved_comment, approved_comments)
self.assertNotIn(pending_comment, approved_comments)
class CommentReplyTest(TestCase):
"""测试评论回复业务逻辑"""
def setUp(self):
"""设置测试环境"""
self.category = Category.objects.create(
name='Test Category',
slug='test-category'
)
self.author = BlogUser.objects.create_user(
username='author',
email='author@example.com',
password='password'
)
self.commenter1 = BlogUser.objects.create_user(
username='commenter1',
email='commenter1@example.com',
password='password'
)
self.commenter2 = BlogUser.objects.create_user(
username='commenter2',
email='commenter2@example.com',
password='password'
)
self.article = Article.objects.create(
title='Test Article',
body='Test content',
author=self.author,
category=self.category,
status='p',
type='a',
comment_status='o'
)
def test_comment_can_have_no_parent(self):
"""测试评论可以没有父评论(根评论)"""
comment = Comment.objects.create(
body='Root comment',
author=self.commenter1,
article=self.article,
parent_comment=None
)
self.assertIsNone(comment.parent_comment)
def test_comment_can_have_parent(self):
"""测试评论可以有父评论(回复)"""
parent_comment = Comment.objects.create(
body='Parent comment',
author=self.commenter1,
article=self.article,
is_enable=True
)
reply_comment = Comment.objects.create(
body='Reply comment',
author=self.commenter2,
article=self.article,
parent_comment=parent_comment,
is_enable=True
)
self.assertEqual(reply_comment.parent_comment, parent_comment)
def test_parent_comment_has_replies(self):
"""测试父评论有回复"""
parent_comment = Comment.objects.create(
body='Parent comment',
author=self.commenter1,
article=self.article,
is_enable=True
)
reply1 = Comment.objects.create(
body='Reply 1',
author=self.commenter2,
article=self.article,
parent_comment=parent_comment,
is_enable=True
)
reply2 = Comment.objects.create(
body='Reply 2',
author=self.commenter1,
article=self.article,
parent_comment=parent_comment,
is_enable=True
)
# 查询父评论的所有回复
replies = Comment.objects.filter(parent_comment=parent_comment)
self.assertEqual(replies.count(), 2)
self.assertIn(reply1, replies)
self.assertIn(reply2, replies)
def test_nested_comment_structure(self):
"""测试嵌套评论结构"""
# 创建根评论
root = Comment.objects.create(
body='Root',
author=self.commenter1,
article=self.article,
is_enable=True
)
# 创建一级回复
level1 = Comment.objects.create(
body='Level 1',
author=self.commenter2,
article=self.article,
parent_comment=root,
is_enable=True
)
# 创建二级回复
level2 = Comment.objects.create(
body='Level 2',
author=self.commenter1,
article=self.article,
parent_comment=level1,
is_enable=True
)
# 验证嵌套关系
self.assertIsNone(root.parent_comment)
self.assertEqual(level1.parent_comment, root)
self.assertEqual(level2.parent_comment, level1)
def test_multiple_replies_to_same_comment(self):
"""测试同一评论的多个回复"""
parent = Comment.objects.create(
body='Parent',
author=self.commenter1,
article=self.article,
is_enable=True
)
# 创建多个回复
replies = []
for i in range(5):
reply = Comment.objects.create(
body=f'Reply {i+1}',
author=self.commenter2,
article=self.article,
parent_comment=parent,
is_enable=True
)
replies.append(reply)
# 验证所有回复都关联到同一父评论
parent_replies = Comment.objects.filter(parent_comment=parent)
self.assertEqual(parent_replies.count(), 5)
for reply in replies:
self.assertIn(reply, parent_replies)
class CommentArticleStatusTest(TestCase):
"""测试评论与文章状态的关系"""
def setUp(self):
"""设置测试环境"""
self.category = Category.objects.create(
name='Test Category',
slug='test-category'
)
self.author = BlogUser.objects.create_user(
username='author',
email='author@example.com',
password='password'
)
self.commenter = BlogUser.objects.create_user(
username='commenter',
email='commenter@example.com',
password='password'
)
def test_can_comment_on_open_comment_article(self):
"""测试可以在开放评论的文章上评论"""
article = Article.objects.create(
title='Open Comment Article',
body='Content',
author=self.author,
category=self.category,
status='p',
type='a',
comment_status='o' # 开放评论
)
# 业务逻辑层面:评论状态开放
self.assertEqual(article.comment_status, 'o')
# 创建评论应该成功
comment = Comment.objects.create(
body='Test comment',
author=self.commenter,
article=article,
is_enable=True
)
self.assertIsNotNone(comment.id)
def test_comment_status_closed_validation(self):
"""测试关闭评论的文章状态"""
article = Article.objects.create(
title='Closed Comment Article',
body='Content',
author=self.author,
category=self.category,
status='p',
type='a',
comment_status='c' # 关闭评论
)
# 验证文章评论状态
self.assertEqual(article.comment_status, 'c')
# 注意:实际的验证应该在视图层进行
# 这里我们只测试模型层面的状态
def test_comments_belong_to_correct_article(self):
"""测试评论属于正确的文章"""
article1 = Article.objects.create(
title='Article 1',
body='Content 1',
author=self.author,
category=self.category,
status='p',
type='a'
)
article2 = Article.objects.create(
title='Article 2',
body='Content 2',
author=self.author,
category=self.category,
status='p',
type='a'
)
comment1 = Comment.objects.create(
body='Comment on Article 1',
author=self.commenter,
article=article1,
is_enable=True
)
comment2 = Comment.objects.create(
body='Comment on Article 2',
author=self.commenter,
article=article2,
is_enable=True
)
# 验证评论属于正确的文章
article1_comments = Comment.objects.filter(article=article1)
article2_comments = Comment.objects.filter(article=article2)
self.assertIn(comment1, article1_comments)
self.assertNotIn(comment2, article1_comments)
self.assertIn(comment2, article2_comments)
self.assertNotIn(comment1, article2_comments)
class CommentQueryTest(TestCase):
"""测试评论查询业务逻辑"""
def setUp(self):
"""设置测试环境"""
self.category = Category.objects.create(
name='Test Category',
slug='test-category'
)
self.author = BlogUser.objects.create_user(
username='author',
email='author@example.com',
password='password'
)
self.commenter = BlogUser.objects.create_user(
username='commenter',
email='commenter@example.com',
password='password'
)
self.article = Article.objects.create(
title='Test Article',
body='Test content',
author=self.author,
category=self.category,
status='p',
type='a'
)
def test_query_comments_by_article(self):
"""测试按文章查询评论"""
# 创建多个评论
for i in range(5):
Comment.objects.create(
body=f'Comment {i+1}',
author=self.commenter,
article=self.article,
is_enable=True
)
comments = Comment.objects.filter(article=self.article)
self.assertEqual(comments.count(), 5)
def test_query_comments_by_author(self):
"""测试按作者查询评论"""
# 创建评论
for i in range(3):
Comment.objects.create(
body=f'Comment {i+1}',
author=self.commenter,
article=self.article,
is_enable=True
)
comments = Comment.objects.filter(author=self.commenter)
self.assertEqual(comments.count(), 3)
def test_query_root_comments_only(self):
"""测试只查询根评论(无父评论的评论)"""
# 创建根评论
root1 = Comment.objects.create(
body='Root 1',
author=self.commenter,
article=self.article,
is_enable=True
)
root2 = Comment.objects.create(
body='Root 2',
author=self.commenter,
article=self.article,
is_enable=True
)
# 创建回复
Comment.objects.create(
body='Reply to Root 1',
author=self.commenter,
article=self.article,
parent_comment=root1,
is_enable=True
)
# 查询根评论
root_comments = Comment.objects.filter(
article=self.article,
parent_comment__isnull=True
)
self.assertEqual(root_comments.count(), 2)
self.assertIn(root1, root_comments)
self.assertIn(root2, root_comments)
def test_comment_ordering(self):
"""测试评论排序"""
# 创建多个评论
comments = []
for i in range(3):
comment = Comment.objects.create(
body=f'Comment {i+1}',
author=self.commenter,
article=self.article,
is_enable=True
)
comments.append(comment)
# 查询评论(应该按照模型定义的ordering排序)
ordered_comments = list(Comment.objects.filter(article=self.article))
# 验证至少返回了正确数量的评论
self.assertEqual(len(ordered_comments), 3)
class CommentDeletionTest(TestCase):
"""测试评论删除业务逻辑"""
def setUp(self):
"""设置测试环境"""
self.category = Category.objects.create(
name='Test Category',
slug='test-category'
)
self.author = BlogUser.objects.create_user(
username='author',
email='author@example.com',
password='password'
)
self.commenter = BlogUser.objects.create_user(
username='commenter',
email='commenter@example.com',
password='password'
)
self.article = Article.objects.create(
title='Test Article',
body='Test content',
author=self.author,
category=self.category,
status='p',
type='a'
)
def test_comment_can_be_deleted(self):
"""测试评论可以被删除"""
comment = Comment.objects.create(
body='Test comment',
author=self.commenter,
article=self.article,
is_enable=True
)
comment_id = comment.id
# 删除评论
comment.delete()
# 验证评论已被删除
with self.assertRaises(Comment.DoesNotExist):
Comment.objects.get(id=comment_id)
def test_deleting_parent_comment_with_replies(self):
"""测试删除有回复的父评论"""
parent = Comment.objects.create(
body='Parent',
author=self.commenter,
article=self.article,
is_enable=True
)
reply = Comment.objects.create(
body='Reply',
author=self.commenter,
article=self.article,
parent_comment=parent,
is_enable=True
)
parent_id = parent.id
reply_id = reply.id
# 删除父评论
parent.delete()
# 验证父评论被删除
with self.assertRaises(Comment.DoesNotExist):
Comment.objects.get(id=parent_id)
# 验证回复的处理(取决于模型的on_delete设置)
# 如果是CASCADE,回复也应该被删除
# 如果是SET_NULL,回复的parent应该为None
================================================
FILE: comments/test_views.py
================================================
"""
Comments Views 测试
测试评论功能的错误处理和边界条件
"""
from django.test import TransactionTestCase
from django.urls import reverse
from comments.models import Comment
from djangoblog.test_base import BaseTestCase, ViewTestMixin
class CommentViewTest(TransactionTestCase, ViewTestMixin):
"""测试评论视图"""
def setUp(self):
from django.test import Client
from accounts.models import BlogUser
from blog.models import Article, Category, BlogSettings
from django.utils import timezone
self.client = Client()
self.user = BlogUser.objects.create_user(
username='testuser',
email='test@test.com',
password='testpass123'
)
self.category = Category.objects.create(
name='测试分类',
creation_time=timezone.now(),
last_modify_time=timezone.now()
)
self.article = Article.objects.create(
title='测试文章',
body='测试内容',
author=self.user,
category=self.category,
type='a',
status='p'
)
self.blog_settings, _ = BlogSettings.objects.get_or_create(
id=1,
defaults={
'site_name': '测试博客',
'site_description': '测试描述',
'comment_need_review': False,
}
)
def login_user(self):
"""登录测试用户"""
return self.client.login(username='testuser', password='testpass123')
def test_post_comment_authenticated(self):
"""测试已登录用户发表评论"""
self.login_user()
url = reverse('comments:postcomment', kwargs={'article_id': self.article.id})
response = self.client.post(url, {'body': '这是一条测试评论'})
self.assertEqual(response.status_code, 302)
# 验证评论已创建
comments = Comment.objects.filter(article=self.article)
self.assertGreater(comments.count(), 0)
def test_post_comment_unauthenticated(self):
"""测试未登录用户发表评论"""
self.client.logout()
url = reverse('comments:postcomment', kwargs={'article_id': self.article.id})
response = self.client.post(url, {'body': '匿名评论'})
# 未登录用户会被视图处理,可能返回错误或重定向
# 由于视图会尝试获取用户,会产生错误,这是预期的
self.assertIn(response.status_code, [200, 302, 403, 500])
def test_post_comment_empty_body(self):
"""测试提交空评论"""
self.login_user()
url = reverse('comments:postcomment', kwargs={'article_id': self.article.id})
response = self.client.post(url, {'body': ''})
# 应该返回表单错误
self.assertIn(response.status_code, [200, 302])
def test_post_comment_invalid_article(self):
"""测试对不存在的文章评论"""
self.login_user()
url = reverse('comments:postcomment', kwargs={'article_id': 99999})
response = self.client.post(url, {'body': '评论'})
self.assertEqual(response.status_code, 404)
def test_post_reply_comment(self):
"""测试回复评论"""
self.login_user()
# 先创建一条评论
parent_comment = Comment.objects.create(
body='父评论',
author=self.user,
article=self.article
)
parent_comment.is_enable = True
parent_comment.save()
# 回复这条评论
url = reverse('comments:postcomment', kwargs={'article_id': self.article.id})
response = self.client.post(url, {
'body': '这是回复',
'parent_comment_id': parent_comment.id
})
self.assertIn(response.status_code, [200, 302])
def test_comment_moderation(self):
"""测试评论审核"""
# 设置需要审核
self.blog_settings.comment_need_review = True
self.blog_settings.save()
self.login_user()
url = reverse('comments:postcomment', kwargs={'article_id': self.article.id})
response = self.client.post(url, {'body': '待审核的评论'})
self.assertEqual(response.status_code, 302)
# 验证评论需要审核
comment = Comment.objects.filter(article=self.article).latest('id')
self.assertFalse(comment.is_enable)
def test_comment_display_on_article(self):
"""测试评论在文章页显示"""
comment = Comment.objects.create(
body='测试显示评论',
author=self.user,
article=self.article,
is_enable=True
)
response = self.client.get(self.article.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_disabled_comment_not_display(self):
"""测试未启用的评论不显示"""
comment = Comment.objects.create(
body='未启用的评论',
author=self.user,
article=self.article,
is_enable=False
)
response = self.client.get(self.article.get_absolute_url())
self.assertEqual(response.status_code, 200)
class CommentSpamTest(TransactionTestCase, ViewTestMixin):
"""测试评论垃圾防护"""
def setUp(self):
from django.test import Client
from accounts.models import BlogUser
from blog.models import Article, Category, BlogSettings
from django.utils import timezone
self.client = Client()
self.user = BlogUser.objects.create_user(
username='testuser',
email='test@test.com',
password='testpass123'
)
self.category = Category.objects.create(
name='测试分类',
creation_time=timezone.now(),
last_modify_time=timezone.now()
)
self.article = Article.objects.create(
title='测试文章',
body='测试内容',
author=self.user,
category=self.category,
type='a',
status='p'
)
self.blog_settings, _ = BlogSettings.objects.get_or_create(
id=1,
defaults={'comment_need_review': False}
)
def login_user(self):
return self.client.login(username='testuser', password='testpass123')
def test_duplicate_comment(self):
"""测试重复评论"""
self.login_user()
url = reverse('comments:postcomment', kwargs={'article_id': self.article.id})
comment_data = {'body': '重复的评论内容'}
# 第一次提交
response1 = self.client.post(url, comment_data)
self.assertEqual(response1.status_code, 302)
# 第二次提交相同内容
response2 = self.client.post(url, comment_data)
# 应该被阻止或显示错误
self.assertIn(response2.status_code, [200, 302])
def test_comment_rate_limit(self):
"""测试评论频率限制"""
self.login_user()
url = reverse('comments:postcomment', kwargs={'article_id': self.article.id})
# 快速连续发表多条评论
for i in range(5):
response = self.client.post(url, {'body': f'评论{i}'})
# 后续评论可能被限制
self.assertIn(response.status_code, [200, 302, 429])
class CommentSecurityTest(TransactionTestCase, ViewTestMixin):
"""测试评论安全性"""
def setUp(self):
from django.test import Client
from accounts.models import BlogUser
from blog.models import Article, Category, BlogSettings
from django.utils import timezone
self.client = Client()
self.user = BlogUser.objects.create_user(
username='testuser',
email='test@test.com',
password='testpass123'
)
self.category = Category.objects.create(
name='测试分类',
creation_time=timezone.now(),
last_modify_time=timezone.now()
)
self.article = Article.objects.create(
title='测试文章',
body='测试内容',
author=self.user,
category=self.category,
type='a',
status='p'
)
self.blog_settings, _ = BlogSettings.objects.get_or_create(
id=1,
defaults={'comment_need_review': False}
)
def login_user(self):
return self.client.login(username='testuser', password='testpass123')
def test_xss_protection(self):
"""测试 XSS 防护"""
self.login_user()
url = reverse('comments:postcomment', kwargs={'article_id': self.article.id})
# 提交包含 script 标签的评论
xss_body = '普通内容'
response = self.client.post(url, {'body': xss_body})
self.assertEqual(response.status_code, 302)
# 验证评论已创建(XSS 过滤在渲染时处理)
comment = Comment.objects.filter(article=self.article).latest('id')
self.assertIsNotNone(comment)
def test_sql_injection_protection(self):
"""测试 SQL 注入防护"""
self.login_user()
url = reverse('comments:postcomment', kwargs={'article_id': self.article.id})
# 提交包含 SQL 注入尝试的评论
sql_body = "'; DROP TABLE comments; --"
response = self.client.post(url, {'body': sql_body})
# 应该正常处理,不会执行 SQL
self.assertIn(response.status_code, [200, 302])
# 验证表仍然存在
self.assertTrue(Comment.objects.exists())
================================================
FILE: comments/tests.py
================================================
from django.test import Client, RequestFactory, TransactionTestCase
from django.urls import reverse
from accounts.models import BlogUser
from blog.models import Category, Article
from comments.models import Comment
from comments.templatetags.comments_tags import *
from djangoblog.utils import get_max_articleid_commentid
# Create your tests here.
class CommentsTest(TransactionTestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
from blog.models import BlogSettings
value = BlogSettings()
value.comment_need_review = True
value.save()
self.user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
def update_article_comment_status(self, article):
comments = article.comment_set.all()
for comment in comments:
comment.is_enable = True
comment.save()
def test_validate_comment(self):
self.client.login(username='liangliangyy1', password='liangliangyy1')
category = Category()
category.name = "categoryccc"
category.save()
article = Article()
article.title = "nicetitleccc"
article.body = "nicecontentccc"
article.author = self.user
article.category = category
article.type = 'a'
article.status = 'p'
article.save()
comment_url = reverse(
'comments:postcomment', kwargs={
'article_id': article.id})
response = self.client.post(comment_url,
{
'body': '123ffffffffff'
})
self.assertEqual(response.status_code, 302)
article = Article.objects.get(pk=article.pk)
self.assertEqual(len(article.comment_list()), 0)
self.update_article_comment_status(article)
self.assertEqual(len(article.comment_list()), 1)
response = self.client.post(comment_url,
{
'body': '123ffffffffff',
})
self.assertEqual(response.status_code, 302)
article = Article.objects.get(pk=article.pk)
self.update_article_comment_status(article)
self.assertEqual(len(article.comment_list()), 2)
parent_comment_id = article.comment_list()[0].id
response = self.client.post(comment_url,
{
'body': '''
# Title1
```python
import os
```
[url](https://www.lylinux.net/)
[ddd](http://www.baidu.com)
''',
'parent_comment_id': parent_comment_id
})
self.assertEqual(response.status_code, 302)
self.update_article_comment_status(article)
article = Article.objects.get(pk=article.pk)
self.assertEqual(len(article.comment_list()), 3)
comment = Comment.objects.get(id=parent_comment_id)
tree = parse_commenttree(article.comment_list(), comment)
self.assertEqual(len(tree), 1)
data = show_comment_item(comment, True)
self.assertIsNotNone(data)
s = get_max_articleid_commentid()
self.assertIsNotNone(s)
from comments.utils import send_comment_email
send_comment_email(comment)
================================================
FILE: comments/urls.py
================================================
from django.urls import path
from . import views
app_name = "comments"
urlpatterns = [
path(
'article//postcomment',
views.CommentPostView.as_view(),
name='postcomment'),
path(
'comment//react',
views.CommentReactionView.as_view(),
name='comment_react'),
]
================================================
FILE: comments/utils.py
================================================
import logging
from django.utils.translation import gettext_lazy as _
from djangoblog.utils import get_current_site
from djangoblog.utils import send_email
logger = logging.getLogger(__name__)
def send_comment_email(comment):
site = get_current_site().domain
subject = _('Thanks for your comment')
article_url = f"https://{site}{comment.article.get_absolute_url()}"
html_content = _("""Thank you very much for your comments on this site
You can visit %(article_title)s
to review your comments,
Thank you again!
If the link above cannot be opened, please copy this link to your browser.
%(article_url)s""") % {'article_url': article_url, 'article_title': comment.article.title}
tomail = comment.author.email
send_email([tomail], subject, html_content)
try:
if comment.parent_comment:
html_content = _("""Your comment on %(article_title)s has
received a reply. %(comment_body)s
go check it out!
If the link above cannot be opened, please copy this link to your browser.
%(article_url)s
""") % {'article_url': article_url, 'article_title': comment.article.title,
'comment_body': comment.parent_comment.body}
tomail = comment.parent_comment.author.email
send_email([tomail], subject, html_content)
except Exception as e:
logger.error(e)
================================================
FILE: comments/views.py
================================================
# Create your views here.
from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404
from django.views import View
from accounts.models import BlogUser
from blog.models import Article
from djangoblog.base_views import AuthenticatedFormView
from .forms import CommentForm
from .models import Comment, CommentReaction
class CommentPostView(AuthenticatedFormView):
"""
评论提交视图
使用 AuthenticatedFormView 基类,自动提供:
- 登录验证(未登录用户会被重定向)
- CSRF 保护
"""
form_class = CommentForm
template_name = 'blog/article_detail.html'
def get(self, request, *args, **kwargs):
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
url = article.get_absolute_url()
return HttpResponseRedirect(url + "#comments")
def form_invalid(self, form):
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
return self.render_to_response({
'form': form,
'article': article
})
def form_valid(self, form):
"""提交的数据验证合法后的逻辑"""
user = self.request.user
author = BlogUser.objects.get(pk=user.pk)
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
if article.comment_status == 'c' or article.status == 'c':
raise ValidationError("该文章评论已关闭.")
comment = form.save(False)
comment.article = article
from djangoblog.utils import get_blog_setting
settings = get_blog_setting()
if not settings.comment_need_review:
comment.is_enable = True
comment.author = author
if form.cleaned_data['parent_comment_id']:
parent_comment = Comment.objects.get(
pk=form.cleaned_data['parent_comment_id'])
comment.parent_comment = parent_comment
comment.save(True)
return HttpResponseRedirect(
"%s#div-comment-%d" %
(article.get_absolute_url(), comment.pk))
class CommentReactionView(View):
"""
评论 Emoji 反应 API
GET /comment//react - 获取 reactions(公开)
POST /comment//react - 切换 reaction(需要登录)
"""
def get(self, request, comment_id):
"""获取评论的 reactions 数据(公开访问)"""
comment = get_object_or_404(Comment, id=comment_id, is_enable=True)
# 传递用户信息,如果未登录则传递 None
user = request.user if request.user.is_authenticated else None
reactions_data = comment.get_reactions_summary(user)
return JsonResponse({
'success': True,
'reactions': reactions_data
})
def post(self, request, comment_id):
# POST 需要登录验证
if not request.user.is_authenticated:
return JsonResponse({
'success': False,
'error': 'Authentication required'
}, status=401)
# 获取评论(只有已启用的评论才能点赞)
comment = get_object_or_404(Comment, id=comment_id, is_enable=True)
# 获取 reaction 类型
reaction_type = request.POST.get('reaction_type')
# 验证 reaction_type 是否合法
valid_reactions = [choice[0] for choice in CommentReaction.REACTION_CHOICES]
if reaction_type not in valid_reactions:
return JsonResponse({
'error': 'Invalid reaction type'
}, status=400)
# 切换 reaction(如果已存在则删除,否则创建)
reaction, created = CommentReaction.objects.get_or_create(
comment=comment,
user=request.user,
reaction_type=reaction_type
)
if not created:
# 已存在,删除它(取消点赞)
reaction.delete()
action = 'removed'
else:
action = 'added'
# 返回该评论的所有 reactions 统计
reactions_data = comment.get_reactions_summary(request.user)
return JsonResponse({
'success': True,
'action': action,
'reactions': reactions_data
})
================================================
FILE: deploy/docker-compose/docker-compose.es.yml
================================================
version: '3'
services:
es:
image: liangliangyy/elasticsearch-analysis-ik:8.6.1
container_name: es
restart: always
environment:
- discovery.type=single-node
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ports:
- 9200:9200
volumes:
- ./bin/datas/es/:/usr/share/elasticsearch/data/
kibana:
image: kibana:8.6.1
restart: always
container_name: kibana
ports:
- 5601:5601
environment:
- ELASTICSEARCH_HOSTS=http://es:9200
djangoblog:
build: .
restart: always
command: bash -c 'sh /code/djangoblog/bin/docker_start.sh'
ports:
- "8000:8000"
volumes:
- ./collectedstatic:/code/djangoblog/collectedstatic
- ./uploads:/code/djangoblog/uploads
environment:
- DJANGO_MYSQL_DATABASE=djangoblog
- DJANGO_MYSQL_USER=root
- DJANGO_MYSQL_PASSWORD=QQQQwww123!@#
- DJANGO_MYSQL_HOST=db
- DJANGO_MYSQL_PORT=3306
- DJANGO_MEMCACHED_LOCATION=memcached:11211
- DJANGO_ELASTICSEARCH_HOST=es:9200
links:
- db
- memcached
depends_on:
- db
container_name: djangoblog
================================================
FILE: deploy/docker-compose/docker-compose.yml
================================================
version: '3'
services:
db:
image: mysql:latest
restart: always
environment:
- MYSQL_DATABASE=djangoblog
- MYSQL_ROOT_PASSWORD=QQQQwww123!@#
ports:
- 3306:3306
volumes:
- mysql_data:/var/lib/mysql
depends_on:
- redis
container_name: db
djangoblog:
build:
context: ../../
dockerfile: Dockerfile
restart: always
command: bash -c 'sh /code/djangoblog/deploy/entrypoint.sh'
ports:
- "8000:8000"
volumes:
# 使用named volume共享静态文件给nginx
- static_files:/code/djangoblog/collectedstatic
- ./logs:/code/djangoblog/logs
- ./uploads:/code/djangoblog/uploads
environment:
- DJANGO_DEBUG=False
- DJANGO_MYSQL_DATABASE=djangoblog
- DJANGO_MYSQL_USER=root
- DJANGO_MYSQL_PASSWORD=QQQQwww123!@#
- DJANGO_MYSQL_HOST=db
- DJANGO_MYSQL_PORT=3306
- DJANGO_REDIS_URL=redis:6379
links:
- db
- redis
depends_on:
- db
container_name: djangoblog
nginx:
restart: always
image: nginx:latest
ports:
- "80:80"
- "443:443"
volumes:
- ../nginx.conf:/etc/nginx/nginx.conf
# 使用同一个named volume访问静态文件
- static_files:/code/djangoblog/collectedstatic:ro
links:
- djangoblog:djangoblog
container_name: nginx
redis:
restart: always
image: redis:latest
container_name: redis
ports:
- "6379:6379"
volumes:
mysql_data:
static_files:
================================================
FILE: deploy/entrypoint.sh
================================================
#!/usr/bin/env bash
NAME="djangoblog"
DJANGODIR=/code/djangoblog
USER=root
GROUP=root
NUM_WORKERS=1
DJANGO_WSGI_MODULE=djangoblog.wsgi
echo "Starting $NAME as `whoami`"
cd $DJANGODIR
export PYTHONPATH=$DJANGODIR:$PYTHONPATH
python manage.py makemigrations && \
python manage.py migrate && \
python manage.py collectstatic --noinput && \
echo "Verifying Vite build artifacts..." && \
ls -la blog/static/blog/dist/css/ && \
ls -la blog/static/blog/dist/js/ && \
echo "Vite manifest content:" && \
cat blog/static/blog/dist/.vite/manifest.json && \
echo "Copying .vite directory to collectedstatic..." && \
mkdir -p collectedstatic/blog/dist/.vite && \
cp -r blog/static/blog/dist/.vite/* collectedstatic/blog/dist/.vite/ && \
python manage.py compress --force && \
python manage.py build_index && \
python manage.py compilemessages || exit 1
exec gunicorn ${DJANGO_WSGI_MODULE}:application \
--name $NAME \
--workers $NUM_WORKERS \
--user=$USER --group=$GROUP \
--bind 0.0.0.0:8000 \
--log-level=debug \
--log-file=- \
--worker-class gevent \
--threads 4
================================================
FILE: deploy/k8s/configmap.yaml
================================================
apiVersion: v1
kind: ConfigMap
metadata:
name: web-nginx-config
namespace: djangoblog
data:
nginx.conf: |
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 8;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
# Include server configurations
include /etc/nginx/conf.d/*.conf;
}
djangoblog.conf: |
server {
server_name lylinux.net;
root /code/djangoblog/collectedstatic/;
listen 80;
keepalive_timeout 70;
location /static/ {
expires max;
alias /code/djangoblog/collectedstatic/;
}
location ~* (robots\.txt|ads\.txt|favicon\.ico|favion\.ico|crossdomain\.xml|google93fd32dbd906620a\.html|BingSiteAuth\.xml|baidu_verify_Ijeny6KrmS\.html)$ {
root /resource/djangopub;
expires 1d;
access_log off;
error_log off;
}
location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_redirect off;
if (!-f $request_filename) {
proxy_pass http://djangoblog:8000;
break;
}
}
}
server {
server_name www.lylinux.net;
listen 80;
return 301 https://lylinux.net$request_uri;
}
resource.lylinux.net.conf: |
server {
index index.html index.htm;
server_name resource.lylinux.net;
root /resource/;
location /djangoblog/ {
alias /code/djangoblog/collectedstatic/;
}
access_log off;
error_log off;
include lylinux/resource.conf;
}
lylinux.resource.conf: |
expires max;
access_log off;
log_not_found off;
add_header Pragma public;
add_header Cache-Control "public";
add_header "Access-Control-Allow-Origin" "*";
---
apiVersion: v1
kind: ConfigMap
metadata:
name: djangoblog-env
namespace: djangoblog
data:
DJANGO_MYSQL_DATABASE: djangoblog
DJANGO_MYSQL_USER: root
DJANGO_MYSQL_PASSWORD: QQQQwww123!@#
DJANGO_MYSQL_HOST: db
DJANGO_MYSQL_PORT: "3306"
DJANGO_REDIS_URL: "redis:6379"
DJANGO_DEBUG: "False"
MYSQL_ROOT_PASSWORD: QQQQwww123!@#
MYSQL_DATABASE: djangoblog
MYSQL_PASSWORD: QQQQwww123!@#
DJANGO_SECRET_KEY: k8s-test-secret-key-12345678
================================================
FILE: deploy/k8s/deployment.yaml
================================================
apiVersion: apps/v1
kind: Deployment
metadata:
name: djangoblog
namespace: djangoblog
labels:
app: djangoblog
spec:
replicas: 3
selector:
matchLabels:
app: djangoblog
template:
metadata:
labels:
app: djangoblog
spec:
containers:
- name: djangoblog
image: liangliangyy/djangoblog:latest
imagePullPolicy: Always
ports:
- containerPort: 8000
envFrom:
- configMapRef:
name: djangoblog-env
readinessProbe:
httpGet:
path: /health/
port: 8000
initialDelaySeconds: 60
periodSeconds: 30
livenessProbe:
httpGet:
path: /health/
port: 8000
initialDelaySeconds: 60
periodSeconds: 30
resources:
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: "2"
memory: 2Gi
volumeMounts:
- name: djangoblog
mountPath: /code/djangoblog/collectedstatic
- name: resource
mountPath: /resource
volumes:
- name: djangoblog
persistentVolumeClaim:
claimName: djangoblog-pvc
- name: resource
persistentVolumeClaim:
claimName: resource-pvc
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: djangoblog
labels:
app: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 6379
resources:
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: 200m
memory: 2Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: db
namespace: djangoblog
labels:
app: db
spec:
replicas: 1
selector:
matchLabels:
app: db
template:
metadata:
labels:
app: db
spec:
containers:
- name: db
image: mysql:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3306
envFrom:
- configMapRef:
name: djangoblog-env
readinessProbe:
exec:
command:
- mysqladmin
- ping
- "-h"
- "127.0.0.1"
- "-u"
- "root"
- "-p$MYSQL_ROOT_PASSWORD"
initialDelaySeconds: 30
periodSeconds: 10
livenessProbe:
exec:
command:
- mysqladmin
- ping
- "-h"
- "127.0.0.1"
- "-u"
- "root"
- "-p$MYSQL_ROOT_PASSWORD"
initialDelaySeconds: 30
periodSeconds: 10
resources:
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: "2"
memory: 2Gi
volumeMounts:
- name: db-data
mountPath: /var/lib/mysql
volumes:
- name: db-data
persistentVolumeClaim:
claimName: db-pvc
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
namespace: djangoblog
labels:
app: nginx
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
resources:
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: "2"
memory: 2Gi
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
- name: nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: djangoblog.conf
- name: nginx-config
mountPath: /etc/nginx/conf.d/resource.lylinux.net.conf
subPath: resource.lylinux.net.conf
- name: nginx-config
mountPath: /etc/nginx/lylinux/resource.conf
subPath: lylinux.resource.conf
- name: djangoblog-pvc
mountPath: /code/djangoblog/collectedstatic
- name: resource-pvc
mountPath: /resource
volumes:
- name: nginx-config
configMap:
name: web-nginx-config
- name: djangoblog-pvc
persistentVolumeClaim:
claimName: djangoblog-pvc
- name: resource-pvc
persistentVolumeClaim:
claimName: resource-pvc
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: elasticsearch
namespace: djangoblog
labels:
app: elasticsearch
spec:
replicas: 1
selector:
matchLabels:
app: elasticsearch
template:
metadata:
labels:
app: elasticsearch
spec:
containers:
- name: elasticsearch
image: liangliangyy/elasticsearch-analysis-ik:8.6.1
imagePullPolicy: IfNotPresent
env:
- name: discovery.type
value: single-node
- name: ES_JAVA_OPTS
value: "-Xms256m -Xmx256m"
- name: xpack.security.enabled
value: "false"
- name: xpack.monitoring.templates.enabled
value: "false"
ports:
- containerPort: 9200
resources:
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: "2"
memory: 2Gi
readinessProbe:
httpGet:
path: /
port: 9200
initialDelaySeconds: 15
periodSeconds: 30
livenessProbe:
httpGet:
path: /
port: 9200
initialDelaySeconds: 15
periodSeconds: 30
volumeMounts:
- name: elasticsearch-data
mountPath: /usr/share/elasticsearch/data/
volumes:
- name: elasticsearch-data
persistentVolumeClaim:
claimName: elasticsearch-pvc
================================================
FILE: deploy/k8s/gateway.yaml
================================================
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nginx
namespace: djangoblog
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: nginx
port:
number: 80
================================================
FILE: deploy/k8s/pv.yaml
================================================
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-db
spec:
capacity:
storage: 10Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /mnt/local-storage-db
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- master
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-djangoblog
spec:
capacity:
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /mnt/local-storage-djangoblog
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- master
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-resource
spec:
capacity:
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /mnt/resource/
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- master
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-elasticsearch
spec:
capacity:
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /mnt/local-storage-elasticsearch
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- master
================================================
FILE: deploy/k8s/pvc.yaml
================================================
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: db-pvc
namespace: djangoblog
spec:
storageClassName: local-storage
volumeName: local-pv-db
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: djangoblog-pvc
namespace: djangoblog
spec:
volumeName: local-pv-djangoblog
storageClassName: local-storage
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: resource-pvc
namespace: djangoblog
spec:
volumeName: local-pv-resource
storageClassName: local-storage
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: elasticsearch-pvc
namespace: djangoblog
spec:
volumeName: local-pv-elasticsearch
storageClassName: local-storage
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
================================================
FILE: deploy/k8s/service.yaml
================================================
apiVersion: v1
kind: Service
metadata:
name: djangoblog
namespace: djangoblog
labels:
app: djangoblog
spec:
selector:
app: djangoblog
ports:
- protocol: TCP
port: 8000
targetPort: 8000
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: nginx
namespace: djangoblog
labels:
app: nginx
spec:
selector:
app: nginx
ports:
- protocol: TCP
port: 80
targetPort: 80
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: djangoblog
labels:
app: redis
spec:
selector:
app: redis
ports:
- protocol: TCP
port: 6379
targetPort: 6379
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: db
namespace: djangoblog
labels:
app: db
spec:
selector:
app: db
ports:
- protocol: TCP
port: 3306
targetPort: 3306
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: elasticsearch
namespace: djangoblog
labels:
app: elasticsearch
spec:
selector:
app: elasticsearch
ports:
- protocol: TCP
port: 9200
targetPort: 9200
type: ClusterIP
================================================
FILE: deploy/k8s/storageclass.yaml
================================================
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-storage
annotations:
storageclass.kubernetes.io/is-default-class: "true"
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: Immediate
================================================
FILE: deploy/nginx.conf
================================================
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
#gzip on;
server {
root /code/djangoblog/collectedstatic/;
listen 80;
keepalive_timeout 70;
location /static/ {
expires max;
alias /code/djangoblog/collectedstatic/;
}
location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_redirect off;
if (!-f $request_filename) {
proxy_pass http://djangoblog:8000;
break;
}
}
}
}
================================================
FILE: djangoblog/__init__.py
================================================
default_app_config = 'djangoblog.apps.DjangoblogAppConfig'
================================================
FILE: djangoblog/admin_site.py
================================================
from django.contrib.admin import AdminSite
from django.contrib.admin.models import LogEntry
from django.contrib.sites.admin import SiteAdmin
from django.contrib.sites.models import Site
from accounts.admin import *
from blog.admin import *
from blog.models import *
from comments.admin import *
from comments.models import *
from djangoblog.logentryadmin import LogEntryAdmin
from oauth.admin import *
from oauth.models import *
from owntracks.admin import *
from owntracks.models import *
from servermanager.admin import *
from servermanager.models import *
class DjangoBlogAdminSite(AdminSite):
site_header = 'djangoblog administration'
site_title = 'djangoblog site admin'
def __init__(self, name='admin'):
super().__init__(name)
def has_permission(self, request):
return request.user.is_superuser
# def get_urls(self):
# urls = super().get_urls()
# from django.urls import path
# from blog.views import refresh_memcache
#
# my_urls = [
# path('refresh/', self.admin_view(refresh_memcache), name="refresh"),
# ]
# return urls + my_urls
admin_site = DjangoBlogAdminSite(name='admin')
admin_site.register(Article, ArticlelAdmin)
admin_site.register(Category, CategoryAdmin)
admin_site.register(Tag, TagAdmin)
admin_site.register(Links, LinksAdmin)
admin_site.register(SideBar, SideBarAdmin)
admin_site.register(BlogSettings, BlogSettingsAdmin)
admin_site.register(commands, CommandsAdmin)
admin_site.register(EmailSendLog, EmailSendLogAdmin)
admin_site.register(BlogUser, BlogUserAdmin)
admin_site.register(Comment, CommentAdmin)
admin_site.register(OAuthUser, OAuthUserAdmin)
admin_site.register(OAuthConfig, OAuthConfigAdmin)
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin)
admin_site.register(Site, SiteAdmin)
admin_site.register(LogEntry, LogEntryAdmin)
================================================
FILE: djangoblog/apps.py
================================================
from django.apps import AppConfig
class DjangoblogAppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'djangoblog'
def ready(self):
super().ready()
# Import and load plugins here
from .plugin_manage.loader import load_plugins
load_plugins()
================================================
FILE: djangoblog/base_views.py
================================================
#!/usr/bin/env python
# encoding: utf-8
"""
Django Blog 基础视图类
提供带有常用装饰器的视图基类,减少重复的 dispatch 方法定义
"""
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import FormView, RedirectView
class SecureFormView(FormView):
"""
安全的 FormView 基类
自动添加 CSRF 保护,适用于所有需要表单提交的视图
Usage:
class MyFormView(SecureFormView):
form_class = MyForm
template_name = 'my_form.html'
def form_valid(self, form):
# 处理表单数据
return super().form_valid(form)
"""
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
"""添加 CSRF 保护"""
return super().dispatch(*args, **kwargs)
class AuthenticatedFormView(FormView):
"""
需要登录的 FormView
自动检查用户登录状态并添加 CSRF 保护
未登录用户会被重定向到登录页面
Usage:
class MyAuthFormView(AuthenticatedFormView):
form_class = MyForm
template_name = 'my_form.html'
"""
@method_decorator(login_required)
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
"""添加登录要求和 CSRF 保护"""
return super().dispatch(*args, **kwargs)
class LoginFormView(FormView):
"""
登录专用 FormView
包含以下保护措施:
- 敏感参数保护(password 等)
- CSRF 保护
- 禁用缓存(防止登录状态被缓存)
Usage:
class LoginView(LoginFormView):
form_class = LoginForm
template_name = 'login.html'
def form_valid(self, form):
# 处理登录逻辑
return super().form_valid(form)
"""
@method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
"""添加敏感参数保护、CSRF 保护和禁用缓存"""
return super().dispatch(request, *args, **kwargs)
class LogoutRedirectView(RedirectView):
"""
登出专用 RedirectView
自动禁用缓存,确保登出操作不会被缓存
Usage:
class LogoutView(LogoutRedirectView):
url = '/login/'
def get(self, request, *args, **kwargs):
logout(request)
return super().get(request, *args, **kwargs)
"""
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
"""禁用缓存"""
return super().dispatch(request, *args, **kwargs)
class NoCacheFormView(FormView):
"""
禁用缓存的 FormView
适用于需要实时数据的表单(如验证码、动态内容等)
Usage:
class MyCacheDisabledFormView(NoCacheFormView):
form_class = MyForm
template_name = 'my_form.html'
"""
@method_decorator(never_cache)
@method_decorator(csrf_protect)
def dispatch(self, request, *args, **kwargs):
"""禁用缓存并添加 CSRF 保护"""
return super().dispatch(request, *args, **kwargs)
================================================
FILE: djangoblog/blog_signals.py
================================================
import _thread
import logging
import django.dispatch
from django.conf import settings
from django.contrib.admin.models import LogEntry
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.core.mail import EmailMultiAlternatives
from django.db.models.signals import post_save
from django.dispatch import receiver
from comments.models import Comment
from comments.utils import send_comment_email
from djangoblog.spider_notify import SpiderNotify
from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, delete_view_cache
from djangoblog.utils import get_current_site
from oauth.models import OAuthUser
logger = logging.getLogger(__name__)
oauth_user_login_signal = django.dispatch.Signal(['id'])
send_email_signal = django.dispatch.Signal(
['emailto', 'title', 'content'])
@receiver(send_email_signal)
def send_email_signal_handler(sender, **kwargs):
emailto = kwargs['emailto']
title = kwargs['title']
content = kwargs['content']
msg = EmailMultiAlternatives(
title,
content,
from_email=settings.DEFAULT_FROM_EMAIL,
to=emailto)
msg.content_subtype = "html"
from servermanager.models import EmailSendLog
log = EmailSendLog()
log.title = title
log.content = content
log.emailto = ','.join(emailto)
try:
result = msg.send()
log.send_result = result > 0
except Exception as e:
logger.error(f"失败邮箱号: {emailto}, {e}")
log.send_result = False
log.save()
@receiver(oauth_user_login_signal)
def oauth_user_login_signal_handler(sender, **kwargs):
id = kwargs['id']
oauthuser = OAuthUser.objects.get(id=id)
site = get_current_site().domain
if oauthuser.picture and not oauthuser.picture.find(site) >= 0:
from djangoblog.utils import save_user_avatar
oauthuser.picture = save_user_avatar(oauthuser.picture)
oauthuser.save()
delete_sidebar_cache()
@receiver(post_save)
def model_post_save_callback(
sender,
instance,
created,
raw,
using,
update_fields,
**kwargs):
if isinstance(instance, LogEntry):
return
# 检查是否只更新了浏览量
is_update_views = update_fields == {'views'}
if is_update_views:
return # 浏览量更新不需要清理缓存
# 搜索引擎通知
if 'get_full_url' in dir(instance):
if not settings.TESTING:
try:
notify_url = instance.get_full_url()
SpiderNotify.baidu_notify([notify_url])
except Exception as ex:
logger.error("notify sipder", ex)
# 评论相关的缓存清理
if isinstance(instance, Comment):
if instance.is_enable:
path = instance.article.get_absolute_url()
site = get_current_site().domain
if site.find(':') > 0:
site = site[0:site.find(':')]
expire_view_cache(
path,
servername=site,
serverport=80,
key_prefix='blogdetail')
# 清理评论相关缓存
comment_cache_key = 'article_comments_{id}'.format(
id=instance.article.id)
cache.delete(comment_cache_key)
delete_view_cache('article_comments', [str(instance.article.pk)])
delete_sidebar_cache()
cache.delete('seo_processor')
_thread.start_new_thread(send_comment_email, (instance,))
# 文章相关的精细化缓存清理
elif 'get_full_url' in dir(instance):
from blog.models import Article, Category, Tag
if isinstance(instance, Article):
# 清理文章列表首页缓存
cache.delete('index_1')
# 清理文章详情缓存
article_cache_key = f'article_comments_{instance.id}'
cache.delete(article_cache_key)
# 清理分类相关缓存
if instance.category:
category_name = instance.category.name
cache.delete(f'category_list_{category_name}_1')
# 清理标签相关缓存
try:
for tag in instance.tags.all():
cache.delete(f'tag_{tag.name}_1')
except Exception:
pass # 可能在创建时tags还未关联
# 清理作者相关缓存
if instance.author:
from uuslug import slugify
author_slug = slugify(instance.author.username)
cache.delete(f'author_{author_slug}_1')
# 清理归档缓存
cache.delete('archives')
# 清理侧边栏和上下文处理器缓存
delete_sidebar_cache()
cache.delete('seo_processor')
elif isinstance(instance, Category):
# 清理分类相关缓存
cache.delete(f'category_list_{instance.name}_1')
delete_sidebar_cache()
cache.delete('seo_processor')
elif isinstance(instance, Tag):
# 清理标签相关缓存
cache.delete(f'tag_{instance.name}_1')
delete_sidebar_cache()
# 其他模型的缓存清理
else:
# 对于其他有get_full_url的模型,清理基础缓存
delete_sidebar_cache()
cache.delete('seo_processor')
@receiver(user_logged_in)
@receiver(user_logged_out)
def user_auth_callback(sender, request, user, **kwargs):
if user and user.username:
logger.info(user)
delete_sidebar_cache()
# cache.clear()
================================================
FILE: djangoblog/constants.py
================================================
#!/usr/bin/env python
# encoding: utf-8
"""
Django Blog 全局常量定义
包含缓存超时时间、缓存键模板等配置
"""
# ===== 缓存过期时间(秒)=====
class CacheTimeout:
"""
缓存超时时间常量
集中管理所有缓存过期时间,便于统一调整缓存策略
"""
# 分钟级
MINUTE_1 = 60
MINUTE_5 = 60 * 5
MINUTE_10 = 60 * 10
MINUTE_30 = 60 * 30
# 小时级
HOUR_1 = 60 * 60
HOUR_2 = 60 * 60 * 2
HOUR_10 = 60 * 60 * 10
HOUR_24 = 60 * 60 * 24
# 天级
DAY_7 = 60 * 60 * 24 * 7
DAY_30 = 60 * 60 * 24 * 30
# 默认缓存时间
DEFAULT = HOUR_10 # 10小时
# ===== 缓存键前缀 =====
class CacheKey:
"""
缓存键模板
使用字符串格式化模板,避免缓存键拼写错误
"""
# 文章相关
ARTICLE_COMMENTS = 'article_comments_{article_id}'
ARTICLE_NEXT = 'article_next_{article_id}'
ARTICLE_PREV = 'article_prev_{article_id}'
ARTICLE_CATEGORY_TREE = 'article_category_tree_{article_id}'
# 列表页缓存
INDEX_LIST = 'index_{page}'
CATEGORY_LIST = 'category_list_{name}_{page}'
TAG_LIST = 'tag_{name}_{page}'
AUTHOR_LIST = 'author_{name}_{page}'
ARCHIVES = 'archives'
# 分类和标签
CATEGORY_TREE = 'category_tree_{category_id}'
SUB_CATEGORIES = 'sub_categories_{category_id}'
TAG_ARTICLE_COUNT = 'tag_article_count_{tag_id}'
# 全局设置
BLOG_SETTINGS = 'blog_settings'
CURRENT_SITE = 'current_site'
SIDEBAR = 'sidebar_{type}'
# 侧边栏相关
SIDEBAR_LATEST_ARTICLES = 'sidebar_latest_articles'
SIDEBAR_HOT_ARTICLES = 'sidebar_hot_articles'
SIDEBAR_LATEST_COMMENTS = 'sidebar_latest_comments'
# ===== HTTP 状态码 =====
class HttpStatus:
"""HTTP 状态码常量"""
OK = 200
CREATED = 201
NO_CONTENT = 204
BAD_REQUEST = 400
UNAUTHORIZED = 401
FORBIDDEN = 403
NOT_FOUND = 404
INTERNAL_SERVER_ERROR = 500
BAD_GATEWAY = 502
SERVICE_UNAVAILABLE = 503
# ===== 分页配置 =====
class Pagination:
"""分页相关常量"""
DEFAULT_PAGE_SIZE = 10
MAX_PAGE_SIZE = 100
PAGE_QUERY_PARAM = 'page'
================================================
FILE: djangoblog/elasticsearch_backend.py
================================================
from django.utils.encoding import force_str
from elasticsearch_dsl import Q
from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query
from haystack.forms import ModelSearchForm
from haystack.models import SearchResult
from haystack.utils import log as logging
from blog.documents import ArticleDocument, ArticleDocumentManager
from blog.models import Article
logger = logging.getLogger(__name__)
class ElasticSearchBackend(BaseSearchBackend):
def __init__(self, connection_alias, **connection_options):
super(
ElasticSearchBackend,
self).__init__(
connection_alias,
**connection_options)
self.manager = ArticleDocumentManager()
self.include_spelling = True
def _get_models(self, iterable):
models = iterable if iterable and iterable[0] else Article.objects.all()
docs = self.manager.convert_to_doc(models)
return docs
def _create(self, models):
self.manager.create_index()
docs = self._get_models(models)
self.manager.rebuild(docs)
def _delete(self, models):
for m in models:
m.delete()
return True
def _rebuild(self, models):
models = models if models else Article.objects.all()
docs = self.manager.convert_to_doc(models)
self.manager.update_docs(docs)
def update(self, index, iterable, commit=True):
models = self._get_models(iterable)
self.manager.update_docs(models)
def remove(self, obj_or_string):
models = self._get_models([obj_or_string])
self._delete(models)
def clear(self, models=None, commit=True):
self.remove(None)
@staticmethod
def get_suggestion(query: str) -> str:
"""获取推荐词, 如果没有找到添加原搜索词"""
search = ArticleDocument.search() \
.query("match", body=query) \
.suggest('suggest_search', query, term={'field': 'body'}) \
.execute()
keywords = []
for suggest in search.suggest.suggest_search:
if suggest["options"]:
keywords.append(suggest["options"][0]["text"])
else:
keywords.append(suggest["text"])
return ' '.join(keywords)
@log_query
def search(self, query_string, **kwargs):
logger.info('search query_string:' + query_string)
start_offset = kwargs.get('start_offset')
end_offset = kwargs.get('end_offset')
highlight = kwargs.get('highlight', True) # 默认启用高亮
# 推荐词搜索
if getattr(self, "is_suggest", None):
suggestion = self.get_suggestion(query_string)
else:
suggestion = query_string
q = Q('bool',
should=[Q('match', body=suggestion), Q('match', title=suggestion)],
minimum_should_match=1) # 至少匹配1个should子句
search = ArticleDocument.search() \
.query('bool', filter=[q]) \
.filter('term', status='p') \
.filter('term', type='a')
# 添加高亮配置
if highlight:
search = search.highlight('title', 'body',
fragment_size=150, # 片段大小
number_of_fragments=3, # 片段数量
pre_tags=[''], # 高亮开始标签
post_tags=[' ']) # 高亮结束标签
search = search.source(False)[start_offset: end_offset]
results = search.execute()
# ES 8.x: total 现在是对象 {'value': 123, 'relation': 'eq'}
hits_total = results['hits']['total']
hits = hits_total['value'] if isinstance(hits_total, dict) else hits_total
raw_results = []
for raw_result in results['hits']['hits']:
app_label = 'blog'
model_name = 'Article'
additional_fields = {}
# 添加高亮内容
if highlight and 'highlight' in raw_result:
highlighted = {}
if 'title' in raw_result['highlight']:
highlighted['title'] = raw_result['highlight']['title']
if 'body' in raw_result['highlight']:
highlighted['body'] = raw_result['highlight']['body']
if highlighted:
additional_fields['highlighted'] = highlighted
result_class = SearchResult
result = result_class(
app_label,
model_name,
raw_result['_id'],
raw_result['_score'],
**additional_fields)
raw_results.append(result)
facets = {}
spelling_suggestion = None if query_string == suggestion else suggestion
return {
'results': raw_results,
'hits': hits,
'facets': facets,
'spelling_suggestion': spelling_suggestion,
}
class ElasticSearchQuery(BaseSearchQuery):
def _convert_datetime(self, date):
if hasattr(date, 'hour'):
return force_str(date.strftime('%Y%m%d%H%M%S'))
else:
return force_str(date.strftime('%Y%m%d000000'))
def clean(self, query_fragment):
"""
Provides a mechanism for sanitizing user input before presenting the
value to the backend.
Whoosh 1.X differs here in that you can no longer use a backslash
to escape reserved characters. Instead, the whole word should be
quoted.
"""
words = query_fragment.split()
cleaned_words = []
for word in words:
if word in self.backend.RESERVED_WORDS:
word = word.replace(word, word.lower())
for char in self.backend.RESERVED_CHARACTERS:
if char in word:
word = "'%s'" % word
break
cleaned_words.append(word)
return ' '.join(cleaned_words)
def build_query_fragment(self, field, filter_type, value):
return value.query_string
def get_count(self):
results = self.get_results()
return len(results) if results else 0
def get_spelling_suggestion(self, preferred_query=None):
return self._spelling_suggestion
def build_params(self, spelling_query=None):
kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query)
return kwargs
class ElasticSearchModelSearchForm(ModelSearchForm):
def search(self):
# 是否建议搜索
self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no"
sqs = super().search()
return sqs
class ElasticSearchEngine(BaseEngine):
backend = ElasticSearchBackend
query = ElasticSearchQuery
================================================
FILE: djangoblog/error_views.py
================================================
#!/usr/bin/env python
# encoding: utf-8
"""
Django Blog 统一错误处理视图
提供统一的错误页面渲染,减少重复代码
"""
import logging
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
logger = logging.getLogger(__name__)
def render_error_page(request, status_code, message, exception=None):
"""
通用错误页面渲染函数
统一处理各种 HTTP 错误,提供一致的错误页面展示
Args:
request: HTTP 请求对象
status_code: HTTP 状态码(404, 403, 500等)
message: 错误消息(支持国际化)
exception: 异常对象(可选),会被记录到日志
Returns:
HttpResponse: 渲染后的错误页面
Usage:
def my_error_handler(request, exception):
return render_error_page(request, 404, "Page not found", exception)
"""
if exception:
logger.error(
f'HTTP {status_code} Error: {exception}',
exc_info=True,
extra={
'request': request,
'status_code': status_code
}
)
return render(
request,
'blog/error_page.html',
{
'message': message,
'statuscode': str(status_code)
},
status=status_code
)
def page_not_found_view(request, exception, template_name='blog/error_page.html'):
"""
404 错误页面处理器
当用户访问不存在的页面时显示
Args:
request: HTTP 请求对象
exception: 异常对象
template_name: 模板名称(保留参数以兼容 Django 标准)
Returns:
HttpResponse: 404 错误页面
"""
return render_error_page(
request,
404,
_('Sorry, the page you requested is not found, please click the home page to see other?'),
exception
)
def server_error_view(request, template_name='blog/error_page.html'):
"""
500 错误页面处理器
当服务器内部错误时显示
Args:
request: HTTP 请求对象
template_name: 模板名称(保留参数以兼容 Django 标准)
Returns:
HttpResponse: 500 错误页面
"""
return render_error_page(
request,
500,
_('Sorry, the server is busy, please click the home page to see other?')
)
def permission_denied_view(request, exception, template_name='blog/error_page.html'):
"""
403 错误页面处理器
当用户无权限访问时显示
Args:
request: HTTP 请求对象
exception: 异常对象
template_name: 模板名称(保留参数以兼容 Django 标准)
Returns:
HttpResponse: 403 错误页面
"""
return render_error_page(
request,
403,
_('Sorry, you do not have permission to access this page?'),
exception
)
def bad_request_view(request, exception, template_name='blog/error_page.html'):
"""
400 错误页面处理器
当请求格式错误时显示
Args:
request: HTTP 请求对象
exception: 异常对象
template_name: 模板名称(保留参数以兼容 Django 标准)
Returns:
HttpResponse: 400 错误页面
"""
return render_error_page(
request,
400,
_('Sorry, the request was invalid?'),
exception
)
================================================
FILE: djangoblog/feeds.py
================================================
from django.contrib.auth import get_user_model
from django.contrib.syndication.views import Feed
from django.utils import timezone
from django.utils.feedgenerator import Rss201rev2Feed
from blog.models import Article
from djangoblog.utils import CommonMarkdown
class DjangoBlogFeed(Feed):
feed_type = Rss201rev2Feed
description = '大巧无工,重剑无锋.'
title = "且听风吟 大巧无工,重剑无锋. "
link = "/feed/"
def author_name(self):
return get_user_model().objects.first().nickname
def author_link(self):
return get_user_model().objects.first().get_absolute_url()
def items(self):
return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5]
def item_title(self, item):
return item.title
def item_description(self, item):
return CommonMarkdown.get_markdown(item.body)
def feed_copyright(self):
now = timezone.now()
return "Copyright© {year} 且听风吟".format(year=now.year)
def item_link(self, item):
return item.get_absolute_url()
def item_guid(self, item):
return
================================================
FILE: djangoblog/logentryadmin.py
================================================
from django.contrib import admin
from django.contrib.admin.models import DELETION
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse, NoReverseMatch
from django.utils.encoding import force_str
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
class LogEntryAdmin(admin.ModelAdmin):
list_filter = [
'content_type'
]
search_fields = [
'object_repr',
'change_message'
]
list_display_links = [
'action_time',
'get_change_message',
]
list_display = [
'action_time',
'user_link',
'content_type',
'object_link',
'get_change_message',
]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return (
request.user.is_superuser or
request.user.has_perm('admin.change_logentry')
) and request.method != 'POST'
def has_delete_permission(self, request, obj=None):
return False
def object_link(self, obj):
object_link = escape(obj.object_repr)
content_type = obj.content_type
if obj.action_flag != DELETION and content_type is not None:
# try returning an actual link instead of object repr string
try:
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.object_id]
)
object_link = '{} '.format(url, object_link)
except NoReverseMatch:
pass
return mark_safe(object_link)
object_link.admin_order_field = 'object_repr'
object_link.short_description = _('object')
def user_link(self, obj):
content_type = ContentType.objects.get_for_model(type(obj.user))
user_link = escape(force_str(obj.user))
try:
# try returning an actual link instead of object repr string
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.user.pk]
)
user_link = '{} '.format(url, user_link)
except NoReverseMatch:
pass
return mark_safe(user_link)
user_link.admin_order_field = 'user'
user_link.short_description = _('user')
def get_queryset(self, request):
queryset = super(LogEntryAdmin, self).get_queryset(request)
return queryset.prefetch_related('content_type')
def get_actions(self, request):
actions = super(LogEntryAdmin, self).get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
================================================
FILE: djangoblog/mixins.py
================================================
#!/usr/bin/env python
# encoding: utf-8
"""
Django Blog 混入类 (Mixins)
提供可复用的功能模块,减少代码重复
"""
import logging
from django.db import models
from django.shortcuts import get_object_or_404
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
logger = logging.getLogger(__name__)
# ===== 模型层 Mixin =====
class TimeStampedModel(models.Model):
"""
抽象模型:为所有模型提供统一的时间戳字段
提供 created_at 和 updated_at 字段,自动管理时间戳
继承此模型可以消除重复的时间字段定义
Usage:
class MyModel(TimeStampedModel):
name = models.CharField(max_length=100)
"""
created_at = models.DateTimeField(
_('creation time'),
default=now,
db_index=True,
help_text=_('The date and time when this object was created')
)
updated_at = models.DateTimeField(
_('last modify time'),
default=now,
help_text=_('The date and time when this object was last modified')
)
class Meta:
abstract = True
ordering = ['-created_at']
get_latest_by = 'created_at'
def save(self, *args, **kwargs):
"""
重写 save 方法,自动更新 updated_at 字段
注意:如果使用 update_fields 参数,需要明确包含 updated_at
"""
# 检查是否是部分更新(指定了 update_fields)
update_fields = kwargs.get('update_fields')
if update_fields:
# 如果指定了 update_fields 但不包含 updated_at,则添加它
if 'updated_at' not in update_fields:
update_fields = list(update_fields) + ['updated_at']
kwargs['update_fields'] = update_fields
# 更新时间戳
self.updated_at = now()
super().save(*args, **kwargs)
# ===== 视图层 Mixin =====
class SlugCachedMixin:
"""
Mixin: 缓存 slug 查询结果,避免重复数据库查询
在同一个请求周期内,多次获取同一个 slug 对应的对象时,
只会执行一次数据库查询,后续调用会使用缓存的对象
Attributes:
slug_url_kwarg: URL 参数名,默认为 'slug'
slug_model: 要查询的模型类
Usage:
class MyView(SlugCachedMixin, ListView):
slug_url_kwarg = 'category_slug'
slug_model = Category
def get_queryset(self):
category = self.get_slug_object()
return Article.objects.filter(category=category)
"""
slug_url_kwarg = 'slug'
slug_model = None
def get_slug_object(self):
"""
获取并缓存 slug 对应的对象
Returns:
Model instance: slug 对应的模型实例
Raises:
Http404: 如果 slug 对应的对象不存在
"""
if not hasattr(self, '_slug_object'):
if self.slug_model is None:
raise ValueError(
f'{self.__class__.__name__} must define slug_model attribute'
)
slug = self.kwargs.get(self.slug_url_kwarg)
self._slug_object = get_object_or_404(self.slug_model, slug=slug)
logger.debug(
f'Loaded {self.slug_model.__name__} object: {self._slug_object} (slug={slug})'
)
return self._slug_object
class OptimizedArticleQueryMixin:
"""
Mixin: 优化文章查询(预加载关联对象)
使用 select_related 和 prefetch_related 优化文章查询,
减少数据库查询次数,避免 N+1 查询问题
Usage:
class MyView(OptimizedArticleQueryMixin, ListView):
def get_queryset(self):
return self.get_optimized_article_queryset().filter(status='p')
"""
def get_optimized_article_queryset(self):
"""
返回优化后的 Article queryset
使用 select_related 预加载外键关联:
- author: 文章作者
- category: 文章分类
使用 prefetch_related 预加载多对多关联:
- tags: 文章标签
Returns:
QuerySet: 优化后的 Article queryset
"""
from blog.models import Article
return Article.objects.select_related(
'author', # 预加载作者(ForeignKey)
'category' # 预加载分类(ForeignKey)
).prefetch_related(
'tags' # 预加载标签(ManyToMany)
)
class CachedListViewMixin:
"""
Mixin: 为 ListView 提供统一的缓存逻辑
自动缓存 queryset 结果,减少数据库查询
子类需要实现 get_queryset_cache_key() 和 get_queryset_data() 方法
Usage:
class MyView(CachedListViewMixin, ListView):
def get_queryset_cache_key(self):
return f'my_list_{self.page_number}'
def get_queryset_data(self):
return Article.objects.filter(status='p')
"""
def get_queryset_cache_key(self):
"""
子类实现:返回缓存 key
Returns:
str: 缓存键
Raises:
NotImplementedError: 子类必须实现此方法
"""
raise NotImplementedError(
f'{self.__class__.__name__} must implement get_queryset_cache_key()'
)
def get_queryset_data(self):
"""
子类实现:返回实际数据
Returns:
QuerySet: 要缓存的 queryset
Raises:
NotImplementedError: 子类必须实现此方法
"""
raise NotImplementedError(
f'{self.__class__.__name__} must implement get_queryset_data()'
)
def get_queryset_from_cache(self, cache_key):
"""
从缓存获取 queryset,如果缓存不存在则查询并缓存
Args:
cache_key: 缓存键
Returns:
QuerySet: 查询结果
"""
from djangoblog.utils import cache
value = cache.get(cache_key)
if value:
logger.info(f'Cache HIT: {cache_key}')
return value
queryset = self.get_queryset_data()
cache.set(cache_key, queryset)
logger.info(f'Cache MISS: {cache_key}')
return queryset
def get_queryset(self):
"""
重写 get_queryset,使用缓存
Returns:
QuerySet: 查询结果(从缓存或数据库)
"""
key = self.get_queryset_cache_key()
return self.get_queryset_from_cache(key)
class PageNumberMixin:
"""
Mixin: 提供页码获取功能
从 URL 参数或 GET 参数中获取当前页码
Usage:
class MyView(PageNumberMixin, ListView):
def get_queryset_cache_key(self):
return f'list_{self.page_number}'
"""
page_kwarg = 'page'
@property
def page_number(self):
"""
获取当前页码
从 URL kwargs 或 GET 参数中获取页码,默认为 1
Returns:
int: 当前页码
"""
page = self.kwargs.get(self.page_kwarg) or \
self.request.GET.get(self.page_kwarg) or 1
try:
return int(page)
except (ValueError, TypeError):
return 1
class ArticleListMixin(
OptimizedArticleQueryMixin,
CachedListViewMixin,
PageNumberMixin
):
"""
Mixin: 组合多个 Mixin,提供完整的文章列表功能
继承此 Mixin 的视图自动具备:
- 优化的文章查询
- 缓存支持
- 页码处理
Usage:
class MyArticleListView(ArticleListMixin, ListView):
def get_queryset_data(self):
return self.get_optimized_article_queryset().filter(status='p')
def get_queryset_cache_key(self):
return f'my_list_{self.page_number}'
"""
pass
================================================
FILE: djangoblog/plugin_manage/base_plugin.py
================================================
import logging
from pathlib import Path
from django.template import TemplateDoesNotExist
from django.template.loader import render_to_string
logger = logging.getLogger(__name__)
class BasePlugin:
# 插件元数据
PLUGIN_NAME = None
PLUGIN_DESCRIPTION = None
PLUGIN_VERSION = None
PLUGIN_AUTHOR = None
# 插件配置
SUPPORTED_POSITIONS = [] # 支持的显示位置
DEFAULT_PRIORITY = 100 # 默认优先级(数字越小优先级越高)
POSITION_PRIORITIES = {} # 各位置的优先级 {'sidebar': 50, 'article_bottom': 80}
def __init__(self):
if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]):
raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.")
# 设置插件路径
self.plugin_dir = self._get_plugin_directory()
self.plugin_slug = self._get_plugin_slug()
self.init_plugin()
self.register_hooks()
def _get_plugin_directory(self):
"""获取插件目录路径"""
import inspect
plugin_file = inspect.getfile(self.__class__)
return Path(plugin_file).parent
def _get_plugin_slug(self):
"""获取插件标识符(目录名)"""
return self.plugin_dir.name
def init_plugin(self):
"""
插件初始化逻辑
子类可以重写此方法来实现特定的初始化操作
"""
logger.info(f'{self.PLUGIN_NAME} initialized.')
def register_hooks(self):
"""
注册插件钩子
子类可以重写此方法来注册特定的钩子
"""
pass
# === 位置渲染系统 ===
def render_position_widget(self, position, context, **kwargs):
"""
根据位置渲染插件组件
Args:
position: 位置标识
context: 模板上下文
**kwargs: 额外参数
Returns:
dict: {'html': 'HTML内容', 'priority': 优先级} 或 None
"""
if position not in self.SUPPORTED_POSITIONS:
return None
# 检查条件显示
if not self.should_display(position, context, **kwargs):
return None
# 调用具体的位置渲染方法
method_name = f'render_{position}_widget'
if hasattr(self, method_name):
html = getattr(self, method_name)(context, **kwargs)
if html:
priority = self.POSITION_PRIORITIES.get(position, self.DEFAULT_PRIORITY)
return {
'html': html,
'priority': priority,
'plugin_name': self.PLUGIN_NAME
}
return None
def should_display(self, position, context, **kwargs):
"""
判断插件是否应该在指定位置显示
子类可重写此方法实现条件显示逻辑
Args:
position: 位置标识
context: 模板上下文
**kwargs: 额外参数
Returns:
bool: 是否显示
"""
return True
# === 各位置渲染方法 - 子类重写 ===
def render_sidebar_widget(self, context, **kwargs):
"""渲染侧边栏组件"""
return None
def render_article_bottom_widget(self, context, **kwargs):
"""渲染文章底部组件"""
return None
def render_article_top_widget(self, context, **kwargs):
"""渲染文章顶部组件"""
return None
def render_header_widget(self, context, **kwargs):
"""渲染页头组件"""
return None
def render_footer_widget(self, context, **kwargs):
"""渲染页脚组件"""
return None
def render_comment_before_widget(self, context, **kwargs):
"""渲染评论前组件"""
return None
def render_comment_after_widget(self, context, **kwargs):
"""渲染评论后组件"""
return None
# === 模板系统 ===
def render_template(self, template_name, context=None):
"""
渲染插件模板
Args:
template_name: 模板文件名
context: 模板上下文
Returns:
HTML字符串
"""
if context is None:
context = {}
template_path = f"plugins/{self.plugin_slug}/{template_name}"
try:
return render_to_string(template_path, context)
except TemplateDoesNotExist:
logger.warning(f"Plugin template not found: {template_path}")
return ""
# === 静态资源系统 ===
def get_static_url(self, static_file):
"""获取插件静态文件URL"""
from django.templatetags.static import static
return static(f"{self.plugin_slug}/static/{self.plugin_slug}/{static_file}")
def get_css_files(self):
"""获取插件CSS文件列表"""
return []
def get_js_files(self):
"""获取插件JavaScript文件列表"""
return []
def get_critical_head_html(self, context=None):
"""
获取需要在最早执行的关键HTML内容(阻塞式加载)
用于防闪烁脚本等必须在页面渲染前执行的关键代码。
此方法返回的内容会在所有CSS和其他资源之前加载。
注意:此方法应该只用于真正需要阻塞加载的关键资源,
普通资源请使用 get_head_html()
"""
return ""
def get_head_html(self, context=None):
"""获取需要插入到中的HTML内容(在CSS之后)"""
return ""
def get_body_html(self, context=None):
"""获取需要插入到底部的HTML内容"""
return ""
def get_plugin_info(self):
"""
获取插件信息
:return: 包含插件元数据的字典
"""
return {
'name': self.PLUGIN_NAME,
'description': self.PLUGIN_DESCRIPTION,
'version': self.PLUGIN_VERSION,
'author': self.PLUGIN_AUTHOR,
'slug': self.plugin_slug,
'directory': str(self.plugin_dir),
'supported_positions': self.SUPPORTED_POSITIONS,
'priorities': self.POSITION_PRIORITIES
}
================================================
FILE: djangoblog/plugin_manage/hook_constants.py
================================================
ARTICLE_DETAIL_LOAD = 'article_detail_load'
ARTICLE_CREATE = 'article_create'
ARTICLE_UPDATE = 'article_update'
ARTICLE_DELETE = 'article_delete'
ARTICLE_CONTENT_HOOK_NAME = "the_content"
# 位置钩子常量
POSITION_HOOKS = {
'article_top': 'article_top_widgets',
'article_bottom': 'article_bottom_widgets',
'sidebar': 'sidebar_widgets',
'header': 'header_widgets',
'footer': 'footer_widgets',
'comment_before': 'comment_before_widgets',
'comment_after': 'comment_after_widgets',
}
# 资源注入钩子
HEAD_RESOURCES_HOOK = 'head_resources'
BODY_RESOURCES_HOOK = 'body_resources'
================================================
FILE: djangoblog/plugin_manage/hooks.py
================================================
import logging
logger = logging.getLogger(__name__)
_hooks = {}
def register(hook_name: str, callback: callable):
"""
注册一个钩子回调。
"""
if hook_name not in _hooks:
_hooks[hook_name] = []
_hooks[hook_name].append(callback)
logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'")
def run_action(hook_name: str, *args, **kwargs):
"""
执行一个 Action Hook。
它会按顺序执行所有注册到该钩子上的回调函数。
"""
if hook_name in _hooks:
logger.debug(f"Running action hook '{hook_name}'")
for callback in _hooks[hook_name]:
try:
callback(*args, **kwargs)
except Exception as e:
logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
def apply_filters(hook_name: str, value, *args, **kwargs):
"""
执行一个 Filter Hook。
它会把 value 依次传递给所有注册的回调函数进行处理。
"""
if hook_name in _hooks:
logger.debug(f"Applying filter hook '{hook_name}'")
for callback in _hooks[hook_name]:
try:
value = callback(value, *args, **kwargs)
except Exception as e:
logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
return value
================================================
FILE: djangoblog/plugin_manage/loader.py
================================================
import os
import logging
from django.conf import settings
logger = logging.getLogger(__name__)
# 全局插件注册表
_loaded_plugins = []
def load_plugins():
"""
Dynamically loads and initializes plugins from the 'plugins' directory.
This function is intended to be called when the Django app registry is ready.
"""
global _loaded_plugins
_loaded_plugins = []
for plugin_name in settings.ACTIVE_PLUGINS:
plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name)
if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')):
try:
# 导入插件模块
plugin_module = __import__(f'plugins.{plugin_name}.plugin', fromlist=['plugin'])
# 获取插件实例
if hasattr(plugin_module, 'plugin'):
plugin_instance = plugin_module.plugin
_loaded_plugins.append(plugin_instance)
logger.info(f"Successfully loaded plugin: {plugin_name} - {plugin_instance.PLUGIN_NAME}")
else:
logger.warning(f"Plugin {plugin_name} does not have 'plugin' instance")
except ImportError as e:
logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)
except AttributeError as e:
logger.error(f"Failed to get plugin instance: {plugin_name}", exc_info=e)
except Exception as e:
logger.error(f"Unexpected error loading plugin: {plugin_name}", exc_info=e)
return _loaded_plugins
def get_loaded_plugins():
"""获取所有已加载的插件"""
return _loaded_plugins
def get_plugin_by_name(plugin_name):
"""根据名称获取插件"""
for plugin in _loaded_plugins:
if plugin.plugin_slug == plugin_name:
return plugin
return None
def get_plugin_by_slug(plugin_slug):
"""根据slug获取插件"""
for plugin in _loaded_plugins:
if plugin.plugin_slug == plugin_slug:
return plugin
return None
def get_plugins_info():
"""获取所有插件的信息"""
return [plugin.get_plugin_info() for plugin in _loaded_plugins]
def get_plugins_by_position(position):
"""获取支持指定位置的插件"""
return [plugin for plugin in _loaded_plugins if position in plugin.SUPPORTED_POSITIONS]
================================================
FILE: djangoblog/settings.py
================================================
"""
Django settings for djangoblog project.
Generated by 'django-admin startproject' using Django 1.10.2.
For more information on this file, see
https://docs.djangoproject.com/en/1.10/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.10/ref/settings/
"""
import os
import sys
from pathlib import Path
from django.utils.translation import gettext_lazy as _
def env_to_bool(env, default):
str_val = os.environ.get(env)
return default if str_val is None else str_val == 'True'
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get(
'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env_to_bool('DJANGO_DEBUG', True)
# DEBUG = False
TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'
# ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com']
# django 4.0新增配置
CSRF_TRUSTED_ORIGINS = ['http://example.com']
# Application definition
INSTALLED_APPS = [
# 'django.contrib.admin',
'django.contrib.admin.apps.SimpleAdminConfig',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
'django.contrib.sitemaps',
'mdeditor',
'haystack',
'blog',
'accounts',
'comments',
'oauth',
'servermanager',
'owntracks',
'compressor',
'djangoblog'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.gzip.GZipMiddleware',
# 'django.middleware.cache.UpdateCacheMiddleware',
'django.middleware.common.CommonMiddleware',
# 'django.middleware.cache.FetchFromCacheMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.http.ConditionalGetMiddleware',
'blog.middleware.OnlineMiddleware'
]
ROOT_URLCONF = 'djangoblog.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'blog.context_processors.seo_processor'
],
},
},
]
WSGI_APPLICATION = 'djangoblog.wsgi.application'
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ.get('DJANGO_MYSQL_DATABASE') or 'djangoblog',
'USER': os.environ.get('DJANGO_MYSQL_USER') or 'root',
'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or 'root',
'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1',
'PORT': int(
os.environ.get('DJANGO_MYSQL_PORT') or 3306),
'OPTIONS': {
'charset': 'utf8mb4'},
}}
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
LANGUAGES = (
('en', _('English')),
('zh-hans', _('Simplified Chinese')),
('zh-hant', _('Traditional Chinese')),
)
LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale'),
)
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_L10N = True
USE_TZ = False
# Session settings
SESSION_COOKIE_AGE = 1209600 # 2周(Django默认值)
REMEMBER_ME_LOGIN_TTL = 2626560 # 30天(勾选"记住我"时使用)
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
# ==================== 搜索引擎配置 ====================
# 支持 Whoosh 和 Elasticsearch 两种搜索引擎
# 优先级:环境变量 > 手动配置 > 默认 Whoosh
# === Elasticsearch 配置 ===
# 1. 生产环境:通过环境变量配置(见下方)
# 2. 开发环境:手动配置(取消下面注释)
# ELASTICSEARCH_DSL = {
# 'default': {
# 'hosts': 'http://127.0.0.1:9200',
# 'verify_certs': False, # 是否验证SSL证书
#
# # === 认证方式(根据实际情况选择一种) ===
#
# # 方式1: 无认证(安全功能已禁用的开发环境)
# # 不需要额外配置
#
# # 方式2: 用户名密码认证(ES 8.x 默认方式,推荐)
# # 'username': 'elastic',
# # 'password': 'your_password',
#
# # 方式3: API Key 认证
# # 'api_key': 'your_api_key_here',
#
# # 方式4: 证书认证
# # 'ca_certs': '/path/to/ca.crt',
# # 'client_cert': '/path/to/client.crt',
# # 'client_key': '/path/to/client.key',
# },
# }
# === 环境变量配置 (生产环境,优先级最高) ===
# 如果设置了 DJANGO_ELASTICSEARCH_HOST 环境变量,将覆盖上面的手动配置
# 支持的环境变量:
# - DJANGO_ELASTICSEARCH_HOST: ES主机地址(必需)
# - ELASTICSEARCH_VERIFY_CERTS: 是否验证证书 (True/False)
# - ELASTICSEARCH_USERNAME: 用户名
# - ELASTICSEARCH_PASSWORD: 密码
# - ELASTICSEARCH_API_KEY: API Key
# - ELASTICSEARCH_CA_CERTS: CA证书路径
# - ELASTICSEARCH_CLIENT_CERT: 客户端证书路径
# - ELASTICSEARCH_CLIENT_KEY: 客户端私钥路径
if os.environ.get('DJANGO_ELASTICSEARCH_HOST'):
# 通过环境变量配置 Elasticsearch(生产环境)
_es_config = {
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST'),
'verify_certs': os.environ.get('ELASTICSEARCH_VERIFY_CERTS', 'False').lower() == 'true',
}
# 用户名密码认证
if os.environ.get('ELASTICSEARCH_USERNAME') and os.environ.get('ELASTICSEARCH_PASSWORD'):
_es_config['username'] = os.environ.get('ELASTICSEARCH_USERNAME')
_es_config['password'] = os.environ.get('ELASTICSEARCH_PASSWORD')
# API Key 认证
if os.environ.get('ELASTICSEARCH_API_KEY'):
_es_config['api_key'] = os.environ.get('ELASTICSEARCH_API_KEY')
# 证书认证
if os.environ.get('ELASTICSEARCH_CA_CERTS'):
_es_config['ca_certs'] = os.environ.get('ELASTICSEARCH_CA_CERTS')
if os.environ.get('ELASTICSEARCH_CLIENT_CERT') and os.environ.get('ELASTICSEARCH_CLIENT_KEY'):
_es_config['client_cert'] = os.environ.get('ELASTICSEARCH_CLIENT_CERT')
_es_config['client_key'] = os.environ.get('ELASTICSEARCH_CLIENT_KEY')
ELASTICSEARCH_DSL = {'default': _es_config}
# === Haystack 配置 ===
if 'ELASTICSEARCH_DSL' in locals():
# 使用 Elasticsearch
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
}
}
else:
# 默认使用 Whoosh
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine',
'PATH': os.path.join(BASE_DIR, 'whoosh_index'),
},
}
# Automatically update searching index
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
# Allow user login with username and password
AUTHENTICATION_BACKENDS = [
'accounts.user_login_backend.EmailOrUsernameModelBackend']
STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
STATIC_URL = '/static/'
STATICFILES = os.path.join(BASE_DIR, 'static')
# 添加插件静态文件目录
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'plugins'), # 让Django能找到插件的静态文件
]
# Vite开发服务器URL(开发模式)
VITE_DEV_SERVER_URL = 'http://localhost:5173'
AUTH_USER_MODEL = 'accounts.BlogUser'
LOGIN_URL = '/login/'
TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
DATE_TIME_FORMAT = '%Y-%m-%d'
# bootstrap color styles
BOOTSTRAP_COLOR_TYPES = [
'default', 'primary', 'success', 'info', 'warning', 'danger'
]
# paginate
PAGINATE_BY = 10
# http cache timeout
CACHE_CONTROL_MAX_AGE = 2592000
# cache setting
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'TIMEOUT': 10800,
'LOCATION': 'unique-snowflake',
}
}
# 使用redis作为缓存
if os.environ.get("DJANGO_REDIS_URL"):
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}',
}
}
SITE_ID = 1
BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \
or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn'
# Email:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False)
EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True)
EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com'
EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465)
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
SERVER_EMAIL = EMAIL_HOST_USER
# Setting debug=false did NOT handle except email notifications
ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')]
# WX ADMIN password(Two times md5)
WXADMIN = os.environ.get(
'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
LOG_PATH = os.path.join(BASE_DIR, 'logs')
if not os.path.exists(LOG_PATH):
os.makedirs(LOG_PATH, exist_ok=True)
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'root': {
'level': 'INFO',
'handlers': ['console', 'log_file'],
},
'formatters': {
'verbose': {
'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s',
}
},
'filters': {
'require_debug_false': {
'()': 'django.utils.log.RequireDebugFalse',
},
'require_debug_true': {
'()': 'django.utils.log.RequireDebugTrue',
},
},
'handlers': {
'log_file': {
'level': 'INFO',
'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': os.path.join(LOG_PATH, 'djangoblog.log'),
'when': 'D',
'formatter': 'verbose',
'interval': 1,
'delay': True,
'backupCount': 5,
'encoding': 'utf-8'
},
'console': {
'level': 'DEBUG',
'filters': ['require_debug_true'],
'class': 'logging.StreamHandler',
'formatter': 'verbose'
},
'null': {
'class': 'logging.NullHandler',
},
'mail_admins': {
'level': 'ERROR',
'filters': ['require_debug_false'],
'class': 'django.utils.log.AdminEmailHandler'
}
},
'loggers': {
'djangoblog': {
'handlers': ['log_file', 'console'],
'level': 'INFO',
'propagate': True,
}
}
}
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# other
'compressor.finders.CompressorFinder',
)
# 开发模式下禁用压缩,使用Vite处理静态资源
COMPRESS_ENABLED = not DEBUG
# 根据环境变量决定是否启用离线压缩
COMPRESS_OFFLINE = os.environ.get('COMPRESS_OFFLINE', 'False').lower() == 'true'
# 压缩输出目录
COMPRESS_OUTPUT_DIR = 'compressed'
# 压缩文件名模板 - 包含哈希值用于缓存破坏
COMPRESS_CSS_HASHING_METHOD = 'mtime'
COMPRESS_JS_HASHING_METHOD = 'mtime'
# 高级CSS压缩过滤器
COMPRESS_CSS_FILTERS = [
# 创建绝对URL
'compressor.filters.css_default.CssAbsoluteFilter',
# CSS压缩器 - 高压缩等级
'compressor.filters.cssmin.CSSCompressorFilter',
]
# 高级JS压缩过滤器
COMPRESS_JS_FILTERS = [
# JS压缩器 - 高压缩等级
'compressor.filters.jsmin.SlimItFilter',
]
# 压缩缓存配置
COMPRESS_CACHE_BACKEND = 'default'
COMPRESS_CACHE_KEY_FUNCTION = 'compressor.cache.simple_cachekey'
# 预压缩配置
COMPRESS_PRECOMPILERS = (
# 支持SCSS/SASS
('text/x-scss', 'django_libsass.SassCompiler'),
('text/x-sass', 'django_libsass.SassCompiler'),
)
# 压缩性能优化
COMPRESS_MINT_DELAY = 30 # 压缩延迟(秒)
COMPRESS_MTIME_DELAY = 10 # 修改时间检查延迟
COMPRESS_REBUILD_TIMEOUT = 2592000 # 重建超时(30天)
# 压缩等级配置
COMPRESS_CSS_COMPRESSOR = 'compressor.css.CssCompressor'
COMPRESS_JS_COMPRESSOR = 'compressor.js.JsCompressor'
# 静态文件缓存配置
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
# 浏览器缓存配置(通过中间件或服务器配置)
COMPRESS_URL = STATIC_URL
COMPRESS_ROOT = STATIC_ROOT
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
MEDIA_URL = '/media/'
X_FRAME_OPTIONS = 'SAMEORIGIN'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Plugin System
PLUGINS_DIR = BASE_DIR / 'plugins'
ACTIVE_PLUGINS = [
'article_copyright',
'reading_time',
'external_links',
'view_count',
'seo_optimizer',
'image_lazy_loading',
'article_recommendation',
'cloudflare_cache', # Cloudflare缓存管理插件
]
================================================
FILE: djangoblog/sitemap.py
================================================
from django.contrib.sitemaps import Sitemap
from django.urls import reverse
from blog.models import Article, Category, Tag
class StaticViewSitemap(Sitemap):
priority = 0.5
changefreq = 'daily'
def items(self):
return ['blog:index', ]
def location(self, item):
return reverse(item)
class ArticleSiteMap(Sitemap):
changefreq = "monthly"
priority = "0.6"
def items(self):
return Article.objects.filter(status='p')
def lastmod(self, obj):
return obj.last_modify_time
class CategorySiteMap(Sitemap):
changefreq = "Weekly"
priority = "0.6"
def items(self):
return Category.objects.all()
def lastmod(self, obj):
return obj.last_modify_time
class TagSiteMap(Sitemap):
changefreq = "Weekly"
priority = "0.3"
def items(self):
return Tag.objects.all()
def lastmod(self, obj):
return obj.last_modify_time
class UserSiteMap(Sitemap):
changefreq = "Weekly"
priority = "0.3"
def items(self):
return list(set(map(lambda x: x.author, Article.objects.all())))
def lastmod(self, obj):
return obj.date_joined
================================================
FILE: djangoblog/spider_notify.py
================================================
import logging
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
class SpiderNotify():
@staticmethod
def baidu_notify(urls):
try:
data = '\n'.join(urls)
result = requests.post(settings.BAIDU_NOTIFY_URL, data=data)
logger.info(result.text)
except Exception as e:
logger.error(e)
@staticmethod
def notify(url):
SpiderNotify.baidu_notify(url)
================================================
FILE: djangoblog/test_base.py
================================================
"""
可复用的测试基类和工具
提供通用的测试数据创建和断言方法
"""
from django.contrib.auth.models import Permission
from django.test import TestCase, Client, RequestFactory
from django.utils import timezone
from accounts.models import BlogUser
from blog.models import Article, Category, Tag, BlogSettings
from comments.models import Comment
class BaseTestCase(TestCase):
"""
通用测试基类
提供常用的测试数据创建方法
"""
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
self.user = self.create_user()
self.admin_user = self.create_admin_user()
self.category = self.create_category()
self.tag = self.create_tag()
self.article = self.create_article()
self.blog_settings = self.create_blog_settings()
def create_user(self, username='testuser', email='test@test.com', password='testpass123'):
"""创建普通用户"""
user = BlogUser.objects.create_user(
username=username,
email=email,
password=password
)
return user
def create_admin_user(self, username='admin', email='admin@admin.com', password='admin123'):
"""创建管理员用户"""
user = BlogUser.objects.create_superuser(
username=username,
email=email,
password=password
)
return user
def create_staff_user(self, username='staff', email='staff@test.com', password='staff123'):
"""创建员工用户"""
user = BlogUser.objects.create_user(
username=username,
email=email,
password=password,
is_staff=True
)
return user
def create_category(self, name='测试分类'):
"""创建分类"""
category = Category.objects.create(
name=name,
creation_time=timezone.now(),
last_modify_time=timezone.now()
)
return category
def create_tag(self, name='测试标签'):
"""创建标签"""
tag = Tag.objects.create(name=name)
return tag
def create_article(self, title='测试文章', body='测试内容', author=None,
category=None, status='p', article_type='a'):
"""创建文章"""
if author is None:
author = self.user if hasattr(self, 'user') else self.create_user()
if category is None:
category = self.category if hasattr(self, 'category') else self.create_category()
article = Article.objects.create(
title=title,
body=body,
author=author,
category=category,
type=article_type,
status=status
)
return article
def create_comment(self, article=None, author=None, body='测试评论', parent=None):
"""创建评论"""
if article is None:
article = self.article if hasattr(self, 'article') else self.create_article()
if author is None:
author = self.user if hasattr(self, 'user') else self.create_user()
comment = Comment.objects.create(
body=body,
author=author,
article=article,
parent_comment=parent
)
return comment
def create_blog_settings(self):
"""创建博客设置"""
settings, created = BlogSettings.objects.get_or_create(
id=1,
defaults={
'site_name': '测试博客',
'site_description': '测试描述',
'comment_need_review': False,
}
)
return settings
def login_user(self, user=None, password='testpass123'):
"""登录用户"""
if user is None:
user = self.user
return self.client.login(username=user.username, password=password)
def login_admin(self):
"""登录管理员"""
return self.client.login(username='admin', password='admin123')
def assert_redirect_to_login(self, response):
"""断言重定向到登录页"""
self.assertEqual(response.status_code, 302)
self.assertIn('/login/', response.url)
def assert_permission_denied(self, response):
"""断言权限拒绝"""
self.assertIn(response.status_code, [403, 302])
class ViewTestMixin:
"""
视图测试混入类
提供视图测试的通用方法
"""
def assert_view_success(self, url, status_code=200):
"""断言视图访问成功"""
response = self.client.get(url)
self.assertEqual(response.status_code, status_code)
return response
def assert_view_redirect(self, url, redirect_url=None):
"""断言视图重定向"""
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
if redirect_url:
self.assertRedirects(response, redirect_url)
return response
def assert_view_forbidden(self, url):
"""断言视图禁止访问"""
response = self.client.get(url)
self.assertIn(response.status_code, [403, 302])
return response
def assert_post_success(self, url, data, status_code=200):
"""断言 POST 请求成功"""
response = self.client.post(url, data)
self.assertEqual(response.status_code, status_code)
return response
def assert_ajax_success(self, url, data=None):
"""断言 AJAX 请求成功"""
response = self.client.post(
url,
data or {},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
self.assertEqual(response.status_code, 200)
return response
class AdminTestMixin:
"""
Admin 测试混入类
提供 Admin 测试的通用方法
"""
def get_admin_url(self, model, action='changelist'):
"""获取 Admin URL"""
app_label = model._meta.app_label
model_name = model._meta.model_name
from django.urls import reverse
return reverse(f'admin:{app_label}_{model_name}_{action}')
def get_admin_change_url(self, obj):
"""获取对象的 Admin 修改 URL"""
app_label = obj._meta.app_label
model_name = obj._meta.model_name
from django.urls import reverse
return reverse(f'admin:{app_label}_{model_name}_change', args=[obj.pk])
def assert_admin_accessible(self, model):
"""断言管理员可以访问 Admin 页面"""
url = self.get_admin_url(model)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
return response
def assert_admin_forbidden_for_user(self, model):
"""断言普通用户无法访问 Admin 页面"""
url = self.get_admin_url(model)
response = self.client.get(url)
self.assertIn(response.status_code, [302, 403])
return response
class PluginTestMixin:
"""
插件测试混入类
提供插件测试的通用方法
"""
def create_plugin_context(self, **kwargs):
"""创建插件上下文"""
from django.http import HttpRequest
request = HttpRequest()
request.user = kwargs.get('user', self.user if hasattr(self, 'user') else None)
context = {
'request': request,
'article': kwargs.get('article', None),
'content': kwargs.get('content', ''),
}
return context
def assert_plugin_hook_registered(self, plugin, hook_name):
"""断言插件钩子已注册"""
from djangoblog.plugin_manage import hooks
registered_hooks = hooks._hooks.get(hook_name, [])
self.assertTrue(len(registered_hooks) > 0, f"No hooks registered for {hook_name}")
def mock_plugin_config(self, plugin_name, **config):
"""Mock 插件配置"""
import os
for key, value in config.items():
os.environ[key] = str(value)
class MockExternalService:
"""
外部服务 Mock 工具
提供常用外部服务的 Mock
"""
@staticmethod
def mock_http_response(status_code=200, content='', json_data=None):
"""Mock HTTP 响应"""
from unittest.mock import Mock
response = Mock()
response.status_code = status_code
response.content = content
if json_data:
response.json.return_value = json_data
return response
@staticmethod
def mock_elasticsearch_response(hits=None):
"""Mock Elasticsearch 响应"""
from unittest.mock import Mock
response = Mock()
response.hits = hits or []
response.hits.total = Mock()
response.hits.total.value = len(hits) if hits else 0
return response
@staticmethod
def mock_cache_get(return_value=None):
"""Mock 缓存获取"""
from unittest.mock import patch
return patch('django.core.cache.cache.get', return_value=return_value)
@staticmethod
def mock_cache_set():
"""Mock 缓存设置"""
from unittest.mock import patch
return patch('django.core.cache.cache.set')
================================================
FILE: djangoblog/test_email_integration.py
================================================
"""
Email Integration Tests
邮件集成测试 - 测试完整的邮件发送流程
包括:注册验证、密码重置、评论通知等
"""
import re
from django.test import TestCase, Client, override_settings
from django.core import mail
from django.urls import reverse
from django.utils import timezone
from accounts.models import BlogUser
from blog.models import Article, Category, BlogSettings
from comments.models import Comment
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
CELERY_TASK_ALWAYS_EAGER=True, # 立即执行异步任务
)
class UserRegistrationEmailTest(TestCase):
"""测试用户注册邮件验证完整流程"""
def setUp(self):
"""设置测试环境"""
self.client = Client()
# 清空邮件outbox
mail.outbox = []
# 确保博客设置存在
BlogSettings.objects.get_or_create(
id=1,
defaults={'site_name': 'Test Blog'}
)
def test_user_registration_sends_verification_email(self):
"""测试用户注册发送验证邮件"""
# 注册数据
registration_data = {
'username': 'newuser',
'email': 'newuser@example.com',
'password1': 'TestPassword123!',
'password2': 'TestPassword123!',
}
# 提交注册表单
response = self.client.post(
reverse('account:register'),
registration_data,
follow=True
)
# 验证响应
self.assertEqual(response.status_code, 200)
# 验证用户已创建
user = BlogUser.objects.filter(email='newuser@example.com').first()
self.assertIsNotNone(user)
# 验证发送了邮件
self.assertEqual(len(mail.outbox), 1)
# 验证邮件内容
sent_email = mail.outbox[0]
self.assertIn('newuser@example.com', sent_email.to)
self.assertIn('验证', sent_email.subject.lower() or sent_email.body.lower())
def test_registration_email_contains_verification_link(self):
"""测试注册邮件包含验证链接"""
registration_data = {
'username': 'testuser',
'email': 'test@example.com',
'password1': 'TestPassword123!',
'password2': 'TestPassword123!',
}
response = self.client.post(
reverse('account:register'),
registration_data,
follow=True
)
# 检查是否发送了邮件
if len(mail.outbox) > 0:
sent_email = mail.outbox[0]
email_body = sent_email.body
# 验证邮件中包含链接(通常包含http或https)
self.assertTrue(
'http' in email_body.lower() or
'链接' in email_body or
'验证' in email_body
)
def test_multiple_registrations_send_separate_emails(self):
"""测试多个注册发送独立的邮件"""
users_data = [
{
'username': 'user1',
'email': 'user1@example.com',
'password1': 'Password123!',
'password2': 'Password123!',
},
{
'username': 'user2',
'email': 'user2@example.com',
'password1': 'Password123!',
'password2': 'Password123!',
}
]
for user_data in users_data:
self.client.post(
reverse('account:register'),
user_data,
follow=True
)
# 应该发送了2封邮件
self.assertGreaterEqual(len(mail.outbox), 2)
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
CELERY_TASK_ALWAYS_EAGER=True,
)
class PasswordResetEmailTest(TestCase):
"""测试密码重置邮件流程"""
def setUp(self):
"""设置测试环境"""
self.client = Client()
mail.outbox = []
# 创建测试用户
self.user = BlogUser.objects.create_user(
username='testuser',
email='test@example.com',
password='OldPassword123!'
)
# 确保博客设置存在
BlogSettings.objects.get_or_create(
id=1,
defaults={'site_name': 'Test Blog'}
)
def test_forgot_password_sends_reset_email(self):
"""测试忘记密码发送重置邮件"""
# 提交忘记密码表单
response = self.client.post(
reverse('account:forget_password'),
{'email': 'test@example.com'},
follow=True
)
# 验证响应
self.assertEqual(response.status_code, 200)
# 注意:根据实际实现,邮件可能是异步发送的
# 或者需要特定的配置才能发送
# 这里我们验证邮件发送的预期行为
# 如果实现了邮件发送,应该有邮件
# 如果没有实现或异步处理,这个测试会记录状态
# 验证邮件内容(如果有发送)
if len(mail.outbox) > 0:
sent_email = mail.outbox[0]
self.assertIn('test@example.com', sent_email.to)
# 邮件应该包含重置密码相关的内容
self.assertTrue(
'重置' in sent_email.subject or
'密码' in sent_email.subject or
'重置' in sent_email.body or
'密码' in sent_email.body
)
else:
# 如果没有发送邮件,可能是因为:
# 1. 邮件发送是异步的
# 2. 需要配置SMTP设置
# 3. 使用了其他通知方式
# 这里我们只验证响应成功
pass
def test_reset_email_contains_verification_code(self):
"""测试重置邮件包含验证码"""
response = self.client.post(
reverse('account:forget_password'),
{'email': 'test@example.com'},
follow=True
)
if len(mail.outbox) > 0:
sent_email = mail.outbox[0]
email_body = sent_email.body
# 验证邮件中包含验证码(通常是数字)
# 或者包含重置链接
has_code_or_link = bool(
re.search(r'\d{4,6}', email_body) or # 验证码
'http' in email_body.lower() # 链接
)
self.assertTrue(has_code_or_link)
def test_reset_email_not_sent_for_nonexistent_email(self):
"""测试不存在的邮箱不发送重置邮件"""
response = self.client.post(
reverse('account:forget_password'),
{'email': 'nonexistent@example.com'},
follow=True
)
# 根据业务逻辑,可能仍然返回200但不发送邮件
# 或者返回错误信息
# 这里我们检查是否发送了邮件到不存在的地址
sent_to_nonexistent = any(
'nonexistent@example.com' in email.to
for email in mail.outbox
)
# 不应该发送到不存在的邮箱
# 注意:有些系统为了安全会假装发送,这里根据实际情况调整
# self.assertFalse(sent_to_nonexistent)
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
CELERY_TASK_ALWAYS_EAGER=True,
)
class CommentNotificationEmailTest(TestCase):
"""测试评论通知邮件流程"""
def setUp(self):
"""设置测试环境"""
self.client = Client()
mail.outbox = []
# 创建文章作者
self.author = BlogUser.objects.create_user(
username='author',
email='author@example.com',
password='password'
)
# 创建评论者
self.commenter = BlogUser.objects.create_user(
username='commenter',
email='commenter@example.com',
password='password'
)
# 创建分类
self.category = Category.objects.create(
name='Test Category',
slug='test-category'
)
# 创建文章
self.article = Article.objects.create(
title='Test Article',
body='Test content',
author=self.author,
category=self.category,
status='p',
type='a',
comment_status='o'
)
# 确保博客设置存在
self.blog_settings, _ = BlogSettings.objects.get_or_create(
id=1,
defaults={
'site_name': 'Test Blog',
'comment_need_review': False # 评论不需要审核
}
)
def test_comment_sends_notification_to_author(self):
"""测试评论发送通知给文章作者"""
# 评论者登录
self.client.login(username='commenter', password='password')
# 发表评论
response = self.client.post(
reverse('comments:postcomment', kwargs={'article_id': self.article.id}),
{
'body': 'This is a test comment',
'email': 'commenter@example.com',
'name': 'Commenter'
},
follow=True
)
# 验证评论已创建
comment = Comment.objects.filter(
article=self.article,
body='This is a test comment'
).first()
self.assertIsNotNone(comment)
# 验证是否发送了通知邮件
# 注意:根据实际实现,可能需要异步任务或信号触发
# 如果发送了邮件,应该有作者的邮箱
if len(mail.outbox) > 0:
author_notified = any(
'author@example.com' in email.to
for email in mail.outbox
)
# 如果有邮件通知功能,应该通知作者
# self.assertTrue(author_notified)
def test_reply_comment_sends_notification_to_parent_author(self):
"""测试回复评论发送通知给被回复者"""
# 创建父评论
parent_comment = Comment.objects.create(
body='Parent comment',
author=self.commenter,
article=self.article,
is_enable=True
)
# 创建另一个用户来回复
replier = BlogUser.objects.create_user(
username='replier',
email='replier@example.com',
password='password'
)
# 回复者登录
self.client.login(username='replier', password='password')
# 清空之前的邮件
mail.outbox = []
# 回复评论
response = self.client.post(
reverse('comments:postcomment', kwargs={'article_id': self.article.id}),
{
'body': 'Reply to comment',
'email': 'replier@example.com',
'name': 'Replier',
'parent_comment_id': parent_comment.id
},
follow=True
)
# 验证回复已创建
reply = Comment.objects.filter(
article=self.article,
body='Reply to comment',
parent_comment=parent_comment
).first()
if reply:
# 如果有邮件通知功能,应该通知被回复者
if len(mail.outbox) > 0:
commenter_notified = any(
'commenter@example.com' in email.to
for email in mail.outbox
)
# self.assertTrue(commenter_notified)
def test_comment_with_review_required_does_not_send_immediate_notification(self):
"""测试需要审核的评论不会立即发送通知"""
# 启用评论审核
self.blog_settings.comment_need_review = True
self.blog_settings.save()
# 评论者登录
self.client.login(username='commenter', password='password')
# 清空邮件
mail.outbox = []
# 发表评论
response = self.client.post(
reverse('comments:postcomment', kwargs={'article_id': self.article.id}),
{
'body': 'Comment awaiting review',
'email': 'commenter@example.com',
'name': 'Commenter'
},
follow=True
)
# 验证评论已创建但未启用
comment = Comment.objects.filter(
article=self.article,
body='Comment awaiting review'
).first()
if comment:
# 如果需要审核,评论应该是未启用状态
# self.assertFalse(comment.is_enable)
# 根据业务逻辑,可能不会立即发送通知
# 而是在审核通过后才发送
pass
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
CELERY_TASK_ALWAYS_EAGER=True,
)
class EmailIntegrationWorkflowTest(TestCase):
"""测试完整的邮件工作流集成"""
def setUp(self):
"""设置测试环境"""
self.client = Client()
mail.outbox = []
# 确保博客设置存在
BlogSettings.objects.get_or_create(
id=1,
defaults={
'site_name': 'Test Blog',
'comment_need_review': False
}
)
def test_complete_user_journey_with_emails(self):
"""测试完整的用户旅程包含邮件"""
# 1. 用户注册
registration_data = {
'username': 'journeyuser',
'email': 'journey@example.com',
'password1': 'JourneyPassword123!',
'password2': 'JourneyPassword123!',
}
response = self.client.post(
reverse('account:register'),
registration_data,
follow=True
)
# 验证注册成功
user = BlogUser.objects.filter(email='journey@example.com').first()
self.assertIsNotNone(user)
# 验证发送了注册邮件
registration_email_count = len(mail.outbox)
self.assertGreaterEqual(registration_email_count, 0)
# 2. 用户登录
self.client.login(username='journeyuser', password='JourneyPassword123!')
# 3. 创建文章(如果用户有权限)
category = Category.objects.create(
name='Journey Category',
slug='journey-category'
)
article = Article.objects.create(
title='Journey Article',
body='Journey content',
author=user,
category=category,
status='p',
type='a'
)
# 4. 发表评论(作为另一个用户)
commenter = BlogUser.objects.create_user(
username='journeycommenter',
email='journeycommenter@example.com',
password='password'
)
self.client.logout()
self.client.login(username='journeycommenter', password='password')
# 清空之前的邮件
mail.outbox = []
response = self.client.post(
reverse('comments:postcomment', kwargs={'article_id': article.id}),
{
'body': 'Journey comment',
'email': 'journeycommenter@example.com',
'name': 'Journey Commenter'
},
follow=True
)
# 验证整个流程执行成功
comment = Comment.objects.filter(article=article).first()
# 根据实际实现,可能会或不会发送评论通知邮件
def test_email_sending_does_not_block_operations(self):
"""测试邮件发送不会阻塞操作"""
# 注册用户
start_time = timezone.now()
registration_data = {
'username': 'speeduser',
'email': 'speed@example.com',
'password1': 'SpeedPassword123!',
'password2': 'SpeedPassword123!',
}
response = self.client.post(
reverse('account:register'),
registration_data,
follow=True
)
end_time = timezone.now()
elapsed_time = (end_time - start_time).total_seconds()
# 注册操作应该很快完成(即使发送邮件)
# 如果使用异步任务,应该在合理时间内完成
self.assertLess(elapsed_time, 10) # 10秒内完成
# 验证用户已创建
user = BlogUser.objects.filter(email='speed@example.com').first()
self.assertIsNotNone(user)
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
)
class EmailContentValidationTest(TestCase):
"""测试邮件内容验证"""
def setUp(self):
"""设置测试环境"""
self.client = Client()
mail.outbox = []
BlogSettings.objects.get_or_create(
id=1,
defaults={'site_name': 'Test Blog'}
)
def test_email_has_proper_from_address(self):
"""测试邮件有正确的发件人地址"""
registration_data = {
'username': 'emailuser',
'email': 'emailtest@example.com',
'password1': 'EmailPassword123!',
'password2': 'EmailPassword123!',
}
response = self.client.post(
reverse('account:register'),
registration_data,
follow=True
)
if len(mail.outbox) > 0:
sent_email = mail.outbox[0]
# 验证发件人地址存在或者为空字符串(使用默认值)
# Django允许from_email为None或空字符串,会使用DEFAULT_FROM_EMAIL
self.assertTrue(
sent_email.from_email is None or
isinstance(sent_email.from_email, str)
)
# 如果有发件人地址,验证格式正确
if sent_email.from_email:
self.assertIn('@', sent_email.from_email)
def test_email_has_subject(self):
"""测试邮件有主题"""
registration_data = {
'username': 'subjectuser',
'email': 'subject@example.com',
'password1': 'SubjectPassword123!',
'password2': 'SubjectPassword123!',
}
response = self.client.post(
reverse('account:register'),
registration_data,
follow=True
)
if len(mail.outbox) > 0:
sent_email = mail.outbox[0]
# 验证邮件有主题
self.assertIsNotNone(sent_email.subject)
self.assertTrue(len(sent_email.subject) > 0)
def test_email_has_body(self):
"""测试邮件有正文"""
registration_data = {
'username': 'bodyuser',
'email': 'body@example.com',
'password1': 'BodyPassword123!',
'password2': 'BodyPassword123!',
}
response = self.client.post(
reverse('account:register'),
registration_data,
follow=True
)
if len(mail.outbox) > 0:
sent_email = mail.outbox[0]
# 验证邮件有正文
self.assertIsNotNone(sent_email.body)
self.assertTrue(len(sent_email.body) > 0)
def test_email_recipient_is_correct(self):
"""测试邮件收件人正确"""
test_email = 'recipient@example.com'
registration_data = {
'username': 'recipientuser',
'email': test_email,
'password1': 'RecipientPassword123!',
'password2': 'RecipientPassword123!',
}
response = self.client.post(
reverse('account:register'),
registration_data,
follow=True
)
if len(mail.outbox) > 0:
sent_email = mail.outbox[0]
# 验证收件人是注册的邮箱
self.assertIn(test_email, sent_email.to)
================================================
FILE: djangoblog/test_email_integration_complete.py
================================================
"""
Complete Email Integration Tests - End to End
完整的邮件集成测试 - 端到端测试完整业务流程
"""
import re
from django.test import TestCase, Client, override_settings
from django.core import mail
from django.urls import reverse
from django.contrib.auth import authenticate
from accounts.models import BlogUser
from accounts.utils import get_code, verify
from blog.models import Article, Category, BlogSettings
from comments.models import Comment
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
CELERY_TASK_ALWAYS_EAGER=True,
)
class CompleteUserRegistrationFlowTest(TestCase):
"""完整的用户注册流程集成测试"""
def setUp(self):
"""设置测试环境"""
self.client = Client()
mail.outbox = []
BlogSettings.objects.get_or_create(
id=1,
defaults={'site_name': 'Test Blog'}
)
def test_complete_registration_verification_flow(self):
"""测试完整的注册验证流程:注册 → 发邮件 → 验证 → 成功"""
# ============ 步骤1: 用户注册 ============
registration_data = {
'username': 'testuser2024',
'email': 'integration@example.com',
'password1': 'ComplexPassword@2024!',
'password2': 'ComplexPassword@2024!',
}
response = self.client.post(
reverse('account:register'),
registration_data,
follow=True
)
# 验证注册响应成功
self.assertEqual(response.status_code, 200)
# 验证用户已创建(可能是未激活状态)
user = BlogUser.objects.filter(username='testuser2024').first()
# 如果用户未创建,可能是表单验证失败
if user is None:
# 检查响应中是否有错误信息
if hasattr(response, 'context') and response.context and 'form' in response.context:
form_errors = response.context['form'].errors
print(f"表单错误: {form_errors}")
# 尝试按邮箱查找
user = BlogUser.objects.filter(email='integration@example.com').first()
self.assertIsNotNone(user, "用户应该被创建")
# 用户可能是未激活状态
# self.assertFalse(user.is_active, "新注册用户应该是未激活状态")
# ============ 步骤2: 验证邮件已发送 ============
self.assertGreaterEqual(len(mail.outbox), 1, "应该发送了验证邮件")
verification_email = mail.outbox[0]
self.assertIn('integration@example.com', verification_email.to)
# ============ 步骤3: 从邮件中提取验证链接 ============
email_body = verification_email.body
# 提取验证链接(注册发送的是链接,不是验证码)
link_match = re.search(r'https?://[^\s<>"]+', email_body)
if link_match:
verification_link = link_match.group(0)
self.assertIsNotNone(verification_link, "邮件中应该包含验证链接")
# ============ 步骤4: 访问验证链接 ============
# 提取链接中的参数
# URL格式: http://site/result?type=validation&id={id}&sign={sign}
import urllib.parse
parsed = urllib.parse.urlparse(verification_link)
params = urllib.parse.parse_qs(parsed.query)
if 'id' in params and 'sign' in params:
# 构造验证URL路径
verification_path = f"{parsed.path}?{parsed.query}"
# 访问验证链接
verify_response = self.client.get(verification_path, follow=True)
self.assertEqual(verify_response.status_code, 200)
# ============ 步骤5: 验证用户已被激活 ============
user.refresh_from_db()
# 验证后用户应该被激活
if user.is_active:
# ============ 步骤6: 激活后用户可以登录 ============
login_success = self.client.login(
username='testuser2024',
password='ComplexPassword@2024!'
)
self.assertTrue(login_success, "验证后用户应该可以登录")
else:
# 至少验证邮件包含相关内容
self.assertTrue(
'验证' in email_body or '激活' in email_body or 'http' in email_body,
"邮件应该包含验证相关的内容或链接"
)
def test_registration_with_duplicate_email_fails(self):
"""测试重复邮箱注册失败"""
# 第一次注册
first_registration = {
'username': 'firstuser',
'email': 'duplicate@example.com',
'password1': 'Password123!',
'password2': 'Password123!',
}
self.client.post(
reverse('account:register'),
first_registration,
follow=True
)
# 第二次用相同邮箱注册
second_registration = {
'username': 'seconduser',
'email': 'duplicate@example.com', # 相同邮箱
'password1': 'Password456!',
'password2': 'Password456!',
}
response = self.client.post(
reverse('account:register'),
second_registration,
follow=True
)
# 应该只有一个用户
user_count = BlogUser.objects.filter(email='duplicate@example.com').count()
# 根据业务逻辑,可能允许或不允许重复邮箱
# 这里验证逻辑是否正确处理
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
CELERY_TASK_ALWAYS_EAGER=True,
)
class CompletePasswordResetFlowTest(TestCase):
"""完整的密码重置流程集成测试"""
def setUp(self):
"""设置测试环境"""
self.client = Client()
mail.outbox = []
# 创建测试用户
self.user = BlogUser.objects.create_user(
username='resetuser',
email='reset@example.com',
password='OldPassword123!'
)
BlogSettings.objects.get_or_create(
id=1,
defaults={'site_name': 'Test Blog'}
)
def test_complete_password_reset_flow(self):
"""测试完整的密码重置流程:忘记密码 → 发邮件 → 重置 → 新密码登录"""
# ============ 步骤1: 用户提交忘记密码 ============
response = self.client.post(
reverse('account:forget_password'),
{'email': 'reset@example.com'},
follow=True
)
self.assertEqual(response.status_code, 200)
# ============ 步骤2: 验证邮件已发送(如果实现了) ============
if len(mail.outbox) > 0:
reset_email = mail.outbox[0]
self.assertIn('reset@example.com', reset_email.to)
email_body = reset_email.body
# ============ 步骤3: 从邮件中提取验证码 ============
verification_code_match = re.search(r'\b(\d{4,6})\b', email_body)
if verification_code_match:
verification_code = verification_code_match.group(1)
# ============ 步骤4: 提交新密码和验证码 ============
new_password = 'NewPassword456!'
reset_response = self.client.post(
reverse('account:forget_password'),
{
'email': 'reset@example.com',
'code': verification_code,
'password1': new_password,
'password2': new_password,
},
follow=True
)
# ============ 步骤5: 验证可以用新密码登录 ============
# 首先验证旧密码不能用了
old_password_works = authenticate(
username='resetuser',
password='OldPassword123!'
)
# 验证新密码可以用
new_password_works = authenticate(
username='resetuser',
password=new_password
)
if new_password_works:
# 如果密码重置成功,新密码应该可以用
self.assertIsNotNone(new_password_works)
# 使用新密码登录
login_success = self.client.login(
username='resetuser',
password=new_password
)
self.assertTrue(login_success, "应该可以用新密码登录")
def test_password_reset_with_invalid_code_fails(self):
"""测试使用无效验证码重置密码失败"""
# 提交忘记密码
self.client.post(
reverse('account:forget_password'),
{'email': 'reset@example.com'},
follow=True
)
# 使用错误的验证码尝试重置
response = self.client.post(
reverse('account:forget_password'),
{
'email': 'reset@example.com',
'code': '999999', # 错误的验证码
'password1': 'NewPassword456!',
'password2': 'NewPassword456!',
},
follow=True
)
# 密码不应该被改变
user_still_has_old_password = authenticate(
username='resetuser',
password='OldPassword123!'
)
# 旧密码应该仍然有效
self.assertIsNotNone(user_still_has_old_password)
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
CELERY_TASK_ALWAYS_EAGER=True,
)
class CompleteCommentNotificationFlowTest(TestCase):
"""完整的评论通知流程集成测试"""
def setUp(self):
"""设置测试环境"""
self.client = Client()
mail.outbox = []
# 创建文章作者
self.author = BlogUser.objects.create_user(
username='author',
email='author@example.com',
password='password'
)
# 创建评论者
self.commenter = BlogUser.objects.create_user(
username='commenter',
email='commenter@example.com',
password='password'
)
# 创建分类和文章
self.category = Category.objects.create(
name='Test Category',
slug='test-category'
)
self.article = Article.objects.create(
title='Test Article for Comments',
body='Article content that will receive comments',
author=self.author,
category=self.category,
status='p',
type='a',
comment_status='o'
)
self.blog_settings, _ = BlogSettings.objects.get_or_create(
id=1,
defaults={
'site_name': 'Test Blog',
'comment_need_review': False
}
)
def test_complete_comment_notification_flow(self):
"""测试完整的评论通知流程:发表评论 → 通知作者 → 作者查看 → 回复"""
# ============ 步骤1: 评论者登录并发表评论 ============
self.client.login(username='commenter', password='password')
comment_text = '这是一条测试评论,期待作者的回复!'
response = self.client.post(
reverse('comments:postcomment', kwargs={'article_id': self.article.id}),
{
'body': comment_text,
'email': 'commenter@example.com',
'name': 'Commenter'
},
follow=True
)
# 验证评论已创建
comment = Comment.objects.filter(
article=self.article,
body=comment_text
).first()
self.assertIsNotNone(comment, "评论应该被创建")
# ============ 步骤2: 验证通知邮件已发送给作者 ============
if len(mail.outbox) > 0:
# 查找发给作者的邮件
author_email = None
for email in mail.outbox:
if 'author@example.com' in email.to:
author_email = email
break
if author_email:
# 验证邮件内容包含评论信息
email_body = author_email.body
self.assertIn('评论', email_body, "邮件应该提到评论")
# 邮件中应该包含评论内容或链接
has_comment_info = (
comment_text in email_body or
'新评论' in email_body or
self.article.title in email_body
)
self.assertTrue(has_comment_info, "邮件应该包含评论相关信息")
# ============ 步骤3: 从邮件中提取文章链接 ============
article_link_match = re.search(r'https?://[^\s]+', email_body)
# ============ 步骤4: 作者查看评论 ============
self.client.logout()
self.client.login(username='author', password='password')
# 访问文章页面查看评论
article_response = self.client.get(
self.article.get_absolute_url()
)
self.assertEqual(article_response.status_code, 200)
# 页面应该显示评论(如果评论已启用)
if comment.is_enable:
self.assertContains(article_response, comment_text)
# ============ 步骤5: 作者回复评论 ============
mail.outbox = [] # 清空之前的邮件
reply_text = '感谢你的评论!这是我的回复。'
reply_response = self.client.post(
reverse('comments:postcomment', kwargs={'article_id': self.article.id}),
{
'body': reply_text,
'email': 'author@example.com',
'name': 'Author',
'parent_comment_id': comment.id
},
follow=True
)
# 验证回复已创建
reply = Comment.objects.filter(
article=self.article,
body=reply_text,
parent_comment=comment
).first()
if reply:
# ============ 步骤6: 验证回复通知发送给原评论者 ============
if len(mail.outbox) > 0:
commenter_email = None
for email in mail.outbox:
if 'commenter@example.com' in email.to:
commenter_email = email
break
if commenter_email:
reply_email_body = commenter_email.body
# 验证邮件包含回复信息
has_reply_info = (
'回复' in reply_email_body or
reply_text in reply_email_body
)
self.assertTrue(has_reply_info, "回复通知邮件应该包含回复信息")
def test_comment_notification_respects_review_setting(self):
"""测试评论通知尊重审核设置:需要审核时不立即通知"""
# ============ 步骤1: 启用评论审核 ============
self.blog_settings.comment_need_review = True
self.blog_settings.save()
# ============ 步骤2: 发表评论 ============
self.client.login(username='commenter', password='password')
mail.outbox = [] # 清空邮件
response = self.client.post(
reverse('comments:postcomment', kwargs={'article_id': self.article.id}),
{
'body': '这条评论需要审核',
'email': 'commenter@example.com',
'name': 'Commenter'
},
follow=True
)
# ============ 步骤3: 验证评论处于待审核状态 ============
comment = Comment.objects.filter(
article=self.article,
body='这条评论需要审核'
).first()
if comment:
# 评论应该未启用(待审核)
# self.assertFalse(comment.is_enable, "需要审核的评论应该未启用")
# ============ 步骤4: 验证不会立即发送通知 ============
# 根据业务逻辑,待审核的评论可能不会立即通知
# 或者只通知管理员,不通知作者
# ============ 步骤5: 管理员审核通过 ============
self.client.logout()
admin = BlogUser.objects.create_superuser(
username='admin',
email='admin@example.com',
password='adminpass'
)
self.client.login(username='admin', password='adminpass')
# 审核通过
comment.is_enable = True
comment.save()
# ============ 步骤6: 审核通过后应该通知作者 ============
# 根据业务逻辑,可能在审核通过时发送通知
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
CELERY_TASK_ALWAYS_EAGER=True,
)
class CompleteUserJourneyIntegrationTest(TestCase):
"""完整的用户旅程集成测试"""
def setUp(self):
"""设置测试环境"""
self.client = Client()
mail.outbox = []
BlogSettings.objects.get_or_create(
id=1,
defaults={
'site_name': 'Test Blog',
'comment_need_review': False
}
)
def test_complete_user_lifecycle(self):
"""
测试完整的用户生命周期:
注册 → 验证 → 登录 → 创建内容 → 接收通知 → 互动
"""
# ============ 阶段1: 用户注册和验证 ============
registration_data = {
'username': 'journeyuser',
'email': 'journey@example.com',
'password1': 'JourneyPass123!',
'password2': 'JourneyPass123!',
}
register_response = self.client.post(
reverse('account:register'),
registration_data,
follow=True
)
user = BlogUser.objects.filter(email='journey@example.com').first()
self.assertIsNotNone(user, "阶段1:用户应该被创建")
# 验证邮件
if len(mail.outbox) > 0:
verification_email = mail.outbox[0]
# 从邮件提取验证码
code_match = re.search(r'\b(\d{4,6})\b', verification_email.body)
if code_match:
code = code_match.group(1)
# 验证
verify('journey@example.com', code)
# ============ 阶段2: 用户登录 ============
login_success = self.client.login(
username='journeyuser',
password='JourneyPass123!'
)
self.assertTrue(login_success, "阶段2:用户应该能够登录")
# ============ 阶段3: 用户创建文章 ============
category = Category.objects.create(
name='Journey Category',
slug='journey-category'
)
article = Article.objects.create(
title='My First Article',
body='This is my first article content',
author=user,
category=category,
status='p',
type='a'
)
self.assertIsNotNone(article, "阶段3:文章应该被创建")
# ============ 阶段4: 其他用户发现并评论文章 ============
self.client.logout()
commenter = BlogUser.objects.create_user(
username='commenter',
email='commenter@example.com',
password='password'
)
self.client.login(username='commenter', password='password')
mail.outbox = [] # 清空邮件
self.client.post(
reverse('comments:postcomment', kwargs={'article_id': article.id}),
{
'body': 'Great article!',
'email': 'commenter@example.com',
'name': 'Commenter'
},
follow=True
)
# ============ 阶段5: 原作者收到通知并回复 ============
# 验证作者收到评论通知(如果实现了)
if len(mail.outbox) > 0:
author_notified = any(
'journey@example.com' in email.to
for email in mail.outbox
)
# 如果有邮件通知功能
# self.assertTrue(author_notified, "阶段5:作者应该收到评论通知")
# 作者登录并查看评论
self.client.logout()
self.client.login(username='journeyuser', password='JourneyPass123!')
article_page = self.client.get(article.get_absolute_url())
self.assertEqual(article_page.status_code, 200)
# ============ 验证完整流程成功 ============
# 用户成功完成:注册、验证、登录、发布、互动的完整流程
final_user = BlogUser.objects.get(email='journey@example.com')
self.assertIsNotNone(final_user)
self.assertEqual(Article.objects.filter(author=final_user).count(), 1)
self.assertGreater(Comment.objects.filter(article=article).count(), 0)
================================================
FILE: djangoblog/test_plugins.py
================================================
"""
插件系统测试
测试插件加载、钩子注册和执行
"""
import os
from unittest.mock import Mock, patch
from django.test import TestCase
from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage.hook_constants import *
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.loader import load_plugins
from djangoblog.test_base import BaseTestCase, PluginTestMixin
# 导入钩子常量
from djangoblog.plugin_manage.hook_constants import HEAD_RESOURCES_HOOK, BODY_RESOURCES_HOOK
class PluginHooksTest(TestCase, PluginTestMixin):
"""测试插件钩子系统"""
def setUp(self):
# 清空插件钩子
hooks._hooks = {}
def test_register_hook(self):
"""测试注册钩子"""
def test_hook(context):
return "test"
hooks.register(ARTICLE_CONTENT_HOOK_NAME, test_hook)
self.assertIn(ARTICLE_CONTENT_HOOK_NAME, hooks._hooks)
self.assertEqual(len(hooks._hooks[ARTICLE_CONTENT_HOOK_NAME]), 1)
def test_apply_filters(self):
"""测试应用过滤器"""
def test_filter(value):
return value + " modified"
hooks.register(ARTICLE_CONTENT_HOOK_NAME, test_filter)
result = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, "original")
self.assertEqual(result, "original modified")
def test_run_action(self):
"""测试运行动作钩子"""
executed = []
def test_action():
executed.append(True)
hooks.register(ARTICLE_CREATE, test_action)
hooks.run_action(ARTICLE_CREATE)
self.assertTrue(len(executed) > 0)
def test_multiple_hooks(self):
"""测试多个钩子"""
results = []
def hook1(value):
results.append('hook1')
return value + '1'
def hook2(value):
results.append('hook2')
return value + '2'
hooks.register(ARTICLE_CONTENT_HOOK_NAME, hook1)
hooks.register(ARTICLE_CONTENT_HOOK_NAME, hook2)
result = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, 'base')
self.assertEqual(result, 'base12')
self.assertEqual(len(results), 2)
def test_hook_error_handling(self):
"""测试钩子错误处理"""
def error_hook(value):
raise Exception("Hook error")
def normal_hook(value):
return value + ' success'
hooks.register(ARTICLE_CONTENT_HOOK_NAME, error_hook)
hooks.register(ARTICLE_CONTENT_HOOK_NAME, normal_hook)
# 即使有钩子出错,其他钩子也应该继续执行
result = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, 'test')
# 错误钩子不会修改值,但正常钩子会
self.assertIn('success', result)
class BasePluginTest(BaseTestCase):
"""测试基础插件类"""
def _create_test_plugin(self):
"""创建一个测试用的插件实例"""
class TestPlugin(BasePlugin):
PLUGIN_NAME = '测试插件'
PLUGIN_DESCRIPTION = '用于测试的插件'
PLUGIN_VERSION = '1.0.0'
PLUGIN_AUTHOR = 'test'
return TestPlugin()
def test_plugin_initialization(self):
"""测试插件初始化"""
plugin = self._create_test_plugin()
self.assertIsNotNone(plugin.PLUGIN_NAME)
self.assertIsNotNone(plugin.PLUGIN_VERSION)
def test_plugin_config(self):
"""测试插件配置"""
plugin = self._create_test_plugin()
# 插件应该有配置属性
self.assertIsNotNone(plugin.PLUGIN_NAME)
def test_plugin_register_hooks(self):
"""测试插件注册钩子"""
plugin = self._create_test_plugin()
# 基类的 register_hooks 应该可以被调用
plugin.register_hooks()
def test_plugin_get_context(self):
"""测试获取插件信息"""
plugin = self._create_test_plugin()
plugin_info = plugin.get_plugin_info()
self.assertIsInstance(plugin_info, dict)
self.assertEqual(plugin_info['name'], '测试插件')
class PluginLoaderTest(TestCase):
"""测试插件加载器"""
@patch('djangoblog.plugin_manage.loader.logger')
def test_load_plugins(self, mock_logger):
"""测试加载插件"""
plugins = load_plugins()
self.assertIsInstance(plugins, list)
def test_load_plugins_handles_errors(self):
"""测试插件加载错误处理"""
# 测试当插件目录不存在或插件有错误时的处理
with patch('os.path.exists', return_value=False):
plugins = load_plugins()
# 应该返回空列表或处理错误
self.assertIsInstance(plugins, list)
class ReadingTimePluginTest(BaseTestCase, PluginTestMixin):
"""测试阅读时间插件"""
def test_reading_time_plugin_loaded(self):
"""测试阅读时间插件已加载"""
from plugins.reading_time.plugin import ReadingTimePlugin
plugin = ReadingTimePlugin()
self.assertEqual(plugin.PLUGIN_NAME, '阅读时间预测')
def test_calculate_reading_time(self):
"""测试计算阅读时间"""
from plugins.reading_time.plugin import ReadingTimePlugin
plugin = ReadingTimePlugin()
# 测试短文本
short_text = '这是一段短文本
' * 10
result = plugin.add_reading_time(short_text)
self.assertIn('预计阅读时间', result)
self.assertIn('分钟', result)
# 测试长文本
long_text = '这是一段长文本
' * 1000
long_result = plugin.add_reading_time(long_text)
self.assertIn('预计阅读时间', long_result)
# 长文本应该比短文本有更多内容
self.assertGreater(len(long_result), len(result))
class ViewCountPluginTest(BaseTestCase, PluginTestMixin):
"""测试浏览次数插件"""
def test_view_count_plugin_loaded(self):
"""测试浏览次数插件已加载"""
from plugins.view_count.plugin import ViewCountPlugin
plugin = ViewCountPlugin()
self.assertEqual(plugin.PLUGIN_NAME, '文章浏览次数统计')
def test_view_count_increment(self):
"""测试浏览次数增加"""
initial_views = self.article.views
# 模拟访问文章
self.client.get(self.article.get_absolute_url())
self.article.refresh_from_db()
# 浏览次数应该增加
self.assertGreaterEqual(self.article.views, initial_views)
class SEOOptimizerPluginTest(BaseTestCase, PluginTestMixin):
"""测试 SEO 优化插件"""
def test_seo_optimizer_plugin_loaded(self):
"""测试 SEO 优化插件已加载"""
from plugins.seo_optimizer.plugin import SeoOptimizerPlugin
plugin = SeoOptimizerPlugin()
self.assertEqual(plugin.PLUGIN_NAME, 'SEO 优化器')
def test_seo_meta_tags(self):
"""测试 SEO meta 标签"""
from plugins.seo_optimizer.plugin import SeoOptimizerPlugin
plugin = SeoOptimizerPlugin()
response = self.client.get(self.article.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 应该包含 meta 标签
self.assertContains(response, ' str:
"""生成随机数验证码"""
return ''.join(random.sample(string.digits, 6))
def parse_dict_to_url(dict):
from urllib.parse import quote
url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/'))
for k, v in dict.items()])
return url
def get_blog_setting():
value = cache.get('get_blog_setting')
if value:
return value
else:
from blog.models import BlogSettings
if not BlogSettings.objects.count():
setting = BlogSettings()
setting.site_name = 'djangoblog'
setting.site_description = '基于Django的博客系统'
setting.site_seo_description = '基于Django的博客系统'
setting.site_keywords = 'Django,Python'
setting.article_sub_length = 300
setting.sidebar_article_count = 10
setting.sidebar_comment_count = 5
setting.show_google_adsense = False
setting.open_site_comment = True
setting.analytics_code = ''
setting.beian_code = ''
setting.show_gongan_code = False
setting.comment_need_review = False
setting.save()
value = BlogSettings.objects.first()
logger.info('set cache get_blog_setting')
cache.set('get_blog_setting', value)
return value
def save_user_avatar(url):
'''
保存用户头像
:param url:头像url
:return: 本地路径
'''
logger.info(url)
try:
basedir = os.path.join(settings.STATICFILES, 'avatar')
rsp = requests.get(url, timeout=2)
if rsp.status_code == 200:
if not os.path.exists(basedir):
os.makedirs(basedir)
image_extensions = ['.jpg', '.png', 'jpeg', '.gif']
isimage = len([i for i in image_extensions if url.endswith(i)]) > 0
ext = os.path.splitext(url)[1] if isimage else '.jpg'
save_filename = str(uuid.uuid4().hex) + ext
logger.info('保存用户头像:' + basedir + save_filename)
with open(os.path.join(basedir, save_filename), 'wb+') as file:
file.write(rsp.content)
return static('avatar/' + save_filename)
except Exception as e:
logger.error(e)
return static('blog/img/avatar.png')
def delete_sidebar_cache():
from blog.models import LinkShowType
keys = ["sidebar" + x for x in LinkShowType.values]
for k in keys:
logger.info('delete sidebar key:' + k)
cache.delete(k)
def delete_view_cache(prefix, keys):
from django.core.cache.utils import make_template_fragment_key
key = make_template_fragment_key(prefix, keys)
cache.delete(key)
def get_resource_url():
if settings.STATIC_URL:
return settings.STATIC_URL
else:
site = get_current_site()
return 'http://' + site.domain + '/static/'
# 允许的HTML标签白名单 - 支持markdown常用元素
ALLOWED_TAGS = [
'a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'ul', 'pre', 'strong',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', # 标题
'p', 'span', 'div', 'br', 'hr', # 段落和分隔
'table', 'thead', 'tbody', 'tr', 'th', 'td', # 表格
'dl', 'dt', 'dd', # 定义列表
'img', # 图片(需配合ALLOWED_ATTRIBUTES限制src)
'del', 'ins', 'sub', 'sup', # 文本修饰
]
# 安全的class值白名单 - 只允许代码高亮相关的class
ALLOWED_CLASSES = [
'codehilite', 'highlight', 'hll', 'c', 'err', 'k', 'l', 'n', 'o', 'p', 'cm', 'cp', 'c1', 'cs',
'gd', 'ge', 'gr', 'gh', 'gi', 'go', 'gp', 'gs', 'gu', 'gt', 'kc', 'kd', 'kn', 'kp', 'kr', 'kt',
'ld', 'm', 'mf', 'mh', 'mi', 'mo', 'na', 'nb', 'nc', 'no', 'nd', 'ni', 'ne', 'nf', 'nl', 'nn',
'nt', 'nv', 'ow', 'w', 'mb', 'mh', 'mi', 'mo', 'sb', 'sc', 'sd', 'se', 'sh', 'si', 'sx', 's2',
's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il'
]
def class_filter(tag, name, value):
"""自定义class属性过滤器"""
if name == 'class':
# 只允许预定义的安全class值
allowed_classes = [cls for cls in value.split() if cls in ALLOWED_CLASSES]
return ' '.join(allowed_classes) if allowed_classes else False
return value
# 安全的属性白名单
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title', 'rel'], # rel="nofollow" 用于外部链接
'abbr': ['title'],
'acronym': ['title'],
'img': ['src', 'alt', 'title', 'width', 'height'], # 图片属性
'table': ['border', 'cellpadding', 'cellspacing'],
'th': ['align', 'valign'],
'td': ['align', 'valign'],
'span': class_filter,
'div': class_filter,
'pre': class_filter,
'code': class_filter
}
# 安全的协议白名单 - 防止javascript:等危险协议
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
def sanitize_html(html):
"""
安全的HTML清理函数
使用bleach库进行白名单过滤,防止XSS攻击
"""
cleaned = bleach.clean(
html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
protocols=ALLOWED_PROTOCOLS, # 限制允许的协议
strip=True, # 移除不允许的标签而不是转义
strip_comments=True # 移除HTML注释
)
# 移除空的 style 属性(bleach 有时会保留 style="")
import re
cleaned = re.sub(r'\s*style\s*=\s*["\'][\s]*["\']', '', cleaned)
return cleaned
================================================
FILE: djangoblog/whoosh_cn_backend.py
================================================
# encoding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
import json
import os
import re
import shutil
import threading
import warnings
import six
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from datetime import datetime
from django.utils.encoding import force_str
from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query
from haystack.constants import DJANGO_CT, DJANGO_ID, ID
from haystack.exceptions import MissingDependency, SearchBackendError, SkipDocument
from haystack.inputs import Clean, Exact, PythonData, Raw
from haystack.models import SearchResult
from haystack.utils import get_identifier, get_model_ct
from haystack.utils import log as logging
from haystack.utils.app_loading import haystack_get_model
from jieba.analyse import ChineseAnalyzer
from whoosh import index
from whoosh.analysis import StemmingAnalyzer
from whoosh.fields import BOOLEAN, DATETIME, IDLIST, KEYWORD, NGRAM, NGRAMWORDS, NUMERIC, Schema, TEXT
from whoosh.fields import ID as WHOOSH_ID
from whoosh.filedb.filestore import FileStorage, RamStorage
from whoosh.highlight import ContextFragmenter, HtmlFormatter
from whoosh.highlight import highlight as whoosh_highlight
from whoosh.qparser import QueryParser
from whoosh.searching import ResultsPage
from whoosh.writing import AsyncWriter
try:
import whoosh
except ImportError:
raise MissingDependency(
"The 'whoosh' backend requires the installation of 'Whoosh'. Please refer to the documentation.")
# Handle minimum requirement.
if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0):
raise MissingDependency(
"The 'whoosh' backend requires version 2.5.0 or greater.")
# Bubble up the correct error.
DATETIME_REGEX = re.compile(
'^(?P\d{4})-(?P\d{2})-(?P\d{2})T(?P\d{2}):(?P\d{2}):(?P\d{2})(\.\d{3,6}Z?)?$')
LOCALS = threading.local()
LOCALS.RAM_STORE = None
class WhooshHtmlFormatter(HtmlFormatter):
"""
This is a HtmlFormatter simpler than the whoosh.HtmlFormatter.
We use it to have consistent results across backends. Specifically,
Solr, Xapian and Elasticsearch are using this formatting.
"""
template = '<%(tag)s>%(t)s%(tag)s>'
class WhooshSearchBackend(BaseSearchBackend):
# Word reserved by Whoosh for special use.
RESERVED_WORDS = (
'AND',
'NOT',
'OR',
'TO',
)
# Characters reserved by Whoosh for special use.
# The '\\' must come first, so as not to overwrite the other slash
# replacements.
RESERVED_CHARACTERS = (
'\\', '+', '-', '&&', '||', '!', '(', ')', '{', '}',
'[', ']', '^', '"', '~', '*', '?', ':', '.',
)
def __init__(self, connection_alias, **connection_options):
super(
WhooshSearchBackend,
self).__init__(
connection_alias,
**connection_options)
self.setup_complete = False
self.use_file_storage = True
self.post_limit = getattr(
connection_options,
'POST_LIMIT',
128 * 1024 * 1024)
self.path = connection_options.get('PATH')
if connection_options.get('STORAGE', 'file') != 'file':
self.use_file_storage = False
if self.use_file_storage and not self.path:
raise ImproperlyConfigured(
"You must specify a 'PATH' in your settings for connection '%s'." %
connection_alias)
self.log = logging.getLogger('haystack')
def setup(self):
"""
Defers loading until needed.
"""
from haystack import connections
new_index = False
# Make sure the index is there.
if self.use_file_storage and not os.path.exists(self.path):
os.makedirs(self.path)
new_index = True
if self.use_file_storage and not os.access(self.path, os.W_OK):
raise IOError(
"The path to your Whoosh index '%s' is not writable for the current user/group." %
self.path)
if self.use_file_storage:
self.storage = FileStorage(self.path)
else:
global LOCALS
if getattr(LOCALS, 'RAM_STORE', None) is None:
LOCALS.RAM_STORE = RamStorage()
self.storage = LOCALS.RAM_STORE
self.content_field_name, self.schema = self.build_schema(
connections[self.connection_alias].get_unified_index().all_searchfields())
self.parser = QueryParser(self.content_field_name, schema=self.schema)
if new_index is True:
self.index = self.storage.create_index(self.schema)
else:
try:
self.index = self.storage.open_index(schema=self.schema)
except index.EmptyIndexError:
self.index = self.storage.create_index(self.schema)
self.setup_complete = True
def build_schema(self, fields):
schema_fields = {
ID: WHOOSH_ID(stored=True, unique=True),
DJANGO_CT: WHOOSH_ID(stored=True),
DJANGO_ID: WHOOSH_ID(stored=True),
}
# Grab the number of keys that are hard-coded into Haystack.
# We'll use this to (possibly) fail slightly more gracefully later.
initial_key_count = len(schema_fields)
content_field_name = ''
for field_name, field_class in fields.items():
if field_class.is_multivalued:
if field_class.indexed is False:
schema_fields[field_class.index_fieldname] = IDLIST(
stored=True, field_boost=field_class.boost)
else:
schema_fields[field_class.index_fieldname] = KEYWORD(
stored=True, commas=True, scorable=True, field_boost=field_class.boost)
elif field_class.field_type in ['date', 'datetime']:
schema_fields[field_class.index_fieldname] = DATETIME(
stored=field_class.stored, sortable=True)
elif field_class.field_type == 'integer':
schema_fields[field_class.index_fieldname] = NUMERIC(
stored=field_class.stored, numtype=int, field_boost=field_class.boost)
elif field_class.field_type == 'float':
schema_fields[field_class.index_fieldname] = NUMERIC(
stored=field_class.stored, numtype=float, field_boost=field_class.boost)
elif field_class.field_type == 'boolean':
# Field boost isn't supported on BOOLEAN as of 1.8.2.
schema_fields[field_class.index_fieldname] = BOOLEAN(
stored=field_class.stored)
elif field_class.field_type == 'ngram':
schema_fields[field_class.index_fieldname] = NGRAM(
minsize=3, maxsize=15, stored=field_class.stored, field_boost=field_class.boost)
elif field_class.field_type == 'edge_ngram':
schema_fields[field_class.index_fieldname] = NGRAMWORDS(minsize=2, maxsize=15, at='start',
stored=field_class.stored,
field_boost=field_class.boost)
else:
# schema_fields[field_class.index_fieldname] = TEXT(stored=True, analyzer=StemmingAnalyzer(), field_boost=field_class.boost, sortable=True)
schema_fields[field_class.index_fieldname] = TEXT(
stored=True, analyzer=ChineseAnalyzer(), field_boost=field_class.boost, sortable=True)
if field_class.document is True:
content_field_name = field_class.index_fieldname
schema_fields[field_class.index_fieldname].spelling = True
# Fail more gracefully than relying on the backend to die if no fields
# are found.
if len(schema_fields) <= initial_key_count:
raise SearchBackendError(
"No fields were found in any search_indexes. Please correct this before attempting to search.")
return (content_field_name, Schema(**schema_fields))
def update(self, index, iterable, commit=True):
if not self.setup_complete:
self.setup()
self.index = self.index.refresh()
writer = AsyncWriter(self.index)
for obj in iterable:
try:
doc = index.full_prepare(obj)
except SkipDocument:
self.log.debug(u"Indexing for object `%s` skipped", obj)
else:
# Really make sure it's unicode, because Whoosh won't have it any
# other way.
for key in doc:
doc[key] = self._from_python(doc[key])
# Document boosts aren't supported in Whoosh 2.5.0+.
if 'boost' in doc:
del doc['boost']
try:
writer.update_document(**doc)
except Exception as e:
if not self.silently_fail:
raise
# We'll log the object identifier but won't include the actual object
# to avoid the possibility of that generating encoding errors while
# processing the log message:
self.log.error(
u"%s while preparing object for update" %
e.__class__.__name__,
exc_info=True,
extra={
"data": {
"index": index,
"object": get_identifier(obj)}})
if len(iterable) > 0:
# For now, commit no matter what, as we run into locking issues
# otherwise.
writer.commit()
def remove(self, obj_or_string, commit=True):
if not self.setup_complete:
self.setup()
self.index = self.index.refresh()
whoosh_id = get_identifier(obj_or_string)
try:
self.index.delete_by_query(
q=self.parser.parse(
u'%s:"%s"' %
(ID, whoosh_id)))
except Exception as e:
if not self.silently_fail:
raise
self.log.error(
"Failed to remove document '%s' from Whoosh: %s",
whoosh_id,
e,
exc_info=True)
def clear(self, models=None, commit=True):
if not self.setup_complete:
self.setup()
self.index = self.index.refresh()
if models is not None:
assert isinstance(models, (list, tuple))
try:
if models is None:
self.delete_index()
else:
models_to_delete = []
for model in models:
models_to_delete.append(
u"%s:%s" %
(DJANGO_CT, get_model_ct(model)))
self.index.delete_by_query(
q=self.parser.parse(
u" OR ".join(models_to_delete)))
except Exception as e:
if not self.silently_fail:
raise
if models is not None:
self.log.error(
"Failed to clear Whoosh index of models '%s': %s",
','.join(models_to_delete),
e,
exc_info=True)
else:
self.log.error(
"Failed to clear Whoosh index: %s", e, exc_info=True)
def delete_index(self):
# Per the Whoosh mailing list, if wiping out everything from the index,
# it's much more efficient to simply delete the index files.
if self.use_file_storage and os.path.exists(self.path):
shutil.rmtree(self.path)
elif not self.use_file_storage:
self.storage.clean()
# Recreate everything.
self.setup()
def optimize(self):
if not self.setup_complete:
self.setup()
self.index = self.index.refresh()
self.index.optimize()
def calculate_page(self, start_offset=0, end_offset=None):
# Prevent against Whoosh throwing an error. Requires an end_offset
# greater than 0.
if end_offset is not None and end_offset <= 0:
end_offset = 1
# Determine the page.
page_num = 0
if end_offset is None:
end_offset = 1000000
if start_offset is None:
start_offset = 0
page_length = end_offset - start_offset
if page_length and page_length > 0:
page_num = int(start_offset / page_length)
# Increment because Whoosh uses 1-based page numbers.
page_num += 1
return page_num, page_length
@log_query
def search(
self,
query_string,
sort_by=None,
start_offset=0,
end_offset=None,
fields='',
highlight=False,
facets=None,
date_facets=None,
query_facets=None,
narrow_queries=None,
spelling_query=None,
within=None,
dwithin=None,
distance_point=None,
models=None,
limit_to_registered_models=None,
result_class=None,
**kwargs):
if not self.setup_complete:
self.setup()
# A zero length query should return no results.
if len(query_string) == 0:
return {
'results': [],
'hits': 0,
}
query_string = force_str(query_string)
# A one-character query (non-wildcard) gets nabbed by a stopwords
# filter and should yield zero results.
if len(query_string) <= 1 and query_string != u'*':
return {
'results': [],
'hits': 0,
}
reverse = False
if sort_by is not None:
# Determine if we need to reverse the results and if Whoosh can
# handle what it's being asked to sort by. Reversing is an
# all-or-nothing action, unfortunately.
sort_by_list = []
reverse_counter = 0
for order_by in sort_by:
if order_by.startswith('-'):
reverse_counter += 1
if reverse_counter and reverse_counter != len(sort_by):
raise SearchBackendError("Whoosh requires all order_by fields"
" to use the same sort direction")
for order_by in sort_by:
if order_by.startswith('-'):
sort_by_list.append(order_by[1:])
if len(sort_by_list) == 1:
reverse = True
else:
sort_by_list.append(order_by)
if len(sort_by_list) == 1:
reverse = False
sort_by = sort_by_list[0]
if facets is not None:
warnings.warn(
"Whoosh does not handle faceting.",
Warning,
stacklevel=2)
if date_facets is not None:
warnings.warn(
"Whoosh does not handle date faceting.",
Warning,
stacklevel=2)
if query_facets is not None:
warnings.warn(
"Whoosh does not handle query faceting.",
Warning,
stacklevel=2)
narrowed_results = None
self.index = self.index.refresh()
if limit_to_registered_models is None:
limit_to_registered_models = getattr(
settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True)
if models and len(models):
model_choices = sorted(get_model_ct(model) for model in models)
elif limit_to_registered_models:
# Using narrow queries, limit the results to only models handled
# with the current routers.
model_choices = self.build_models_list()
else:
model_choices = []
if len(model_choices) > 0:
if narrow_queries is None:
narrow_queries = set()
narrow_queries.add(' OR '.join(
['%s:%s' % (DJANGO_CT, rm) for rm in model_choices]))
narrow_searcher = None
if narrow_queries is not None:
# Potentially expensive? I don't see another way to do it in
# Whoosh...
narrow_searcher = self.index.searcher()
for nq in narrow_queries:
recent_narrowed_results = narrow_searcher.search(
self.parser.parse(force_str(nq)), limit=None)
if len(recent_narrowed_results) <= 0:
return {
'results': [],
'hits': 0,
}
if narrowed_results:
narrowed_results.filter(recent_narrowed_results)
else:
narrowed_results = recent_narrowed_results
self.index = self.index.refresh()
if self.index.doc_count():
searcher = self.index.searcher()
parsed_query = self.parser.parse(query_string)
# In the event of an invalid/stopworded query, recover gracefully.
if parsed_query is None:
return {
'results': [],
'hits': 0,
}
page_num, page_length = self.calculate_page(
start_offset, end_offset)
search_kwargs = {
'pagelen': page_length,
'sortedby': sort_by,
'reverse': reverse,
}
# Handle the case where the results have been narrowed.
if narrowed_results is not None:
search_kwargs['filter'] = narrowed_results
try:
raw_page = searcher.search_page(
parsed_query,
page_num,
**search_kwargs
)
except ValueError:
if not self.silently_fail:
raise
return {
'results': [],
'hits': 0,
'spelling_suggestion': None,
}
# Because as of Whoosh 2.5.1, it will return the wrong page of
# results if you request something too high. :(
if raw_page.pagenum < page_num:
return {
'results': [],
'hits': 0,
'spelling_suggestion': None,
}
results = self._process_results(
raw_page,
highlight=highlight,
query_string=query_string,
spelling_query=spelling_query,
result_class=result_class)
searcher.close()
if hasattr(narrow_searcher, 'close'):
narrow_searcher.close()
return results
else:
if self.include_spelling:
if spelling_query:
spelling_suggestion = self.create_spelling_suggestion(
spelling_query)
else:
spelling_suggestion = self.create_spelling_suggestion(
query_string)
else:
spelling_suggestion = None
return {
'results': [],
'hits': 0,
'spelling_suggestion': spelling_suggestion,
}
def more_like_this(
self,
model_instance,
additional_query_string=None,
start_offset=0,
end_offset=None,
models=None,
limit_to_registered_models=None,
result_class=None,
**kwargs):
if not self.setup_complete:
self.setup()
# Deferred models will have a different class ("RealClass_Deferred_fieldname")
# which won't be in our registry:
model_klass = model_instance._meta.concrete_model
field_name = self.content_field_name
narrow_queries = set()
narrowed_results = None
self.index = self.index.refresh()
if limit_to_registered_models is None:
limit_to_registered_models = getattr(
settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True)
if models and len(models):
model_choices = sorted(get_model_ct(model) for model in models)
elif limit_to_registered_models:
# Using narrow queries, limit the results to only models handled
# with the current routers.
model_choices = self.build_models_list()
else:
model_choices = []
if len(model_choices) > 0:
if narrow_queries is None:
narrow_queries = set()
narrow_queries.add(' OR '.join(
['%s:%s' % (DJANGO_CT, rm) for rm in model_choices]))
if additional_query_string and additional_query_string != '*':
narrow_queries.add(additional_query_string)
narrow_searcher = None
if narrow_queries is not None:
# Potentially expensive? I don't see another way to do it in
# Whoosh...
narrow_searcher = self.index.searcher()
for nq in narrow_queries:
recent_narrowed_results = narrow_searcher.search(
self.parser.parse(force_str(nq)), limit=None)
if len(recent_narrowed_results) <= 0:
return {
'results': [],
'hits': 0,
}
if narrowed_results:
narrowed_results.filter(recent_narrowed_results)
else:
narrowed_results = recent_narrowed_results
page_num, page_length = self.calculate_page(start_offset, end_offset)
self.index = self.index.refresh()
raw_results = EmptyResults()
if self.index.doc_count():
query = "%s:%s" % (ID, get_identifier(model_instance))
searcher = self.index.searcher()
parsed_query = self.parser.parse(query)
results = searcher.search(parsed_query)
if len(results):
raw_results = results[0].more_like_this(
field_name, top=end_offset)
# Handle the case where the results have been narrowed.
if narrowed_results is not None and hasattr(raw_results, 'filter'):
raw_results.filter(narrowed_results)
try:
raw_page = ResultsPage(raw_results, page_num, page_length)
except ValueError:
if not self.silently_fail:
raise
return {
'results': [],
'hits': 0,
'spelling_suggestion': None,
}
# Because as of Whoosh 2.5.1, it will return the wrong page of
# results if you request something too high. :(
if raw_page.pagenum < page_num:
return {
'results': [],
'hits': 0,
'spelling_suggestion': None,
}
results = self._process_results(raw_page, result_class=result_class)
searcher.close()
if hasattr(narrow_searcher, 'close'):
narrow_searcher.close()
return results
def _process_results(
self,
raw_page,
highlight=False,
query_string='',
spelling_query=None,
result_class=None):
from haystack import connections
results = []
# It's important to grab the hits first before slicing. Otherwise, this
# can cause pagination failures.
hits = len(raw_page)
if result_class is None:
result_class = SearchResult
facets = {}
spelling_suggestion = None
unified_index = connections[self.connection_alias].get_unified_index()
indexed_models = unified_index.get_indexed_models()
for doc_offset, raw_result in enumerate(raw_page):
score = raw_page.score(doc_offset) or 0
app_label, model_name = raw_result[DJANGO_CT].split('.')
additional_fields = {}
model = haystack_get_model(app_label, model_name)
if model and model in indexed_models:
for key, value in raw_result.items():
index = unified_index.get_index(model)
string_key = str(key)
if string_key in index.fields and hasattr(
index.fields[string_key], 'convert'):
# Special-cased due to the nature of KEYWORD fields.
if index.fields[string_key].is_multivalued:
if value is None or len(value) == 0:
additional_fields[string_key] = []
else:
additional_fields[string_key] = value.split(
',')
else:
additional_fields[string_key] = index.fields[string_key].convert(
value)
else:
additional_fields[string_key] = self._to_python(value)
del (additional_fields[DJANGO_CT])
del (additional_fields[DJANGO_ID])
if highlight:
# 使用中文分词器
ca = ChineseAnalyzer()
highlighted = {}
# 高亮标题(如果存在)
if 'title' in additional_fields and additional_fields['title']:
import re
title_text = additional_fields['title']
# 使用正则表达式高亮,不转义HTML
for term in query_string.split():
if len(term) >= 2:
# 不区分大小写地替换
title_text = re.sub(
r'(' + re.escape(term) + r')',
r'\1 ',
title_text,
flags=re.IGNORECASE
)
if '' in title_text:
highlighted['title'] = [title_text]
# 高亮正文 - 返回markdown片段,让模板处理
if 'body' in additional_fields and additional_fields['body']:
import re
from djangoblog.utils import CommonMarkdown
# 先转换为HTML,便于提取摘要
html_content = CommonMarkdown.get_markdown(additional_fields['body'])
# 提取纯文本用于搜索匹配的上下文
from django.utils.html import strip_tags
plain_text = strip_tags(html_content)
# 找到关键词的位置,提取上下文
match_pos = -1
for term in query_string.split():
if len(term) >= 2:
pos = plain_text.lower().find(term.lower())
if pos >= 0:
match_pos = pos
break
if match_pos >= 0:
# 提取匹配位置前后的内容
start = max(0, match_pos - 150)
end = min(len(plain_text), match_pos + 350)
snippet = plain_text[start:end]
# 对snippet进行高亮
for term in query_string.split():
if len(term) >= 2:
snippet = re.sub(
r'(' + re.escape(term) + r')',
r'\1 ',
snippet,
flags=re.IGNORECASE
)
highlighted['body'] = [snippet]
if highlighted:
additional_fields['highlighted'] = highlighted
result = result_class(
app_label,
model_name,
raw_result[DJANGO_ID],
score,
**additional_fields)
results.append(result)
else:
hits -= 1
if self.include_spelling:
if spelling_query:
spelling_suggestion = self.create_spelling_suggestion(
spelling_query)
else:
spelling_suggestion = self.create_spelling_suggestion(
query_string)
return {
'results': results,
'hits': hits,
'facets': facets,
'spelling_suggestion': spelling_suggestion,
}
def create_spelling_suggestion(self, query_string):
spelling_suggestion = None
reader = self.index.reader()
corrector = reader.corrector(self.content_field_name)
cleaned_query = force_str(query_string)
if not query_string:
return spelling_suggestion
# Clean the string.
for rev_word in self.RESERVED_WORDS:
cleaned_query = cleaned_query.replace(rev_word, '')
for rev_char in self.RESERVED_CHARACTERS:
cleaned_query = cleaned_query.replace(rev_char, '')
# Break it down.
query_words = cleaned_query.split()
suggested_words = []
for word in query_words:
suggestions = corrector.suggest(word, limit=1)
if len(suggestions) > 0:
suggested_words.append(suggestions[0])
spelling_suggestion = ' '.join(suggested_words)
return spelling_suggestion
def _from_python(self, value):
"""
Converts Python values to a string for Whoosh.
Code courtesy of pysolr.
"""
if hasattr(value, 'strftime'):
if not hasattr(value, 'hour'):
value = datetime(value.year, value.month, value.day, 0, 0, 0)
elif isinstance(value, bool):
if value:
value = 'true'
else:
value = 'false'
elif isinstance(value, (list, tuple)):
value = u','.join([force_str(v) for v in value])
elif isinstance(value, (six.integer_types, float)):
# Leave it alone.
pass
else:
value = force_str(value)
return value
def _to_python(self, value):
"""
Converts values from Whoosh to native Python values.
A port of the same method in pysolr, as they deal with data the same way.
"""
if value == 'true':
return True
elif value == 'false':
return False
if value and isinstance(value, six.string_types):
possible_datetime = DATETIME_REGEX.search(value)
if possible_datetime:
date_values = possible_datetime.groupdict()
for dk, dv in date_values.items():
date_values[dk] = int(dv)
return datetime(
date_values['year'],
date_values['month'],
date_values['day'],
date_values['hour'],
date_values['minute'],
date_values['second'])
try:
# Attempt to use json to load the values.
converted_value = json.loads(value)
# Try to handle most built-in types.
if isinstance(
converted_value,
(list,
tuple,
set,
dict,
six.integer_types,
float,
complex)):
return converted_value
except BaseException:
# If it fails (SyntaxError or its ilk) or we don't trust it,
# continue on.
pass
return value
class WhooshSearchQuery(BaseSearchQuery):
def _convert_datetime(self, date):
if hasattr(date, 'hour'):
return force_str(date.strftime('%Y%m%d%H%M%S'))
else:
return force_str(date.strftime('%Y%m%d000000'))
def clean(self, query_fragment):
"""
Provides a mechanism for sanitizing user input before presenting the
value to the backend.
Whoosh 1.X differs here in that you can no longer use a backslash
to escape reserved characters. Instead, the whole word should be
quoted.
"""
words = query_fragment.split()
cleaned_words = []
for word in words:
if word in self.backend.RESERVED_WORDS:
word = word.replace(word, word.lower())
for char in self.backend.RESERVED_CHARACTERS:
if char in word:
word = "'%s'" % word
break
cleaned_words.append(word)
return ' '.join(cleaned_words)
def build_query_fragment(self, field, filter_type, value):
from haystack import connections
query_frag = ''
is_datetime = False
if not hasattr(value, 'input_type_name'):
# Handle when we've got a ``ValuesListQuerySet``...
if hasattr(value, 'values_list'):
value = list(value)
if hasattr(value, 'strftime'):
is_datetime = True
if isinstance(value, six.string_types) and value != ' ':
# It's not an ``InputType``. Assume ``Clean``.
value = Clean(value)
else:
value = PythonData(value)
# Prepare the query using the InputType.
prepared_value = value.prepare(self)
if not isinstance(prepared_value, (set, list, tuple)):
# Then convert whatever we get back to what pysolr wants if needed.
prepared_value = self.backend._from_python(prepared_value)
# 'content' is a special reserved word, much like 'pk' in
# Django's ORM layer. It indicates 'no special field'.
if field == 'content':
index_fieldname = ''
else:
index_fieldname = u'%s:' % connections[self._using].get_unified_index(
).get_index_fieldname(field)
filter_types = {
'content': '%s',
'contains': '*%s*',
'endswith': "*%s",
'startswith': "%s*",
'exact': '%s',
'gt': "{%s to}",
'gte': "[%s to]",
'lt': "{to %s}",
'lte': "[to %s]",
'fuzzy': u'%s~',
}
if value.post_process is False:
query_frag = prepared_value
else:
if filter_type in [
'content',
'contains',
'startswith',
'endswith',
'fuzzy']:
if value.input_type_name == 'exact':
query_frag = prepared_value
else:
# Iterate over terms & incorportate the converted form of
# each into the query.
terms = []
if isinstance(prepared_value, six.string_types):
possible_values = prepared_value.split(' ')
else:
if is_datetime is True:
prepared_value = self._convert_datetime(
prepared_value)
possible_values = [prepared_value]
for possible_value in possible_values:
terms.append(
filter_types[filter_type] %
self.backend._from_python(possible_value))
if len(terms) == 1:
query_frag = terms[0]
else:
query_frag = u"(%s)" % " AND ".join(terms)
elif filter_type == 'in':
in_options = []
for possible_value in prepared_value:
is_datetime = False
if hasattr(possible_value, 'strftime'):
is_datetime = True
pv = self.backend._from_python(possible_value)
if is_datetime is True:
pv = self._convert_datetime(pv)
if isinstance(pv, six.string_types) and not is_datetime:
in_options.append('"%s"' % pv)
else:
in_options.append('%s' % pv)
query_frag = "(%s)" % " OR ".join(in_options)
elif filter_type == 'range':
start = self.backend._from_python(prepared_value[0])
end = self.backend._from_python(prepared_value[1])
if hasattr(prepared_value[0], 'strftime'):
start = self._convert_datetime(start)
if hasattr(prepared_value[1], 'strftime'):
end = self._convert_datetime(end)
query_frag = u"[%s to %s]" % (start, end)
elif filter_type == 'exact':
if value.input_type_name == 'exact':
query_frag = prepared_value
else:
prepared_value = Exact(prepared_value).prepare(self)
query_frag = filter_types[filter_type] % prepared_value
else:
if is_datetime is True:
prepared_value = self._convert_datetime(prepared_value)
query_frag = filter_types[filter_type] % prepared_value
if len(query_frag) and not isinstance(value, Raw):
if not query_frag.startswith('(') and not query_frag.endswith(')'):
query_frag = "(%s)" % query_frag
return u"%s%s" % (index_fieldname, query_frag)
# if not filter_type in ('in', 'range'):
# # 'in' is a bit of a special case, as we don't want to
# # convert a valid list/tuple to string. Defer handling it
# # until later...
# value = self.backend._from_python(value)
class WhooshEngine(BaseEngine):
backend = WhooshSearchBackend
query = WhooshSearchQuery
================================================
FILE: djangoblog/wsgi.py
================================================
"""
WSGI config for djangoblog project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings")
application = get_wsgi_application()
================================================
FILE: docs/README-en.md
================================================
# DjangoBlog
A powerful, elegant, and modern blog system.
English • 简体中文
---
DjangoBlog is a high-performance blog platform built with Python 3.10+ and Django 5.2. It not only provides all the core functionalities of a traditional blog but also features a flexible plugin system, allowing you to easily extend and customize your website. Whether you are a personal blogger, a tech enthusiast, or a content creator, DjangoBlog aims to provide a stable, efficient, and easy-to-maintain environment for writing and publishing.
## ✨ Features
- **Powerful Content Management**: Full support for managing articles, standalone pages, categories, and tags. Comes with a powerful built-in Markdown editor with syntax highlighting.
- **Full-Text Search**: Integrated Elasticsearch/Whoosh search engine for fast and accurate content searching, with keyword highlighting support.
- **Interactive Comment System**: Supports replies, email notifications, and Markdown formatting in comments. Modern comment interface with infinite nested replies.
- **Flexible Sidebar**: Customizable modules for displaying recent articles, most viewed posts, tag cloud, and more.
- **Social Login**: Built-in OAuth support, with integrations for Google, GitHub, Facebook, Weibo, QQ, and other major platforms.
- **Dark Mode Support**: Toggle between light and dark themes with system preference support for comfortable reading experience. Anti-FOUC (Flash of Unstyled Content) implementation.
- **Modern Frontend**: Built with Alpine.js + Tailwind CSS + HTMX, providing SPA-like navigation experience with HTML-over-the-wire architecture.
- **High-Performance Caching**: Native support for Redis caching with an automatic refresh mechanism to ensure high-speed website responses.
- **SEO Friendly**: Basic SEO features are included, with automatic notifications to Google and Baidu upon new content publication.
- **Extensible Plugin System**: Extend blog functionalities by creating standalone plugins, ensuring decoupled and maintainable code. 8 built-in plugins including view counting, SEO optimization, article recommendations, lazy image loading, and more!
- **Integrated Image Hosting**: A simple, built-in image hosting feature for easy uploads and management.
- **Automated Build**: Uses Vite to build frontend assets with hot reload and automatic optimization.
- **Robust Operations**: Built-in email notifications for website exceptions and management capabilities through a WeChat Official Account.
## 🛠️ Tech Stack
- **Backend**: Python 3.10+, Django 5.2
- **Database**: MySQL, SQLite (configurable)
- **Cache**: Redis, LocalMem (configurable)
- **Frontend**: Alpine.js 3.13, Tailwind CSS 3.4, HTMX 2.0, Vite 5.4
- **Search**: Whoosh, Elasticsearch (configurable)
- **Editor**: Markdown (mdeditor)
## 🚀 Getting Started
### 1. Prerequisites
Ensure you have Python 3.10+ and MySQL/MariaDB installed on your system.
### 2. Clone & Installation
```bash
# Clone the project to your local machine
git clone https://github.com/liangliangyy/DjangoBlog.git
cd DjangoBlog
# Install dependencies
pip install -r requirements.txt
```
### 3. Project Configuration
- **Database**:
Open `djangoblog/settings.py`, locate the `DATABASES` section, and update it with your MySQL connection details.
```python
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'djangoblog',
'USER': 'root',
'PASSWORD': 'your_password',
'HOST': '127.0.0.1',
'PORT': 3306,
}
}
```
Create the database in MySQL:
```sql
CREATE DATABASE `djangoblog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
- **More Configurations**:
For advanced settings such as email, OAuth, caching, and more, please refer to our [Detailed Configuration Guide](/docs/config-en.md).
### 4. Database Initialization
```bash
python manage.py makemigrations
python manage.py migrate
# Create a superuser account
python manage.py createsuperuser
```
### 5. Build Frontend Assets
```bash
# Navigate to frontend directory
cd frontend
# Install dependencies (required for first run)
npm install
# Build production assets
npm run build
# Return to project root
cd ..
```
### 6. Running the Project
```bash
# (Optional) Generate some test data
python manage.py create_testdata
# Collect static files
python manage.py collectstatic --noinput
# (Optional) Compress static files
python manage.py compress --force
# Start the development server
python manage.py runserver
```
Now, open your browser and navigate to `http://127.0.0.1:8000/`. You should see the DjangoBlog homepage!
### Development Mode
If you need to develop frontend code, you can use Vite's hot reload feature:
```bash
# Start development server in frontend directory
cd frontend
npm run dev
```
This will start the Vite development server, and frontend code changes will be automatically rebuilt.
## Deployment
- **Traditional Deployment**: A detailed guide for server deployment is available here: [Deployment Tutorial](https://www.lylinux.net/article/2019/8/5/58.html) (in Chinese).
- **Docker Deployment**: This project fully supports Docker. If you are familiar with containerization, please refer to the [Docker Deployment Guide](/docs/docker-en.md) for a quick start.
- **Kubernetes Deployment**: We also provide a complete [Kubernetes Deployment Guide](/docs/k8s-en.md) to help you go cloud-native easily.
## 🧩 Plugin System
The plugin system is a core feature of DjangoBlog. It allows you to add new functionalities to your blog without modifying the core codebase by writing standalone plugins.
- **How it Works**: Plugins operate by registering callback functions to predefined "hooks". For instance, when an article is rendered, the `after_article_body_get` hook is triggered, and all functions registered to this hook are executed.
- **Built-in Plugins**: The project includes the following useful plugins
- `view_count` - Article view counter
- `seo_optimizer` - SEO optimization enhancements
- `article_copyright` - Article copyright notices (modern style)
- `article_recommendation` - Smart article recommendations (responsive card layout)
- `external_links` - External link handling (automatic icon addition)
- `image_lazy_loading` - Image lazy loading optimization (fade-in animation)
- `reading_time` - Article reading time estimation
- `cloudflare_cache` - Cloudflare cache management
- **Develop Your Own Plugin**: Simply create a new folder under the `plugins` directory and write your `plugin.py`. We welcome you to explore and contribute your creative ideas to the DjangoBlog community!
## 🤝 Contributing
We warmly welcome contributions of any kind! If you have great ideas or have found a bug, please feel free to open an issue or submit a pull request.
## 📄 License
This project is open-sourced under the [MIT License](LICENSE).
---
## ❤️ Support & Sponsorship
If you find this project helpful and wish to support its continued maintenance and development, please consider buying me a coffee! Your support is my greatest motivation.
(Left) Alipay / (Right) WeChat
## 🙏 Acknowledgements
A special thanks to **JetBrains** for providing a free open-source license for this project.
---
> If this project has helped you, please leave your website URL [here](https://github.com/liangliangyy/DjangoBlog/issues/214) to let more people see it. Your feedback is the driving force for my continued updates and maintenance.
================================================
FILE: docs/config-en.md
================================================
# Introduction to Main Features Settings
## Cache Configuration
The cache uses `localmem` (local memory cache) by default. If you have a Redis environment, you can automatically switch to Redis cache by setting the `DJANGO_REDIS_URL` environment variable.
### Using Redis Cache
Set the environment variable:
```bash
export DJANGO_REDIS_URL="127.0.0.1:6379/0"
```
Or directly modify the cache configuration in `settings.py`:
```python
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/0',
}
}
```
Reference code: https://github.com/liangliangyy/DjangoBlog/blob/master/djangoblog/settings.py#L201-L215
## OAuth Login:
QQ, Weibo, Google, GitHub and Facebook are now supported for OAuth login. Fetch OAuth login permissions from the corresponding open platform, and save them with `appkey`, `appsecret` and callback address in **Backend->OAuth** configuration.
### Callback address examples:
QQ: http://your-domain-name/oauth/authorize?type=qq
Weibo: http://your-domain-name/oauth/authorize?type=weibo
type is in the type field of `oauthmanager`.
## owntracks:
owntracks is a location tracking application. It will send your locaiton to the server by timing.Simple support owntracks features. Just install owntracks app and set api address as `your-domain-name/owntracks/logtracks`. Visit `your-domain-name/owntracks/show_dates` and you will see the date with latitude and langitude, click it and see the motion track. The map is drawn by AMap.
## Email feature:
Same as before, Configure your own error msg recvie email information with`ADMINS = [('liangliang', 'liangliangyy@gmail.com')]` in `settings.py`. And modify:
```python
EMAIL_HOST = 'smtp.zoho.com'
EMAIL_PORT = 587
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
SERVER_EMAIL = os.environ.get('DJANGO_EMAIL_USER')
```
with your email account information.
## WeChat Official Account
Simple wechat official account features integrated. Set token as `your-domain-name/robot` in wechat backend. Default token is `lylinux`, you can change it to your own in `servermanager/robot.py`. Add a new command in `Backend->Servermanager->command`, in this way, you can manage the system through wechat official account.
## Introduction to website configuration
You can add website configuration in **Backend->BLOG->WebSiteConfiguration**. Such as: keywords, description, Google Ad, website stats code, case number, etc.
OAuth user avatar path is saved in *StaticFileSavedAddress*. Please input absolute path, code directory for default.
## Source code highlighting
If the code block in your article didn't show hightlight, please write the code blocks as following:

That is, you should add the corresponding language name before the code block.
## Update & Version Notes
### Database Migration Errors
If you encounter errors while executing database migrations:
```python
django.db.migrations.exceptions.MigrationSchemaMissing: Unable to create the django_migrations table ((1064, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '(6) NOT NULL)' at line 1"))
```
This problem may be caused by MySQL version < 5.6. Please upgrade to MySQL >= 5.6.
### Django Version Configuration
#### Django 4.0+ CSRF Configuration
Django 4.0 and above require `CSRF_TRUSTED_ORIGINS` configuration, otherwise you may encounter CSRF errors during login.
Configure your domain in `settings.py`:
```python
CSRF_TRUSTED_ORIGINS = [
'http://example.com',
'https://example.com',
'http://www.example.com',
'https://www.example.com',
]
```
**Note**: Replace `example.com` with your actual domain, including the protocol (http/https).
Reference code: https://github.com/liangliangyy/DjangoBlog/blob/master/djangoblog/settings.py#L41
#### Django 5.2 Notes
This project currently uses Django 5.2.9, which has been thoroughly tested and runs stably. If upgrading from an older version, please note:
- Ensure Python version >= 3.10
- Run database migrations: `python manage.py migrate`
- Update dependencies: `pip install -r requirements.txt`
## Frontend Development Configuration
### Tech Stack
- **Alpine.js 3.13**: Lightweight reactive framework
- **Tailwind CSS 3.4**: Utility-first CSS framework
- **HTMX 2.0**: HTML-over-the-wire architecture
- **Vite 5.4**: Modern build tool
### Development Mode
When developing frontend code, you can use Vite's hot reload feature:
```bash
cd frontend
npm run dev
```
This will start the Vite development server (default port 5173), and frontend code changes will be automatically rebuilt and refreshed in the browser.
### Production Build
```bash
cd frontend
npm run build
```
Build outputs will be generated to `blog/static/blog/dist/` directory, including:
- CSS files (compiled with Tailwind JIT and compressed)
- JavaScript files (code-split and minified)
- Vite manifest file (for asset mapping)
### Dark Mode
The project has built-in dark mode support:
- **Auto-switching**: Follows system theme preferences automatically
- **Persistence**: User choice is saved to localStorage
- **No Flash**: Seamless theme loading without white screen flash
- **Keyboard Shortcut**: `Ctrl+Shift+D` / `Cmd+Shift+D` for quick toggling
Users can manually toggle the theme using the button in the top-right corner of the page.
### Color Schemes
Supports 8 color themes, configurable in `djangoblog/settings.py`:
```python
# Set in template context
COLOR_SCHEME = 'purple' # purple, blue, green, orange, pink, red, indigo, teal
```
Color schemes are implemented through CSS variables system and do not require rebuilding frontend assets after changes.
================================================
FILE: docs/config.md
================================================
# 主要功能配置介绍
## 缓存配置
缓存默认使用 `localmem`(本地内存缓存)。如果你有 Redis 环境,可以通过设置 `DJANGO_REDIS_URL` 环境变量来自动切换到 Redis 缓存。
### 使用 Redis 缓存
设置环境变量:
```bash
export DJANGO_REDIS_URL="127.0.0.1:6379/0"
```
或者在 `settings.py` 中直接修改缓存配置:
```python
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/0',
}
}
```
参考代码:https://github.com/liangliangyy/DjangoBlog/blob/master/djangoblog/settings.py#L201-L215
## oauth登录:
现在已经支持QQ,微博,Google,GitHub,Facebook登录,需要在其对应的开放平台申请oauth登录权限,然后在
**后台->Oauth** 配置中新增配置,填写对应的`appkey`和`appsecret`以及回调地址。
### 回调地址示例:
qq:http://你的域名/oauth/authorize?type=qq
微博:http://你的域名/oauth/authorize?type=weibo
type对应在`oauthmanager`中的type字段。
## owntracks:
owntracks是一个位置追踪软件,可以定时的将你的坐标提交到你的服务器上,现在简单的支持owntracks功能,需要安装owntracks的app,然后将api地址设置为:
`你的域名/owntracks/logtracks`就可以了。然后访问`你的域名/owntracks/show_dates`就可以看到有经纬度记录的日期,点击之后就可以看到运动轨迹了。地图是使用高德地图绘制。
## 邮件功能:
同样,将`settings.py`中的`ADMINS = [('liangliang', 'liangliangyy@gmail.com')]`配置为你自己的错误接收邮箱,另外修改:
```python
EMAIL_HOST = 'smtp.zoho.com'
EMAIL_PORT = 587
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
SERVER_EMAIL = os.environ.get('DJANGO_EMAIL_USER')
```
为你自己的邮箱配置。
## 微信公众号
集成了简单的微信公众号功能,在微信后台将token地址设置为:`你的域名/robot` 即可,默认token为`lylinux`,当然你可以修改为你自己的,在`servermanager/robot.py`中。
然后在**后台->Servermanager->命令**中新增命令,这样就可以使用微信公众号来管理了。
## 网站配置介绍
在**后台->BLOG->网站配置**中,可以新增网站配置,比如关键字,描述等,以及谷歌广告,网站统计代码及备案号等等。
其中的*静态文件保存地址*是保存oauth用户登录的头像路径,填写绝对路径,默认是代码目录。
## 代码高亮
如果你发现你文章的代码没有高亮,请这样书写代码块:

也就是说,需要在代码块开始位置加入这段代码对应的语言。
## update
如果你发现执行数据库迁移的时候出现如下报错:
```python
django.db.migrations.exceptions.MigrationSchemaMissing: Unable to create the django_migrations table ((1064, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '(6) NOT NULL)' at line 1"))
```
可能是因为你的mysql版本低于5.6,需要升级mysql版本>=5.6即可。
## Django 版本配置说明
### Django 4.0+ CSRF 配置
Django 4.0 及以上版本需要配置 `CSRF_TRUSTED_ORIGINS`,否则可能会在登录时报 CSRF 错误。
在 `settings.py` 中配置您的域名:
```python
CSRF_TRUSTED_ORIGINS = [
'http://example.com',
'https://example.com',
'http://www.example.com',
'https://www.example.com',
]
```
**注意**:请将 `example.com` 替换为您的实际域名,包括协议(http/https)。
参考代码:https://github.com/liangliangyy/DjangoBlog/blob/master/djangoblog/settings.py#L41
### Django 5.2 说明
本项目目前使用 Django 5.2.9,已经过充分测试,运行稳定。如果从旧版本升级,请注意:
- 确保 Python 版本 >= 3.10
- 运行数据库迁移:`python manage.py migrate`
- 更新依赖:`pip install -r requirements.txt`
## 前端开发配置
### 技术栈
- **Alpine.js 3.13**: 轻量级响应式框架
- **Tailwind CSS 3.4**: 实用优先的 CSS 框架
- **HTMX 2.0**: HTML-over-the-wire 架构
- **Vite 5.4**: 现代化构建工具
### 开发模式
在开发前端代码时,可以使用 Vite 的热更新功能:
```bash
cd frontend
npm run dev
```
这将启动 Vite 开发服务器(默认端口 5173),修改前端代码后会自动重新构建和刷新浏览器。
### 生产构建
```bash
cd frontend
npm run build
```
构建产物将输出到 `blog/static/blog/dist/` 目录,包括:
- CSS 文件(经过 Tailwind JIT 编译和压缩)
- JavaScript 文件(经过代码分割和压缩)
- Vite manifest 文件(用于资源映射)
### 深色模式
项目内置深色模式功能:
- **自动切换**: 支持跟随系统主题自动切换
- **持久化**: 用户选择会保存到 localStorage
- **防闪烁**: 页面加载时无白屏闪烁
- **快捷键**: 支持 `Ctrl+Shift+D` / `Cmd+Shift+D` 快速切换
用户可以通过页面右上角的按钮手动切换主题。
### 配色方案
支持 8 种配色主题,可在 `djangoblog/settings.py` 中配置:
```python
# 在模板中设置
COLOR_SCHEME = 'purple' # purple, blue, green, orange, pink, red, indigo, teal
```
配色方案通过 CSS 变量系统实现,修改后无需重新构建前端资源。
================================================
FILE: docs/docker-en.md
================================================
# Deploying DjangoBlog with Docker



This project fully supports containerized deployment using Docker, providing you with a fast, consistent, and isolated runtime environment. We recommend using `docker-compose` to launch the entire blog service stack with a single command.
## 1. Prerequisites
Before you begin, please ensure you have the following software installed on your system:
- [Docker Engine](https://docs.docker.com/engine/install/)
- [Docker Compose](https://docs.docker.com/compose/install/) (Included with Docker Desktop for Mac and Windows)
## 2. Recommended Method: Using `docker-compose` (One-Click Deployment)
This is the simplest and most recommended way to deploy. It automatically creates and manages the Django application, a MySQL database, and an optional Elasticsearch service for you.
### Step 1: Start the Basic Services
From the project's root directory, run the following command:
```bash
# Build and start the containers in detached mode (includes Django app and MySQL)
docker-compose up -d --build
```
`docker-compose` will read the `docker-compose.yml` file, pull the necessary images, build the project image, and start all services.
- **Access Your Blog**: Once the services are up, you can access the blog by navigating to `http://127.0.0.1` in your browser.
- **Data Persistence**: MySQL data files will be stored in the `data/mysql` directory within the project root, ensuring that your data persists across container restarts.
### Step 2: (Optional) Enable Elasticsearch for Full-Text Search
If you want to use Elasticsearch for more powerful full-text search capabilities, you can include the `docker-compose.es.yml` configuration file:
```bash
# Build and start all services in detached mode (Django, MySQL, Elasticsearch)
docker-compose -f docker-compose.yml -f deploy/docker-compose/docker-compose.es.yml up -d --build
```
- **Data Persistence**: Elasticsearch data will be stored in the `data/elasticsearch` directory.
### Step 3: First-Time Initialization
After the containers start for the first time, you'll need to execute some initialization commands inside the application container.
```bash
# Get a shell inside the djangoblog application container (named 'web')
docker-compose exec web bash
# Inside the container, run the following commands:
# Create a superuser account (follow the prompts to set username, email, and password)
python manage.py createsuperuser
# (Optional) Create some test data
python manage.py create_testdata
# (Optional, if ES is enabled) Create the search index
python manage.py rebuild_index
# Exit the container
exit
```
## 3. Alternative Method: Using the Standalone Docker Image
If you already have an external MySQL database running, you can run the DjangoBlog application image by itself.
```bash
# Pull the latest image from Docker Hub
docker pull liangliangyy/djangoblog:latest
# Run the container and connect it to your external database
docker run -d \
-p 8000:8000 \
-e DJANGO_SECRET_KEY='your-strong-secret-key' \
-e DJANGO_MYSQL_HOST='your-mysql-host' \
-e DJANGO_MYSQL_USER='your-mysql-user' \
-e DJANGO_MYSQL_PASSWORD='your-mysql-password' \
-e DJANGO_MYSQL_DATABASE='djangoblog' \
--name djangoblog \
liangliangyy/djangoblog:latest
```
- **Access Your Blog**: After startup, visit `http://127.0.0.1:8000`.
- **Create Superuser**: `docker exec -it djangoblog python manage.py createsuperuser`
## 4. Configuration (Environment Variables)
Most of the project's configuration is managed through environment variables. You can modify them in the `docker-compose.yml` file or pass them using the `-e` flag with the `docker run` command.
| Environment Variable | Default/Example Value | Notes |
|---------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------|
| `DJANGO_SECRET_KEY` | `your-strong-secret-key` | **Must be changed to a random, complex string!** |
| `DJANGO_DEBUG` | `False` | Toggles Django's debug mode. |
| `DJANGO_MYSQL_HOST` | `mysql` | Database hostname. |
| `DJANGO_MYSQL_PORT` | `3306` | Database port. |
| `DJANGO_MYSQL_DATABASE` | `djangoblog` | Database name. |
| `DJANGO_MYSQL_USER` | `root` | Database username. |
| `DJANGO_MYSQL_PASSWORD` | `djangoblog_123` | Database password. |
| `DJANGO_REDIS_URL` | `redis:6379/0` | Redis connection URL (for caching). |
| `DJANGO_ELASTICSEARCH_HOST`| `elasticsearch:9200` | Elasticsearch host address. |
| `DJANGO_EMAIL_HOST` | `smtp.example.org` | Email server address. |
| `DJANGO_EMAIL_PORT` | `465` | Email server port. |
| `DJANGO_EMAIL_USER` | `user@example.org` | Email account username. |
| `DJANGO_EMAIL_PASSWORD` | `your-email-password` | Email account password. |
| `DJANGO_EMAIL_USE_SSL` | `True` | Whether to use SSL. |
| `DJANGO_EMAIL_USE_TLS` | `False` | Whether to use TLS. |
| `DJANGO_ADMIN_EMAIL` | `admin@example.org` | Admin email for receiving error reports. |
| `DJANGO_BAIDU_NOTIFY_URL` | `http://data.zz.baidu.com/...` | Push API from [Baidu Webmaster Tools](https://ziyuan.baidu.com/linksubmit/index). |
---
After deployment, please review and adjust these environment variables according to your needs, especially `DJANGO_SECRET_KEY` and the database and email settings.
================================================
FILE: docs/docker.md
================================================
# 使用 Docker 部署 DjangoBlog



本项目全面支持使用 Docker 进行容器化部署,为您提供了快速、一致且隔离的运行环境。我们推荐使用 `docker-compose` 来一键启动整个博客服务栈。
## 1. 环境准备
在开始之前,请确保您的系统中已经安装了以下软件:
- [Docker Engine](https://docs.docker.com/engine/install/)
- [Docker Compose](https://docs.docker.com/compose/install/) (对于 Docker Desktop 用户,它已内置)
## 2. 推荐方式:使用 `docker-compose` (一键部署)
这是最简单、最推荐的部署方式。它会自动为您创建并管理 Django 应用、MySQL 数据库,以及可选的 Elasticsearch 服务。
### 步骤 1: 启动基础服务
在项目根目录下,执行以下命令:
```bash
# 构建并以后台模式启动容器 (包含 Django 应用和 MySQL)
docker-compose up -d --build
```
`docker-compose` 会读取 `docker-compose.yml` 文件,自动拉取所需镜像、构建项目镜像,并启动所有服务。
- **访问您的博客**: 服务启动后,在浏览器中访问 `http://127.0.0.1` 即可看到博客首页。
- **数据持久化**: MySQL 的数据文件将存储在项目根目录下的 `data/mysql` 文件夹中,确保数据在容器重启后不丢失。
### 步骤 2: (可选) 启用 Elasticsearch 全文搜索
如果您希望使用 Elasticsearch 提供更强大的全文搜索功能,可以额外加载 `docker-compose.es.yml` 配置文件:
```bash
# 构建并以后台模式启动所有服务 (Django, MySQL, Elasticsearch)
docker-compose -f docker-compose.yml -f deploy/docker-compose/docker-compose.es.yml up -d --build
```
- **数据持久化**: Elasticsearch 的数据将存储在 `data/elasticsearch` 文件夹中。
### 步骤 3: 首次运行的初始化操作
当容器首次启动后,您需要进入容器来执行一些初始化命令。
```bash
# 进入 djangoblog 应用容器
docker-compose exec web bash
# 在容器内执行以下命令:
# 创建超级管理员账户 (请按照提示设置用户名、邮箱和密码)
python manage.py createsuperuser
# (可选) 创建一些测试数据
python manage.py create_testdata
# (可选,如果启用了 ES) 创建索引
python manage.py rebuild_index
# 退出容器
exit
```
## 3. 备选方式:使用独立的 Docker 镜像
如果您已经拥有一个正在运行的外部 MySQL 数据库,您也可以只运行 DjangoBlog 的应用镜像。
```bash
# 从 Docker Hub 拉取最新镜像
docker pull liangliangyy/djangoblog:latest
# 运行容器,并链接到您的外部数据库
docker run -d \
-p 8000:8000 \
-e DJANGO_SECRET_KEY='your-strong-secret-key' \
-e DJANGO_MYSQL_HOST='your-mysql-host' \
-e DJANGO_MYSQL_USER='your-mysql-user' \
-e DJANGO_MYSQL_PASSWORD='your-mysql-password' \
-e DJANGO_MYSQL_DATABASE='djangoblog' \
--name djangoblog \
liangliangyy/djangoblog:latest
```
- **访问您的博客**: 启动完成后,访问 `http://127.0.0.1:8000`。
- **创建管理员**: `docker exec -it djangoblog python manage.py createsuperuser`
## 4. 配置说明 (环境变量)
本项目的大部分配置都通过环境变量来管理。您可以在 `docker-compose.yml` 文件中修改它们,或者在使用 `docker run` 命令时通过 `-e` 参数传入。
| 环境变量名称 | 默认值/示例 | 备注 |
|-------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------|
| `DJANGO_SECRET_KEY` | `your-strong-secret-key` | **请务必修改为一个随机且复杂的字符串!** |
| `DJANGO_DEBUG` | `False` | 是否开启 Django 的调试模式 |
| `DJANGO_MYSQL_HOST` | `mysql` | 数据库主机名 |
| `DJANGO_MYSQL_PORT` | `3306` | 数据库端口 |
| `DJANGO_MYSQL_DATABASE` | `djangoblog` | 数据库名称 |
| `DJANGO_MYSQL_USER` | `root` | 数据库用户名 |
| `DJANGO_MYSQL_PASSWORD` | `djangoblog_123` | 数据库密码 |
| `DJANGO_REDIS_URL` | `redis:6379/0` | Redis 连接地址 (用于缓存) |
| `DJANGO_ELASTICSEARCH_HOST` | `elasticsearch:9200` | Elasticsearch 主机地址 |
| `DJANGO_EMAIL_HOST` | `smtp.example.org` | 邮件服务器地址 |
| `DJANGO_EMAIL_PORT` | `465` | 邮件服务器端口 |
| `DJANGO_EMAIL_USER` | `user@example.org` | 邮件账户 |
| `DJANGO_EMAIL_PASSWORD` | `your-email-password` | 邮件密码 |
| `DJANGO_EMAIL_USE_SSL` | `True` | 是否使用 SSL |
| `DJANGO_EMAIL_USE_TLS` | `False` | 是否使用 TLS |
| `DJANGO_ADMIN_EMAIL` | `admin@example.org` | 接收异常报告的管理员邮箱 |
| `DJANGO_BAIDU_NOTIFY_URL` | `http://data.zz.baidu.com/...` | [百度站长平台](https://ziyuan.baidu.com/linksubmit/index) 的推送接口 |
---
部署完成后,请务必检查并根据您的实际需求调整这些环境变量,特别是 `DJANGO_SECRET_KEY` 和数据库、邮件相关的配置。
================================================
FILE: docs/es.md
================================================
# 集成Elasticsearch
如果你已经有了`Elasticsearch`环境,那么可以将搜索从`Whoosh`换成`Elasticsearch`,集成方式也很简单,
首先需要注意如下几点:
1. 你的`Elasticsearch`支持`ik`中文分词
2. 你的`Elasticsearch`版本>=7.3.0
接下来在`settings.py`做如下改动即可:
- 增加es链接,如下所示:
```python
ELASTICSEARCH_DSL = {
'default': {
'hosts': '127.0.0.1:9200'
},
}
```
- 修改`HAYSTACK`配置:
```python
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
},
}
```
然后终端执行:
```shell script
./manage.py build_index
```
这将会在你的es中创建两个索引,分别是`blog`和`performance`,其中`blog`索引就是搜索所使用的,而`performance`会记录每个请求的响应时间,以供将来优化使用。
================================================
FILE: docs/k8s-en.md
================================================
# Deploying DjangoBlog with Kubernetes
This document guides you through deploying the DjangoBlog application on a Kubernetes (K8s) cluster. We provide a complete set of `.yaml` configuration files in the `deploy/k8s` directory to deploy a full service stack, including the DjangoBlog application, Nginx, MySQL, Redis, and Elasticsearch.
## Architecture Overview
This deployment utilizes a microservices-based, cloud-native architecture:
- **Core Components**: Each core service (DjangoBlog, Nginx, MySQL, Redis, Elasticsearch) runs as a separate `Deployment`.
- **Configuration Management**: Nginx configurations and Django application environment variables are managed via `ConfigMap`. **Note: For sensitive information like passwords, using `Secret` is highly recommended.**
- **Service Discovery**: All services are exposed internally within the cluster as `ClusterIP` type `Service`, enabling communication via service names.
- **External Access**: An `Ingress` resource is used to route external HTTP traffic to the Nginx service, which acts as the single entry point for the entire blog application.
- **Data Persistence**: A `local-storage` solution based on node-local paths is used. This requires you to manually create storage directories on a specific K8s node and statically bind them using `PersistentVolume` (PV) and `PersistentVolumeClaim` (PVC).
## 1. Prerequisites
Before you begin, please ensure you have the following:
- A running Kubernetes cluster.
- The `kubectl` command-line tool configured to connect to your cluster.
- An [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/deploy/) installed and configured in your cluster.
- Filesystem access to one of the nodes in your cluster (defaulted to `master` in the configs) to create local storage directories.
## 2. Deployment Steps
### Step 1: Create a Namespace
We recommend deploying all DjangoBlog-related resources in a dedicated namespace for better management.
```bash
# Create a namespace named 'djangoblog'
kubectl create namespace djangoblog
```
### Step 2: Configure Persistent Storage
This setup uses Local Persistent Volumes. You need to create the data storage directories on a node within your cluster (the default is the `master` node in `pv.yaml`).
```bash
# Log in to your master node
ssh user@master-node
# Create the required storage directories
sudo mkdir -p /mnt/local-storage-db
sudo mkdir -p /mnt/local-storage-djangoblog
sudo mkdir -p /mnt/resource/
sudo mkdir -p /mnt/local-storage-elasticsearch
# Log out from the node
exit
```
**Note**: If you wish to store data on a different node or use different paths, you must modify the `nodeAffinity` and `local.path` settings in the `deploy/k8s/pv.yaml` file.
After creating the directories, apply the storage-related configurations:
```bash
# Apply the StorageClass
kubectl apply -f deploy/k8s/storageclass.yaml
# Apply the PersistentVolumes (PVs)
kubectl apply -f deploy/k8s/pv.yaml
# Apply the PersistentVolumeClaims (PVCs)
kubectl apply -f deploy/k8s/pvc.yaml
```
### Step 3: Configure the Application
Before deploying the application, you need to edit the `deploy/k8s/configmap.yaml` file to modify sensitive information and custom settings.
**It is strongly recommended to change the following fields:**
- `DJANGO_SECRET_KEY`: Change to a random, complex string.
- `DJANGO_MYSQL_PASSWORD` and `MYSQL_ROOT_PASSWORD`: Change to your own secure database password.
```bash
# Edit the ConfigMap file
vim deploy/k8s/configmap.yaml
# Apply the configuration
kubectl apply -f deploy/k8s/configmap.yaml
```
### Step 4: Deploy the Application Stack
Now, we can deploy all the core services.
```bash
# Deploy the Deployments (DjangoBlog, MySQL, Redis, Nginx, ES)
kubectl apply -f deploy/k8s/deployment.yaml
# Deploy the Services (to create internal endpoints for the Deployments)
kubectl apply -f deploy/k8s/service.yaml
```
The deployment may take some time. You can run the following command to check if all Pods are running successfully (STATUS should be `Running`):
```bash
kubectl get pods -n djangoblog -w
```
### Step 5: Expose the Application Externally
Finally, expose the Nginx service to external traffic by applying the `Ingress` rule.
```bash
# Apply the Ingress rule
kubectl apply -f deploy/k8s/gateway.yaml
```
Once deployed, you can access your blog via the external IP address of your Ingress Controller. Use the following command to find the address:
```bash
kubectl get ingress -n djangoblog
```
### Step 6: First-Time Initialization
Similar to the Docker deployment, you need to get a shell into the DjangoBlog application Pod to perform database initialization and create a superuser on the first run.
```bash
# First, get the name of a djangoblog pod
kubectl get pods -n djangoblog | grep djangoblog
# Exec into one of the Pods (replace [pod-name] with the name from the previous step)
kubectl exec -it [pod-name] -n djangoblog -- bash
# Inside the Pod, run the following commands:
# Create a superuser account (follow the prompts)
python manage.py createsuperuser
# (Optional) Create some test data
python manage.py create_testdata
# (Optional, if ES is enabled) Create the search index
python manage.py rebuild_index
# Exit the Pod
exit
```
Congratulations! You have successfully deployed DjangoBlog on your Kubernetes cluster.
================================================
FILE: docs/k8s.md
================================================
# 使用 Kubernetes 部署 DjangoBlog
本文档将指导您如何在 Kubernetes (K8s) 集群上部署 DjangoBlog 应用。我们提供了一套完整的 `.yaml` 配置文件,位于 `deploy/k8s` 目录下,用于部署一个包含 DjangoBlog 应用、Nginx、MySQL、Redis 和 Elasticsearch 的完整服务栈。
## 架构概览
本次部署采用的是微服务化的云原生架构:
- **核心组件**: 每个核心服务 (DjangoBlog, Nginx, MySQL, Redis, Elasticsearch) 都将作为独立的 `Deployment` 运行。
- **配置管理**: Nginx 的配置文件和 Django 应用的环境变量通过 `ConfigMap` 进行管理。**注意:敏感信息(如密码)建议使用 `Secret` 进行管理。**
- **服务发现**: 所有服务都通过 `ClusterIP` 类型的 `Service` 在集群内部暴露,并通过服务名相互通信。
- **外部访问**: 使用 `Ingress` 资源将外部的 HTTP 流量路由到 Nginx 服务,作为整个博客应用的统一入口。
- **数据持久化**: 采用基于节点本地路径的 `local-storage` 方案。这需要您在指定的 K8s 节点上手动创建存储目录,并通过 `PersistentVolume` (PV) 和 `PersistentVolumeClaim` (PVC) 进行静态绑定。
## 1. 环境准备
在开始之前,请确保您已具备以下环境:
- 一个正在运行的 Kubernetes 集群。
- `kubectl` 命令行工具已配置并能够连接到您的集群。
- 集群中已安装并配置好 [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/deploy/)。
- 对集群中的一个节点(默认为 `master`)拥有文件系统访问权限,用于创建本地存储目录。
## 2. 部署步骤
### 步骤 1: 创建命名空间
我们建议将 DjangoBlog 相关的所有资源都部署在一个独立的命名空间中,便于管理。
```bash
# 创建一个名为 djangoblog 的命名空间
kubectl create namespace djangoblog
```
### 步骤 2: 配置持久化存储
此方案使用本地持久卷 (Local Persistent Volume)。您需要在集群的一个节点上(在 `pv.yaml` 文件中默认为 `master` 节点)创建用于数据存储的目录。
```bash
# 登录到您的 master 节点
ssh user@master-node
# 创建所需的存储目录
sudo mkdir -p /mnt/local-storage-db
sudo mkdir -p /mnt/local-storage-djangoblog
sudo mkdir -p /mnt/resource/
sudo mkdir -p /mnt/local-storage-elasticsearch
# 退出节点
exit
```
**注意**: 如果您希望将数据存储在其他节点或使用不同的路径,请务必修改 `deploy/k8s/pv.yaml` 文件中 `nodeAffinity` 和 `local.path` 的配置。
创建目录后,应用存储相关的配置文件:
```bash
# 应用 StorageClass
kubectl apply -f deploy/k8s/storageclass.yaml
# 应用 PersistentVolume (PV)
kubectl apply -f deploy/k8s/pv.yaml
# 应用 PersistentVolumeClaim (PVC)
kubectl apply -f deploy/k8s/pvc.yaml
```
### 步骤 3: 配置应用
在部署应用之前,您需要编辑 `deploy/k8s/configmap.yaml` 文件,修改其中的敏感信息和个性化配置。
**强烈建议修改以下字段:**
- `DJANGO_SECRET_KEY`: 修改为一个随机且复杂的字符串。
- `DJANGO_MYSQL_PASSWORD` 和 `MYSQL_ROOT_PASSWORD`: 修改为您自己的数据库密码。
```bash
# 编辑 ConfigMap 文件
vim deploy/k8s/configmap.yaml
# 应用配置
kubectl apply -f deploy/k8s/configmap.yaml
```
### 步骤 4: 部署应用服务栈
现在,我们可以部署所有的核心服务了。
```bash
# 部署 Deployments (DjangoBlog, MySQL, Redis, Nginx, ES)
kubectl apply -f deploy/k8s/deployment.yaml
# 部署 Services (为 Deployments 创建内部访问端点)
kubectl apply -f deploy/k8s/service.yaml
```
部署需要一些时间,您可以运行以下命令检查所有 Pod 是否都已成功运行 (STATUS 为 `Running`):
```bash
kubectl get pods -n djangoblog -w
```
### 步骤 5: 暴露应用到外部
最后,通过应用 `Ingress` 规则来将外部流量引导至我们的 Nginx 服务。
```bash
# 应用 Ingress 规则
kubectl apply -f deploy/k8s/gateway.yaml
```
部署完成后,您可以通过 Ingress Controller 的外部 IP 地址来访问您的博客。执行以下命令获取地址:
```bash
kubectl get ingress -n djangoblog
```
### 步骤 6: 首次运行的初始化操作
与 Docker 部署类似,首次运行时,您需要进入 DjangoBlog 应用的 Pod 来执行数据库初始化和创建管理员账户。
```bash
# 首先,获取 djangoblog pod 的名称
kubectl get pods -n djangoblog | grep djangoblog
# 进入其中一个 Pod (将 [pod-name] 替换为上一步获取到的名称)
kubectl exec -it [pod-name] -n djangoblog -- bash
# 在 Pod 内部执行以下命令:
# 创建超级管理员账户 (请按照提示操作)
python manage.py createsuperuser
# (可选) 创建测试数据
python manage.py create_testdata
# (可选,如果启用了 ES) 创建索引
python manage.py rebuild_index
# 退出 Pod
exit
```
至此,您已成功在 Kubernetes 集群上完成了 DjangoBlog 的部署!
================================================
FILE: docs/search-engine-config.md
================================================
# 搜索引擎配置说明
## 概述
DjangoBlog 支持两种搜索引擎:
- **Whoosh** - 纯 Python 实现,开箱即用(默认)
- **Elasticsearch** - 高性能分布式搜索引擎(推荐生产环境)
配置优先级:**环境变量 > 手动配置 > 默认 Whoosh**
## 快速开始
### 使用 Whoosh(默认)
无需配置,直接使用:
```bash
python manage.py rebuild_index
python manage.py runserver
```
索引存储在 `whoosh_index/` 目录。
### 使用 Elasticsearch(开发环境)
1. **启动 ES 服务:**
```bash
# Docker 方式
docker run -d -p 9200:9200 -e "discovery.type=single-node" elasticsearch:8.6.1
# 或直接运行 ES
./bin/elasticsearch
```
2. **编辑 `djangoblog/settings.py`:**
取消以下配置的注释:
```python
ELASTICSEARCH_DSL = {
'default': {
# hosts 必须包含 http:// 或 https://(如果忘记会自动添加http://)
'hosts': 'http://127.0.0.1:9200',
'verify_certs': False,
# 如果启用了安全特性,添加认证信息
# 'username': 'elastic',
# 'password': 'your_password',
},
}
```
**提示**:`hosts` 参数支持以下格式:
- 单个主机:`'http://127.0.0.1:9200'` 或 `'127.0.0.1:9200'`(自动添加http://)
- 多个主机:`['http://es1:9200', 'http://es2:9200']`(ES集群)
3. **重建索引:**
```bash
python manage.py rebuild_index
```
### 使用 Elasticsearch(生产环境)
通过环境变量配置,无需修改代码:
```bash
# 基本配置
export DJANGO_ELASTICSEARCH_HOST=https://es.example.com:9200
export ELASTICSEARCH_VERIFY_CERTS=True
# 用户名密码认证
export ELASTICSEARCH_USERNAME=elastic
export ELASTICSEARCH_PASSWORD=your_password
# 启动应用
python manage.py runserver
```
## 配置详解
### 开发环境手动配置
编辑 `djangoblog/settings.py`,在搜索引擎配置部分添加:
```python
ELASTICSEARCH_DSL = {
'default': {
'hosts': 'http://127.0.0.1:9200',
'verify_certs': False,
# === 选择一种认证方式 ===
# 方式1: 无认证(开发环境)
# 不需要额外配置
# 方式2: 用户名密码认证
'username': 'elastic',
'password': 'changeme',
# 方式3: API Key 认证
# 'api_key': 'your_api_key',
# 方式4: 证书认证
# 'ca_certs': '/path/to/ca.crt',
# 'client_cert': '/path/to/client.crt',
# 'client_key': '/path/to/client.key',
},
}
```
### 生产环境环境变量
| 环境变量 | 说明 | 必需 | 示例 |
|---------|------|------|------|
| `DJANGO_ELASTICSEARCH_HOST` | ES 主机地址 | ✅ | `https://es.example.com:9200` |
| `ELASTICSEARCH_VERIFY_CERTS` | 验证 SSL 证书 | ❌ | `True` / `False` |
| `ELASTICSEARCH_USERNAME` | 用户名 | ❌ | `elastic` |
| `ELASTICSEARCH_PASSWORD` | 密码 | ❌ | `your_password` |
| `ELASTICSEARCH_API_KEY` | API Key | ❌ | `your_api_key` |
| `ELASTICSEARCH_CA_CERTS` | CA 证书路径 | ❌ | `/etc/ssl/certs/ca.crt` |
| `ELASTICSEARCH_CLIENT_CERT` | 客户端证书 | ❌ | `/etc/ssl/certs/client.crt` |
| `ELASTICSEARCH_CLIENT_KEY` | 客户端私钥 | ❌ | `/etc/ssl/private/client.key` |
### Docker Compose 示例
```yaml
version: '3.8'
services:
web:
build: .
environment:
- DJANGO_ELASTICSEARCH_HOST=http://elasticsearch:9200
- ELASTICSEARCH_USERNAME=elastic
- ELASTICSEARCH_PASSWORD=${ES_PASSWORD}
depends_on:
- elasticsearch
elasticsearch:
image: elasticsearch:8.6.1
environment:
- discovery.type=single-node
- xpack.security.enabled=true
- ELASTIC_PASSWORD=${ES_PASSWORD}
ports:
- "9200:9200"
```
### Kubernetes 示例
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: djangoblog-es-config
data:
DJANGO_ELASTICSEARCH_HOST: "http://elasticsearch-service:9200"
ELASTICSEARCH_VERIFY_CERTS: "false"
---
apiVersion: v1
kind: Secret
metadata:
name: djangoblog-es-secret
type: Opaque
stringData:
ELASTICSEARCH_USERNAME: elastic
ELASTICSEARCH_PASSWORD: your_password_here
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: djangoblog
spec:
template:
spec:
containers:
- name: web
envFrom:
- configMapRef:
name: djangoblog-es-config
- secretRef:
name: djangoblog-es-secret
```
## 切换搜索引擎
### 从 Whoosh 切换到 Elasticsearch
1. 配置 ES(见上文)
2. 重建索引:
```bash
python manage.py rebuild_index
```
### 从 Elasticsearch 切换回 Whoosh
1. 注释掉 `ELASTICSEARCH_DSL` 配置
2. 或删除 `DJANGO_ELASTICSEARCH_HOST` 环境变量
3. 重建索引:
```bash
python manage.py rebuild_index
```
## 维护命令
```bash
# 重建索引(清空后重建)
python manage.py rebuild_index
# 更新索引(增量更新)
python manage.py update_index
# 清空索引
python manage.py clear_index
# 仅针对 ES:手动构建索引
python manage.py build_index
```
## 性能对比
| 特性 | Whoosh | Elasticsearch |
|------|--------|--------------|
| 安装难度 | ⭐ 简单 | ⭐⭐⭐ 复杂 |
| 性能 | 中等(适合小型博客) | 极高(适合大规模) |
| 中文分词 | ✅ jieba | ✅ ik_analyzer |
| 分布式 | ❌ | ✅ |
| 实时性 | 一般 | 近实时 |
| 资源占用 | 低 | 较高 |
## 故障排查
### 问题:索引不更新
**解决:**
```bash
# 检查 Haystack 信号处理器
python manage.py shell
>>> from django.conf import settings
>>> print(settings.HAYSTACK_SIGNAL_PROCESSOR)
# 应该输出:haystack.signals.RealtimeSignalProcessor
```
### 问题:搜索无结果
**解决:**
```bash
# 重建索引
python manage.py rebuild_index --noinput
# 检查索引文档数量
python manage.py shell
>>> from haystack.query import SearchQuerySet
>>> print(SearchQuerySet().count())
```
### 问题:ES 连接失败
**解决:**
1. 确认 ES 正在运行:`curl http://localhost:9200`
2. 检查防火墙设置
3. 验证认证信息是否正确
## 更多信息
- [Elasticsearch 配置详解](./elasticsearch-config.md)
- [Haystack 官方文档](https://django-haystack.readthedocs.io/)
- [Whoosh 文档](https://whoosh.readthedocs.io/)
================================================
FILE: frontend/.gitignore
================================================
node_modules/
dist/
.DS_Store
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.vite/
================================================
FILE: frontend/package.json
================================================
{
"name": "djangoblog-frontend",
"version": "1.0.0",
"description": "Modern frontend for DjangoBlog",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"watch": "vite build --watch",
"preview": "vite preview"
},
"dependencies": {
"@alpinejs/collapse": "^3.15.8",
"@alpinejs/focus": "^3.15.8",
"@alpinejs/intersect": "^3.15.8",
"alpinejs": "^3.15.8"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.27",
"cssnano": "^7.1.3",
"cssnano-preset-advanced": "^7.0.11",
"htmx.org": "^2.0.8",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.1",
"terser": "^5.46.0",
"vite": "^5.1.0"
},
"overrides": {
"esbuild": ">=0.25.0"
}
}
================================================
FILE: frontend/postcss.config.js
================================================
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
// 生产环境 CSS 压缩
...(process.env.NODE_ENV === 'production' ? {
cssnano: {
preset: ['advanced', {
// 最激进的优化
discardComments: { removeAll: true },
// 规范化显示值
normalizeDisplayValues: true,
// 规范化位置
normalizePositions: true,
// 规范化重复样式
normalizeRepeatStyle: true,
// 规范化字符串
normalizeString: true,
// 规范化时间
normalizeTiming: true,
// 规范化 Unicode
normalizeUnicode: true,
// 规范化 URL
normalizeUrl: true,
// 规范化空白
normalizeWhitespace: true,
// 合并长手属性
mergeLonghand: true,
// 合并规则
mergeRules: true,
// 最小化选择器
minifySelectors: true,
// 最小化字体值
minifyFontValues: true,
// 最小化渐变
minifyGradients: true,
// 最小化参数
minifyParams: true,
// 转换颜色为最短形式
colormin: true,
// 转换字体粗细
convertValues: true,
// 丢弃重复项
discardDuplicates: true,
// 丢弃空规则
discardEmpty: true,
// 丢弃覆盖的声明
discardOverridden: true,
// 丢弃未使用的规则
discardUnused: true,
// 合并媒体查询
mergeMedia: true,
// 减少初始值
reduceInitial: true,
// 减少变换
reduceTransforms: true,
// SVG 优化
svgo: {
encode: true,
plugins: [
{ removeViewBox: false },
{ cleanupIDs: true }
]
},
// Z-index 优化 - 启用但排除重要的z-index值
zindex: {
// 排除需要保持原值的z-index(fixed元素等)
exclude: [9999]
},
// 排序属性
cssDeclarationSorter: { order: 'smacss' }
}]
}
} : {})
},
};
================================================
FILE: frontend/src/components/backToTop.js
================================================
/**
* 回到顶部组件
* 替代原有的jQuery实现
*/
export default () => ({
// ==================== 状态 ====================
isVisible: false,
isAnimating: false,
// ==================== 初始化 ====================
init() {
// 初始检查滚动位置
this.checkScroll();
// 监听滚动事件(使用防抖)
this.handleScroll = this.debounce(this.checkScroll.bind(this), 100);
window.addEventListener('scroll', this.handleScroll);
console.log('🚀 Back to Top Initialized');
},
// ==================== 销毁 ====================
destroy() {
window.removeEventListener('scroll', this.handleScroll);
},
// ==================== 检查滚动位置 ====================
checkScroll() {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
this.isVisible = scrollTop > 200;
},
// ==================== 滚动到顶部 ====================
scrollToTop() {
if (this.isAnimating) return;
this.isAnimating = true;
// 使用现代API平滑滚动
window.scrollTo({
top: 0,
behavior: 'smooth'
});
// 添加火箭动画效果
const rocket = this.$el;
rocket.classList.add('move');
setTimeout(() => {
rocket.classList.remove('move');
this.isAnimating = false;
}, 800);
console.log('🚀 Scrolling to top');
},
// ==================== 工具函数 ====================
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
});
================================================
FILE: frontend/src/components/commentSystem.js
================================================
/**
* 评论系统组件
* 使用Alpine.js重构,替代原有的jQuery实现
*/
export default () => ({
// ==================== 状态管理 ====================
comments: [],
replyingTo: null,
replyContent: '',
isLoading: false,
error: null,
articleId: null,
// ==================== 初始化 ====================
init() {
// 从DOM中获取文章ID
this.articleId = this.$el.dataset.articleId;
if (this.articleId) {
this.loadComments();
}
console.log('💬 Comment System Initialized');
},
// ==================== 加载评论 ====================
async loadComments() {
this.isLoading = true;
this.error = null;
try {
// 如果需要通过API加载,取消注释以下代码
// const response = await fetch(`/api/comments/?article_id=${this.articleId}`);
// if (!response.ok) throw new Error('Failed to load comments');
// this.comments = await response.json();
// 目前评论由Django模板渲染,这里只是占位
console.log('📝 Comments loaded from Django template');
} catch (err) {
this.error = err.message;
console.error('Error loading comments:', err);
} finally {
this.isLoading = false;
}
},
// ==================== 回复评论 ====================
startReply(commentId) {
this.replyingTo = commentId;
this.replyContent = '';
// 等待DOM更新后聚焦到textarea
this.$nextTick(() => {
const textarea = document.querySelector(`#reply-textarea-${commentId}`);
if (textarea) {
textarea.focus();
}
});
console.log('💬 Replying to comment:', commentId);
},
cancelReply() {
this.replyingTo = null;
this.replyContent = '';
console.log('❌ Reply cancelled');
},
// ==================== 提交回复 ====================
async submitReply(commentId) {
if (!this.replyContent.trim()) {
alert('回复内容不能为空');
return;
}
// 使用HTMX提交表单,不会导致整页刷新
const form = document.getElementById('commentform');
if (!form) {
console.error('❌ Comment form not found');
alert('评论表单未找到,请刷新页面重试');
return;
}
// 设置父评论ID
const parentField = document.getElementById('id_parent_comment_id');
if (parentField) {
parentField.value = commentId;
}
// 设置评论内容
const bodyField = document.querySelector('[name="body"]');
if (bodyField) {
bodyField.value = this.replyContent;
}
// 触发HTMX提交(表单上已有hx-post属性)
console.log('💬 Submitting reply via HTMX...');
window.htmx.trigger(form, 'submit');
},
// ==================== 发布新评论 ====================
async submitComment() {
if (!this.replyContent.trim()) {
alert('评论内容不能为空');
return;
}
this.isLoading = true;
this.error = null;
try {
const csrfToken = this.getCsrfToken();
const response = await fetch('/api/comments/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
article_id: this.articleId,
content: this.replyContent,
}),
});
if (!response.ok) {
throw new Error('提交失败');
}
const data = await response.json();
console.log('✅ Comment submitted:', data);
// 重新加载评论列表
await this.loadComments();
// 清空表单
this.replyContent = '';
// 提示成功
this.showNotification('评论成功!');
} catch (err) {
this.error = err.message;
console.error('Error submitting comment:', err);
alert('提交失败:' + err.message);
} finally {
this.isLoading = false;
}
},
// ==================== 工具函数 ====================
getCsrfToken() {
// 从cookie中获取CSRF token
const name = 'csrftoken';
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
},
showNotification(message) {
// 简单的通知实现,可以后续优化
const notification = document.createElement('div');
notification.className = 'fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 animate-fade-in';
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('opacity-0', 'transition-opacity', 'duration-300');
setTimeout(() => notification.remove(), 300);
}, 3000);
},
// ==================== 判断方法 ====================
isReplying(commentId) {
return this.replyingTo === commentId;
},
canReply() {
return !this.isLoading;
},
});
================================================
FILE: frontend/src/components/imageLightbox.js
================================================
/**
* 图片灯箱组件
* 点击文章内容中的图片可以查看大图
*/
export default function imageLightbox() {
return {
showLightbox: false,
currentImage: '',
currentAlt: '',
init() {
// 为所有文章内容中的图片添加点击事件
this.$nextTick(() => {
const images = document.querySelectorAll('.entry-content img');
images.forEach(img => {
// 排除badge图片和小图片(不需要查看大图)
const isBadge = img.src.includes('badge.svg') ||
img.src.includes('shields.io') ||
img.src.includes('/badge/') ||
img.alt.toLowerCase().includes('badge');
// 排除小于200px的图片
const isSmallImage = img.naturalWidth < 200 || img.naturalHeight < 200;
if (!isBadge && !isSmallImage) {
img.addEventListener('click', (e) => {
e.preventDefault();
this.openLightbox(img.src, img.alt || '');
});
}
});
});
},
openLightbox(src, alt) {
this.currentImage = src;
this.currentAlt = alt;
this.showLightbox = true;
document.body.style.overflow = 'hidden';
},
closeLightbox() {
this.showLightbox = false;
document.body.style.overflow = '';
},
handleKeydown(e) {
if (e.key === 'Escape') {
this.closeLightbox();
}
}
};
}
================================================
FILE: frontend/src/components/navigation.js
================================================
/**
* 导航栏组件
* 处理移动端菜单、搜索等交互
*/
export default () => ({
// ==================== 状态 ====================
menuOpen: false,
windowWidth: window.innerWidth,
isSearchOpen: false,
searchQuery: '',
// ==================== 初始化 ====================
init() {
console.log('🧭 Navigation Initialized');
// 监听窗口大小变化
window.addEventListener('resize', () => {
this.windowWidth = window.innerWidth;
if (window.innerWidth >= 768 && this.menuOpen) {
this.menuOpen = false;
document.body.style.overflow = '';
}
});
// 监听HTMX导航事件,自动关闭移动端菜单
document.body.addEventListener('htmx:beforeRequest', (event) => {
// 如果是导航链接触发的请求,并且在移动端模式,则关闭菜单
if (this.windowWidth < 768 && this.menuOpen) {
console.log('🔗 HTMX navigation detected, closing mobile menu');
this.closeMobileMenu();
}
});
},
// ==================== 移动端菜单 ====================
toggleMenu() {
this.menuOpen = !this.menuOpen;
// 移动端防止背景滚动
if (this.windowWidth < 768) {
if (this.menuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
}
console.log('📱 Mobile menu:', this.menuOpen ? 'opened' : 'closed');
},
closeMobileMenu() {
this.menuOpen = false;
document.body.style.overflow = '';
},
// ==================== 搜索功能 ====================
toggleSearch() {
this.isSearchOpen = !this.isSearchOpen;
if (this.isSearchOpen) {
// 聚焦到搜索框
this.$nextTick(() => {
this.$refs.searchInput?.focus();
});
}
console.log('🔍 Search:', this.isSearchOpen ? 'opened' : 'closed');
},
submitSearch() {
if (this.searchQuery.trim()) {
window.location.href = `/search/?q=${encodeURIComponent(this.searchQuery)}`;
}
},
// ==================== 主题切换(与dark_mode插件配合) ====================
toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
console.log('🌓 Theme switched to:', newTheme);
},
});
================================================
FILE: frontend/src/components/reactionPicker.js
================================================
/**
* Emoji Reaction Picker 组件
* 为评论添加 GitHub 风格的 emoji 反应功能
*/
export default (commentId) => {
return {
// ==================== 状态管理 ====================
reactions: {},
showPicker: false,
isLoading: false,
// ==================== 初始化 ====================
init() {
// 优先从 data 属性读取初始数据(SSR)
this.loadFromDataAttribute();
},
// ==================== 从 data 属性加载(SSR 数据)====================
loadFromDataAttribute() {
try {
const dataAttr = this.$el.dataset.reactions;
if (dataAttr) {
this.reactions = JSON.parse(dataAttr);
} else {
// 如果没有 SSR 数据,降级到 API 加载
this.loadFromAPI();
}
} catch (error) {
console.error('Error parsing reactions from data attribute:', error);
// 解析失败,降级到 API 加载
this.loadFromAPI();
}
},
// ==================== 从 API 加载(降级方案)====================
async loadFromAPI() {
try {
this.isLoading = true;
const response = await fetch(`/comment/${commentId}/react`);
if (!response.ok) {
throw new Error('Failed to load reactions');
}
const data = await response.json();
if (data.success) {
this.reactions = data.reactions || {};
}
} catch (error) {
console.error('Error loading reactions from API:', error);
this.reactions = {};
} finally {
this.isLoading = false;
}
},
// ==================== 格式化用户列表 ====================
/**
* 格式化用户列表文本,用于 tooltip 显示
* @param {Array} users - 用户名数组
* @param {number} totalCount - 总点赞数
* @returns {string} 格式化后的文本
*/
formatUsersText(users, totalCount) {
if (!users || users.length === 0) {
return '暂无';
}
if (users.length === totalCount) {
// 显示所有用户
return users.join(', ');
} else {
// 显示前几个用户,并标注还有多少人
const displayUsers = users.slice(0, 5).join(', ');
const remaining = totalCount - users.length;
if (remaining > 0) {
return `${displayUsers} 和其他 ${remaining} 人`;
}
return displayUsers;
}
},
// ==================== 检查登录状态 ====================
/**
* 检查用户是否已登录
* @returns {boolean}
*/
isAuthenticated() {
return document.body.dataset.authenticated === 'true';
},
// ==================== 显示登录提示 ====================
showLoginPrompt() {
const loginUrl = `/login/?next=${encodeURIComponent(window.location.pathname)}`;
// 创建美观的提示框
const modal = document.createElement('div');
modal.className = 'fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50 animate-fade-in';
modal.innerHTML = `
点赞功能需要登录后才能使用,是否前往登录页面?
取消
前往登录
`;
document.body.appendChild(modal);
// 绑定事件
const cancelBtn = modal.querySelector('#modal-cancel');
const confirmBtn = modal.querySelector('#modal-confirm');
cancelBtn.addEventListener('click', () => {
modal.classList.add('animate-fade-out');
setTimeout(() => modal.remove(), 200);
});
confirmBtn.addEventListener('click', () => {
window.location.href = loginUrl;
});
// 点击背景关闭
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.add('animate-fade-out');
setTimeout(() => modal.remove(), 200);
}
});
},
// ==================== 切换 Reaction ====================
/**
* 切换 reaction(添加或删除)
* @param {string} emoji - emoji 字符
*/
async toggleReaction(emoji) {
// 检查登录状态
if (!this.isAuthenticated()) {
this.showLoginPrompt();
return;
}
try {
// 获取 CSRF token
const csrfToken = this.getCsrfToken();
if (!csrfToken) {
console.error('CSRF token not found');
this.showNotification('无法获取安全令牌,请刷新页面重试', 'error');
return;
}
// 发送请求
const formData = new FormData();
formData.append('reaction_type', emoji);
formData.append('csrfmiddlewaretoken', csrfToken);
const response = await fetch(`/comment/${commentId}/react`, {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': csrfToken
}
});
if (!response.ok) {
// 处理 401 未授权错误
if (response.status === 401) {
this.showNotification('登录已过期,请重新登录', 'error');
setTimeout(() => {
window.location.href = `/login/?next=${encodeURIComponent(window.location.pathname)}`;
}, 1500);
return;
}
throw new Error('Failed to toggle reaction');
}
const data = await response.json();
if (data.success) {
// 更新本地 reactions 数据
this.reactions = data.reactions;
this.showPicker = false;
} else {
throw new Error(data.error || '操作失败');
}
} catch (error) {
console.error('Error toggling reaction:', error);
this.showNotification('操作失败,请重试', 'error');
}
},
// ==================== 显示通知 ====================
/**
* 显示美观的通知消息
* @param {string} message - 消息内容
* @param {string} type - 消息类型:success, error, info
*/
showNotification(message, type = 'info') {
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
info: 'bg-blue-500'
};
const icons = {
success: ' ',
error: ' ',
info: ' '
};
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 ${colors[type]} text-white px-6 py-4 rounded-lg shadow-lg z-50 flex items-center gap-3 animate-slide-in-right max-w-md`;
notification.innerHTML = `
${icons[type]}
${message}
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('animate-fade-out');
setTimeout(() => notification.remove(), 300);
}, 3000);
},
// ==================== 工具函数 ====================
/**
* 从 cookie 中获取 CSRF token
* @returns {string|null} CSRF token
*/
getCsrfToken() {
const name = 'csrftoken';
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
},
};
};
================================================
FILE: frontend/src/features/darkMode.js
================================================
/**
* Dark Mode 核心功能
* 实现主题切换、持久化存储和系统主题跟随
*/
const STORAGE_KEY = 'dark-mode-enabled';
const THEME_ATTR = 'data-theme';
const ENABLE_SYSTEM = true;
/**
* 获取首选主题
*/
function getPreferredTheme() {
// 1. 优先使用用户保存的偏好
const saved = localStorage.getItem(STORAGE_KEY);
if (saved !== null) {
return saved === 'dark' ? 'dark' : 'light';
}
// 2. 如果启用系统偏好跟随,检测系统设置
if (ENABLE_SYSTEM && window.matchMedia) {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
}
// 3. 默认主题
return 'light';
}
/**
* 应用主题
*/
function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute(THEME_ATTR, 'dark');
document.body.setAttribute(THEME_ATTR, 'dark');
} else {
document.documentElement.removeAttribute(THEME_ATTR);
document.body.removeAttribute(THEME_ATTR);
}
}
/**
* 获取当前主题
*/
function getCurrentTheme() {
return document.documentElement.getAttribute(THEME_ATTR) || 'light';
}
/**
* 设置主题
*/
function setTheme(theme) {
const validTheme = theme === 'dark' ? 'dark' : 'light';
// 应用主题
applyTheme(validTheme);
// 保存到localStorage
localStorage.setItem(STORAGE_KEY, validTheme);
// 触发自定义事件
const event = new CustomEvent('themeChanged', {
detail: { theme: validTheme }
});
document.dispatchEvent(event);
return validTheme;
}
/**
* 切换主题
*/
function toggleTheme() {
const current = getCurrentTheme();
const next = current === 'dark' ? 'light' : 'dark';
return setTheme(next);
}
/**
* 初始化(防闪烁)
* 必须在DOM渲染前执行
*/
function initTheme() {
const theme = getPreferredTheme();
applyTheme(theme);
}
/**
* 设置键盘快捷键
*/
function setupKeyboardShortcut() {
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'D') {
e.preventDefault();
toggleTheme();
}
});
}
/**
* 监听系统主题变化
*/
function setupSystemThemeListener() {
if (!ENABLE_SYSTEM || !window.matchMedia) return;
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
const listener = function(e) {
// 只有在用户未手动设置时才跟随系统
if (localStorage.getItem(STORAGE_KEY) === null) {
setTheme(e.matches ? 'dark' : 'light');
}
};
if (darkModeQuery.addEventListener) {
darkModeQuery.addEventListener('change', listener);
} else if (darkModeQuery.addListener) {
darkModeQuery.addListener(listener);
}
}
/**
* 初始化Dark Mode
*/
export function initDarkMode() {
// 设置全局API
window.DarkMode = {
getCurrentTheme,
setTheme,
toggle: toggleTheme
};
// 设置键盘快捷键
setupKeyboardShortcut();
// 监听系统主题变化
setupSystemThemeListener();
console.log('🌗 Dark Mode initialized');
}
// 立即执行防闪烁初始化(在模块加载时)
initTheme();
================================================
FILE: frontend/src/main.js
================================================
/**
* DjangoBlog 前端主入口文件
* 使用 Alpine.js + HTMX 实现现代化服务端渲染
*/
// 导入样式文件(Vite开发模式必需)
import './styles/main.css';
import Alpine from 'alpinejs';
import focus from '@alpinejs/focus';
import intersect from '@alpinejs/intersect';
import collapse from '@alpinejs/collapse';
import htmx from 'htmx.org';
// 导入Dark Mode(会自动初始化防闪烁)
import { initDarkMode } from './features/darkMode.js';
// 注册Alpine插件
Alpine.plugin(focus);
Alpine.plugin(intersect);
Alpine.plugin(collapse);
// 导入组件
import commentSystem from './components/commentSystem.js';
import backToTop from './components/backToTop.js';
import navigation from './components/navigation.js';
import imageLightbox from './components/imageLightbox.js';
import reactionPicker from './components/reactionPicker.js';
// 注册全局Alpine数据
Alpine.data('commentSystem', commentSystem);
Alpine.data('backToTop', backToTop);
Alpine.data('navigation', navigation);
Alpine.data('imageLightbox', imageLightbox);
Alpine.data('reactionPicker', reactionPicker);
// 全局工具函数
window.Alpine = Alpine;
window.htmx = htmx;
// 启动Alpine
Alpine.start();
// 初始化Dark Mode
initDarkMode();
// HTMX 配置
htmx.config.defaultSwapStyle = 'innerHTML';
htmx.config.defaultSwapDelay = 0;
htmx.config.defaultSettleDelay = 20;
// HTMX boost 配置:自动提取 #main 内容
document.body.addEventListener('htmx:beforeSwap', function(evt) {
// 对于 boost 的请求,确保正确提取内容
if (evt.detail.boosted && evt.detail.target.id === 'main') {
console.log('HTMX boost navigation:', evt.detail.pathInfo.requestPath);
}
});
// HTMX 加载完成后重新初始化 Alpine 组件
document.body.addEventListener('htmx:afterSwap', function(evt) {
// Alpine 会自动检测新的 DOM 元素并初始化
console.log('Content swapped, Alpine auto-initializing new components');
// 滚动到顶部(可选)
if (evt.detail.boosted) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
// NProgress页面加载进度条(保留原有功能)
import NProgress from './utils/nprogress.js';
NProgress.configure({ showSpinner: false });
// 页面加载时的进度条
NProgress.start();
NProgress.set(0.4);
const interval = setInterval(() => {
NProgress.inc();
}, 1000);
window.addEventListener('DOMContentLoaded', () => {
NProgress.done();
clearInterval(interval);
});
// 页面导航时的进度条
window.addEventListener('beforeunload', () => {
NProgress.start();
});
// HTMX 事件监听 - 配合 NProgress
document.body.addEventListener('htmx:beforeRequest', () => {
NProgress.start();
});
document.body.addEventListener('htmx:afterRequest', () => {
NProgress.done();
});
console.log('✨ DjangoBlog Frontend Loaded (Alpine.js + HTMX + Tailwind CSS)');
================================================
FILE: frontend/src/styles/main.css
================================================
/**
* DjangoBlog 现代化样式系统
* 完全基于 Tailwind CSS 重写,移除所有旧的CSS依赖
*
* 架构:
* 1. Tailwind 基础层 - 重置和基础样式
* 2. 组件层 - 可复用的组件类
* 3. 工具层 - Tailwind 工具类
* 4. 插件适配 - 兼容现有插件样式
*
* 响应式断点 (Mobile-First策略):
* - Base (0px): 超小手机基准,所有设备
* - sm (640px): 大手机/小平板
* - md (768px): 平板竖屏
* - lg (1024px): 平板横屏/小桌面 [侧边栏切换点]
* - xl (1280px): 桌面
* - 2xl (1536px): 大桌面
*
* 关键断点验证: 320px, 375px, 768px, 1024px, 1920px
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ==================== 主题配色方案 CSS 变量 ==================== */
:root {
/* 默认主题:紫色 Purple Dream */
--color-primary-50: 240 249 255;
--color-primary-100: 224 242 254;
--color-primary-200: 186 230 253;
--color-primary-300: 125 211 252;
--color-primary-400: 102 126 234;
--color-primary-500: 102 126 234;
--color-primary-600: 118 75 162;
--color-primary-700: 91 33 182;
--color-primary-800: 76 29 149;
--color-primary-900: 59 7 100;
}
/* 紫色主题 - Purple Dream */
:root[data-color-scheme="purple"],
body[data-color-scheme="purple"] {
--color-primary-50: 250 245 255;
--color-primary-100: 243 232 255;
--color-primary-200: 233 213 255;
--color-primary-300: 216 180 254;
--color-primary-400: 192 132 252;
--color-primary-500: 168 85 247;
--color-primary-600: 147 51 234;
--color-primary-700: 126 34 206;
--color-primary-800: 107 33 168;
--color-primary-900: 88 28 135;
}
/* 蓝色主题 - Ocean Blue */
:root[data-color-scheme="blue"],
body[data-color-scheme="blue"] {
--color-primary-50: 239 246 255;
--color-primary-100: 219 234 254;
--color-primary-200: 191 219 254;
--color-primary-300: 147 197 253;
--color-primary-400: 96 165 250;
--color-primary-500: 59 130 246;
--color-primary-600: 37 99 235;
--color-primary-700: 29 78 216;
--color-primary-800: 30 64 175;
--color-primary-900: 30 58 138;
}
/* 绿色主题 - Forest Green */
:root[data-color-scheme="green"],
body[data-color-scheme="green"] {
--color-primary-50: 240 253 244;
--color-primary-100: 220 252 231;
--color-primary-200: 187 247 208;
--color-primary-300: 134 239 172;
--color-primary-400: 74 222 128;
--color-primary-500: 34 197 94;
--color-primary-600: 22 163 74;
--color-primary-700: 21 128 61;
--color-primary-800: 22 101 52;
--color-primary-900: 20 83 45;
}
/* 橙色主题 - Sunset Orange */
:root[data-color-scheme="orange"],
body[data-color-scheme="orange"] {
--color-primary-50: 255 247 237;
--color-primary-100: 255 237 213;
--color-primary-200: 254 215 170;
--color-primary-300: 253 186 116;
--color-primary-400: 251 146 60;
--color-primary-500: 249 115 22;
--color-primary-600: 234 88 12;
--color-primary-700: 194 65 12;
--color-primary-800: 154 52 18;
--color-primary-900: 124 45 18;
}
/* 粉色主题 - Cherry Blossom */
:root[data-color-scheme="pink"],
body[data-color-scheme="pink"] {
--color-primary-50: 253 242 248;
--color-primary-100: 252 231 243;
--color-primary-200: 251 207 232;
--color-primary-300: 249 168 212;
--color-primary-400: 244 114 182;
--color-primary-500: 236 72 153;
--color-primary-600: 219 39 119;
--color-primary-700: 190 24 93;
--color-primary-800: 157 23 77;
--color-primary-900: 131 24 67;
}
/* 红色主题 - Ruby Red */
:root[data-color-scheme="red"],
body[data-color-scheme="red"] {
--color-primary-50: 254 242 242;
--color-primary-100: 254 226 226;
--color-primary-200: 254 202 202;
--color-primary-300: 252 165 165;
--color-primary-400: 248 113 113;
--color-primary-500: 239 68 68;
--color-primary-600: 220 38 38;
--color-primary-700: 185 28 28;
--color-primary-800: 153 27 27;
--color-primary-900: 127 29 29;
}
/* 靛蓝主题 - Midnight Indigo */
:root[data-color-scheme="indigo"],
body[data-color-scheme="indigo"] {
--color-primary-50: 238 242 255;
--color-primary-100: 224 231 255;
--color-primary-200: 199 210 254;
--color-primary-300: 165 180 252;
--color-primary-400: 129 140 248;
--color-primary-500: 99 102 241;
--color-primary-600: 79 70 229;
--color-primary-700: 67 56 202;
--color-primary-800: 55 48 163;
--color-primary-900: 49 46 129;
}
/* 青色主题 - Teal Wave */
:root[data-color-scheme="teal"],
body[data-color-scheme="teal"] {
--color-primary-50: 240 253 250;
--color-primary-100: 204 251 241;
--color-primary-200: 153 246 228;
--color-primary-300: 94 234 212;
--color-primary-400: 45 212 191;
--color-primary-500: 20 184 166;
--color-primary-600: 13 148 136;
--color-primary-700: 15 118 110;
--color-primary-800: 17 94 89;
--color-primary-900: 19 78 74;
}
/* ==================== 语义化 CSS 变量系统 (对标 Next.js) ==================== */
:root {
/* 基础色 - 亮色模式 - 使用OKLCH色彩空间获得更好的感知均匀性 */
--background: 249 250 251; /* rgb(249 250 251) - 柔和的灰白背景 */
--foreground: 17 24 39; /* rgb(17 24 39) - 深灰色文本 */
/* 卡片 */
--card: 255 255 255; /* rgb(255 255 255) - 纯白卡片 */
--card-foreground: 17 24 39; /* rgb(17 24 39) - 卡片文本色 */
/* Popover浮层 */
--popover: 255 255 255; /* rgb(255 255 255) */
--popover-foreground: 17 24 39; /* rgb(17 24 39) */
/* 边框和输入 */
--border: 229 231 235; /* rgb(229 231 235) - 边框色 */
--input: 229 231 235; /* rgb(229 231 235) - 输入框边框 */
--ring: var(--color-primary-500); /* 使用主题色作为聚焦环 */
/* 静音色 */
--muted: 243 244 246; /* rgb(243 244 246) - 静音背景 */
--muted-foreground: 107 114 128; /* rgb(107 114 128) - 静音文本 */
/* 主色 - 使用现有的 primary 变量 */
--primary: var(--color-primary-500);
--primary-foreground: 255 255 255; /* rgb(255 255 255) - 主色上的白色文本 */
/* 次要色 */
--secondary: 243 244 246; /* rgb(243 244 246) - 次要背景 */
--secondary-foreground: 31 41 55; /* rgb(31 41 55) - 次要文本 */
/* 强调色 */
--accent: var(--color-primary-400);
--accent-foreground: 255 255 255; /* rgb(255 255 255) - 强调色上的白色文本 */
/* 破坏性操作 */
--destructive: 239 68 68; /* rgb(239 68 68) - 红色 */
--destructive-foreground: 255 255 255;
/* 圆角系统 */
--radius: 0.5rem; /* 8px - 基准圆角 */
/* 阴影系统 - 使用更柔和的阴影 */
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-card-hover: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-elevated: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
}
/* 深色模式变量 */
.dark,
[data-theme="dark"] {
--background: 15 23 42; /* rgb(15 23 42) - Slate-900 深灰背景 */
--foreground: 226 232 240; /* rgb(226 232 240) - Slate-200 亮灰文本 */
--card: 30 41 59; /* rgb(30 41 59) - Slate-800 深色卡片 */
--card-foreground: 226 232 240; /* rgb(226 232 240) - 卡片文本 */
--popover: 30 41 59; /* rgb(30 41 59) */
--popover-foreground: 226 232 240; /* rgb(226 232 240) */
--border: 51 65 85; /* rgb(51 65 85) - Slate-700 深色边框 */
--input: 51 65 85; /* rgb(51 65 85) - 输入框边框 */
--muted: 51 65 85; /* rgb(51 65 85) - 静音背景 */
--muted-foreground: 148 163 184; /* rgb(148 163 184) - Slate-400 静音文本 */
--secondary: 51 65 85; /* rgb(51 65 85) - 次要背景 */
--secondary-foreground: 226 232 240; /* rgb(226 232 240) - 次要文本 */
/* 深色模式阴影 - 使用更深更自然的阴影 */
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3);
--shadow-card-hover: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.4);
--shadow-elevated: 0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.5);
}
/* ==================== 基础样式层 ==================== */
@layer base {
/* Nav header search input — reset global input styles injected by component layer */
#header-search-input {
padding: 0 !important;
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
/* Suppress browser-default search field decorations */
input[type="search"]::-webkit-search-decoration,
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-results-button,
input[type="search"]::-webkit-search-results-decoration {
-webkit-appearance: none;
display: none;
}
/* HTML根元素 */
html {
@apply scroll-smooth antialiased;
}
/* Body基础样式 - 现代化柔和配色 */
body {
/* 亮色模式:柔和的灰白背景(#f9fafb),避免纯白刺眼 */
@apply bg-gray-50;
/* 暗色模式:深Slate背景(#0f172a),比纯黑更现代 */
@apply dark:bg-slate-900;
/* 文本色:深灰色而非纯黑,更柔和 */
@apply text-gray-800;
/* 暗色模式文本:柔和的灰白色(#e2e8f0) */
@apply dark:text-slate-100;
@apply font-sans;
line-height: 1.75; /* 舒适的阅读行高 */
font-size: 1rem; /* 16px基准字体 */
}
/* 链接样式 */
a {
@apply text-primary-500 hover:text-primary-600 transition-colors duration-200;
@apply dark:text-primary-400 dark:hover:text-primary-300;
}
/* 标题样式 - 清晰的视觉层次,现代化柔和配色 */
h1, h2, h3, h4, h5, h6 {
@apply font-bold;
/* 亮色模式:深灰色(#1f2937)而非纯黑,更柔和现代 */
@apply text-gray-800;
/* 暗色模式:亮灰白色(#f1f5f9),高对比但不刺眼 */
@apply dark:text-slate-100;
line-height: 1.25; /* 紧凑的标题行高 */
}
/* 排版比例 - 明显的层次梯度 */
h1 {
font-size: 2.5rem; /* 40px */
@apply mt-0 mb-6;
}
h2 {
font-size: 2rem; /* 32px */
@apply mt-12 mb-4;
}
h3 {
font-size: 1.5rem; /* 24px */
@apply mt-8 mb-4;
}
h4 {
font-size: 1.25rem; /* 20px */
@apply mt-6 mb-3;
}
/* 侧边栏标题重置 - 移除默认的 margin */
aside h3 {
@apply mt-0 mb-0;
font-size: 0.875rem; /* 14px - 与模板中的 text-sm 一致 */
line-height: 1.5;
}
h5 {
font-size: 1.125rem; /* 18px */
@apply mt-4 mb-2;
}
h6 {
font-size: 1rem; /* 16px */
@apply mt-4 mb-2;
}
/* 段落和文本 */
p {
@apply mb-4;
line-height: 1.75; /* 继承body的行高 */
}
/* 段落间距优化 */
.prose p,
.entry-content p {
@apply mb-6; /* 增加段落间距,提高可读性 */
}
/* 列表样式 */
ul, ol {
@apply mb-4;
}
/* 图片 */
img {
@apply max-w-full h-auto;
}
/* 代码块 */
/* 内联代码 */
code {
@apply px-1.5 py-0.5 bg-gray-100 rounded text-sm;
color: #24292e;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
/* 深色模式 - 内联代码 */
[data-theme="dark"] code,
[data-theme="dark"] kbd,
[data-theme="dark"] tt,
[data-theme="dark"] var {
background-color: #2d2d2d !important;
color: #d4d4d4 !important;
}
/* 代码块容器 */
pre {
@apply p-3 md:p-4 rounded-lg overflow-x-auto mb-4;
@apply text-sm md:text-base;
background-color: #f9f9f9;
border: 1px solid #e1e4e8;
max-width: 100%;
-webkit-overflow-scrolling: touch;
}
/* 代码块自定义scrollbar样式 - 细小、半透明 */
pre::-webkit-scrollbar {
height: 6px;
}
pre::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 3px;
}
pre::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
pre::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* 深色模式的scrollbar */
[data-theme="dark"] pre::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
[data-theme="dark"] pre::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
[data-theme="dark"] pre::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* 深色模式 - 代码块容器 */
[data-theme="dark"] pre {
background-color: #1e1e1e !important;
color: #d4d4d4 !important;
border-color: #30363d;
}
/* 代码块中的code标签 */
pre code {
@apply p-0 bg-transparent;
@apply text-sm md:text-base;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
color: #24292e;
border: none;
display: block;
}
/* 深色模式 - 代码块中的code */
[data-theme="dark"] pre code {
background-color: transparent !important;
color: #d4d4d4 !important;
padding: 0;
border: none;
}
/* 表格 */
table {
@apply w-full mb-4 border-collapse;
}
th, td {
@apply border border-gray-300 dark:border-gray-700 px-4 py-2;
}
th {
@apply bg-gray-100 dark:bg-gray-800 font-semibold;
}
/* 引用块 */
blockquote {
@apply pl-4 py-2 border-l-4 border-primary-500 italic;
@apply bg-gray-50 dark:bg-gray-800 mb-4;
}
/* HR分隔线 */
hr {
@apply my-6 border-t border-border/60;
}
}
/* ==================== 组件层 - 通用组件 ==================== */
@layer components {
/* ========== 按钮组件 ========== */
.btn {
@apply inline-flex items-center justify-center;
@apply px-4 py-2 rounded-md font-medium;
@apply min-h-[44px] min-w-[44px]; /* 触摸目标最小尺寸 */
@apply transition-all duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-offset-2;
@apply disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply bg-primary-500 text-white hover:bg-primary-600;
@apply focus:ring-primary-500;
@apply shadow-md hover:shadow-lg;
}
.btn-secondary {
@apply bg-gray-200 text-gray-900 hover:bg-gray-300;
@apply dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600;
@apply focus:ring-gray-500;
}
.btn-sm {
@apply px-3 py-1.5 text-sm;
}
.btn-lg {
@apply px-6 py-3 text-lg;
}
/* ========== 表单组件 ========== */
.input, .textarea {
@apply w-full px-3 py-2 border border-gray-300 rounded-md;
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
@apply bg-white dark:bg-gray-800 dark:border-gray-600;
@apply text-gray-900 dark:text-gray-100;
@apply transition-all duration-200;
}
.textarea {
@apply resize-y min-h-[100px];
}
.input:disabled, .textarea:disabled {
@apply bg-gray-100 dark:bg-gray-900 cursor-not-allowed opacity-60;
}
/* ========== 卡片组件 - 增强视觉层次,现代化配色 ========== */
.card {
/* 使用语义化变量 */
background-color: rgb(var(--card));
color: rgb(var(--card-foreground));
border-color: rgb(var(--border));
@apply rounded-xl p-6;
@apply border;
@apply transition-all duration-300;
/* 增强阴影效果 - 使用变量 */
box-shadow: var(--shadow-card);
}
.card:hover {
/* hover时边框更明显 */
border-color: rgb(var(--primary) / 0.2);
box-shadow: var(--shadow-card-hover);
transform: translateY(-2px);
}
/* ========== 使用语义化变量的工具类 ========== */
.bg-background {
background-color: rgb(var(--background));
}
.bg-foreground {
background-color: rgb(var(--foreground));
}
.bg-card {
background-color: rgb(var(--card));
}
.bg-muted {
background-color: rgb(var(--muted));
}
.bg-secondary {
background-color: rgb(var(--secondary));
}
.bg-accent {
background-color: rgb(var(--accent));
}
.text-foreground {
color: rgb(var(--foreground));
}
.text-card-foreground {
color: rgb(var(--card-foreground));
}
.text-muted-foreground {
color: rgb(var(--muted-foreground));
}
.text-secondary-foreground {
color: rgb(var(--secondary-foreground));
}
.border-border {
border-color: rgb(var(--border));
}
/* ========== 毛玻璃效果类 ========== */
.backdrop-blur-header {
@apply backdrop-blur-xl;
background-color: rgba(255, 255, 255, 0.8);
border-color: rgba(229, 231, 235, 0.6);
}
[data-theme="dark"] .backdrop-blur-header {
background-color: rgba(31, 41, 55, 0.8);
border-color: rgba(55, 65, 81, 0.6);
}
/* 毛玻璃效果降级方案 */
@supports not (backdrop-filter: blur(0)) {
.backdrop-blur-header {
background-color: rgb(255, 255, 255);
}
[data-theme="dark"] .backdrop-blur-header {
background-color: rgb(31, 41, 55);
}
}
/* ========== 阴影工具类 ========== */
.shadow-card {
box-shadow: var(--shadow-card);
}
.shadow-card-hover {
box-shadow: var(--shadow-card-hover);
}
.shadow-elevated {
box-shadow: var(--shadow-elevated);
}
/* ========== 站点布局 ========== */
/* 网站容器 */
.site {
@apply min-h-screen flex flex-col;
}
/* 头部 - 毛玻璃效果和渐变背景 */
.site-header {
@apply shadow-md;
@apply sticky top-0 z-40;
@apply relative;
/* 毛玻璃效果 */
@apply backdrop-blur-xl;
background: linear-gradient(135deg,
rgba(var(--color-primary-500), 0.08) 0%,
rgba(var(--color-primary-600), 0.08) 50%,
rgba(var(--color-primary-400), 0.06) 100%),
rgba(255, 255, 255, 0.8);
border-bottom: 1px solid rgba(229, 231, 235, 0.6);
}
/* 深色模式的渐变 */
[data-theme="dark"] .site-header {
background: linear-gradient(135deg,
rgba(var(--color-primary-500), 0.15) 0%,
rgba(var(--color-primary-600), 0.15) 50%,
rgba(var(--color-primary-400), 0.12) 100%),
rgba(31, 41, 55, 0.8);
border-bottom-color: rgba(55, 65, 81, 0.6);
}
/* 毛玻璃效果降级方案 */
@supports not (backdrop-filter: blur(0)) {
.site-header {
background: linear-gradient(135deg,
rgba(var(--color-primary-500), 0.08) 0%,
rgba(var(--color-primary-600), 0.08) 50%,
rgba(var(--color-primary-400), 0.06) 100%),
rgb(255, 255, 255);
}
[data-theme="dark"] .site-header {
background: linear-gradient(135deg,
rgba(var(--color-primary-500), 0.15) 0%,
rgba(var(--color-primary-600), 0.15) 50%,
rgba(var(--color-primary-400), 0.12) 100%),
rgb(31, 41, 55);
}
}
/* 顶部装饰线 - 增加高度和动画 */
.site-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg,
rgb(var(--color-primary-500)) 0%,
rgb(var(--color-primary-600)) 25%,
rgb(var(--color-primary-400)) 50%,
rgb(var(--color-primary-500)) 75%,
rgb(var(--color-primary-600)) 100%);
background-size: 200% 100%;
animation: gradientShift 8s ease infinite;
pointer-events: none; /* 装饰元素不应阻止点击 */
}
@keyframes gradientShift {
0%, 100% { background-position: 0% 0%; }
50% { background-position: 100% 0%; }
}
/* Header内容容器 - 增加垂直空间,统一容器宽度 */
.header-content {
@apply flex items-center justify-between px-3 md:px-6 py-6 md:py-8;
/* 从max-w-7xl增加到max-w-screen-2xl,与主内容容器保持一致 */
@apply max-w-screen-2xl mx-auto;
@apply gap-4 md:gap-6;
@apply relative;
}
/* 装饰性背景元素 */
.header-content::before {
content: '';
position: absolute;
right: 5%;
top: 50%;
transform: translateY(-50%);
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(102, 126, 234, 0.08) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
animation: pulseGlow 4s ease-in-out infinite;
}
@keyframes pulseGlow {
0%, 100% {
transform: translateY(-50%) scale(1);
opacity: 0.5;
}
50% {
transform: translateY(-50%) scale(1.1);
opacity: 0.8;
}
}
.site-header hgroup {
@apply text-left;
/* 改为 flex-shrink,只占据内容宽度,不扩展占据整个空间 */
@apply flex-shrink;
@apply px-0 py-0;
@apply bg-transparent;
@apply min-w-0;
/* 移除 z-10,不需要提升层级 */
}
.site-title {
@apply text-4xl md:text-6xl lg:text-7xl font-bold;
@apply mb-2;
/* 移除 truncate,让标题自然换行而不是占据整行 */
}
.site-title a {
@apply no-underline;
/* 改为 inline-flex,只占据文字宽度 */
@apply inline-flex items-center gap-3;
@apply transition-all duration-300;
@apply relative;
/* 文字渐变效果 - 使用配色方案变量 */
@apply text-primary-500; /* 降级颜色 */
background: linear-gradient(135deg,
rgb(var(--color-primary-500)) 0%,
rgb(var(--color-primary-600)) 50%,
rgb(var(--color-primary-400)) 100%);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: textShimmer 8s ease-in-out infinite;
}
/* 浏览器不支持background-clip: text时的降级样式 */
@supports not (-webkit-background-clip: text) {
.site-title a {
@apply text-primary-500;
background: none;
}
}
@keyframes textShimmer {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
/* 深色模式下的标题 */
[data-theme="dark"] .site-title a {
@apply text-primary-400; /* 降级颜色 */
background: linear-gradient(135deg,
rgb(var(--color-primary-400)) 0%,
rgb(var(--color-primary-300)) 50%,
rgb(var(--color-primary-500)) 100%);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* 添加标题前的装饰图标 */
.site-title a::before {
content: '✦';
font-size: 0.6em;
opacity: 0;
transform: translateX(-10px) rotate(0deg);
transition: all 0.3s ease;
@apply text-primary-500;
}
.site-title a:hover::before {
opacity: 1;
transform: translateX(0) rotate(360deg);
}
/* 添加标题hover下划线效果 */
.site-title a::after {
content: '';
position: absolute;
bottom: -4px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 3px;
background: linear-gradient(90deg,
rgb(var(--color-primary-500)),
rgb(var(--color-primary-600)),
rgb(var(--color-primary-400)));
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 2px;
}
.site-title a:hover::after {
/* 从中心向两边扩展,覆盖文字区域 */
width: 100%;
}
.site-description {
@apply text-gray-600 dark:text-gray-400 text-lg md:text-xl;
@apply opacity-90;
@apply font-light italic;
@apply relative;
/* 添加微妙的动画 */
animation: fadeInUp 0.6s ease-out 0.2s both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 0.9;
transform: translateY(0);
}
}
/* 快捷入口图标组 */
.header-actions {
@apply flex items-center gap-1.5;
@apply relative;
@apply flex-shrink-0;
}
.header-icon-btn {
@apply flex items-center justify-center;
@apply w-12 h-12 md:w-11 md:h-11 rounded-full; /* 移动端48px,符合触摸标准 */
@apply text-gray-700 dark:text-gray-300;
@apply bg-white dark:bg-gray-800;
@apply hover:bg-gradient-to-br hover:from-primary-500 hover:to-primary-600;
@apply hover:text-white;
@apply transition-all duration-300;
@apply shadow-sm hover:shadow-lg hover:scale-110;
@apply border border-gray-200 dark:border-gray-700;
@apply hover:border-transparent;
@apply transform;
}
.header-icon-btn svg {
@apply w-5 h-5 md:w-7 md:h-7;
}
/* 搜索框 - 下拉式(在搜索图标下方) */
.header-search {
@apply absolute right-0 z-50;
@apply bg-white dark:bg-gray-800;
@apply rounded-lg shadow-xl;
@apply border border-gray-200 dark:border-gray-700;
@apply mt-2;
@apply w-[calc(100vw-2rem)] max-w-80;
/* 定位在header-actions下方 */
top: 100%;
}
.search-form {
@apply flex items-center gap-2 p-3;
}
.search-input {
@apply flex-1 px-4 py-2 rounded-lg;
@apply border border-gray-300 dark:border-gray-600;
@apply bg-gray-50 dark:bg-gray-900;
@apply text-gray-900 dark:text-gray-100;
@apply text-sm;
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500;
@apply placeholder-gray-400 dark:placeholder-gray-500;
}
.search-submit {
@apply flex items-center justify-center;
@apply w-12 h-12 rounded-lg;
@apply bg-primary-600 text-white;
@apply hover:bg-primary-700;
@apply transition-colors duration-200;
@apply flex-shrink-0;
@apply border-0;
@apply cursor-pointer;
}
.search-submit svg {
@apply w-7 h-7;
}
/* 导航菜单 - 毛玻璃效果 */
.main-navigation {
@apply sticky top-0 z-50;
@apply backdrop-blur-xl;
@apply relative;
background-color: rgba(var(--background) / 0.8);
border-bottom: 1px solid rgb(var(--border) / 0.6);
}
/* 桌面端当前菜单项高亮 */
.main-navigation .current-menu-item > a,
.main-navigation .current_page_item > a {
@apply bg-secondary text-foreground;
@apply relative;
}
/* 移动端当前菜单项高亮已在 HTML 内联样式中处理 */
/* 菜单切换按钮(仅移动端显示) */
.menu-toggle {
@apply md:hidden flex items-center justify-center;
@apply w-full px-4 py-2.5 bg-white dark:bg-gray-800;
@apply border-b border-gray-200 dark:border-gray-700;
@apply text-gray-700 dark:text-gray-300;
@apply hover:bg-gray-50 dark:hover:bg-gray-700;
@apply transition-colors duration-200;
}
.menu-toggle .screen-reader-text {
@apply sr-only;
}
/* 菜单容器 - 降低padding */
.main-navigation ul {
@apply flex flex-wrap gap-1.5 px-4 py-2;
}
/* 移动端:菜单垂直排列 */
@media (max-width: 767px) {
.main-navigation ul {
@apply flex-col gap-0 p-0;
@apply bg-white dark:bg-gray-800;
@apply border-t border-gray-200 dark:border-gray-700;
}
.main-navigation ul.toggled-on {
@apply block;
}
.main-navigation li {
@apply border-b border-gray-200 dark:border-gray-700;
}
.main-navigation a {
@apply block w-full px-4 py-3 rounded-none;
}
}
.main-navigation li {
@apply list-none;
}
/* 导航链接 - 增强样式和动画 */
.main-navigation a {
@apply px-3 py-1.5 rounded-lg text-gray-700 dark:text-gray-300;
@apply hover:bg-gradient-to-r hover:from-primary-500 hover:to-primary-600 hover:text-white;
@apply transition-all duration-300;
@apply flex items-center gap-1.5;
@apply font-medium;
@apply text-base md:text-lg; /* 移动端16px,桌面端18px */
@apply shadow-sm hover:shadow-md;
@apply border border-transparent hover:border-primary-300;
@apply transform hover:scale-105;
@apply relative;
}
.main-navigation a.current-menu-item,
.main-navigation a.current_page_item {
@apply bg-gradient-to-r from-primary-500 to-primary-600 text-white;
@apply shadow-md;
}
/* 导航图标 */
.nav-icon {
@apply w-4 h-4;
@apply transition-transform duration-200;
}
.main-navigation a:hover .nav-icon {
@apply scale-110;
}
/* 当前菜单项 */
.main-navigation .current-menu-item a,
.main-navigation .current_page_item a {
@apply bg-primary-500 text-white;
@apply shadow-md;
}
/* 子菜单样式 */
.main-navigation .sub-menu {
@apply absolute left-0 py-2 w-48;
@apply bg-white shadow-lg rounded-md;
@apply border border-gray-200;
@apply hidden;
z-index: 1000;
/* 减少顶部间距,让鼠标更容易移过去 */
margin-top: 0.25rem;
/* 增加顶部的透明区域,防止鼠标移动时菜单消失 */
padding-top: 0.75rem;
}
/* 在父菜单和子菜单之间添加不可见的桥接区域 */
.main-navigation .sub-menu::before {
content: '';
position: absolute;
top: -0.5rem;
left: 0;
right: 0;
height: 0.5rem;
background: transparent;
}
[data-theme="dark"] .main-navigation .sub-menu {
@apply bg-gray-800 border-gray-700;
}
.main-navigation .menu-item-has-children {
@apply relative;
}
/* 增加hover延迟,让菜单不会立即消失 */
.main-navigation .menu-item-has-children:hover > .sub-menu,
.main-navigation .menu-item-has-children:focus-within > .sub-menu {
@apply block;
animation: fadeIn 0.15s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.main-navigation .sub-menu li {
@apply block w-full;
}
.main-navigation .sub-menu a {
@apply block w-full px-4 py-2 rounded-none;
@apply text-gray-700 hover:bg-primary-500 hover:text-white;
}
[data-theme="dark"] .main-navigation .sub-menu a {
@apply text-gray-300;
}
/* ========== 侧边抽屉导航 ========== */
/* 遮罩层 */
.drawer-overlay {
@apply fixed inset-0 bg-black bg-opacity-50 z-40;
@apply md:hidden;
}
/* 汉堡菜单按钮 */
.hamburger-btn {
@apply md:hidden flex items-center justify-center;
@apply w-full px-4 py-2.5 bg-white dark:bg-gray-800;
@apply border-b border-gray-200 dark:border-gray-700;
@apply text-gray-700 dark:text-gray-300;
@apply hover:bg-gray-50 dark:hover:bg-gray-700;
@apply transition-colors duration-200;
}
/* 菜单容器 - 桌面端正常显示,移动端为侧边抽屉 */
.menu-container {
@apply md:block;
}
/* 移动端:侧边抽屉模式 */
@media (max-width: 767px) {
.menu-container {
@apply fixed left-0 top-0 bottom-0 w-80 max-w-[85vw];
@apply bg-white dark:bg-gray-800;
@apply shadow-2xl z-50;
@apply transform -translate-x-full;
@apply transition-transform duration-300 ease-out;
@apply overflow-y-auto;
}
.menu-container.drawer-open {
@apply translate-x-0;
}
/* 抽屉头部 */
.drawer-header {
@apply flex items-center justify-between;
@apply px-6 py-5 border-b border-gray-200 dark:border-gray-700;
@apply bg-gradient-to-br from-primary-500 via-primary-600 to-primary-700;
@apply relative overflow-hidden;
}
/* 抽屉头部装饰背景 */
.drawer-header::before {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
border-radius: 50%;
}
.drawer-header-content {
@apply flex items-center gap-3;
@apply relative z-10;
}
.drawer-site-icon {
@apply w-12 h-12 rounded-full;
@apply bg-white bg-opacity-20;
@apply flex items-center justify-center;
@apply text-white;
@apply backdrop-blur-sm;
@apply shadow-lg;
}
.drawer-site-info {
@apply flex flex-col;
}
.drawer-title {
@apply text-lg font-bold text-white;
@apply m-0 leading-tight;
}
.drawer-subtitle {
@apply text-sm text-white text-opacity-90;
@apply m-0 mt-0.5;
@apply font-light;
}
.drawer-close-btn {
@apply w-10 h-10 rounded-full;
@apply flex items-center justify-center;
@apply text-white hover:bg-white hover:bg-opacity-20;
@apply transition-all duration-200;
@apply relative z-10;
@apply hover:rotate-90;
}
/* 抽屉内的菜单列表 */
.menu-container ul.nav-menu {
@apply flex-col gap-0 p-0;
@apply bg-transparent;
@apply border-0;
}
.menu-container li {
@apply border-b border-gray-200 dark:border-gray-700;
}
.menu-container li:last-child {
@apply border-b-0;
}
.menu-container a {
@apply block w-full px-4 py-3 rounded-none;
@apply text-gray-700 dark:text-gray-300;
@apply hover:bg-primary-50 dark:hover:bg-gray-700;
@apply border-0 shadow-none;
@apply transform-none;
}
/* 移动端当前菜单项高亮样式 - 使用更明显的渐变背景 */
.menu-container .current-menu-item > a,
.menu-container .current_page_item > a {
@apply bg-gradient-to-r from-primary-500 to-primary-600;
@apply text-white;
@apply border-l-4 border-primary-700;
@apply font-semibold;
}
/* 移动端子菜单样式 - 显示为缩进列表 */
.menu-container .sub-menu {
/* 覆盖桌面端的绝对定位 */
position: static !important;
@apply block;
@apply w-full;
@apply bg-gray-50 dark:bg-gray-900;
@apply border-l-2 border-primary-300 dark:border-primary-700;
@apply ml-4;
@apply mt-0 mb-0;
@apply shadow-none;
@apply rounded-none;
@apply border-0 border-l-2;
}
.menu-container .sub-menu li {
@apply border-b border-gray-100 dark:border-gray-800;
}
.menu-container .sub-menu a {
@apply pl-6 py-2;
@apply text-sm;
@apply text-gray-600 dark:text-gray-400;
}
.menu-container .sub-menu a:hover {
@apply bg-primary-100 dark:bg-gray-800;
@apply text-primary-700 dark:text-primary-300;
}
/* 子菜单的当前项样式 */
.menu-container .sub-menu .current-menu-item > a,
.menu-container .sub-menu .current_page_item > a {
@apply bg-primary-200 dark:bg-primary-900;
@apply text-primary-800 dark:text-primary-200;
@apply border-l-2 border-primary-600;
@apply font-medium;
}
}
/* 主内容区域 */
.wrapper {
@apply flex-1 max-w-7xl mx-auto w-full py-8;
/* 移动端使用更小的padding,桌面端正常padding */
@apply px-2 sm:px-4 md:px-6 lg:px-8;
@apply flex flex-col lg:flex-row gap-8;
}
.site-content {
@apply flex-1 min-w-0; /* 添加 min-w-0 防止flex子元素溢出 */
}
/* ========== 侧边栏 - 增强视觉区分 ========== */
.widget-area {
/* 使用Grid的4列自适应宽度 (8:4比例) */
@apply w-full flex-shrink-0 space-y-6;
}
/* 桌面端侧边栏样式 */
@media (min-width: 1024px) {
.widget-area {
/* 关键:让侧边栏高度跟随内容,不拉伸到整行 */
align-self: start;
/* 只设置左右和底部padding,顶部不要padding以消除高度差 */
@apply px-4 pb-4 rounded-xl;
/* 微妙的背景区分 */
background: linear-gradient(to bottom,
rgba(249, 250, 251, 0.5),
rgba(243, 244, 246, 0.5));
}
[data-theme="dark"] .widget-area {
background: linear-gradient(to bottom,
rgba(17, 24, 39, 0.5),
rgba(31, 41, 55, 0.5));
}
}
/* 页脚 */
/* ========== Footer 样式 ========== */
.site-footer {
@apply relative;
@apply bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-950;
@apply mt-16;
/* 添加微妙的阴影 */
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.03);
}
/* 装饰性顶部分隔线 */
.footer-divider {
@apply h-1 w-full;
background: linear-gradient(90deg,
transparent 0%,
rgb(var(--color-primary-500)) 20%,
rgb(var(--color-primary-600)) 50%,
rgb(var(--color-primary-400)) 80%,
transparent 100%);
opacity: 0.6;
}
.footer-content {
/* 从max-w-7xl增加到max-w-screen-2xl,与主内容容器保持一致 */
@apply max-w-screen-2xl mx-auto px-4 md:px-6 py-8;
}
.footer-section {
@apply py-4;
}
/* 第一行:版权和主要链接 */
.footer-main {
@apply flex flex-col md:flex-row items-center justify-between gap-6;
@apply border-b border-gray-200 dark:border-gray-800;
}
.footer-copyright {
@apply flex items-center gap-2;
}
.copyright-text {
@apply flex items-center text-gray-600 dark:text-gray-400 text-sm;
}
.footer-link-brand {
@apply font-semibold text-primary-600 dark:text-primary-400;
@apply hover:text-primary-700 dark:hover:text-primary-300;
@apply transition-colors duration-200;
}
.footer-links {
@apply flex flex-wrap items-center justify-center gap-4;
}
.footer-link {
@apply inline-flex items-center gap-1.5;
@apply px-3 py-1.5 rounded-md;
@apply text-sm text-gray-600 dark:text-gray-400;
@apply hover:text-primary-600 dark:hover:text-primary-400;
@apply hover:bg-white dark:hover:bg-gray-800;
@apply transition-all duration-200;
@apply border border-transparent hover:border-gray-200 dark:hover:border-gray-700;
}
.footer-link svg {
@apply w-4 h-4;
}
/* 第二行:技术栈和性能 */
.footer-meta {
@apply flex flex-col md:flex-row items-center justify-between gap-4;
@apply text-sm text-gray-500 dark:text-gray-400;
}
.footer-tech {
@apply flex flex-wrap items-center justify-center gap-2;
}
.footer-performance {
@apply flex items-center gap-2;
@apply px-3 py-1.5 rounded-md;
@apply bg-white dark:bg-gray-800;
@apply border border-gray-200 dark:border-gray-700;
}
/* 备案信息 */
.footer-beian {
@apply text-center py-4;
}
.footer-beian-link {
@apply inline-block hover:opacity-80 transition-opacity;
}
.footer-beian-text {
@apply text-gray-500 dark:text-gray-400;
@apply text-sm leading-5;
@apply m-0;
}
/* 响应式调整 */
@media (max-width: 768px) {
.footer-main {
@apply text-center;
}
.footer-links {
@apply flex-col gap-2;
}
.footer-meta {
@apply text-center;
}
}
/* ========== 文章相关 ========== */
/* 文章条目 */
.entry-header {
@apply mb-6;
}
.entry-title {
@apply text-3xl font-bold mb-4;
}
.entry-meta {
@apply text-sm text-gray-600 dark:text-gray-400;
@apply mb-4;
}
.entry-meta a {
@apply text-primary-600 dark:text-primary-400 !important;
@apply hover:text-primary-700 dark:hover:text-primary-300 !important;
@apply transition-colors duration-200;
}
/* 文章摘要(列表页) — prose prose-sm 已提供基础 markdown 样式,此处做收口覆盖 */
/* prose 默认 p/ul/ol margin 对卡片太大,压紧 */
.article-excerpt p,
.article-excerpt ul,
.article-excerpt ol,
.article-excerpt li,
.article-excerpt blockquote {
color: rgb(var(--muted-foreground));
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.article-excerpt :first-child {
margin-top: 0;
}
/* 摘要中标题压缩为正文大小,绝不能超过文章标题 */
.article-excerpt h1,
.article-excerpt h2,
.article-excerpt h3,
.article-excerpt h4,
.article-excerpt h5,
.article-excerpt h6 {
font-size: 0.875rem;
line-height: 1.4;
font-weight: 600;
color: rgb(var(--muted-foreground));
margin-top: 0.5rem;
margin-bottom: 0.25rem;
}
/* 摘要内链接不染色 */
.article-excerpt a {
color: inherit;
text-decoration: none;
}
/* 摘要中图片正常显示,圆角+限高,避免超大图撑破卡片 */
.article-excerpt img {
display: block;
max-width: 100%;
max-height: 320px;
width: auto;
height: auto;
object-fit: cover;
border-radius: 0.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
/* 代码块、表格、iframe、视频等复杂元素在摘要中隐藏 */
.article-excerpt pre,
.article-excerpt code,
.article-excerpt table,
.article-excerpt hr,
.article-excerpt iframe,
.article-excerpt video,
.article-excerpt .codehilite {
display: none;
}
/* 详情页文章体:h1 不能超过页面文章标题(text-2xl/text-3xl = 1.5rem/1.875rem)*/
.article.prose h1 {
font-size: 1.5rem; /* text-2xl,与移动端文章标题等高,小于桌面端文章标题 */
line-height: 2rem;
}
.article.prose h2 {
font-size: 1.25rem; /* text-xl */
line-height: 1.75rem;
}
.article.prose h3 {
font-size: 1.125rem; /* text-lg */
line-height: 1.75rem;
}
/* 文章内容 */
.entry-content {
@apply prose prose-base dark:prose-invert max-w-none;
@apply prose-headings:font-bold prose-headings:text-gray-900 dark:prose-headings:text-gray-100;
@apply prose-p:text-gray-700 dark:prose-p:text-gray-300 prose-p:leading-relaxed;
@apply prose-a:text-primary-500 hover:prose-a:text-primary-600;
@apply prose-a:no-underline hover:prose-a:underline;
@apply prose-strong:text-gray-900 dark:prose-strong:text-gray-100;
@apply prose-ul:text-sm prose-ol:text-sm;
@apply prose-li:text-sm;
}
/* 文章内容图片增强效果 */
.entry-content img {
@apply rounded-xl;
@apply shadow-lg hover:shadow-2xl;
@apply transition-all duration-300;
@apply cursor-zoom-in;
@apply border-4 border-white dark:border-gray-800;
@apply ring-1 ring-gray-200 dark:ring-gray-700;
@apply my-6;
}
.entry-content img:hover {
@apply transform scale-105;
@apply ring-2 ring-primary-400 dark:ring-primary-500;
}
/* Badge图片和小图片不需要特效 */
.entry-content img[src*="badge.svg"],
.entry-content img[src*="shields.io"],
.entry-content img[src*="/badge/"],
.entry-content img[alt*="badge" i] {
@apply cursor-default;
@apply border-0;
@apply ring-0;
@apply shadow-none;
@apply my-2;
}
.entry-content img[src*="badge.svg"]:hover,
.entry-content img[src*="shields.io"]:hover,
.entry-content img[src*="/badge/"]:hover,
.entry-content img[alt*="badge" i]:hover {
@apply transform-none;
@apply ring-0;
@apply shadow-none;
}
/* 图片容器居中 - 仅当段落只包含图片时 */
.entry-content p:has(img:only-child) {
@apply text-center;
}
/* 图片本身显示为块级元素并居中 */
.entry-content img {
@apply mx-auto block;
}
/* 归档页面使用正常字体大小 - 覆盖prose样式 */
.entry-content.archive-list,
.entry-content.archive-list ul,
.entry-content.archive-list ol,
.entry-content.archive-list li,
.entry-content.archive-list a {
font-size: 1rem !important;
line-height: 1.75rem !important;
}
.entry-content.archive-list .archive-year {
@apply text-base font-semibold text-gray-900 dark:text-gray-100 mb-2 mt-4;
font-size: 1rem !important;
}
.entry-content.archive-list .archive-month {
@apply text-base text-gray-800 dark:text-gray-200 mb-2 ml-4;
font-size: 1rem !important;
}
.entry-content.archive-list .archive-article {
@apply ml-8 mb-1;
}
.entry-content.archive-list .archive-article a {
@apply text-base text-gray-700 dark:text-gray-300 hover:text-primary-500;
@apply hover:underline transition-colors duration-200;
font-size: 1rem !important;
}
/* 减少目录和正文之间的间距 */
.entry-content .break_line {
@apply my-4;
}
/* 阅读时间预估(由插件注入) */
.reading-time-estimate {
@apply text-xs mb-4;
color: rgb(var(--muted-foreground));
}
.reading-time-estimate em {
font-style: normal;
}
/* 文章目录(覆盖全局 a { text-primary-500 } 规则) */
.toc-nav a {
@apply no-underline;
color: rgb(var(--muted-foreground));
transition: color 150ms;
}
.toc-nav a:hover {
color: rgb(var(--primary));
}
.entry-content > b {
@apply block mb-2;
}
/* 文章内容中的代码块覆盖 */
.entry-content pre {
@apply p-4 rounded-lg overflow-x-auto;
background-color: #f9f9f9;
border: 1px solid #e1e4e8;
}
[data-theme="dark"] .entry-content pre,
[data-theme="dark"] .comment-content pre {
background-color: #1e1e1e !important;
color: #d4d4d4 !important;
border-color: #30363d;
}
.entry-content pre code {
@apply p-0 bg-transparent;
font-size: 0.875rem;
line-height: 1.7;
color: #24292e;
}
[data-theme="dark"] .entry-content pre code,
[data-theme="dark"] .entry-content code,
[data-theme="dark"] .comment-content code {
color: #d4d4d4 !important;
background-color: transparent !important;
}
/* 内联代码(不在pre中的) */
.entry-content code:not(pre code) {
@apply px-2 py-1 rounded;
background-color: #f6f8fa;
color: #24292e;
font-size: 0.875em;
border: 1px solid #e1e4e8;
}
[data-theme="dark"] .entry-content code:not(pre code),
[data-theme="dark"] .comment-content code:not(pre code) {
background-color: #2d2d2d !important;
color: #d4d4d4 !important;
border-color: #30363d;
}
/* 移除 Tailwind Typography 自动添加的反引号 */
.entry-content code:not(pre code)::before,
.entry-content code:not(pre code)::after,
.comment-content code:not(pre code)::before,
.comment-content code:not(pre code)::after {
content: none !important;
}
/* 文章列表 */
.article-list {
@apply space-y-8;
}
.article-item {
@apply card hover:shadow-xl transition-all duration-300;
}
/* 文章导航 */
.nav-single {
@apply mt-8 pt-6 clear-both;
@apply border-t border-gray-200 dark:border-gray-700;
@apply flex justify-between items-start flex-wrap gap-4;
}
.nav-single h3 {
@apply sr-only;
}
.nav-single .nav-previous {
@apply flex-shrink-0;
}
.nav-single .nav-next {
@apply flex-shrink-0 ml-auto;
}
.nav-single a {
@apply text-primary-600 dark:text-primary-400;
@apply hover:text-primary-700 dark:hover:text-primary-300;
@apply transition-colors duration-200;
@apply inline-flex items-center space-x-2;
@apply max-w-md;
}
.nav-single .meta-nav {
@apply text-xl flex-shrink-0;
}
/* 归档页面 */
/* ========== 归档页面现代化样式 ========== */
.archive-header-modern {
@apply mb-12;
}
.archive-hero {
@apply text-center py-12;
@apply bg-gradient-to-br from-primary-50 via-primary-100 to-primary-50;
@apply dark:from-gray-800 dark:via-gray-900 dark:to-gray-800;
@apply rounded-2xl shadow-lg;
@apply border border-gray-200 dark:border-gray-700;
@apply relative overflow-hidden;
}
/* 装饰性背景 */
.archive-hero::before {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(102, 126, 234, 0.1) 0%, transparent 70%);
border-radius: 50%;
animation: float 6s ease-in-out infinite;
}
.archive-hero::after {
content: '';
position: absolute;
bottom: -50%;
left: -20%;
width: 250px;
height: 250px;
background: radial-gradient(circle, rgba(240, 147, 251, 0.1) 0%, transparent 70%);
border-radius: 50%;
animation: float 8s ease-in-out infinite reverse;
}
@keyframes float {
0%, 100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(20px, 20px) scale(1.1);
}
}
.archive-icon {
@apply inline-flex items-center justify-center;
@apply w-20 h-20 rounded-full;
@apply bg-gradient-to-br from-primary-500 to-primary-600;
@apply text-white mb-4;
@apply shadow-lg;
@apply relative z-10;
animation: iconPulse 3s ease-in-out infinite;
}
@keyframes iconPulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
}
50% {
transform: scale(1.05);
box-shadow: 0 15px 35px rgba(102, 126, 234, 0.4);
}
}
.archive-title-modern {
@apply text-4xl md:text-5xl font-bold;
@apply text-gray-900 dark:text-gray-100;
@apply mb-2 relative z-10;
}
.archive-subtitle {
@apply text-gray-600 dark:text-gray-400;
@apply text-lg relative z-10;
}
/* 时间线样式 */
.archive-timeline {
@apply space-y-12;
}
.timeline-year-block {
@apply relative;
}
.timeline-year-badge {
@apply inline-flex items-center gap-3;
@apply px-6 py-3 rounded-full;
@apply bg-gradient-to-r from-primary-500 to-primary-600;
@apply text-white font-bold;
@apply shadow-lg mb-8;
@apply sticky top-20 z-30;
@apply backdrop-blur-sm;
}
.year-number {
@apply text-2xl;
}
.year-count {
@apply text-sm opacity-90;
@apply px-2 py-0.5 rounded-full;
@apply bg-white bg-opacity-20;
}
.timeline-month-block {
@apply relative pl-8 mb-8;
@apply before:content-[''] before:absolute before:left-0 before:top-0 before:bottom-0;
@apply before:w-0.5 before:bg-gradient-to-b before:from-primary-300 before:to-primary-400;
@apply dark:before:from-primary-700 dark:before:to-primary-600;
}
.timeline-month-header {
@apply flex items-center gap-3 mb-4;
@apply relative;
}
.timeline-dot {
@apply absolute -left-8;
@apply w-4 h-4 rounded-full;
@apply bg-primary-500 dark:bg-primary-400;
@apply border-4 border-white dark:border-gray-900;
@apply shadow-md;
animation: dotPulse 2s ease-in-out infinite;
}
@keyframes dotPulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.4);
}
50% {
transform: scale(1.1);
box-shadow: 0 0 0 8px rgba(102, 126, 234, 0);
}
}
.month-title {
@apply text-xl font-semibold text-gray-800 dark:text-gray-200;
}
.month-count {
@apply text-sm text-gray-500 dark:text-gray-400;
@apply px-2 py-0.5 rounded-full;
@apply bg-gray-100 dark:bg-gray-800;
}
.timeline-articles {
@apply space-y-3;
}
.timeline-article-item {
@apply transform transition-all duration-200;
}
.article-link {
@apply flex items-center justify-between gap-4;
@apply p-4 rounded-lg;
@apply bg-white dark:bg-gray-800;
@apply border border-gray-200 dark:border-gray-700;
@apply hover:border-primary-300 dark:hover:border-primary-600;
@apply hover:shadow-md;
@apply transition-all duration-200;
}
/* group class需要在HTML中添加,不能用@apply */
.article-link:hover {
@apply transform -translate-y-1;
}
.article-info {
@apply flex-1 min-w-0;
}
.article-date {
@apply inline-block px-2 py-0.5 rounded;
@apply bg-primary-100 dark:bg-primary-900;
@apply text-primary-600 dark:text-primary-300;
@apply text-xs font-medium mb-2;
}
.article-title {
@apply text-base font-medium text-gray-900 dark:text-gray-100;
@apply group-hover:text-primary-600 dark:group-hover:text-primary-400;
@apply transition-colors duration-200;
@apply truncate;
}
.article-meta {
@apply flex items-center gap-3 mt-2;
@apply text-xs text-gray-500 dark:text-gray-400;
}
.article-category,
.article-views {
@apply inline-flex items-center gap-1;
}
.article-arrow {
@apply w-5 h-5 text-gray-400;
@apply group-hover:text-primary-500;
@apply group-hover:translate-x-1;
@apply transition-all duration-200;
@apply flex-shrink-0;
}
/* 保留旧的归档样式(向后兼容) */
.archive-header,
.page-header {
@apply mb-12 pb-6 border-b-2 border-primary-500;
}
.archive-title,
.page-title {
@apply text-2xl font-bold text-gray-900 dark:text-gray-100;
}
.archive-title span {
@apply text-primary-500;
}
/* 评论链接 */
.comments-link {
@apply mt-6 text-sm text-gray-600 dark:text-gray-400;
}
.comments-link a {
@apply hover:text-primary-500 dark:hover:text-primary-400;
}
/* 阅读更多链接 */
.read-more {
@apply mt-4;
}
.read-more a {
@apply inline-flex items-center px-4 py-2;
@apply bg-primary-500 text-white rounded-md;
@apply hover:bg-primary-600 transition-colors duration-200;
}
/* ========== 评论系统 ========== */
.comments-area {
@apply mt-12 pt-8 border-t-2 border-gray-200 dark:border-gray-700;
}
.comment-tabs {
@apply mb-6;
}
.commentlist {
@apply space-y-6;
}
.comment-item, .comment {
@apply list-none;
}
.comment-body {
@apply p-6 bg-white dark:bg-gray-800 rounded-lg;
@apply border border-gray-200 dark:border-gray-700;
@apply shadow-sm hover:shadow-md transition-all duration-200;
}
.comment-author {
@apply flex items-center space-x-3 mb-3;
}
.comment-author img {
@apply w-12 h-12 rounded-full;
@apply border-2 border-gray-200 dark:border-gray-700;
}
.comment-author .fn {
@apply font-semibold text-gray-900 dark:text-gray-100;
@apply not-italic;
}
.comment-meta {
@apply text-sm text-gray-500 dark:text-gray-400;
}
.comment-content {
@apply text-gray-700 dark:text-gray-300 mb-4;
@apply prose dark:prose-invert max-w-none;
}
.reply a {
@apply text-sm text-gray-600 dark:text-gray-400;
@apply hover:text-primary-500 dark:hover:text-primary-400;
@apply transition-colors duration-200;
}
/* 评论表单 */
.comment-form {
@apply space-y-4;
}
.comment-form label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2;
}
.comment-respond {
@apply mt-8;
}
.comment-reply-title {
@apply text-2xl font-bold mb-6 text-gray-900 dark:text-gray-100;
}
/* 嵌套评论 */
.children {
@apply mt-4 space-y-4;
@apply border-l-2 border-gray-200 dark:border-gray-700 pl-4;
}
/* 移动端嵌套评论更细的边框 */
@media (max-width: 768px) {
.children {
@apply border-l pl-2;
}
}
/* 评论缩进 - PC端(桌面) */
@media (min-width: 769px) {
.comment-depth-0 { margin-left: 0; }
.comment-depth-1 { margin-left: 1rem; }
.comment-depth-2 { margin-left: 2rem; }
.comment-depth-3 { margin-left: 3rem; }
/* 深度4及以上固定在3rem */
.comment-depth-4,
.comment-depth-5,
.comment-depth-6,
.comment-depth-7,
.comment-depth-8,
.comment-depth-9,
.comment-depth-10 {
margin-left: 3rem;
}
}
/* 评论缩进 - 移动端 */
@media (max-width: 768px) {
.comment-depth-0 { margin-left: 0; }
.comment-depth-1 { margin-left: 0.5rem; }
.comment-depth-2 { margin-left: 1rem; }
/* 深度3及以上固定在1.5rem */
.comment-depth-3,
.comment-depth-4,
.comment-depth-5,
.comment-depth-6,
.comment-depth-7,
.comment-depth-8,
.comment-depth-9,
.comment-depth-10 {
margin-left: 1.5rem;
}
}
/* 深层评论使用更紧凑的样式 - PC端 */
@media (min-width: 769px) {
.comment-depth-3 .comment-body,
.comment-depth-4 .comment-body,
.comment-depth-5 .comment-body,
.comment-depth-6 .comment-body,
.comment-depth-7 .comment-body,
.comment-depth-8 .comment-body,
.comment-depth-9 .comment-body,
.comment-depth-10 .comment-body {
@apply p-3;
}
}
/* 移动端深层评论更紧凑 */
@media (max-width: 768px) {
.comment-depth-2 .comment-body,
.comment-depth-3 .comment-body,
.comment-depth-4 .comment-body,
.comment-depth-5 .comment-body,
.comment-depth-6 .comment-body,
.comment-depth-7 .comment-body,
.comment-depth-8 .comment-body,
.comment-depth-9 .comment-body,
.comment-depth-10 .comment-body {
@apply p-2;
}
/* 深层评论的头像也缩小 */
.comment-depth-3 .comment-author img,
.comment-depth-4 .comment-author img,
.comment-depth-5 .comment-author img,
.comment-depth-6 .comment-author img,
.comment-depth-7 .comment-author img,
.comment-depth-8 .comment-author img,
.comment-depth-9 .comment-author img,
.comment-depth-10 .comment-author img {
@apply w-8 h-8;
}
}
.widget {
@apply card;
}
.widget-title {
@apply text-xl font-bold mb-4;
@apply border-b-2 border-primary-500 pb-2;
}
.widget ul {
@apply space-y-2;
}
.widget li {
@apply list-none;
}
.widget a {
@apply hover:text-primary-500 dark:hover:text-primary-400;
@apply transition-colors duration-200;
}
/* 特定widget的链接需要block布局 */
.widget_recent_entries a,
.widget_categories a,
.widget_meta a {
@apply block py-1;
}
/* ========== 侧边栏热门文章 ========== */
.sidebar-hot-articles {
@apply space-y-2;
}
.hot-article-item {
@apply flex items-start gap-2.5 p-2.5 rounded-lg;
@apply bg-white dark:bg-gray-800;
@apply border border-gray-200 dark:border-gray-700;
@apply hover:border-primary-400 dark:hover:border-primary-500;
@apply hover:shadow-md hover:-translate-y-0.5;
@apply transition-all duration-200;
}
.hot-article-rank {
@apply flex items-center justify-center;
@apply w-7 h-7 rounded-full;
@apply bg-gradient-to-br from-primary-500 to-primary-600;
@apply text-white font-bold text-xs;
@apply flex-shrink-0;
@apply shadow-sm;
}
/* 前三名特殊样式 - 金银铜 */
.rank-1 {
@apply from-yellow-400 to-yellow-600;
box-shadow: 0 2px 8px rgba(251, 191, 36, 0.4);
animation: rankPulse 2s ease-in-out infinite;
}
.rank-2 {
@apply from-gray-300 to-gray-400;
box-shadow: 0 2px 8px rgba(156, 163, 175, 0.4);
}
.rank-3 {
@apply from-orange-400 to-orange-600;
box-shadow: 0 2px 8px rgba(251, 146, 60, 0.4);
}
@keyframes rankPulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 2px 8px rgba(251, 191, 36, 0.4);
}
50% {
transform: scale(1.08);
box-shadow: 0 4px 12px rgba(251, 191, 36, 0.6);
}
}
.hot-article-content {
@apply flex-1 min-w-0;
}
.hot-article-title {
@apply block text-sm font-medium mb-1 leading-snug;
@apply text-gray-900 dark:text-gray-100;
@apply hover:text-primary-600 dark:hover:text-primary-400;
@apply transition-colors duration-200;
@apply line-clamp-2;
}
.hot-article-meta {
@apply flex items-center gap-1;
@apply text-xs text-gray-600 dark:text-gray-400;
}
.hot-article-views {
@apply font-medium text-gray-700 dark:text-gray-300;
}
/* ========== 侧边栏分类列表 ========== */
.sidebar-categories {
@apply space-y-1.5;
}
.category-item {
@apply list-none;
}
.category-link {
@apply flex items-center gap-2 px-3 py-2 rounded-lg;
@apply bg-white dark:bg-gray-800;
@apply border border-gray-200 dark:border-gray-700;
@apply hover:border-primary-400 dark:hover:border-primary-500;
@apply hover:shadow-md hover:-translate-y-0.5;
@apply transition-all duration-200;
@apply no-underline;
}
.category-icon {
@apply flex-shrink-0;
@apply text-primary-500 dark:text-primary-400;
}
.category-name {
@apply flex-1 min-w-0;
@apply text-sm font-medium;
@apply text-gray-900 dark:text-gray-100;
@apply truncate;
}
.category-count {
@apply flex-shrink-0;
@apply px-2 py-0.5 rounded-full;
@apply bg-primary-100 dark:bg-primary-900/50;
@apply text-primary-700 dark:text-primary-300;
@apply text-xs font-semibold;
}
/* ========== 侧边栏最新评论 ========== */
.sidebar-recent-comments {
@apply space-y-2.5;
}
.recent-comment-item {
@apply flex items-start gap-2 p-2.5 rounded-lg;
@apply bg-white dark:bg-gray-800;
@apply border border-gray-200 dark:border-gray-700;
@apply hover:border-primary-400 dark:hover:border-primary-500;
@apply hover:shadow-md hover:-translate-y-0.5;
@apply transition-all duration-200;
}
.comment-avatar {
@apply flex-shrink-0;
}
.comment-avatar img {
@apply ring-2 ring-gray-200 dark:ring-gray-700;
}
.comment-content {
@apply flex-1 min-w-0;
}
.comment-text {
@apply text-sm text-gray-900 dark:text-gray-100;
@apply mb-2 leading-relaxed;
@apply line-clamp-3;
@apply font-normal;
}
.comment-meta {
@apply flex items-center flex-wrap gap-1;
@apply text-xs text-gray-500 dark:text-gray-400;
}
.comment-author-name {
@apply font-medium text-gray-700 dark:text-gray-300;
}
.comment-separator {
@apply text-gray-400 dark:text-gray-600;
}
.comment-time {
@apply text-gray-500 dark:text-gray-400;
}
.comment-article-link {
@apply text-primary-600 dark:text-primary-400;
@apply hover:text-primary-700 dark:hover:text-primary-300;
@apply transition-colors duration-200;
@apply no-underline truncate;
}
/* ========== 侧边栏标签云多彩设计 ========== */
.sidebar-tagcloud {
@apply flex flex-wrap gap-1.5;
}
.sidebar-tag {
@apply inline-flex items-center gap-1 px-2.5 py-1 rounded-md;
@apply text-xs font-medium;
@apply transition-all duration-200;
@apply no-underline;
@apply shadow-sm hover:shadow-md;
@apply transform hover:scale-105 hover:-translate-y-0.5;
}
/* 根据标签顺序使用不同颜色 */
.sidebar-tag:nth-child(8n+1) {
@apply bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300;
@apply hover:bg-blue-600 hover:text-white dark:hover:bg-blue-500;
}
.sidebar-tag:nth-child(8n+2) {
@apply bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300;
@apply hover:bg-green-600 hover:text-white dark:hover:bg-green-500;
}
.sidebar-tag:nth-child(8n+3) {
@apply bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300;
@apply hover:bg-purple-600 hover:text-white dark:hover:bg-purple-500;
}
.sidebar-tag:nth-child(8n+4) {
@apply bg-pink-100 dark:bg-pink-900/40 text-pink-700 dark:text-pink-300;
@apply hover:bg-pink-600 hover:text-white dark:hover:bg-pink-500;
}
.sidebar-tag:nth-child(8n+5) {
@apply bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300;
@apply hover:bg-orange-600 hover:text-white dark:hover:bg-orange-500;
}
.sidebar-tag:nth-child(8n+6) {
@apply bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300;
@apply hover:bg-teal-600 hover:text-white dark:hover:bg-teal-500;
}
.sidebar-tag:nth-child(8n+7) {
@apply bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300;
@apply hover:bg-indigo-600 hover:text-white dark:hover:bg-indigo-500;
}
.sidebar-tag:nth-child(8n+8) {
@apply bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300;
@apply hover:bg-red-600 hover:text-white dark:hover:bg-red-500;
}
/* 搜索表单 */
.searchform {
@apply flex items-center gap-2;
}
.searchform input[type="text"] {
@apply flex-1 input;
}
.searchform input[type="submit"] {
@apply btn btn-primary;
}
/* ========== 分页 ========== */
.pagination {
@apply flex justify-center items-center space-x-2 mt-8;
}
.pagination a,
.pagination span {
@apply px-4 py-2 rounded-md;
@apply border border-gray-300 dark:border-gray-600;
@apply transition-all duration-200;
}
.pagination a {
@apply hover:bg-primary-500 hover:text-white hover:border-primary-500;
}
.pagination .current {
@apply bg-primary-500 text-white border-primary-500;
}
/* ========== 面包屑导航 ========== */
/* breadcrumb 现已使用 Tailwind 内联样式,此处仅保留注释 */
/* ========== 标签云 ========== */
.tagcloud {
@apply flex flex-wrap gap-1.5;
@apply leading-tight;
@apply items-start;
}
.tagcloud a {
@apply inline-block px-2.5 py-0.5 rounded-full;
@apply bg-gray-100 dark:bg-gray-700;
@apply text-gray-700 dark:text-gray-300;
@apply text-sm;
@apply hover:bg-primary-500 hover:text-white;
@apply transition-all duration-200;
@apply no-underline;
@apply whitespace-nowrap;
@apply shadow-sm hover:shadow-md;
@apply border border-gray-200 dark:border-gray-600;
@apply hover:border-transparent;
@apply transform hover:scale-105;
@apply leading-tight;
}
.tag-link {
@apply inline-block px-2.5 py-0.5 rounded-full;
@apply bg-gray-100 dark:bg-gray-700;
@apply text-gray-700 dark:text-gray-300;
@apply text-sm;
@apply hover:bg-primary-500 hover:text-white;
@apply transition-all duration-200;
@apply whitespace-nowrap;
@apply shadow-sm hover:shadow-md;
@apply border border-gray-200 dark:border-gray-600;
@apply leading-tight;
}
/* ========== 表单元素 ========== */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
textarea,
select {
@apply input;
}
button[type="submit"],
input[type="submit"] {
@apply btn btn-primary;
}
}
/* ==================== 组件层 - 自定义组件 ==================== */
@layer components {
/* ========== 文章卡片 ========== */
.article-card {
@apply card mb-8;
}
.article-card:hover {
@apply transform -translate-y-1;
}
/* ========== 侧边栏组件 ========== */
.sidebar-widget {
@apply widget;
}
/* ========== 搜索框 ========== */
.search-form {
@apply relative;
}
.search-form input {
@apply w-full pr-10;
}
.search-form button {
@apply absolute right-2 top-1/2 -translate-y-1/2;
@apply text-gray-400 hover:text-primary-500;
}
/* ========== 通知消息 ========== */
.notification {
@apply fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50;
@apply animate-fade-in;
}
.notification-success {
@apply bg-green-500 text-white;
}
.notification-error {
@apply bg-red-500 text-white;
}
.notification-info {
@apply bg-blue-500 text-white;
}
/* ========== 加载动画 ========== */
.spinner {
@apply animate-spin rounded-full;
@apply border-2 border-gray-300 border-t-primary-500;
}
/* ========== OAuth登录按钮 ========== */
.oauth-button {
@apply inline-flex items-center space-x-2;
@apply px-4 py-2 rounded-md;
@apply bg-white dark:bg-gray-800;
@apply border border-gray-300 dark:border-gray-600;
@apply hover:bg-gray-50 dark:hover:bg-gray-700;
@apply transition-all duration-200;
}
.oauth-button img {
@apply w-5 h-5;
}
}
/* ==================== 插件样式适配 ==================== */
/* ========== Dark Mode 核心样式 ========== */
/* CSS变量系统 - Solarized Dark风格 */
:root {
/* 亮色主题变量 */
--dm-bg-primary: #ffffff;
--dm-bg-secondary: #f4f4f4;
--dm-bg-tertiary: #e6e6e6;
--dm-text-primary: #444444;
--dm-text-secondary: #757575;
--dm-link-color: #21759b;
--dm-link-hover: #0f3647;
--dm-border-color: #cccccc;
--dm-shadow: rgba(64, 64, 64, 0.1);
/* 过渡时长 */
--dm-transition: 300ms;
}
/* 暗色主题变量覆盖 */
html[data-theme="dark"] {
--dm-bg-primary: #002b36;
--dm-bg-secondary: #073642;
--dm-bg-tertiary: #0e4450;
--dm-text-primary: #93a1a1;
--dm-text-secondary: #839496;
--dm-link-color: #2aa198;
--dm-link-hover: #3bc1b6;
--dm-border-color: #073642;
--dm-shadow: rgba(0, 0, 0, 0.3);
}
/* 深色模式切换按钮 */
/* 深色模式切换按钮 - 确保始终可点击且清晰可见 */
.dark-mode-toggle-fixed {
/* 使用Tailwind配置的z-index值,让cssnano正确优化 */
@apply fixed top-5 right-5 z-modal;
pointer-events: auto !important; /* 强制可点击,不受 backdrop-blur 等影响 */
isolation: isolate; /* 创建独立的层叠上下文 */
will-change: transform; /* 提升到单独的合成层,避免被 backdrop-blur 影响 */
transform: translateZ(0); /* 强制硬件加速,独立渲染层 */
}
.dark-mode-toggle-btn {
@apply w-12 h-12 rounded-full;
@apply bg-white dark:bg-gray-800;
@apply border-2 border-gray-300 dark:border-gray-600;
@apply flex items-center justify-center;
@apply text-2xl cursor-pointer;
@apply shadow-lg hover:shadow-xl;
@apply transition-all duration-300;
@apply hover:scale-110 hover:rotate-12;
}
.dark-mode-toggle-btn:active {
@apply scale-95;
}
/* 图标切换 */
.dark-mode-toggle-btn .icon-dark {
display: none;
}
html[data-theme="dark"] .dark-mode-toggle-btn .icon-light {
display: none;
}
html[data-theme="dark"] .dark-mode-toggle-btn .icon-dark {
display: inline;
}
/* ========== Article Recommendation 插件适配 ========== */
.article-recommendations {
@apply my-8 p-6 card;
}
.recommendations-title {
@apply text-xl font-bold mb-4;
@apply border-b-2 border-primary-500 pb-2 inline-block;
}
.recommendations-grid {
@apply grid gap-4 mt-4;
@apply grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4;
}
.recommendation-card {
@apply bg-white dark:bg-gray-800;
@apply border border-gray-200 dark:border-gray-700;
@apply rounded-lg overflow-hidden;
@apply transition-all duration-200;
@apply hover:border-primary-500 hover:shadow-md;
}
.recommendation-link {
@apply block p-4 no-underline;
}
.recommendation-title {
@apply text-base font-medium mb-2;
@apply text-gray-900 dark:text-gray-100;
@apply hover:text-primary-500 dark:hover:text-primary-400;
@apply transition-colors duration-200;
}
.recommendation-meta {
@apply flex justify-between items-center;
@apply text-xs text-gray-500 dark:text-gray-400;
}
.recommendation-category {
@apply px-2 py-1 rounded;
@apply bg-gray-100 dark:bg-gray-700;
@apply text-gray-600 dark:text-gray-300;
}
.recommendation-date {
@apply text-gray-500 dark:text-gray-400;
}
/* 侧边栏推荐 */
.widget_recommendations .recommendations-list {
@apply space-y-3;
}
.recommendations-list .recommendation-item {
@apply pb-3 border-b border-gray-200 dark:border-gray-700;
}
.recommendations-list .recommendation-item:last-child {
@apply border-b-0;
}
.recommendations-list .recommendation-item a {
@apply text-sm hover:text-primary-500 dark:hover:text-primary-400;
}
/* ========== Article Copyright 插件适配 ========== */
.article-copyright {
@apply my-8 p-6 rounded-lg;
@apply bg-gradient-to-r from-gray-50 to-gray-100;
@apply dark:from-gray-800 dark:to-gray-900;
@apply border-l-4 border-primary-500;
}
.article-copyright-title {
@apply text-lg font-bold mb-3 text-gray-900 dark:text-gray-100;
@apply flex items-center space-x-2;
}
.article-copyright-content {
@apply text-sm text-gray-700 dark:text-gray-300 space-y-2;
}
.article-copyright-content p {
@apply mb-2;
}
.article-copyright-content a {
@apply text-primary-600 dark:text-primary-400 hover:underline;
}
/* ========== Reading Time 插件适配 ========== */
.reading-time {
@apply inline-flex items-center space-x-1;
@apply text-sm text-gray-600 dark:text-gray-400;
}
.reading-time-icon {
@apply w-4 h-4;
}
/* ========== Image Lazy Loading 插件适配 ========== */
img[loading="lazy"] {
@apply transition-opacity duration-300;
}
img[loading="lazy"]:not([src]) {
@apply opacity-0;
}
img[loading="lazy"][src] {
@apply opacity-100;
}
/* 图片加载占位符 */
.lazy-loading-placeholder {
@apply bg-gray-200 dark:bg-gray-700 animate-pulse;
}
/* ========== View Count 插件适配 ========== */
.view-count {
@apply inline-flex items-center space-x-1;
@apply text-sm text-gray-600 dark:text-gray-400;
}
.view-count-icon {
@apply w-4 h-4;
}
/* ========== SEO Optimizer 插件适配 ========== */
/* SEO插件通常不需要额外样式,主要修改meta标签 */
/* ========== Cloudflare Cache 插件适配 ========== */
/* Cloudflare缓存插件通常不需要前端样式 */
/* ==================== 工具类层 - 自定义工具 ==================== */
@layer utilities {
/* 渐变背景 */
.bg-gradient-primary {
@apply bg-gradient-to-r from-primary-500 to-primary-600;
}
/* 文本渐变 */
.text-gradient-primary {
@apply bg-gradient-to-r from-primary-500 to-primary-600 bg-clip-text text-transparent;
}
/* 文本截断 */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 隐藏滚动条但保持滚动 */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
/* ==================== NProgress 进度条 ==================== */
#nprogress {
pointer-events: none;
}
#nprogress .bar {
@apply bg-primary-500;
position: fixed;
z-index: 1031;
top: 0;
left: 0;
width: 100%;
height: 2px;
}
#nprogress .peg {
display: block;
position: absolute;
right: 0;
width: 100px;
height: 100%;
box-shadow: 0 0 10px rgba(33, 117, 155, 0.6), 0 0 5px rgba(33, 117, 155, 0.6);
opacity: 1;
transform: rotate(3deg) translate(0px, -4px);
}
/* ==================== 回到顶部按钮 ==================== */
#rocket {
@apply fixed bottom-8 right-8 w-12 h-12;
@apply bg-primary-500 hover:bg-primary-600;
@apply text-white rounded-full;
@apply flex items-center justify-center;
@apply shadow-lg hover:shadow-xl;
@apply cursor-pointer;
z-index: 1000;
}
#rocket.move {
animation: rocketMove 0.8s ease-out;
}
@keyframes rocketMove {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-100px);
}
100% {
transform: translateY(-200vh);
opacity: 0;
}
}
/* Reaction 模态框和通知动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(100px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-fade-in {
animation: fadeIn 0.2s ease-out;
}
.animate-fade-out {
animation: fadeOut 0.2s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.2s ease-out;
}
.animate-slide-in-right {
animation: slideInRight 0.3s ease-out;
}
/* ==================== Pygments 代码高亮兼容 ==================== */
.codehilite {
@apply rounded-lg overflow-x-auto mb-4;
background-color: #f9f9f9;
border: 1px solid #e1e4e8;
/* 移动端优化 */
max-width: 100%;
-webkit-overflow-scrolling: touch;
}
/* 深色模式 - Pygments代码高亮 */
[data-theme="dark"] .codehilite {
background-color: #1e1e1e !important;
border-color: #30363d;
}
.codehilite pre {
@apply m-0;
background-color: transparent;
/* 移动端字号优化 */
@apply text-sm md:text-base;
/* 确保内容不会溢出 */
white-space: pre;
overflow-x: auto;
}
[data-theme="dark"] .codehilite pre {
color: #d4d4d4 !important;
border: none !important;
background: transparent !important;
}
[data-theme="dark"] .codehilite code {
color: #d4d4d4 !important;
background-color: transparent !important;
/* 移动端字号 */
@apply text-sm md:text-base;
}
.codehilite code {
/* 移动端字号 */
@apply text-sm md:text-base;
display: block;
}
/* 深色模式 - Pygments Token 颜色覆盖(VS Code Dark+ 风格) */
[data-theme="dark"] .codehilite .hll { background-color: #49483e !important; }
[data-theme="dark"] .codehilite .c { color: #6a9955 !important; } /* Comment */
[data-theme="dark"] .codehilite .err { color: #f48771 !important; } /* Error */
[data-theme="dark"] .codehilite .k { color: #c586c0 !important; } /* Keyword */
[data-theme="dark"] .codehilite .l { color: #ce9178 !important; } /* Literal */
[data-theme="dark"] .codehilite .n { color: #9cdcfe !important; } /* Name */
[data-theme="dark"] .codehilite .o { color: #d4d4d4 !important; } /* Operator */
[data-theme="dark"] .codehilite .p { color: #d4d4d4 !important; } /* Punctuation */
[data-theme="dark"] .codehilite .ch { color: #6a9955 !important; } /* Comment.Hashbang */
[data-theme="dark"] .codehilite .cm { color: #6a9955 !important; } /* Comment.Multiline */
[data-theme="dark"] .codehilite .cp { color: #c586c0 !important; } /* Comment.Preproc */
[data-theme="dark"] .codehilite .cpf { color: #6a9955 !important; } /* Comment.PreprocFile */
[data-theme="dark"] .codehilite .c1 { color: #6a9955 !important; } /* Comment.Single */
[data-theme="dark"] .codehilite .cs { color: #6a9955 !important; } /* Comment.Special */
[data-theme="dark"] .codehilite .gd { color: #f48771 !important; } /* Generic.Deleted */
[data-theme="dark"] .codehilite .ge { font-style: italic !important; } /* Generic.Emph */
[data-theme="dark"] .codehilite .gr { color: #f48771 !important; } /* Generic.Error */
[data-theme="dark"] .codehilite .gh { color: #4ec9b0 !important; font-weight: bold !important; } /* Generic.Heading */
[data-theme="dark"] .codehilite .gi { color: #b5cea8 !important; } /* Generic.Inserted */
[data-theme="dark"] .codehilite .go { color: #808080 !important; } /* Generic.Output */
[data-theme="dark"] .codehilite .gp { color: #808080 !important; font-weight: bold !important; } /* Generic.Prompt */
[data-theme="dark"] .codehilite .gs { font-weight: bold !important; } /* Generic.Strong */
[data-theme="dark"] .codehilite .gu { color: #4ec9b0 !important; font-weight: bold !important; } /* Generic.Subheading */
[data-theme="dark"] .codehilite .gt { color: #f48771 !important; } /* Generic.Traceback */
[data-theme="dark"] .codehilite .kc { color: #569cd6 !important; } /* Keyword.Constant */
[data-theme="dark"] .codehilite .kd { color: #569cd6 !important; } /* Keyword.Declaration */
[data-theme="dark"] .codehilite .kn { color: #c586c0 !important; } /* Keyword.Namespace */
[data-theme="dark"] .codehilite .kp { color: #569cd6 !important; } /* Keyword.Pseudo */
[data-theme="dark"] .codehilite .kr { color: #569cd6 !important; } /* Keyword.Reserved */
[data-theme="dark"] .codehilite .kt { color: #4ec9b0 !important; } /* Keyword.Type */
[data-theme="dark"] .codehilite .ld { color: #ce9178 !important; } /* Literal.Date */
[data-theme="dark"] .codehilite .m { color: #b5cea8 !important; } /* Literal.Number */
[data-theme="dark"] .codehilite .s { color: #ce9178 !important; } /* Literal.String */
[data-theme="dark"] .codehilite .na { color: #9cdcfe !important; } /* Name.Attribute */
[data-theme="dark"] .codehilite .nb { color: #dcdcaa !important; } /* Name.Builtin */
[data-theme="dark"] .codehilite .nc { color: #4ec9b0 !important; } /* Name.Class */
[data-theme="dark"] .codehilite .no { color: #4ec9b0 !important; } /* Name.Constant */
[data-theme="dark"] .codehilite .nd { color: #dcdcaa !important; } /* Name.Decorator */
[data-theme="dark"] .codehilite .ni { color: #4ec9b0 !important; } /* Name.Entity */
[data-theme="dark"] .codehilite .ne { color: #4ec9b0 !important; } /* Name.Exception */
[data-theme="dark"] .codehilite .nf { color: #dcdcaa !important; } /* Name.Function */
[data-theme="dark"] .codehilite .nl { color: #9cdcfe !important; } /* Name.Label */
[data-theme="dark"] .codehilite .nn { color: #4ec9b0 !important; } /* Name.Namespace */
[data-theme="dark"] .codehilite .nx { color: #9cdcfe !important; } /* Name.Other */
[data-theme="dark"] .codehilite .py { color: #9cdcfe !important; } /* Name.Property */
[data-theme="dark"] .codehilite .nt { color: #569cd6 !important; } /* Name.Tag */
[data-theme="dark"] .codehilite .nv { color: #9cdcfe !important; } /* Name.Variable */
[data-theme="dark"] .codehilite .ow { color: #569cd6 !important; } /* Operator.Word */
[data-theme="dark"] .codehilite .w { color: #d4d4d4 !important; } /* Text.Whitespace */
[data-theme="dark"] .codehilite .mb { color: #b5cea8 !important; } /* Literal.Number.Bin */
[data-theme="dark"] .codehilite .mf { color: #b5cea8 !important; } /* Literal.Number.Float */
[data-theme="dark"] .codehilite .mh { color: #b5cea8 !important; } /* Literal.Number.Hex */
[data-theme="dark"] .codehilite .mi { color: #b5cea8 !important; } /* Literal.Number.Integer */
[data-theme="dark"] .codehilite .mo { color: #b5cea8 !important; } /* Literal.Number.Oct */
[data-theme="dark"] .codehilite .sa { color: #ce9178 !important; } /* Literal.String.Affix */
[data-theme="dark"] .codehilite .sb { color: #ce9178 !important; } /* Literal.String.Backtick */
[data-theme="dark"] .codehilite .sc { color: #ce9178 !important; } /* Literal.String.Char */
[data-theme="dark"] .codehilite .dl { color: #ce9178 !important; } /* Literal.String.Delimiter */
[data-theme="dark"] .codehilite .sd { color: #6a9955 !important; } /* Literal.String.Doc */
[data-theme="dark"] .codehilite .s2 { color: #ce9178 !important; } /* Literal.String.Double */
[data-theme="dark"] .codehilite .se { color: #d7ba7d !important; } /* Literal.String.Escape */
[data-theme="dark"] .codehilite .sh { color: #ce9178 !important; } /* Literal.String.Heredoc */
[data-theme="dark"] .codehilite .si { color: #d7ba7d !important; } /* Literal.String.Interpol */
[data-theme="dark"] .codehilite .sx { color: #ce9178 !important; } /* Literal.String.Other */
[data-theme="dark"] .codehilite .sr { color: #d16969 !important; } /* Literal.String.Regex */
[data-theme="dark"] .codehilite .s1 { color: #ce9178 !important; } /* Literal.String.Single */
[data-theme="dark"] .codehilite .ss { color: #ce9178 !important; } /* Literal.String.Symbol */
[data-theme="dark"] .codehilite .bp { color: #dcdcaa !important; } /* Name.Builtin.Pseudo */
[data-theme="dark"] .codehilite .fm { color: #dcdcaa !important; } /* Name.Function.Magic */
[data-theme="dark"] .codehilite .vc { color: #9cdcfe !important; } /* Name.Variable.Class */
[data-theme="dark"] .codehilite .vg { color: #9cdcfe !important; } /* Name.Variable.Global */
[data-theme="dark"] .codehilite .vi { color: #9cdcfe !important; } /* Name.Variable.Instance */
[data-theme="dark"] .codehilite .vm { color: #9cdcfe !important; } /* Name.Variable.Magic */
[data-theme="dark"] .codehilite .il { color: #b5cea8 !important; } /* Literal.Number.Integer.Long */
/* ==================== 响应式优化 ==================== */
@media (max-width: 768px) {
/* 移动端优化 */
.site-header {
@apply px-2;
}
.wrapper {
@apply px-2;
}
.comment-body {
@apply p-4;
}
.children {
@apply ml-4;
}
}
/* ==================== 打印样式 ==================== */
@media print {
.site-header,
.main-navigation,
.widget-area,
.site-footer,
#rocket,
.dark-mode-toggle-fixed,
.comment-respond {
@apply hidden;
}
.wrapper {
@apply block;
}
.site-content {
@apply w-full;
}
}
/* ==================== Alpine.js 支持 ==================== */
/* x-cloak: 隐藏未初始化的Alpine组件 */
[x-cloak] {
display: none !important;
}
/* ==================== 辅助功能 ==================== */
/* 跳过链接(屏幕阅读器) */
.skip-link {
@apply sr-only focus:not-sr-only;
@apply fixed top-2 left-2 z-50;
@apply bg-primary-500 text-white px-4 py-2 rounded;
}
/* 焦点可见性 */
:focus-visible {
@apply outline-2 outline-offset-2 outline-primary-500;
}
/* ==================== 兼容性 ==================== */
/* 平滑过渡所有颜色变化 */
* {
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
}
/* 禁用某些元素的过渡 */
button,
input,
textarea,
select {
transition: none;
}
button:hover,
input:focus,
textarea:focus,
select:focus {
transition: all 0.2s ease;
}
================================================
FILE: frontend/src/utils/nprogress.js
================================================
/**
* 简化版 NProgress 进度条
* 保留原有功能
*/
const NProgress = {
settings: {
minimum: 0.08,
easing: 'ease',
speed: 200,
trickle: true,
trickleSpeed: 200,
showSpinner: true,
},
status: null,
configure(options) {
Object.assign(this.settings, options);
return this;
},
set(n) {
const started = this.isStarted();
n = this.clamp(n, this.settings.minimum, 1);
this.status = n === 1 ? null : n;
const progress = this.render(!started);
const bar = progress.querySelector('.bar');
const speed = this.settings.speed;
const ease = this.settings.easing;
progress.offsetWidth; // Repaint
this.queue((next) => {
bar.style.transition = `all ${speed}ms ${ease}`;
bar.style.width = n * 100 + '%';
if (n === 1) {
progress.style.transition = `all ${speed}ms ${ease}`;
progress.style.opacity = '0';
setTimeout(() => {
this.remove();
next();
}, speed);
} else {
setTimeout(next, speed);
}
});
return this;
},
isStarted() {
return typeof this.status === 'number';
},
start() {
if (!this.status) this.set(0);
const work = () => {
setTimeout(() => {
if (!this.status) return;
this.trickle();
work();
}, this.settings.trickleSpeed);
};
if (this.settings.trickle) work();
return this;
},
done(force) {
if (!force && !this.status) return this;
return this.inc(0.3 + 0.5 * Math.random()).set(1);
},
inc(amount) {
let n = this.status;
if (!n) {
return this.start();
}
if (typeof amount !== 'number') {
amount = (1 - n) * this.clamp(Math.random() * n, 0.1, 0.95);
}
n = this.clamp(n + amount, 0, 0.994);
return this.set(n);
},
trickle() {
return this.inc(Math.random() * 0.02);
},
render(fromStart) {
if (this.isRendered()) return document.getElementById('nprogress');
const progress = document.createElement('div');
progress.id = 'nprogress';
progress.innerHTML = '';
const bar = progress.querySelector('.bar');
const perc = fromStart ? 0 : (this.status || 0) * 100;
bar.style.transition = 'none';
bar.style.width = perc + '%';
if (!this.settings.showSpinner) {
const spinner = progress.querySelector('.spinner');
spinner && spinner.remove();
}
document.body.appendChild(progress);
return progress;
},
remove() {
const progress = document.getElementById('nprogress');
progress && progress.remove();
},
isRendered() {
return !!document.getElementById('nprogress');
},
clamp(n, min, max) {
if (n < min) return min;
if (n > max) return max;
return n;
},
toBarPerc(n) {
return (-1 + n) * 100;
},
queue: (function () {
const pending = [];
function next() {
const fn = pending.shift();
if (fn) fn(next);
}
return function (fn) {
pending.push(fn);
if (pending.length === 1) next();
};
})(),
};
export default NProgress;
================================================
FILE: frontend/tailwind.config.js
================================================
/** @type {import('tailwindcss').Config} */
export default {
// 扫描这些文件以提取使用的CSS类
content: [
"./src/**/*.{js,jsx,ts,tsx}",
"../templates/**/*.html",
"../blog/templates/**/*.html",
"../accounts/templates/**/*.html",
"../comments/templates/**/*.html",
"../oauth/templates/**/*.html",
],
// 深色模式配置 - 使用 data-theme 属性,与 dark_mode 插件配合
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
// 自定义颜色,使用CSS变量支持动态主题
colors: {
// 主题色系统
primary: {
50: 'rgb(var(--color-primary-50) / )',
100: 'rgb(var(--color-primary-100) / )',
200: 'rgb(var(--color-primary-200) / )',
300: 'rgb(var(--color-primary-300) / )',
400: 'rgb(var(--color-primary-400) / )',
500: 'rgb(var(--color-primary-500) / )',
600: 'rgb(var(--color-primary-600) / )',
700: 'rgb(var(--color-primary-700) / )',
800: 'rgb(var(--color-primary-800) / )',
900: 'rgb(var(--color-primary-900) / )',
},
// 语义化颜色(对标 Next.js)
border: 'rgb(var(--border) / )',
input: 'rgb(var(--input) / )',
ring: 'rgb(var(--ring) / )',
background: 'rgb(var(--background) / )',
foreground: 'rgb(var(--foreground) / )',
card: {
DEFAULT: 'rgb(var(--card) / )',
foreground: 'rgb(var(--card-foreground) / )',
},
muted: {
DEFAULT: 'rgb(var(--muted) / )',
foreground: 'rgb(var(--muted-foreground) / )',
},
secondary: {
DEFAULT: 'rgb(var(--secondary) / )',
foreground: 'rgb(var(--secondary-foreground) / )',
},
accent: {
DEFAULT: 'rgb(var(--accent) / )',
foreground: 'rgb(var(--accent-foreground) / )',
},
destructive: {
DEFAULT: 'rgb(var(--destructive) / )',
foreground: 'rgb(var(--destructive-foreground) / )',
},
},
// Z-index 层级定义
zIndex: {
'modal': '9999', // 深色模式按钮等固定元素
},
// 字体家族
fontFamily: {
sans: ['Open Sans', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
mono: ['Consolas', 'Monaco', 'Courier New', 'monospace'],
},
// 容器最大宽度,与现有布局一致
maxWidth: {
'site': '1040px',
},
// 动画
animation: {
'fade-in': 'fadeIn 0.3s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideDown: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [
require('@tailwindcss/typography'),
],
};
================================================
FILE: frontend/vite.config.js
================================================
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
// 构建配置
build: {
// 输出目录 - 直接输出到Django的static目录
outDir: '../blog/static/blog/dist',
// 清空输出目录
emptyOutDir: true,
// 生成manifest文件,方便Django引用
manifest: true,
// 压缩配置 - 最高级别
minify: 'terser',
terserOptions: {
compress: {
// 移除 console 和 debugger
drop_console: true,
drop_debugger: true,
// 移除未使用的代码
pure_funcs: ['console.log', 'console.info', 'console.debug', 'console.warn'],
// 移除死代码
dead_code: true,
// 使用更激进的优化
passes: 3,
// 移除未使用的函数参数
keep_fargs: false,
// 移除未使用的函数名
keep_fnames: false,
// 移除未使用的类名
keep_classnames: false,
// 内联函数
inline: 3,
// 移除不可达代码
conditionals: true,
// 优化布尔表达式
booleans: true,
// 优化循环
loops: true,
// 合并变量声明
join_vars: true,
// 移除未使用的变量
unused: true,
// 折叠常量
evaluate: true,
// 优化 if 语句
if_return: true,
// 移除空语句
sequences: true,
// 压缩属性访问
properties: true,
},
mangle: {
// 混淆变量名
toplevel: true,
// 混淆属性名(谨慎使用)
properties: false,
// 保留类名(避免 Alpine.js 等框架问题)
keep_classnames: false,
keep_fnames: false,
// Safari 10 兼容
safari10: true,
},
format: {
// 移除注释
comments: false,
// 使用 ASCII 输出
ascii_only: true,
// 紧凑输出
beautify: false,
// 压缩到极致
ecma: 2020,
},
},
// 启用 CSS 压缩
cssMinify: true,
// 代码分割阈值(字节)
chunkSizeWarningLimit: 500,
// 报告压缩后的大小
reportCompressedSize: true,
// Rollup 优化配置(合并后)
rollupOptions: {
input: {
main: path.resolve(__dirname, 'src/main.js'),
},
output: {
// 资源文件命名
entryFileNames: 'js/[name]-[hash].js',
chunkFileNames: 'js/[name]-[hash].js',
assetFileNames: (assetInfo) => {
// CSS文件放在css目录
if (assetInfo.name.endsWith('.css')) {
return 'css/[name]-[hash][extname]';
}
// 其他资源放在assets目录
return 'assets/[name]-[hash][extname]';
},
// 手动代码分割
manualChunks: (id) => {
// 将 node_modules 中的包分离
if (id.includes('node_modules')) {
if (id.includes('alpinejs')) return 'alpine';
if (id.includes('htmx')) return 'htmx';
return 'vendor';
}
},
// 最小化输出
compact: true,
// 不生成 sourcemap
sourcemap: false,
},
},
},
// 开发服务器配置
server: {
port: 5173,
host: true,
// CORS配置,允许Django访问
cors: true,
// HMR配置
hmr: {
overlay: true,
},
},
// 路径解析
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@components': path.resolve(__dirname, 'src/components'),
'@styles': path.resolve(__dirname, 'src/styles'),
},
},
// CSS配置
css: {
postcss: './postcss.config.js',
},
});
================================================
FILE: locale/en/LC_MESSAGES/django.po
================================================
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR , YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-09-13 16:02+0800\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: .\accounts\admin.py:12
msgid "password"
msgstr "password"
#: .\accounts\admin.py:13
msgid "Enter password again"
msgstr "Enter password again"
#: .\accounts\admin.py:24 .\accounts\forms.py:89
msgid "passwords do not match"
msgstr "passwords do not match"
#: .\accounts\forms.py:36
msgid "email already exists"
msgstr "email already exists"
#: .\accounts\forms.py:46 .\accounts\forms.py:50
msgid "New password"
msgstr "New password"
#: .\accounts\forms.py:60
msgid "Confirm password"
msgstr "Confirm password"
#: .\accounts\forms.py:70 .\accounts\forms.py:116
msgid "Email"
msgstr "Email"
#: .\accounts\forms.py:76 .\accounts\forms.py:80
msgid "Code"
msgstr "Code"
#: .\accounts\forms.py:100 .\accounts\tests.py:194
msgid "email does not exist"
msgstr "email does not exist"
#: .\accounts\models.py:12 .\oauth\models.py:17
msgid "nick name"
msgstr "nick name"
#: .\accounts\models.py:13 .\blog\models.py:29 .\blog\models.py:266
#: .\blog\models.py:284 .\comments\models.py:13 .\oauth\models.py:23
#: .\oauth\models.py:53
msgid "creation time"
msgstr "creation time"
#: .\accounts\models.py:14 .\comments\models.py:14 .\oauth\models.py:24
#: .\oauth\models.py:54
msgid "last modify time"
msgstr "last modify time"
#: .\accounts\models.py:15
msgid "create source"
msgstr "create source"
#: .\accounts\models.py:33 .\djangoblog\logentryadmin.py:81
msgid "user"
msgstr "user"
#: .\accounts\tests.py:216 .\accounts\utils.py:39
msgid "Verification code error"
msgstr "Verification code error"
#: .\accounts\utils.py:13
msgid "Verify Email"
msgstr "Verify Email"
#: .\accounts\utils.py:21
#, python-format
msgid ""
"You are resetting the password, the verification code is:%(code)s, valid "
"within 5 minutes, please keep it properly"
msgstr ""
"You are resetting the password, the verification code is:%(code)s, valid "
"within 5 minutes, please keep it properly"
#: .\blog\admin.py:13 .\blog\models.py:92 .\comments\models.py:17
#: .\oauth\models.py:12
msgid "author"
msgstr "author"
#: .\blog\admin.py:53
msgid "Publish selected articles"
msgstr "Publish selected articles"
#: .\blog\admin.py:54
msgid "Draft selected articles"
msgstr "Draft selected articles"
#: .\blog\admin.py:55
msgid "Close article comments"
msgstr "Close article comments"
#: .\blog\admin.py:56
msgid "Open article comments"
msgstr "Open article comments"
#: .\blog\admin.py:89 .\blog\models.py:101 .\blog\models.py:183
#: .\templates\blog\tags\sidebar.html:40
msgid "category"
msgstr "category"
#: .\blog\models.py:20 .\blog\models.py:179 .\templates\share_layout\nav.html:8
msgid "index"
msgstr "index"
#: .\blog\models.py:21
msgid "list"
msgstr "list"
#: .\blog\models.py:22
msgid "post"
msgstr "post"
#: .\blog\models.py:23
msgid "all"
msgstr "all"
#: .\blog\models.py:24
msgid "slide"
msgstr "slide"
#: .\blog\models.py:30 .\blog\models.py:267 .\blog\models.py:285
msgid "modify time"
msgstr "modify time"
#: .\blog\models.py:63
msgid "Draft"
msgstr "Draft"
#: .\blog\models.py:64
msgid "Published"
msgstr "Published"
#: .\blog\models.py:67
msgid "Open"
msgstr "Open"
#: .\blog\models.py:68
msgid "Close"
msgstr "Close"
#: .\blog\models.py:71 .\comments\admin.py:47
msgid "Article"
msgstr "Article"
#: .\blog\models.py:72
msgid "Page"
msgstr "Page"
#: .\blog\models.py:74 .\blog\models.py:280
msgid "title"
msgstr "title"
#: .\blog\models.py:75
msgid "body"
msgstr "body"
#: .\blog\models.py:77
msgid "publish time"
msgstr "publish time"
#: .\blog\models.py:79
msgid "status"
msgstr "status"
#: .\blog\models.py:84
msgid "comment status"
msgstr "comment status"
#: .\blog\models.py:88 .\oauth\models.py:43
msgid "type"
msgstr "type"
#: .\blog\models.py:89
msgid "views"
msgstr "views"
#: .\blog\models.py:97 .\blog\models.py:258 .\blog\models.py:282
msgid "order"
msgstr "order"
#: .\blog\models.py:98
msgid "show toc"
msgstr "show toc"
#: .\blog\models.py:105 .\blog\models.py:249
msgid "tag"
msgstr "tag"
#: .\blog\models.py:115 .\comments\models.py:21
msgid "article"
msgstr "article"
#: .\blog\models.py:171
msgid "category name"
msgstr "category name"
#: .\blog\models.py:174
msgid "parent category"
msgstr "parent category"
#: .\blog\models.py:234
msgid "tag name"
msgstr "tag name"
#: .\blog\models.py:256
msgid "link name"
msgstr "link name"
#: .\blog\models.py:257 .\blog\models.py:271
msgid "link"
msgstr "link"
#: .\blog\models.py:260
msgid "is show"
msgstr "is show"
#: .\blog\models.py:262
msgid "show type"
msgstr "show type"
#: .\blog\models.py:281
msgid "content"
msgstr "content"
#: .\blog\models.py:283 .\oauth\models.py:52
msgid "is enable"
msgstr "is enable"
#: .\blog\models.py:289
msgid "sidebar"
msgstr "sidebar"
#: .\blog\models.py:299
msgid "site name"
msgstr "site name"
#: .\blog\models.py:305
msgid "site description"
msgstr "site description"
#: .\blog\models.py:311
msgid "site seo description"
msgstr "site seo description"
#: .\blog\models.py:313
msgid "site keywords"
msgstr "site keywords"
#: .\blog\models.py:318
msgid "article sub length"
msgstr "article sub length"
#: .\blog\models.py:319
msgid "sidebar article count"
msgstr "sidebar article count"
#: .\blog\models.py:320
msgid "sidebar comment count"
msgstr "sidebar comment count"
#: .\blog\models.py:321
msgid "article comment count"
msgstr "article comment count"
#: .\blog\models.py:322
msgid "show adsense"
msgstr "show adsense"
#: .\blog\models.py:324
msgid "adsense code"
msgstr "adsense code"
#: .\blog\models.py:325
msgid "open site comment"
msgstr "open site comment"
#: .\blog\models.py:352
msgid "Website configuration"
msgstr "Website configuration"
#: .\blog\models.py:360
msgid "There can only be one configuration"
msgstr "There can only be one configuration"
#: .\blog\views.py:348
msgid ""
"Sorry, the page you requested is not found, please click the home page to "
"see other?"
msgstr ""
"Sorry, the page you requested is not found, please click the home page to "
"see other?"
#: .\blog\views.py:356
msgid "Sorry, the server is busy, please click the home page to see other?"
msgstr "Sorry, the server is busy, please click the home page to see other?"
#: .\blog\views.py:369
msgid "Sorry, you do not have permission to access this page?"
msgstr "Sorry, you do not have permission to access this page?"
#: .\comments\admin.py:15
msgid "Disable comments"
msgstr "Disable comments"
#: .\comments\admin.py:16
msgid "Enable comments"
msgstr "Enable comments"
#: .\comments\admin.py:46
msgid "User"
msgstr "User"
#: .\comments\models.py:25
msgid "parent comment"
msgstr "parent comment"
#: .\comments\models.py:29
msgid "enable"
msgstr "enable"
#: .\comments\models.py:34 .\templates\blog\tags\article_info.html:30
msgid "comment"
msgstr "comment"
#: .\comments\utils.py:13
msgid "Thanks for your comment"
msgstr "Thanks for your comment"
#: .\comments\utils.py:15
#, python-format
msgid ""
"Thank you very much for your comments on this site
\n"
" You can visit %(article_title)s \n"
" to review your comments,\n"
" Thank you again!\n"
" \n"
" If the link above cannot be opened, please copy this "
"link to your browser.\n"
" %(article_url)s"
msgstr ""
"Thank you very much for your comments on this site
\n"
" You can visit %(article_title)s \n"
" to review your comments,\n"
" Thank you again!\n"
" \n"
" If the link above cannot be opened, please copy this "
"link to your browser.\n"
" %(article_url)s"
#: .\comments\utils.py:26
#, python-format
msgid ""
"Your comment on "
"%(article_title)s has \n"
" received a reply. %(comment_body)s\n"
" \n"
" go check it out!\n"
" \n"
" If the link above cannot be opened, please copy this "
"link to your browser.\n"
" %(article_url)s\n"
" "
msgstr ""
"Your comment on "
"%(article_title)s has \n"
" received a reply. %(comment_body)s\n"
" \n"
" go check it out!\n"
" \n"
" If the link above cannot be opened, please copy this "
"link to your browser.\n"
" %(article_url)s\n"
" "
#: .\djangoblog\logentryadmin.py:63
msgid "object"
msgstr "object"
#: .\djangoblog\settings.py:140
msgid "English"
msgstr "English"
#: .\djangoblog\settings.py:141
msgid "Simplified Chinese"
msgstr "Simplified Chinese"
#: .\djangoblog\settings.py:142
msgid "Traditional Chinese"
msgstr "Traditional Chinese"
#: .\oauth\models.py:30
msgid "oauth user"
msgstr "oauth user"
#: .\oauth\models.py:37
msgid "weibo"
msgstr "weibo"
#: .\oauth\models.py:38
msgid "google"
msgstr "google"
#: .\oauth\models.py:48
msgid "callback url"
msgstr "callback url"
#: .\oauth\models.py:59
msgid "already exists"
msgstr "already exists"
#: .\oauth\views.py:154
#, python-format
msgid ""
"\n"
" Congratulations, you have successfully bound your email address. You "
"can use\n"
" %(oauthuser_type)s to directly log in to this website without a "
"password.
\n"
" You are welcome to continue to follow this site, the address is\n"
" %(site)s \n"
" Thank you again!\n"
" \n"
" If the link above cannot be opened, please copy this link to your "
"browser.\n"
" %(site)s\n"
" "
msgstr ""
"\n"
" Congratulations, you have successfully bound your email address. You "
"can use\n"
" %(oauthuser_type)s to directly log in to this website without a "
"password.
\n"
" You are welcome to continue to follow this site, the address is\n"
" %(site)s \n"
" Thank you again!\n"
" \n"
" If the link above cannot be opened, please copy this link to your "
"browser.\n"
" %(site)s\n"
" "
#: .\oauth\views.py:165
msgid "Congratulations on your successful binding!"
msgstr "Congratulations on your successful binding!"
#: .\oauth\views.py:217
#, python-format
msgid ""
"\n"
" Please click the link below to bind your email
\n"
"\n"
" %(url)s \n"
"\n"
" Thank you again!\n"
" \n"
" If the link above cannot be opened, please copy this link "
"to your browser.\n"
" \n"
" %(url)s\n"
" "
msgstr ""
"\n"
" Please click the link below to bind your email
\n"
"\n"
" %(url)s \n"
"\n"
" Thank you again!\n"
" \n"
" If the link above cannot be opened, please copy this link "
"to your browser.\n"
" \n"
" %(url)s\n"
" "
#: .\oauth\views.py:228 .\oauth\views.py:240
msgid "Bind your email"
msgstr "Bind your email"
#: .\oauth\views.py:242
msgid ""
"Congratulations, the binding is just one step away. Please log in to your "
"email to check the email to complete the binding. Thank you."
msgstr ""
"Congratulations, the binding is just one step away. Please log in to your "
"email to check the email to complete the binding. Thank you."
#: .\oauth\views.py:245
msgid "Binding successful"
msgstr "Binding successful"
#: .\oauth\views.py:247
#, python-format
msgid ""
"Congratulations, you have successfully bound your email address. You can use "
"%(oauthuser_type)s to directly log in to this website without a password. "
"You are welcome to continue to follow this site."
msgstr ""
"Congratulations, you have successfully bound your email address. You can use "
"%(oauthuser_type)s to directly log in to this website without a password. "
"You are welcome to continue to follow this site."
#: .\templates\account\forget_password.html:7
msgid "forget the password"
msgstr "forget the password"
#: .\templates\account\forget_password.html:18
msgid "get verification code"
msgstr "get verification code"
#: .\templates\account\forget_password.html:19
msgid "submit"
msgstr "submit"
#: .\templates\account\login.html:36
msgid "Create Account"
msgstr "Create Account"
#: .\templates\account\login.html:42
#, fuzzy
#| msgid "forget the password"
msgid "Forget Password"
msgstr "forget the password"
#: .\templates\account\result.html:18 .\templates\blog\tags\sidebar.html:126
msgid "login"
msgstr "login"
#: .\templates\account\result.html:22
msgid "back to the homepage"
msgstr "back to the homepage"
#: .\templates\blog\article_archives.html:7
#: .\templates\blog\article_archives.html:24
msgid "article archive"
msgstr "article archive"
#: .\templates\blog\article_archives.html:32
msgid "year"
msgstr "year"
#: .\templates\blog\article_archives.html:36
msgid "month"
msgstr "month"
#: .\templates\blog\tags\article_info.html:12
msgid "pin to top"
msgstr "pin to top"
#: .\templates\blog\tags\article_info.html:28
msgid "comments"
msgstr "comments"
#: .\templates\blog\tags\article_info.html:58
msgid "toc"
msgstr "toc"
#: .\templates\blog\tags\article_meta_info.html:6
msgid "posted in"
msgstr "posted in"
#: .\templates\blog\tags\article_meta_info.html:14
msgid "and tagged"
msgstr "and tagged"
#: .\templates\blog\tags\article_meta_info.html:25
msgid "by "
msgstr "by"
#: .\templates\blog\tags\article_meta_info.html:29
#, python-format
msgid ""
"\n"
" title=\"View all articles published by "
"%(article.author.username)s\"\n"
" "
msgstr ""
"\n"
" title=\"View all articles published by "
"%(article.author.username)s\"\n"
" "
#: .\templates\blog\tags\article_meta_info.html:44
msgid "on"
msgstr "on"
#: .\templates\blog\tags\article_meta_info.html:54
msgid "edit"
msgstr "edit"
#: .\templates\blog\tags\article_pagination.html:4
msgid "article navigation"
msgstr "article navigation"
#: .\templates\blog\tags\article_pagination.html:9
msgid "earlier articles"
msgstr "earlier articles"
#: .\templates\blog\tags\article_pagination.html:12
msgid "newer articles"
msgstr "newer articles"
#: .\templates\blog\tags\article_tag_list.html:5
msgid "tags"
msgstr "tags"
#: .\templates\blog\tags\sidebar.html:7
msgid "search"
msgstr "search"
#: .\templates\blog\tags\sidebar.html:50
msgid "recent comments"
msgstr "recent comments"
#: .\templates\blog\tags\sidebar.html:57
msgid "published on"
msgstr "published on"
#: .\templates\blog\tags\sidebar.html:65
msgid "recent articles"
msgstr "recent articles"
#: .\templates\blog\tags\sidebar.html:77
msgid "bookmark"
msgstr "bookmark"
#: .\templates\blog\tags\sidebar.html:96
msgid "Tag Cloud"
msgstr "Tag Cloud"
#: .\templates\blog\tags\sidebar.html:107
msgid "Welcome to star or fork the source code of this site"
msgstr "Welcome to star or fork the source code of this site"
#: .\templates\blog\tags\sidebar.html:118
msgid "Function"
msgstr "Function"
#: .\templates\blog\tags\sidebar.html:120
msgid "management site"
msgstr "management site"
#: .\templates\blog\tags\sidebar.html:122
msgid "logout"
msgstr "logout"
#: .\templates\blog\tags\sidebar.html:129
msgid "Track record"
msgstr "Track record"
#: .\templates\blog\tags\sidebar.html:135
msgid "Click me to return to the top"
msgstr "Click me to return to the top"
#: .\templates\oauth\oauth_applications.html:5
#| msgid "login"
msgid "quick login"
msgstr "quick login"
#: .\templates\share_layout\nav.html:26
msgid "Article archive"
msgstr "Article archive"
#: templates/blog/article_detail.html templates/blog/article_index.html
msgid "Discover more"
msgstr "Discover more"
================================================
FILE: locale/zh_Hans/LC_MESSAGES/django.po
================================================
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR , YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-09-13 16:02+0800\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: .\accounts\admin.py:12
msgid "password"
msgstr "密码"
#: .\accounts\admin.py:13
msgid "Enter password again"
msgstr "再次输入密码"
#: .\accounts\admin.py:24 .\accounts\forms.py:89
msgid "passwords do not match"
msgstr "密码不匹配"
#: .\accounts\forms.py:36
msgid "email already exists"
msgstr "邮箱已存在"
#: .\accounts\forms.py:46 .\accounts\forms.py:50
msgid "New password"
msgstr "新密码"
#: .\accounts\forms.py:60
msgid "Confirm password"
msgstr "确认密码"
#: .\accounts\forms.py:70 .\accounts\forms.py:116
msgid "Email"
msgstr "邮箱"
#: .\accounts\forms.py:76 .\accounts\forms.py:80
msgid "Code"
msgstr "验证码"
#: .\accounts\forms.py:100 .\accounts\tests.py:194
msgid "email does not exist"
msgstr "邮箱不存在"
#: .\accounts\models.py:12 .\oauth\models.py:17
msgid "nick name"
msgstr "昵称"
#: .\accounts\models.py:13 .\blog\models.py:29 .\blog\models.py:266
#: .\blog\models.py:284 .\comments\models.py:13 .\oauth\models.py:23
#: .\oauth\models.py:53
msgid "creation time"
msgstr "创建时间"
#: .\accounts\models.py:14 .\comments\models.py:14 .\oauth\models.py:24
#: .\oauth\models.py:54
msgid "last modify time"
msgstr "最后修改时间"
#: .\accounts\models.py:15
msgid "create source"
msgstr "来源"
#: .\accounts\models.py:33 .\djangoblog\logentryadmin.py:81
msgid "user"
msgstr "用户"
#: .\accounts\tests.py:216 .\accounts\utils.py:39
msgid "Verification code error"
msgstr "验证码错误"
#: .\accounts\utils.py:13
msgid "Verify Email"
msgstr "验证邮箱"
#: .\accounts\utils.py:21
#, python-format
msgid ""
"You are resetting the password, the verification code is:%(code)s, valid "
"within 5 minutes, please keep it properly"
msgstr "您正在重置密码,验证码为:%(code)s,5分钟内有效 请妥善保管."
#: .\blog\admin.py:13 .\blog\models.py:92 .\comments\models.py:17
#: .\oauth\models.py:12
msgid "author"
msgstr "作者"
#: .\blog\admin.py:53
msgid "Publish selected articles"
msgstr "发布选中的文章"
#: .\blog\admin.py:54
msgid "Draft selected articles"
msgstr "选中文章设为草稿"
#: .\blog\admin.py:55
msgid "Close article comments"
msgstr "关闭文章评论"
#: .\blog\admin.py:56
msgid "Open article comments"
msgstr "打开文章评论"
#: .\blog\admin.py:89 .\blog\models.py:101 .\blog\models.py:183
#: .\templates\blog\tags\sidebar.html:40
msgid "category"
msgstr "分类目录"
#: .\blog\models.py:20 .\blog\models.py:179 .\templates\share_layout\nav.html:8
msgid "index"
msgstr "首页"
#: .\blog\models.py:21
msgid "list"
msgstr "列表"
#: .\blog\models.py:22
msgid "post"
msgstr "文章"
#: .\blog\models.py:23
msgid "all"
msgstr "所有"
#: .\blog\models.py:24
msgid "slide"
msgstr "侧边栏"
#: .\blog\models.py:30 .\blog\models.py:267 .\blog\models.py:285
msgid "modify time"
msgstr "修改时间"
#: .\blog\models.py:63
msgid "Draft"
msgstr "草稿"
#: .\blog\models.py:64
msgid "Published"
msgstr "发布"
#: .\blog\models.py:67
msgid "Open"
msgstr "打开"
#: .\blog\models.py:68
msgid "Close"
msgstr "关闭"
#: .\blog\models.py:71 .\comments\admin.py:47
msgid "Article"
msgstr "文章"
#: .\blog\models.py:72
msgid "Page"
msgstr "页面"
#: .\blog\models.py:74 .\blog\models.py:280
msgid "title"
msgstr "标题"
#: .\blog\models.py:75
msgid "body"
msgstr "内容"
#: .\blog\models.py:77
msgid "publish time"
msgstr "发布时间"
#: .\blog\models.py:79
msgid "status"
msgstr "状态"
#: .\blog\models.py:84
msgid "comment status"
msgstr "评论状态"
#: .\blog\models.py:88 .\oauth\models.py:43
msgid "type"
msgstr "类型"
#: .\blog\models.py:89
msgid "views"
msgstr "阅读量"
#: .\blog\models.py:97 .\blog\models.py:258 .\blog\models.py:282
msgid "order"
msgstr "排序"
#: .\blog\models.py:98
msgid "show toc"
msgstr "显示目录"
#: .\blog\models.py:105 .\blog\models.py:249
msgid "tag"
msgstr "标签"
#: .\blog\models.py:115 .\comments\models.py:21
msgid "article"
msgstr "文章"
#: .\blog\models.py:171
msgid "category name"
msgstr "分类名"
#: .\blog\models.py:174
msgid "parent category"
msgstr "上级分类"
#: .\blog\models.py:234
msgid "tag name"
msgstr "标签名"
#: .\blog\models.py:256
msgid "link name"
msgstr "链接名"
#: .\blog\models.py:257 .\blog\models.py:271
msgid "link"
msgstr "链接"
#: .\blog\models.py:260
msgid "is show"
msgstr "是否显示"
#: .\blog\models.py:262
msgid "show type"
msgstr "显示类型"
#: .\blog\models.py:281
msgid "content"
msgstr "内容"
#: .\blog\models.py:283 .\oauth\models.py:52
msgid "is enable"
msgstr "是否启用"
#: .\blog\models.py:289
msgid "sidebar"
msgstr "侧边栏"
#: .\blog\models.py:299
msgid "site name"
msgstr "站点名称"
#: .\blog\models.py:305
msgid "site description"
msgstr "站点描述"
#: .\blog\models.py:311
msgid "site seo description"
msgstr "站点SEO描述"
#: .\blog\models.py:313
msgid "site keywords"
msgstr "关键字"
#: .\blog\models.py:318
msgid "article sub length"
msgstr "文章摘要长度"
#: .\blog\models.py:319
msgid "sidebar article count"
msgstr "侧边栏文章数目"
#: .\blog\models.py:320
msgid "sidebar comment count"
msgstr "侧边栏评论数目"
#: .\blog\models.py:321
msgid "article comment count"
msgstr "文章页面默认显示评论数目"
#: .\blog\models.py:322
msgid "show adsense"
msgstr "是否显示广告"
#: .\blog\models.py:324
msgid "adsense code"
msgstr "广告内容"
#: .\blog\models.py:325
msgid "open site comment"
msgstr "公共头部"
#: .\blog\models.py:352
msgid "Website configuration"
msgstr "网站配置"
#: .\blog\models.py:360
msgid "There can only be one configuration"
msgstr "只能有一个配置"
#: .\blog\views.py:348
msgid ""
"Sorry, the page you requested is not found, please click the home page to "
"see other?"
msgstr "抱歉,你所访问的页面找不到,请点击首页看看别的?"
#: .\blog\views.py:356
msgid "Sorry, the server is busy, please click the home page to see other?"
msgstr "抱歉,服务出错了,请点击首页看看别的?"
#: .\blog\views.py:369
msgid "Sorry, you do not have permission to access this page?"
msgstr "抱歉,你没用权限访问此页面。"
#: .\comments\admin.py:15
msgid "Disable comments"
msgstr "禁用评论"
#: .\comments\admin.py:16
msgid "Enable comments"
msgstr "启用评论"
#: .\comments\admin.py:46
msgid "User"
msgstr "用户"
#: .\comments\models.py:25
msgid "parent comment"
msgstr "上级评论"
#: .\comments\models.py:29
msgid "enable"
msgstr "启用"
#: .\comments\models.py:34 .\templates\blog\tags\article_info.html:30
msgid "comment"
msgstr "评论"
#: .\comments\utils.py:13
msgid "Thanks for your comment"
msgstr "感谢你的评论"
#: .\comments\utils.py:15
#, python-format
msgid ""
"Thank you very much for your comments on this site
\n"
" You can visit %(article_title)s \n"
" to review your comments,\n"
" Thank you again!\n"
" \n"
" If the link above cannot be opened, please copy this "
"link to your browser.\n"
" %(article_url)s"
msgstr ""
"非常感谢您对此网站的评论
\n"
" 您可以访问%(article_title)s \n"
"查看您的评论,\n"
"再次感谢您!\n"
" \n"
" 如果上面的链接打不开,请复制此链接链接到您的浏览器。\n"
"%(article_url)s"
#: .\comments\utils.py:26
#, python-format
msgid ""
"Your comment on "
"%(article_title)s has \n"
" received a reply. %(comment_body)s\n"
" \n"
" go check it out!\n"
" \n"
" If the link above cannot be opened, please copy this "
"link to your browser.\n"
" %(article_url)s\n"
" "
msgstr ""
"您对 %(article_title)s "
"的评论有\n"
" 收到回复。 %(comment_body)s\n"
" \n"
"快去看看吧!\n"
" \n"
" 如果上面的链接打不开,请复制此链接链接到您的浏览器。\n"
" %(article_url)s\n"
" "
#: .\djangoblog\logentryadmin.py:63
msgid "object"
msgstr "对象"
#: .\djangoblog\settings.py:140
msgid "English"
msgstr "英文"
#: .\djangoblog\settings.py:141
msgid "Simplified Chinese"
msgstr "简体中文"
#: .\djangoblog\settings.py:142
msgid "Traditional Chinese"
msgstr "繁体中文"
#: .\oauth\models.py:30
msgid "oauth user"
msgstr "第三方用户"
#: .\oauth\models.py:37
msgid "weibo"
msgstr "微博"
#: .\oauth\models.py:38
msgid "google"
msgstr "谷歌"
#: .\oauth\models.py:48
msgid "callback url"
msgstr "回调地址"
#: .\oauth\models.py:59
msgid "already exists"
msgstr "已经存在"
#: .\oauth\views.py:154
#, python-format
msgid ""
"\n"
" Congratulations, you have successfully bound your email address. You "
"can use\n"
" %(oauthuser_type)s to directly log in to this website without a "
"password.
\n"
" You are welcome to continue to follow this site, the address is\n"
" %(site)s \n"
" Thank you again!\n"
" \n"
" If the link above cannot be opened, please copy this link to your "
"browser.\n"
" %(site)s\n"
" "
msgstr ""
"\n"
" 恭喜你已经绑定成功 你可以使用\n"
" %(oauthuser_type)s 来免密登录本站
\n"
" 欢迎继续关注本站, 地址是\n"
" %(site)s \n"
" 再次感谢你\n"
" \n"
" 如果上面链接无法打开,请复制此链接到你的浏览器 \n"
" %(site)s\n"
" "
#: .\oauth\views.py:165
msgid "Congratulations on your successful binding!"
msgstr "恭喜你绑定成功"
#: .\oauth\views.py:217
#, python-format
msgid ""
"\n"
" Please click the link below to bind your email
\n"
"\n"
" %(url)s \n"
"\n"
" Thank you again!\n"
" \n"
" If the link above cannot be opened, please copy this link "
"to your browser.\n"
" \n"
" %(url)s\n"
" "
msgstr ""
"\n"
" 请点击下面的链接绑定您的邮箱
\n"
"\n"
" %(url)s \n"
"\n"
"再次感谢您!\n"
" \n"
"如果上面的链接打不开,请复制此链接到您的浏览器。\n"
"%(url)s\n"
" "
#: .\oauth\views.py:228 .\oauth\views.py:240
msgid "Bind your email"
msgstr "绑定邮箱"
#: .\oauth\views.py:242
msgid ""
"Congratulations, the binding is just one step away. Please log in to your "
"email to check the email to complete the binding. Thank you."
msgstr "恭喜您,还差一步就绑定成功了,请登录您的邮箱查看邮件完成绑定,谢谢。"
#: .\oauth\views.py:245
msgid "Binding successful"
msgstr "绑定成功"
#: .\oauth\views.py:247
#, python-format
msgid ""
"Congratulations, you have successfully bound your email address. You can use "
"%(oauthuser_type)s to directly log in to this website without a password. "
"You are welcome to continue to follow this site."
msgstr ""
"恭喜您绑定成功,您以后可以使用%(oauthuser_type)s来直接免密码登录本站啦,感谢"
"您对本站对关注。"
#: .\templates\account\forget_password.html:7
msgid "forget the password"
msgstr "忘记密码"
#: .\templates\account\forget_password.html:18
msgid "get verification code"
msgstr "获取验证码"
#: .\templates\account\forget_password.html:19
msgid "submit"
msgstr "提交"
#: .\templates\account\login.html:36
msgid "Create Account"
msgstr "创建账号"
#: .\templates\account\login.html:42
#| msgid "forget the password"
msgid "Forget Password"
msgstr "忘记密码"
#: .\templates\account\result.html:18 .\templates\blog\tags\sidebar.html:126
msgid "login"
msgstr "登录"
#: .\templates\account\result.html:22
msgid "back to the homepage"
msgstr "返回首页吧"
#: .\templates\blog\article_archives.html:7
#: .\templates\blog\article_archives.html:24
msgid "article archive"
msgstr "文章归档"
#: .\templates\blog\article_archives.html:32
msgid "year"
msgstr "年"
#: .\templates\blog\article_archives.html:36
msgid "month"
msgstr "月"
#: .\templates\blog\tags\article_info.html:12
msgid "pin to top"
msgstr "置顶"
#: .\templates\blog\tags\article_info.html:28
msgid "comments"
msgstr "评论"
#: .\templates\blog\tags\article_info.html:58
msgid "toc"
msgstr "目录"
#: .\templates\blog\tags\article_meta_info.html:6
msgid "posted in"
msgstr "发布于"
#: .\templates\blog\tags\article_meta_info.html:14
msgid "and tagged"
msgstr "并标记为"
#: .\templates\blog\tags\article_meta_info.html:25
msgid "by "
msgstr "由"
#: .\templates\blog\tags\article_meta_info.html:29
#, python-format
msgid ""
"\n"
" title=\"View all articles published by "
"%(article.author.username)s\"\n"
" "
msgstr ""
"\n"
" title=\"查看所有由 %(article.author.username)s\"发布的文章\n"
" "
#: .\templates\blog\tags\article_meta_info.html:44
msgid "on"
msgstr "在"
#: .\templates\blog\tags\article_meta_info.html:54
msgid "edit"
msgstr "编辑"
#: .\templates\blog\tags\article_pagination.html:4
msgid "article navigation"
msgstr "文章导航"
#: .\templates\blog\tags\article_pagination.html:9
msgid "earlier articles"
msgstr "早期文章"
#: .\templates\blog\tags\article_pagination.html:12
msgid "newer articles"
msgstr "较新文章"
#: .\templates\blog\tags\article_tag_list.html:5
msgid "tags"
msgstr "标签"
#: .\templates\blog\tags\sidebar.html:7
msgid "search"
msgstr "搜索"
#: .\templates\blog\tags\sidebar.html:50
msgid "recent comments"
msgstr "近期评论"
#: .\templates\blog\tags\sidebar.html:57
msgid "published on"
msgstr "发表于"
#: .\templates\blog\tags\sidebar.html:65
msgid "recent articles"
msgstr "近期文章"
#: .\templates\blog\tags\sidebar.html:77
msgid "bookmark"
msgstr "书签"
#: .\templates\blog\tags\sidebar.html:96
msgid "Tag Cloud"
msgstr "标签云"
#: .\templates\blog\tags\sidebar.html:107
msgid "Welcome to star or fork the source code of this site"
msgstr "欢迎您STAR或者FORK本站源代码"
#: .\templates\blog\tags\sidebar.html:118
msgid "Function"
msgstr "功能"
#: .\templates\blog\tags\sidebar.html:120
msgid "management site"
msgstr "管理站点"
#: .\templates\blog\tags\sidebar.html:122
msgid "logout"
msgstr "登出"
#: .\templates\blog\tags\sidebar.html:129
msgid "Track record"
msgstr "运动轨迹记录"
#: .\templates\blog\tags\sidebar.html:135
msgid "Click me to return to the top"
msgstr "点我返回顶部"
#: .\templates\oauth\oauth_applications.html:5
#| msgid "login"
msgid "quick login"
msgstr "快捷登录"
#: .\templates\share_layout\nav.html:26
msgid "Article archive"
msgstr "文章归档"
#: templates/blog/article_detail.html templates/blog/article_index.html
msgid "Discover more"
msgstr "发现更多"
================================================
FILE: locale/zh_Hant/LC_MESSAGES/django.po
================================================
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR , YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-09-13 16:02+0800\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: .\accounts\admin.py:12
msgid "password"
msgstr "密碼"
#: .\accounts\admin.py:13
msgid "Enter password again"
msgstr "再次輸入密碼"
#: .\accounts\admin.py:24 .\accounts\forms.py:89
msgid "passwords do not match"
msgstr "密碼不匹配"
#: .\accounts\forms.py:36
msgid "email already exists"
msgstr "郵箱已存在"
#: .\accounts\forms.py:46 .\accounts\forms.py:50
msgid "New password"
msgstr "新密碼"
#: .\accounts\forms.py:60
msgid "Confirm password"
msgstr "確認密碼"
#: .\accounts\forms.py:70 .\accounts\forms.py:116
msgid "Email"
msgstr "郵箱"
#: .\accounts\forms.py:76 .\accounts\forms.py:80
msgid "Code"
msgstr "驗證碼"
#: .\accounts\forms.py:100 .\accounts\tests.py:194
msgid "email does not exist"
msgstr "郵箱不存在"
#: .\accounts\models.py:12 .\oauth\models.py:17
msgid "nick name"
msgstr "昵稱"
#: .\accounts\models.py:13 .\blog\models.py:29 .\blog\models.py:266
#: .\blog\models.py:284 .\comments\models.py:13 .\oauth\models.py:23
#: .\oauth\models.py:53
msgid "creation time"
msgstr "創建時間"
#: .\accounts\models.py:14 .\comments\models.py:14 .\oauth\models.py:24
#: .\oauth\models.py:54
msgid "last modify time"
msgstr "最後修改時間"
#: .\accounts\models.py:15
msgid "create source"
msgstr "來源"
#: .\accounts\models.py:33 .\djangoblog\logentryadmin.py:81
msgid "user"
msgstr "用戶"
#: .\accounts\tests.py:216 .\accounts\utils.py:39
msgid "Verification code error"
msgstr "驗證碼錯誤"
#: .\accounts\utils.py:13
msgid "Verify Email"
msgstr "驗證郵箱"
#: .\accounts\utils.py:21
#, python-format
msgid ""
"You are resetting the password, the verification code is:%(code)s, valid "
"within 5 minutes, please keep it properly"
msgstr "您正在重置密碼,驗證碼為:%(code)s,5分鐘內有效 請妥善保管."
#: .\blog\admin.py:13 .\blog\models.py:92 .\comments\models.py:17
#: .\oauth\models.py:12
msgid "author"
msgstr "作者"
#: .\blog\admin.py:53
msgid "Publish selected articles"
msgstr "發布選中的文章"
#: .\blog\admin.py:54
msgid "Draft selected articles"
msgstr "選中文章設為草稿"
#: .\blog\admin.py:55
msgid "Close article comments"
msgstr "關閉文章評論"
#: .\blog\admin.py:56
msgid "Open article comments"
msgstr "打開文章評論"
#: .\blog\admin.py:89 .\blog\models.py:101 .\blog\models.py:183
#: .\templates\blog\tags\sidebar.html:40
msgid "category"
msgstr "分類目錄"
#: .\blog\models.py:20 .\blog\models.py:179 .\templates\share_layout\nav.html:8
msgid "index"
msgstr "首頁"
#: .\blog\models.py:21
msgid "list"
msgstr "列表"
#: .\blog\models.py:22
msgid "post"
msgstr "文章"
#: .\blog\models.py:23
msgid "all"
msgstr "所有"
#: .\blog\models.py:24
msgid "slide"
msgstr "側邊欄"
#: .\blog\models.py:30 .\blog\models.py:267 .\blog\models.py:285
msgid "modify time"
msgstr "修改時間"
#: .\blog\models.py:63
msgid "Draft"
msgstr "草稿"
#: .\blog\models.py:64
msgid "Published"
msgstr "發布"
#: .\blog\models.py:67
msgid "Open"
msgstr "打開"
#: .\blog\models.py:68
msgid "Close"
msgstr "關閉"
#: .\blog\models.py:71 .\comments\admin.py:47
msgid "Article"
msgstr "文章"
#: .\blog\models.py:72
msgid "Page"
msgstr "頁面"
#: .\blog\models.py:74 .\blog\models.py:280
msgid "title"
msgstr "標題"
#: .\blog\models.py:75
msgid "body"
msgstr "內容"
#: .\blog\models.py:77
msgid "publish time"
msgstr "發布時間"
#: .\blog\models.py:79
msgid "status"
msgstr "狀態"
#: .\blog\models.py:84
msgid "comment status"
msgstr "評論狀態"
#: .\blog\models.py:88 .\oauth\models.py:43
msgid "type"
msgstr "類型"
#: .\blog\models.py:89
msgid "views"
msgstr "閱讀量"
#: .\blog\models.py:97 .\blog\models.py:258 .\blog\models.py:282
msgid "order"
msgstr "排序"
#: .\blog\models.py:98
msgid "show toc"
msgstr "顯示目錄"
#: .\blog\models.py:105 .\blog\models.py:249
msgid "tag"
msgstr "標簽"
#: .\blog\models.py:115 .\comments\models.py:21
msgid "article"
msgstr "文章"
#: .\blog\models.py:171
msgid "category name"
msgstr "分類名"
#: .\blog\models.py:174
msgid "parent category"
msgstr "上級分類"
#: .\blog\models.py:234
msgid "tag name"
msgstr "標簽名"
#: .\blog\models.py:256
msgid "link name"
msgstr "鏈接名"
#: .\blog\models.py:257 .\blog\models.py:271
msgid "link"
msgstr "鏈接"
#: .\blog\models.py:260
msgid "is show"
msgstr "是否顯示"
#: .\blog\models.py:262
msgid "show type"
msgstr "顯示類型"
#: .\blog\models.py:281
msgid "content"
msgstr "內容"
#: .\blog\models.py:283 .\oauth\models.py:52
msgid "is enable"
msgstr "是否啟用"
#: .\blog\models.py:289
msgid "sidebar"
msgstr "側邊欄"
#: .\blog\models.py:299
msgid "site name"
msgstr "站點名稱"
#: .\blog\models.py:305
msgid "site description"
msgstr "站點描述"
#: .\blog\models.py:311
msgid "site seo description"
msgstr "站點SEO描述"
#: .\blog\models.py:313
msgid "site keywords"
msgstr "關鍵字"
#: .\blog\models.py:318
msgid "article sub length"
msgstr "文章摘要長度"
#: .\blog\models.py:319
msgid "sidebar article count"
msgstr "側邊欄文章數目"
#: .\blog\models.py:320
msgid "sidebar comment count"
msgstr "側邊欄評論數目"
#: .\blog\models.py:321
msgid "article comment count"
msgstr "文章頁面默認顯示評論數目"
#: .\blog\models.py:322
msgid "show adsense"
msgstr "是否顯示廣告"
#: .\blog\models.py:324
msgid "adsense code"
msgstr "廣告內容"
#: .\blog\models.py:325
msgid "open site comment"
msgstr "公共頭部"
#: .\blog\models.py:352
msgid "Website configuration"
msgstr "網站配置"
#: .\blog\models.py:360
msgid "There can only be one configuration"
msgstr "只能有一個配置"
#: .\blog\views.py:348
msgid ""
"Sorry, the page you requested is not found, please click the home page to "
"see other?"
msgstr "抱歉,你所訪問的頁面找不到,請點擊首頁看看別的?"
#: .\blog\views.py:356
msgid "Sorry, the server is busy, please click the home page to see other?"
msgstr "抱歉,服務出錯了,請點擊首頁看看別的?"
#: .\blog\views.py:369
msgid "Sorry, you do not have permission to access this page?"
msgstr "抱歉,你沒用權限訪問此頁面。"
#: .\comments\admin.py:15
msgid "Disable comments"
msgstr "禁用評論"
#: .\comments\admin.py:16
msgid "Enable comments"
msgstr "啟用評論"
#: .\comments\admin.py:46
msgid "User"
msgstr "用戶"
#: .\comments\models.py:25
msgid "parent comment"
msgstr "上級評論"
#: .\comments\models.py:29
msgid "enable"
msgstr "啟用"
#: .\comments\models.py:34 .\templates\blog\tags\article_info.html:30
msgid "comment"
msgstr "評論"
#: .\comments\utils.py:13
msgid "Thanks for your comment"
msgstr "感謝你的評論"
#: .\comments\utils.py:15
#, python-format
msgid ""
"Thank you very much for your comments on this site
\n"
" You can visit %(article_title)s \n"
" to review your comments,\n"
" Thank you again!\n"
" \n"
" If the link above cannot be opened, please copy this "
"link to your browser.\n"
" %(article_url)s"
msgstr ""
"非常感謝您對此網站的評論
\n"
" 您可以訪問%(article_title)s \n"
"查看您的評論,\n"
"再次感謝您!\n"
" \n"
" 如果上面的鏈接打不開,請復製此鏈接鏈接到您的瀏覽器。\n"
"%(article_url)s"
#: .\comments\utils.py:26
#, python-format
msgid ""
"Your comment on "
"%(article_title)s has \n"
" received a reply. %(comment_body)s\n"
" \n"
" go check it out!\n"
" \n"
" If the link above cannot be opened, please copy this "
"link to your browser.\n"
" %(article_url)s\n"
" "
msgstr ""
"您對 %(article_title)s "
"的評論有\n"
" 收到回復。 %(comment_body)s\n"
" \n"
"快去看看吧!\n"
" \n"
" 如果上面的鏈接打不開,請復製此鏈接鏈接到您的瀏覽器。\n"
" %(article_url)s\n"
" "
#: .\djangoblog\logentryadmin.py:63
msgid "object"
msgstr "對象"
#: .\djangoblog\settings.py:140
msgid "English"
msgstr "英文"
#: .\djangoblog\settings.py:141
msgid "Simplified Chinese"
msgstr "簡體中文"
#: .\djangoblog\settings.py:142
msgid "Traditional Chinese"
msgstr "繁體中文"
#: .\oauth\models.py:30
msgid "oauth user"
msgstr "第三方用戶"
#: .\oauth\models.py:37
msgid "weibo"
msgstr "微博"
#: .\oauth\models.py:38
msgid "google"
msgstr "谷歌"
#: .\oauth\models.py:48
msgid "callback url"
msgstr "回調地址"
#: .\oauth\models.py:59
msgid "already exists"
msgstr "已經存在"
#: .\oauth\views.py:154
#, python-format
msgid ""
"\n"
" Congratulations, you have successfully bound your email address. You "
"can use\n"
" %(oauthuser_type)s to directly log in to this website without a "
"password.
\n"
" You are welcome to continue to follow this site, the address is\n"
" %(site)s \n"
" Thank you again!\n"
" \n"
" If the link above cannot be opened, please copy this link to your "
"browser.\n"
" %(site)s\n"
" "
msgstr ""
"\n"
" 恭喜你已經綁定成功 你可以使用\n"
" %(oauthuser_type)s 來免密登錄本站
\n"
" 歡迎繼續關註本站, 地址是\n"
" %(site)s \n"
" 再次感謝你\n"
" \n"
" 如果上面鏈接無法打開,請復製此鏈接到你的瀏覽器 \n"
" %(site)s\n"
" "
#: .\oauth\views.py:165
msgid "Congratulations on your successful binding!"
msgstr "恭喜你綁定成功"
#: .\oauth\views.py:217
#, python-format
msgid ""
"\n"
" Please click the link below to bind your email
\n"
"\n"
" %(url)s \n"
"\n"
" Thank you again!\n"
" \n"
" If the link above cannot be opened, please copy this link "
"to your browser.\n"
" \n"
" %(url)s\n"
" "
msgstr ""
"\n"
" 請點擊下面的鏈接綁定您的郵箱
\n"
"\n"
" %(url)s \n"
"\n"
"再次感謝您!\n"
" \n"
"如果上面的鏈接打不開,請復製此鏈接到您的瀏覽器。\n"
"%(url)s\n"
" "
#: .\oauth\views.py:228 .\oauth\views.py:240
msgid "Bind your email"
msgstr "綁定郵箱"
#: .\oauth\views.py:242
msgid ""
"Congratulations, the binding is just one step away. Please log in to your "
"email to check the email to complete the binding. Thank you."
msgstr "恭喜您,還差一步就綁定成功了,請登錄您的郵箱查看郵件完成綁定,謝謝。"
#: .\oauth\views.py:245
msgid "Binding successful"
msgstr "綁定成功"
#: .\oauth\views.py:247
#, python-format
msgid ""
"Congratulations, you have successfully bound your email address. You can use "
"%(oauthuser_type)s to directly log in to this website without a password. "
"You are welcome to continue to follow this site."
msgstr ""
"恭喜您綁定成功,您以後可以使用%(oauthuser_type)s來直接免密碼登錄本站啦,感謝"
"您對本站對關註。"
#: .\templates\account\forget_password.html:7
msgid "forget the password"
msgstr "忘記密碼"
#: .\templates\account\forget_password.html:18
msgid "get verification code"
msgstr "獲取驗證碼"
#: .\templates\account\forget_password.html:19
msgid "submit"
msgstr "提交"
#: .\templates\account\login.html:36
msgid "Create Account"
msgstr "創建賬號"
#: .\templates\account\login.html:42
#, fuzzy
#| msgid "forget the password"
msgid "Forget Password"
msgstr "忘記密碼"
#: .\templates\account\result.html:18 .\templates\blog\tags\sidebar.html:126
msgid "login"
msgstr "登錄"
#: .\templates\account\result.html:22
msgid "back to the homepage"
msgstr "返回首頁吧"
#: .\templates\blog\article_archives.html:7
#: .\templates\blog\article_archives.html:24
msgid "article archive"
msgstr "文章歸檔"
#: .\templates\blog\article_archives.html:32
msgid "year"
msgstr "年"
#: .\templates\blog\article_archives.html:36
msgid "month"
msgstr "月"
#: .\templates\blog\tags\article_info.html:12
msgid "pin to top"
msgstr "置頂"
#: .\templates\blog\tags\article_info.html:28
msgid "comments"
msgstr "評論"
#: .\templates\blog\tags\article_info.html:58
msgid "toc"
msgstr "目錄"
#: .\templates\blog\tags\article_meta_info.html:6
msgid "posted in"
msgstr "發布於"
#: .\templates\blog\tags\article_meta_info.html:14
msgid "and tagged"
msgstr "並標記為"
#: .\templates\blog\tags\article_meta_info.html:25
msgid "by "
msgstr "由"
#: .\templates\blog\tags\article_meta_info.html:29
#, python-format
msgid ""
"\n"
" title=\"View all articles published by "
"%(article.author.username)s\"\n"
" "
msgstr ""
"\n"
" title=\"查看所有由 %(article.author.username)s\"發布的文章\n"
" "
#: .\templates\blog\tags\article_meta_info.html:44
msgid "on"
msgstr "在"
#: .\templates\blog\tags\article_meta_info.html:54
msgid "edit"
msgstr "編輯"
#: .\templates\blog\tags\article_pagination.html:4
msgid "article navigation"
msgstr "文章導航"
#: .\templates\blog\tags\article_pagination.html:9
msgid "earlier articles"
msgstr "早期文章"
#: .\templates\blog\tags\article_pagination.html:12
msgid "newer articles"
msgstr "較新文章"
#: .\templates\blog\tags\article_tag_list.html:5
msgid "tags"
msgstr "標簽"
#: .\templates\blog\tags\sidebar.html:7
msgid "search"
msgstr "搜索"
#: .\templates\blog\tags\sidebar.html:50
msgid "recent comments"
msgstr "近期評論"
#: .\templates\blog\tags\sidebar.html:57
msgid "published on"
msgstr "發表於"
#: .\templates\blog\tags\sidebar.html:65
msgid "recent articles"
msgstr "近期文章"
#: .\templates\blog\tags\sidebar.html:77
msgid "bookmark"
msgstr "書簽"
#: .\templates\blog\tags\sidebar.html:96
msgid "Tag Cloud"
msgstr "標簽雲"
#: .\templates\blog\tags\sidebar.html:107
msgid "Welcome to star or fork the source code of this site"
msgstr "歡迎您STAR或者FORK本站源代碼"
#: .\templates\blog\tags\sidebar.html:118
msgid "Function"
msgstr "功能"
#: .\templates\blog\tags\sidebar.html:120
msgid "management site"
msgstr "管理站點"
#: .\templates\blog\tags\sidebar.html:122
msgid "logout"
msgstr "登出"
#: .\templates\blog\tags\sidebar.html:129
msgid "Track record"
msgstr "運動軌跡記錄"
#: .\templates\blog\tags\sidebar.html:135
msgid "Click me to return to the top"
msgstr "點我返回頂部"
#: .\templates\oauth\oauth_applications.html:5
#| msgid "login"
msgid "quick login"
msgstr "快捷登錄"
#: .\templates\share_layout\nav.html:26
msgid "Article archive"
msgstr "文章歸檔"
#: templates/blog/article_detail.html templates/blog/article_index.html
msgid "Discover more"
msgstr "發現更多"
================================================
FILE: manage.py
================================================
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings")
try:
from django.core.management import execute_from_command_line
except ImportError:
# The above import may fail for some other reason. Ensure that the
# issue is really that Django is missing to avoid masking other
# exceptions on Python 2.
try:
import django
except ImportError:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
)
raise
execute_from_command_line(sys.argv)
================================================
FILE: oauth/__init__.py
================================================
================================================
FILE: oauth/admin.py
================================================
import logging
from django.contrib import admin
# Register your models here.
from django.urls import reverse
from django.utils.html import format_html
logger = logging.getLogger(__name__)
class OAuthUserAdmin(admin.ModelAdmin):
search_fields = ('nickname', 'email')
list_per_page = 20
list_display = (
'id',
'nickname',
'link_to_usermodel',
'show_user_image',
'type',
'email',
)
list_display_links = ('id', 'nickname')
list_filter = ('author', 'type',)
readonly_fields = []
def get_readonly_fields(self, request, obj=None):
return list(self.readonly_fields) + \
[field.name for field in obj._meta.fields] + \
[field.name for field in obj._meta.many_to_many]
def has_add_permission(self, request):
return False
def link_to_usermodel(self, obj):
if obj.author:
info = (obj.author._meta.app_label, obj.author._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
return format_html(
u'%s ' %
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
def show_user_image(self, obj):
img = obj.picture
return format_html(
u' ' %
(img))
link_to_usermodel.short_description = '用户'
show_user_image.short_description = '用户头像'
class OAuthConfigAdmin(admin.ModelAdmin):
list_display = ('type', 'appkey', 'appsecret', 'is_enable')
list_filter = ('type',)
================================================
FILE: oauth/apps.py
================================================
from django.apps import AppConfig
class OauthConfig(AppConfig):
name = 'oauth'
================================================
FILE: oauth/forms.py
================================================
from django.contrib.auth.forms import forms
from django.forms import widgets
class RequireEmailForm(forms.Form):
email = forms.EmailField(label='电子邮箱', required=True)
oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False)
def __init__(self, *args, **kwargs):
super(RequireEmailForm, self).__init__(*args, **kwargs)
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
================================================
FILE: oauth/migrations/0001_initial.py
================================================
# Generated by Django 4.1.7 on 2023-03-07 09:53
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='OAuthConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.CharField(choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')),
('appkey', models.CharField(max_length=200, verbose_name='AppKey')),
('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')),
('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')),
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
options={
'verbose_name': 'oauth配置',
'verbose_name_plural': 'oauth配置',
'ordering': ['-created_time'],
},
),
migrations.CreateModel(
name='OAuthUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('openid', models.CharField(max_length=50)),
('nickname', models.CharField(max_length=50, verbose_name='昵称')),
('token', models.CharField(blank=True, max_length=150, null=True)),
('picture', models.CharField(blank=True, max_length=350, null=True)),
('type', models.CharField(max_length=50)),
('email', models.CharField(blank=True, max_length=50, null=True)),
('metadata', models.TextField(blank=True, null=True)),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': 'oauth用户',
'verbose_name_plural': 'oauth用户',
'ordering': ['-created_time'],
},
),
]
================================================
FILE: oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py
================================================
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('oauth', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='oauthconfig',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'},
),
migrations.AlterModelOptions(
name='oauthuser',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'},
),
migrations.RemoveField(
model_name='oauthconfig',
name='created_time',
),
migrations.RemoveField(
model_name='oauthconfig',
name='last_mod_time',
),
migrations.RemoveField(
model_name='oauthuser',
name='created_time',
),
migrations.RemoveField(
model_name='oauthuser',
name='last_mod_time',
),
migrations.AddField(
model_name='oauthconfig',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='oauthconfig',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
migrations.AddField(
model_name='oauthuser',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='oauthuser',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
migrations.AlterField(
model_name='oauthconfig',
name='callback_url',
field=models.CharField(default='', max_length=200, verbose_name='callback url'),
),
migrations.AlterField(
model_name='oauthconfig',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'),
),
migrations.AlterField(
model_name='oauthconfig',
name='type',
field=models.CharField(choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='type'),
),
migrations.AlterField(
model_name='oauthuser',
name='author',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
migrations.AlterField(
model_name='oauthuser',
name='nickname',
field=models.CharField(max_length=50, verbose_name='nickname'),
),
]
================================================
FILE: oauth/migrations/0003_alter_oauthuser_nickname.py
================================================
# Generated by Django 4.2.7 on 2024-01-26 02:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='oauthuser',
name='nickname',
field=models.CharField(max_length=50, verbose_name='nick name'),
),
]
================================================
FILE: oauth/migrations/__init__.py
================================================
================================================
FILE: oauth/models.py
================================================
# Create your models here.
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
class OAuthUser(models.Model):
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=True,
null=True,
on_delete=models.CASCADE)
openid = models.CharField(max_length=50)
nickname = models.CharField(max_length=50, verbose_name=_('nick name'))
token = models.CharField(max_length=150, null=True, blank=True)
picture = models.CharField(max_length=350, blank=True, null=True)
type = models.CharField(blank=False, null=False, max_length=50)
email = models.CharField(max_length=50, null=True, blank=True)
metadata = models.TextField(null=True, blank=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
def __str__(self):
return self.nickname
class Meta:
verbose_name = _('oauth user')
verbose_name_plural = verbose_name
ordering = ['-creation_time']
class OAuthConfig(models.Model):
TYPE = (
('weibo', _('weibo')),
('google', _('google')),
('github', 'GitHub'),
('facebook', 'FaceBook'),
('qq', 'QQ'),
)
type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a')
appkey = models.CharField(max_length=200, verbose_name='AppKey')
appsecret = models.CharField(max_length=200, verbose_name='AppSecret')
callback_url = models.CharField(
max_length=200,
verbose_name=_('callback url'),
blank=False,
default='')
is_enable = models.BooleanField(
_('is enable'), default=True, blank=False, null=False)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
def clean(self):
if OAuthConfig.objects.filter(
type=self.type).exclude(id=self.id).count():
raise ValidationError(_(self.type + _('already exists')))
def __str__(self):
return self.type
class Meta:
verbose_name = 'oauth配置'
verbose_name_plural = verbose_name
ordering = ['-creation_time']
================================================
FILE: oauth/oauthmanager.py
================================================
import json
import logging
import os
import urllib.parse
from abc import ABCMeta, abstractmethod
import requests
from djangoblog.utils import cache_decorator
from oauth.models import OAuthUser, OAuthConfig
logger = logging.getLogger(__name__)
class OAuthAccessTokenException(Exception):
'''
oauth授权失败异常
'''
class BaseOauthManager(metaclass=ABCMeta):
"""获取用户授权"""
AUTH_URL = None
"""获取token"""
TOKEN_URL = None
"""获取用户信息"""
API_URL = None
'''icon图标名'''
ICON_NAME = None
def __init__(self, access_token=None, openid=None):
self.access_token = access_token
self.openid = openid
@property
def is_access_token_set(self):
return self.access_token is not None
@property
def is_authorized(self):
return self.is_access_token_set and self.access_token is not None and self.openid is not None
@abstractmethod
def get_authorization_url(self, nexturl='/'):
pass
@abstractmethod
def get_access_token_by_code(self, code):
pass
@abstractmethod
def get_oauth_userinfo(self):
pass
@abstractmethod
def get_picture(self, metadata):
pass
def do_get(self, url, params, headers=None):
rsp = requests.get(url=url, params=params, headers=headers)
logger.info(rsp.text)
return rsp.text
def do_post(self, url, params, headers=None):
rsp = requests.post(url, params, headers=headers)
logger.info(rsp.text)
return rsp.text
def get_config(self):
value = OAuthConfig.objects.filter(type=self.ICON_NAME)
return value[0] if value else None
class WBOauthManager(BaseOauthManager):
AUTH_URL = 'https://api.weibo.com/oauth2/authorize'
TOKEN_URL = 'https://api.weibo.com/oauth2/access_token'
API_URL = 'https://api.weibo.com/2/users/show.json'
ICON_NAME = 'weibo'
def __init__(self, access_token=None, openid=None):
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
super(
WBOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, nexturl='/'):
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': self.callback_url + '&next_url=' + nexturl
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
obj = json.loads(rsp)
if 'access_token' in obj:
self.access_token = str(obj['access_token'])
self.openid = str(obj['uid'])
return self.get_oauth_userinfo()
else:
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
if not self.is_authorized:
return None
params = {
'uid': self.openid,
'access_token': self.access_token
}
rsp = self.do_get(self.API_URL, params)
try:
datas = json.loads(rsp)
user = OAuthUser()
user.metadata = rsp
user.picture = datas['avatar_large']
user.nickname = datas['screen_name']
user.openid = datas['id']
user.type = 'weibo'
user.token = self.access_token
if 'email' in datas and datas['email']:
user.email = datas['email']
return user
except Exception as e:
logger.error(e)
logger.error('weibo oauth error.rsp:' + rsp)
return None
def get_picture(self, metadata):
datas = json.loads(metadata)
return datas['avatar_large']
class ProxyManagerMixin:
def __init__(self, *args, **kwargs):
if os.environ.get("HTTP_PROXY"):
self.proxies = {
"http": os.environ.get("HTTP_PROXY"),
"https": os.environ.get("HTTP_PROXY")
}
else:
self.proxies = None
def do_get(self, url, params, headers=None):
rsp = requests.get(url=url, params=params, headers=headers, proxies=self.proxies)
logger.info(rsp.text)
return rsp.text
def do_post(self, url, params, headers=None):
rsp = requests.post(url, params, headers=headers, proxies=self.proxies)
logger.info(rsp.text)
return rsp.text
class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token'
API_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
ICON_NAME = 'google'
def __init__(self, access_token=None, openid=None):
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
super(
GoogleOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, nexturl='/'):
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': self.callback_url,
'scope': 'openid email',
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
obj = json.loads(rsp)
if 'access_token' in obj:
self.access_token = str(obj['access_token'])
self.openid = str(obj['id_token'])
logger.info(self.ICON_NAME + ' oauth ' + rsp)
return self.access_token
else:
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
if not self.is_authorized:
return None
params = {
'access_token': self.access_token
}
rsp = self.do_get(self.API_URL, params)
try:
datas = json.loads(rsp)
user = OAuthUser()
user.metadata = rsp
user.picture = datas['picture']
user.nickname = datas['name']
user.openid = datas['sub']
user.token = self.access_token
user.type = 'google'
if datas['email']:
user.email = datas['email']
return user
except Exception as e:
logger.error(e)
logger.error('google oauth error.rsp:' + rsp)
return None
def get_picture(self, metadata):
datas = json.loads(metadata)
return datas['picture']
class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
AUTH_URL = 'https://github.com/login/oauth/authorize'
TOKEN_URL = 'https://github.com/login/oauth/access_token'
API_URL = 'https://api.github.com/user'
ICON_NAME = 'github'
def __init__(self, access_token=None, openid=None):
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
super(
GitHubOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, next_url='/'):
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': f'{self.callback_url}&next_url={next_url}',
'scope': 'user'
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
from urllib import parse
r = parse.parse_qs(rsp)
if 'access_token' in r:
self.access_token = (r['access_token'][0])
return self.access_token
else:
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
rsp = self.do_get(self.API_URL, params={}, headers={
"Authorization": "token " + self.access_token
})
try:
datas = json.loads(rsp)
user = OAuthUser()
user.picture = datas['avatar_url']
user.nickname = datas['name']
user.openid = datas['id']
user.type = 'github'
user.token = self.access_token
user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email']
return user
except Exception as e:
logger.error(e)
logger.error('github oauth error.rsp:' + rsp)
return None
def get_picture(self, metadata):
datas = json.loads(metadata)
return datas['avatar_url']
class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth'
TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token'
API_URL = 'https://graph.facebook.com/me'
ICON_NAME = 'facebook'
def __init__(self, access_token=None, openid=None):
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
super(
FaceBookOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, next_url='/'):
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': self.callback_url,
'scope': 'email,public_profile'
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
# 'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
obj = json.loads(rsp)
if 'access_token' in obj:
token = str(obj['access_token'])
self.access_token = token
return self.access_token
else:
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
params = {
'access_token': self.access_token,
'fields': 'id,name,picture,email'
}
try:
rsp = self.do_get(self.API_URL, params)
datas = json.loads(rsp)
user = OAuthUser()
user.nickname = datas['name']
user.openid = datas['id']
user.type = 'facebook'
user.token = self.access_token
user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email']
if 'picture' in datas and datas['picture'] and datas['picture']['data'] and datas['picture']['data']['url']:
user.picture = str(datas['picture']['data']['url'])
return user
except Exception as e:
logger.error(e)
return None
def get_picture(self, metadata):
datas = json.loads(metadata)
return str(datas['picture']['data']['url'])
class QQOauthManager(BaseOauthManager):
AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize'
TOKEN_URL = 'https://graph.qq.com/oauth2.0/token'
API_URL = 'https://graph.qq.com/user/get_user_info'
OPEN_ID_URL = 'https://graph.qq.com/oauth2.0/me'
ICON_NAME = 'qq'
def __init__(self, access_token=None, openid=None):
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
super(
QQOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, next_url='/'):
params = {
'response_type': 'code',
'client_id': self.client_id,
'redirect_uri': self.callback_url + '&next_url=' + next_url,
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
def get_access_token_by_code(self, code):
params = {
'grant_type': 'authorization_code',
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_get(self.TOKEN_URL, params)
if rsp:
d = urllib.parse.parse_qs(rsp)
if 'access_token' in d:
token = d['access_token']
self.access_token = token[0]
return token
else:
raise OAuthAccessTokenException(rsp)
def get_open_id(self):
if self.is_access_token_set:
params = {
'access_token': self.access_token
}
rsp = self.do_get(self.OPEN_ID_URL, params)
if rsp:
rsp = rsp.replace(
'callback(', '').replace(
')', '').replace(
';', '')
obj = json.loads(rsp)
openid = str(obj['openid'])
self.openid = openid
return openid
def get_oauth_userinfo(self):
openid = self.get_open_id()
if openid:
params = {
'access_token': self.access_token,
'oauth_consumer_key': self.client_id,
'openid': self.openid
}
rsp = self.do_get(self.API_URL, params)
logger.info(rsp)
obj = json.loads(rsp)
user = OAuthUser()
user.nickname = obj['nickname']
user.openid = openid
user.type = 'qq'
user.token = self.access_token
user.metadata = rsp
if 'email' in obj:
user.email = obj['email']
if 'figureurl' in obj:
user.picture = str(obj['figureurl'])
return user
def get_picture(self, metadata):
datas = json.loads(metadata)
return str(datas['figureurl'])
@cache_decorator(expiration=100 * 60)
def get_oauth_apps():
configs = OAuthConfig.objects.filter(is_enable=True).all()
if not configs:
return []
configtypes = [x.type for x in configs]
applications = BaseOauthManager.__subclasses__()
apps = [x() for x in applications if x().ICON_NAME.lower() in configtypes]
return apps
def get_manager_by_type(type):
applications = get_oauth_apps()
if applications:
finds = list(
filter(
lambda x: x.ICON_NAME.lower() == type.lower(),
applications))
if finds:
return finds[0]
return None
================================================
FILE: oauth/templatetags/__init__.py
================================================
================================================
FILE: oauth/templatetags/oauth_tags.py
================================================
from django import template
from django.urls import reverse
from oauth.oauthmanager import get_oauth_apps
register = template.Library()
@register.inclusion_tag('oauth/oauth_applications.html')
def load_oauth_applications(request):
applications = get_oauth_apps()
if applications:
baseurl = reverse('oauth:oauthlogin')
path = request.get_full_path()
apps = list(map(lambda x: (x.ICON_NAME, '{baseurl}?type={type}&next_url={next}'.format(
baseurl=baseurl, type=x.ICON_NAME, next=path)), applications))
else:
apps = []
return {
'apps': apps
}
================================================
FILE: oauth/test_oauth_business_logic.py
================================================
"""
Test cases for OAuth business logic
包括OAuth配置、OAuth用户、第三方登录等核心业务逻辑
"""
from django.test import TestCase
from accounts.models import BlogUser
from oauth.models import OAuthConfig, OAuthUser
class OAuthConfigTest(TestCase):
"""测试OAuth配置业务逻辑"""
def test_oauth_config_can_be_created(self):
"""测试OAuth配置可以被创建"""
config = OAuthConfig.objects.create(
type='weibo',
appkey='test_app_key',
appsecret='test_app_secret',
callback_url='http://example.com/oauth/callback'
)
self.assertIsNotNone(config.id)
self.assertEqual(config.type, 'weibo')
self.assertEqual(config.appkey, 'test_app_key')
def test_oauth_config_has_required_fields(self):
"""测试OAuth配置有必需字段"""
config = OAuthConfig.objects.create(
type='github',
appkey='github_key',
appsecret='github_secret',
callback_url='http://example.com/oauth/github/callback'
)
self.assertEqual(config.type, 'github')
self.assertIsNotNone(config.appkey)
self.assertIsNotNone(config.appsecret)
self.assertIsNotNone(config.callback_url)
def test_oauth_config_type_uniqueness(self):
"""测试OAuth配置类型唯一性"""
OAuthConfig.objects.create(
type='google',
appkey='key1',
appsecret='secret1',
callback_url='http://example.com/callback1'
)
# 尝试创建相同类型的配置可能失败(取决于模型设计)
# 如果type字段有unique约束
try:
OAuthConfig.objects.create(
type='google',
appkey='key2',
appsecret='secret2',
callback_url='http://example.com/callback2'
)
# 如果没有unique约束,应该能创建成功
except Exception:
# 如果有unique约束,应该抛出异常
pass
def test_oauth_config_query_by_type(self):
"""测试按类型查询OAuth配置"""
OAuthConfig.objects.create(
type='weibo',
appkey='weibo_key',
appsecret='weibo_secret',
callback_url='http://example.com/weibo'
)
OAuthConfig.objects.create(
type='github',
appkey='github_key',
appsecret='github_secret',
callback_url='http://example.com/github'
)
# 查询特定类型的配置
weibo_config = OAuthConfig.objects.filter(type='weibo').first()
self.assertIsNotNone(weibo_config)
self.assertEqual(weibo_config.type, 'weibo')
def test_oauth_config_is_enable_field(self):
"""测试OAuth配置的启用字段"""
config = OAuthConfig.objects.create(
type='facebook',
appkey='fb_key',
appsecret='fb_secret',
callback_url='http://example.com/facebook',
is_enable=True
)
self.assertTrue(config.is_enable)
# 禁用配置
config.is_enable = False
config.save()
config.refresh_from_db()
self.assertFalse(config.is_enable)
class OAuthUserTest(TestCase):
"""测试OAuth用户业务逻辑"""
def setUp(self):
"""设置测试环境"""
self.blog_user = BlogUser.objects.create_user(
username='testuser',
email='test@example.com',
password='password'
)
def test_oauth_user_can_be_created(self):
"""测试OAuth用户可以被创建"""
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='test_openid_123',
nickname='Test User',
token='test_token',
type='weibo'
)
self.assertIsNotNone(oauth_user.id)
self.assertEqual(oauth_user.author, self.blog_user)
self.assertEqual(oauth_user.openid, 'test_openid_123')
def test_oauth_user_links_to_blog_user(self):
"""测试OAuth用户关联到博客用户"""
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='openid_456',
nickname='OAuth User',
token='oauth_token',
type='github'
)
self.assertEqual(oauth_user.author, self.blog_user)
# 验证可以从博客用户查询OAuth用户
oauth_users = OAuthUser.objects.filter(author=self.blog_user)
self.assertIn(oauth_user, oauth_users)
def test_oauth_user_has_openid(self):
"""测试OAuth用户有openid"""
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='unique_openid',
nickname='User',
token='token',
type='google'
)
self.assertEqual(oauth_user.openid, 'unique_openid')
def test_oauth_user_has_type(self):
"""测试OAuth用户有类型"""
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='openid',
nickname='User',
token='token',
type='facebook'
)
self.assertEqual(oauth_user.type, 'facebook')
def test_oauth_user_token_storage(self):
"""测试OAuth用户token存储"""
token = 'test_access_token_12345'
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='openid',
nickname='User',
token=token,
type='weibo'
)
self.assertEqual(oauth_user.token, token)
def test_oauth_user_nickname_storage(self):
"""测试OAuth用户昵称存储"""
nickname = 'OAuth Nickname'
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='openid',
nickname=nickname,
token='token',
type='github'
)
self.assertEqual(oauth_user.nickname, nickname)
class OAuthUserQueryTest(TestCase):
"""测试OAuth用户查询业务逻辑"""
def setUp(self):
"""设置测试环境"""
self.user1 = BlogUser.objects.create_user(
username='user1',
email='user1@example.com',
password='password'
)
self.user2 = BlogUser.objects.create_user(
username='user2',
email='user2@example.com',
password='password'
)
def test_query_oauth_user_by_openid(self):
"""测试按openid查询OAuth用户"""
oauth_user = OAuthUser.objects.create(
author=self.user1,
openid='unique_openid_123',
nickname='User',
token='token',
type='weibo'
)
# 按openid查询
found_user = OAuthUser.objects.get(openid='unique_openid_123')
self.assertEqual(found_user, oauth_user)
def test_query_oauth_user_by_type(self):
"""测试按类型查询OAuth用户"""
weibo_user = OAuthUser.objects.create(
author=self.user1,
openid='weibo_openid',
nickname='Weibo User',
token='token',
type='weibo'
)
github_user = OAuthUser.objects.create(
author=self.user2,
openid='github_openid',
nickname='GitHub User',
token='token',
type='github'
)
# 查询weibo类型的用户
weibo_users = OAuthUser.objects.filter(type='weibo')
self.assertEqual(weibo_users.count(), 1)
self.assertIn(weibo_user, weibo_users)
# 查询github类型的用户
github_users = OAuthUser.objects.filter(type='github')
self.assertEqual(github_users.count(), 1)
self.assertIn(github_user, github_users)
def test_query_oauth_users_by_blog_user(self):
"""测试按博客用户查询OAuth用户"""
# 为user1创建多个OAuth关联
weibo = OAuthUser.objects.create(
author=self.user1,
openid='weibo_id',
nickname='User',
token='token',
type='weibo'
)
github = OAuthUser.objects.create(
author=self.user1,
openid='github_id',
nickname='User',
token='token',
type='github'
)
# 查询user1的所有OAuth关联
oauth_users = OAuthUser.objects.filter(author=self.user1)
self.assertEqual(oauth_users.count(), 2)
self.assertIn(weibo, oauth_users)
self.assertIn(github, oauth_users)
def test_user_can_have_multiple_oauth_accounts(self):
"""测试用户可以关联多个OAuth账号"""
oauth_types = ['weibo', 'github', 'google', 'facebook']
for oauth_type in oauth_types:
OAuthUser.objects.create(
author=self.user1,
openid=f'{oauth_type}_openid',
nickname='User',
token='token',
type=oauth_type
)
# 验证用户有4个OAuth关联
oauth_users = OAuthUser.objects.filter(author=self.user1)
self.assertEqual(oauth_users.count(), 4)
class OAuthUserBindingTest(TestCase):
"""测试OAuth用户绑定业务逻辑"""
def setUp(self):
"""设置测试环境"""
self.blog_user = BlogUser.objects.create_user(
username='user',
email='user@example.com',
password='password'
)
def test_bind_oauth_to_existing_user(self):
"""测试将OAuth绑定到现有用户"""
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='new_openid',
nickname='OAuth User',
token='token',
type='weibo'
)
# 验证绑定关系
self.assertEqual(oauth_user.author, self.blog_user)
# 验证可以通过博客用户找到OAuth用户
oauth_accounts = OAuthUser.objects.filter(author=self.blog_user)
self.assertIn(oauth_user, oauth_accounts)
def test_unbind_oauth_from_user(self):
"""测试解绑OAuth账号"""
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='openid',
nickname='User',
token='token',
type='github'
)
oauth_id = oauth_user.id
# 删除OAuth绑定
oauth_user.delete()
# 验证OAuth用户已删除
with self.assertRaises(OAuthUser.DoesNotExist):
OAuthUser.objects.get(id=oauth_id)
# 博客用户应该仍然存在
self.assertTrue(BlogUser.objects.filter(id=self.blog_user.id).exists())
def test_change_oauth_binding(self):
"""测试更改OAuth绑定"""
new_user = BlogUser.objects.create_user(
username='newuser',
email='new@example.com',
password='password'
)
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='openid',
nickname='User',
token='token',
type='weibo'
)
# 更改绑定到新用户
oauth_user.author = new_user
oauth_user.save()
oauth_user.refresh_from_db()
self.assertEqual(oauth_user.author, new_user)
class OAuthTokenManagementTest(TestCase):
"""测试OAuth token管理业务逻辑"""
def setUp(self):
"""设置测试环境"""
self.blog_user = BlogUser.objects.create_user(
username='user',
email='user@example.com',
password='password'
)
def test_oauth_token_can_be_updated(self):
"""测试OAuth token可以更新"""
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='openid',
nickname='User',
token='old_token',
type='weibo'
)
# 更新token
new_token = 'new_refreshed_token'
oauth_user.token = new_token
oauth_user.save()
oauth_user.refresh_from_db()
self.assertEqual(oauth_user.token, new_token)
def test_oauth_user_token_storage(self):
"""测试OAuth token存储"""
# token字段最大150字符
long_token = 'a' * 140 # 安全的长度
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='openid',
nickname='User',
token=long_token,
type='github'
)
self.assertEqual(oauth_user.token, long_token)
class OAuthUserDeletionTest(TestCase):
"""测试OAuth用户删除业务逻辑"""
def setUp(self):
"""设置测试环境"""
self.blog_user = BlogUser.objects.create_user(
username='user',
email='user@example.com',
password='password'
)
def test_delete_oauth_user(self):
"""测试删除OAuth用户"""
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='openid',
nickname='User',
token='token',
type='weibo'
)
oauth_id = oauth_user.id
# 删除OAuth用户
oauth_user.delete()
# 验证已删除
with self.assertRaises(OAuthUser.DoesNotExist):
OAuthUser.objects.get(id=oauth_id)
def test_delete_blog_user_cascade_oauth(self):
"""测试删除博客用户级联删除OAuth用户"""
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='openid',
nickname='User',
token='token',
type='github'
)
oauth_id = oauth_user.id
# 删除博客用户
self.blog_user.delete()
# 验证OAuth用户也被删除(取决于外键的on_delete设置)
# 如果是CASCADE,OAuth用户应该被删除
with self.assertRaises(OAuthUser.DoesNotExist):
OAuthUser.objects.get(id=oauth_id)
class OAuthMetadataTest(TestCase):
"""测试OAuth元数据业务逻辑"""
def setUp(self):
"""设置测试环境"""
self.blog_user = BlogUser.objects.create_user(
username='user',
email='user@example.com',
password='password'
)
def test_oauth_user_metadata_field(self):
"""测试OAuth用户元数据字段"""
metadata = {
'avatar_url': 'http://example.com/avatar.jpg',
'bio': 'Test bio',
'location': 'Beijing'
}
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='openid',
nickname='User',
token='token',
type='github',
metadata=str(metadata) # 如果是JSONField或TextField
)
self.assertIsNotNone(oauth_user.metadata)
def test_oauth_user_email_field(self):
"""测试OAuth用户邮箱字段"""
oauth_email = 'oauth@example.com'
oauth_user = OAuthUser.objects.create(
author=self.blog_user,
openid='openid',
nickname='User',
token='token',
type='weibo',
email=oauth_email
)
self.assertEqual(oauth_user.email, oauth_email)
================================================
FILE: oauth/tests.py
================================================
import json
from unittest.mock import patch
from django.conf import settings
from django.contrib import auth
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from djangoblog.utils import get_sha256
from oauth.models import OAuthConfig
from oauth.oauthmanager import BaseOauthManager
# Create your tests here.
class OAuthConfigTest(TestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
# 清除 OAuth apps 缓存,避免测试隔离问题
from django.core.cache import cache
cache.clear()
def test_oauth_login_test(self):
c = OAuthConfig()
c.type = 'weibo'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
class OauthLoginTest(TestCase):
def setUp(self) -> None:
self.client = Client()
self.factory = RequestFactory()
# 清除 OAuth apps 缓存,避免测试隔离问题
from django.core.cache import cache
cache.clear()
self.apps = self.init_apps()
def init_apps(self):
applications = [p() for p in BaseOauthManager.__subclasses__()]
for application in applications:
c = OAuthConfig()
c.type = application.ICON_NAME.lower()
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
return applications
def get_app_by_type(self, type):
for app in self.apps:
if app.ICON_NAME.lower() == type:
return app
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_login(self, mock_do_get, mock_do_post):
weibo_app = self.get_app_by_type('weibo')
assert weibo_app
url = weibo_app.get_authorization_url()
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
mock_do_get.return_value = json.dumps({
"avatar_large": "avatar_large",
"screen_name": "screen_name",
"id": "id",
"email": "email",
})
userinfo = weibo_app.get_access_token_by_code('code')
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.openid, 'id')
@patch("oauth.oauthmanager.GoogleOauthManager.do_post")
@patch("oauth.oauthmanager.GoogleOauthManager.do_get")
def test_google_login(self, mock_do_get, mock_do_post):
google_app = self.get_app_by_type('google')
assert google_app
url = google_app.get_authorization_url()
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
"id_token": "id_token",
})
mock_do_get.return_value = json.dumps({
"picture": "picture",
"name": "name",
"sub": "sub",
"email": "email",
})
token = google_app.get_access_token_by_code('code')
userinfo = google_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.openid, 'sub')
@patch("oauth.oauthmanager.GitHubOauthManager.do_post")
@patch("oauth.oauthmanager.GitHubOauthManager.do_get")
def test_github_login(self, mock_do_get, mock_do_post):
github_app = self.get_app_by_type('github')
assert github_app
url = github_app.get_authorization_url()
self.assertTrue("github.com" in url)
self.assertTrue("client_id" in url)
mock_do_post.return_value = "access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer"
mock_do_get.return_value = json.dumps({
"avatar_url": "avatar_url",
"name": "name",
"id": "id",
"email": "email",
})
token = github_app.get_access_token_by_code('code')
userinfo = github_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a')
self.assertEqual(userinfo.openid, 'id')
@patch("oauth.oauthmanager.FaceBookOauthManager.do_post")
@patch("oauth.oauthmanager.FaceBookOauthManager.do_get")
def test_facebook_login(self, mock_do_get, mock_do_post):
facebook_app = self.get_app_by_type('facebook')
assert facebook_app
url = facebook_app.get_authorization_url()
self.assertTrue("facebook.com" in url)
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
})
mock_do_get.return_value = json.dumps({
"name": "name",
"id": "id",
"email": "email",
"picture": {
"data": {
"url": "url"
}
}
})
token = facebook_app.get_access_token_by_code('code')
userinfo = facebook_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'access_token')
@patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[
'access_token=access_token&expires_in=3600',
'callback({"client_id":"appid","openid":"openid"} );',
json.dumps({
"nickname": "nickname",
"email": "email",
"figureurl": "figureurl",
"openid": "openid",
})
])
def test_qq_login(self, mock_do_get):
qq_app = self.get_app_by_type('qq')
assert qq_app
url = qq_app.get_authorization_url()
self.assertTrue("qq.com" in url)
token = qq_app.get_access_token_by_code('code')
userinfo = qq_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'access_token')
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_authoriz_login_with_email(self, mock_do_get, mock_do_post):
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
mock_user_info = {
"avatar_large": "avatar_large",
"screen_name": "screen_name1",
"id": "id",
"email": "email",
}
mock_do_get.return_value = json.dumps(mock_user_info)
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
user = auth.get_user(self.client)
assert user.is_authenticated
self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name'])
self.assertEqual(user.email, mock_user_info['email'])
self.client.logout()
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
user = auth.get_user(self.client)
assert user.is_authenticated
self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name'])
self.assertEqual(user.email, mock_user_info['email'])
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_authoriz_login_without_email(self, mock_do_get, mock_do_post):
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
mock_user_info = {
"avatar_large": "avatar_large",
"screen_name": "screen_name1",
"id": "id",
}
mock_do_get.return_value = json.dumps(mock_user_info)
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
oauth_user_id = int(response.url.split('/')[-1].split('.')[0])
self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html')
response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id})
self.assertEqual(response.status_code, 302)
sign = get_sha256(settings.SECRET_KEY +
str(oauth_user_id) + settings.SECRET_KEY)
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauth_user_id,
})
self.assertEqual(response.url, f'{url}?type=email')
path = reverse('oauth:email_confirm', kwargs={
'id': oauth_user_id,
'sign': sign
})
response = self.client.get(path)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success')
user = auth.get_user(self.client)
from oauth.models import OAuthUser
oauth_user = OAuthUser.objects.get(author=user)
self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name'])
self.assertEqual(user.email, 'test@gmail.com')
self.assertEqual(oauth_user.pk, oauth_user_id)
================================================
FILE: oauth/urls.py
================================================
from django.urls import path
from . import views
app_name = "oauth"
urlpatterns = [
path(
r'oauth/authorize',
views.authorize),
path(
r'oauth/requireemail/.html',
views.RequireEmailView.as_view(),
name='require_email'),
path(
r'oauth/emailconfirm//.html',
views.emailconfirm,
name='email_confirm'),
path(
r'oauth/bindsuccess/.html',
views.bindsuccess,
name='bindsuccess'),
path(
r'oauth/oauthlogin',
views.oauthlogin,
name='oauthlogin')]
================================================
FILE: oauth/views.py
================================================
import logging
# Create your views here.
from urllib.parse import urlparse
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth import login
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.http import HttpResponseForbidden
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView
from django.utils.http import url_has_allowed_host_and_scheme
from djangoblog.blog_signals import oauth_user_login_signal
from djangoblog.utils import get_current_site
from djangoblog.utils import send_email, get_sha256
from oauth.forms import RequireEmailForm
from .models import OAuthUser
from .oauthmanager import get_manager_by_type, OAuthAccessTokenException
logger = logging.getLogger(__name__)
def get_redirecturl(request):
nexturl = request.GET.get('next_url', None)
if not nexturl or nexturl == '/login/' or nexturl == '/login':
return '/'
# Only allow relative URLs or URLs pointing to the current host
site_domain = get_current_site().domain
if url_has_allowed_host_and_scheme(
url=nexturl,
allowed_hosts={site_domain},
require_https=request.is_secure()
):
return nexturl
logger.info('非法url:' + str(nexturl))
return '/'
def oauthlogin(request):
type = request.GET.get('type', None)
if not type:
return HttpResponseRedirect('/')
manager = get_manager_by_type(type)
if not manager:
return HttpResponseRedirect('/')
nexturl = get_redirecturl(request)
authorizeurl = manager.get_authorization_url(nexturl)
return HttpResponseRedirect(authorizeurl)
def authorize(request):
type = request.GET.get('type', None)
if not type:
return HttpResponseRedirect('/')
manager = get_manager_by_type(type)
if not manager:
return HttpResponseRedirect('/')
code = request.GET.get('code', None)
try:
rsp = manager.get_access_token_by_code(code)
except OAuthAccessTokenException as e:
logger.warning("OAuthAccessTokenException:" + str(e))
return HttpResponseRedirect('/')
except Exception as e:
logger.error(e)
rsp = None
nexturl = get_redirecturl(request)
if not rsp:
return HttpResponseRedirect(manager.get_authorization_url(nexturl))
user = manager.get_oauth_userinfo()
if user:
if not user.nickname or not user.nickname.strip():
user.nickname = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
try:
temp = OAuthUser.objects.get(type=type, openid=user.openid)
temp.picture = user.picture
temp.metadata = user.metadata
temp.nickname = user.nickname
user = temp
except ObjectDoesNotExist:
pass
# facebook的token过长
if type == 'facebook':
user.token = ''
if user.email:
with transaction.atomic():
author = None
try:
author = get_user_model().objects.get(id=user.author_id)
except ObjectDoesNotExist:
pass
if not author:
result = get_user_model().objects.get_or_create(email=user.email)
author = result[0]
if result[1]:
try:
get_user_model().objects.get(username=user.nickname)
except ObjectDoesNotExist:
author.username = user.nickname
else:
author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.source = 'authorize'
author.save()
user.author = author
user.save()
oauth_user_login_signal.send(
sender=authorize.__class__, id=user.id)
login(request, author)
# 设置session过期时间为2周(默认)
request.session.set_expiry(settings.SESSION_COOKIE_AGE)
# 设置登录标记 cookie
response = HttpResponseRedirect(nexturl)
response.set_cookie(
'logged_user',
'true',
max_age=settings.SESSION_COOKIE_AGE,
httponly=False, # 允许 JavaScript 访问
samesite='Lax'
)
return response
else:
user.save()
url = reverse('oauth:require_email', kwargs={
'oauthid': user.id
})
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(nexturl)
def emailconfirm(request, id, sign):
if not sign:
return HttpResponseForbidden()
if not get_sha256(settings.SECRET_KEY +
str(id) +
settings.SECRET_KEY).upper() == sign.upper():
return HttpResponseForbidden()
oauthuser = get_object_or_404(OAuthUser, pk=id)
with transaction.atomic():
if oauthuser.author:
author = get_user_model().objects.get(pk=oauthuser.author_id)
else:
result = get_user_model().objects.get_or_create(email=oauthuser.email)
author = result[0]
if result[1]:
author.source = 'emailconfirm'
author.username = oauthuser.nickname.strip() if oauthuser.nickname.strip(
) else "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.save()
oauthuser.author = author
oauthuser.save()
oauth_user_login_signal.send(
sender=emailconfirm.__class__,
id=oauthuser.id)
login(request, author)
# 设置session过期时间为2周(默认)
request.session.set_expiry(settings.SESSION_COOKIE_AGE)
site = 'http://' + get_current_site().domain
content = _('''
Congratulations, you have successfully bound your email address. You can use
%(oauthuser_type)s to directly log in to this website without a password.
You are welcome to continue to follow this site, the address is
%(site)s
Thank you again!
If the link above cannot be opened, please copy this link to your browser.
%(site)s
''') % {'oauthuser_type': oauthuser.type, 'site': site}
send_email(emailto=[oauthuser.email, ], title=_('Congratulations on your successful binding!'), content=content)
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': id
})
url = url + '?type=success'
# 设置登录标记 cookie
response = HttpResponseRedirect(url)
response.set_cookie(
'logged_user',
'true',
max_age=settings.SESSION_COOKIE_AGE,
httponly=False, # 允许 JavaScript 访问
samesite='Lax'
)
return response
class RequireEmailView(FormView):
form_class = RequireEmailForm
template_name = 'oauth/require_email.html'
def get(self, request, *args, **kwargs):
oauthid = self.kwargs['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
if oauthuser.email:
pass
# return HttpResponseRedirect('/')
return super(RequireEmailView, self).get(request, *args, **kwargs)
def get_initial(self):
oauthid = self.kwargs['oauthid']
return {
'email': '',
'oauthid': oauthid
}
def get_context_data(self, **kwargs):
oauthid = self.kwargs['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
if oauthuser.picture:
kwargs['picture'] = oauthuser.picture
return super(RequireEmailView, self).get_context_data(**kwargs)
def form_valid(self, form):
email = form.cleaned_data['email']
oauthid = form.cleaned_data['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
oauthuser.email = email
oauthuser.save()
sign = get_sha256(settings.SECRET_KEY +
str(oauthuser.id) + settings.SECRET_KEY)
site = get_current_site().domain
if settings.DEBUG:
site = '127.0.0.1:8000'
path = reverse('oauth:email_confirm', kwargs={
'id': oauthid,
'sign': sign
})
url = "http://{site}{path}".format(site=site, path=path)
content = _("""
Please click the link below to bind your email
%(url)s
Thank you again!
If the link above cannot be opened, please copy this link to your browser.
%(url)s
""") % {'url': url}
send_email(emailto=[email, ], title=_('Bind your email'), content=content)
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauthid
})
url = url + '?type=email'
return HttpResponseRedirect(url)
def bindsuccess(request, oauthid):
type = request.GET.get('type', None)
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
if type == 'email':
title = _('Bind your email')
content = _(
'Congratulations, the binding is just one step away. '
'Please log in to your email to check the email to complete the binding. Thank you.')
else:
title = _('Binding successful')
content = _(
"Congratulations, you have successfully bound your email address. You can use %(oauthuser_type)s"
" to directly log in to this website without a password. You are welcome to continue to follow this site." % {
'oauthuser_type': oauthuser.type})
return render(request, 'oauth/bindsuccess.html', {
'title': title,
'content': content
})
================================================
FILE: owntracks/__init__.py
================================================
================================================
FILE: owntracks/admin.py
================================================
from django.contrib import admin
# Register your models here.
class OwnTrackLogsAdmin(admin.ModelAdmin):
pass
================================================
FILE: owntracks/apps.py
================================================
from django.apps import AppConfig
class OwntracksConfig(AppConfig):
name = 'owntracks'
================================================
FILE: owntracks/migrations/0001_initial.py
================================================
# Generated by Django 4.1.7 on 2023-03-02 07:14
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='OwnTrackLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tid', models.CharField(max_length=100, verbose_name='用户')),
('lat', models.FloatField(verbose_name='纬度')),
('lon', models.FloatField(verbose_name='经度')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
],
options={
'verbose_name': 'OwnTrackLogs',
'verbose_name_plural': 'OwnTrackLogs',
'ordering': ['created_time'],
'get_latest_by': 'created_time',
},
),
]
================================================
FILE: owntracks/migrations/0002_alter_owntracklog_options_and_more.py
================================================
# Generated by Django 4.2.5 on 2023-09-06 13:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('owntracks', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='owntracklog',
options={'get_latest_by': 'creation_time', 'ordering': ['creation_time'], 'verbose_name': 'OwnTrackLogs', 'verbose_name_plural': 'OwnTrackLogs'},
),
migrations.RenameField(
model_name='owntracklog',
old_name='created_time',
new_name='creation_time',
),
]
================================================
FILE: owntracks/migrations/__init__.py
================================================
================================================
FILE: owntracks/models.py
================================================
from django.db import models
from django.utils.timezone import now
# Create your models here.
class OwnTrackLog(models.Model):
tid = models.CharField(max_length=100, null=False, verbose_name='用户')
lat = models.FloatField(verbose_name='纬度')
lon = models.FloatField(verbose_name='经度')
creation_time = models.DateTimeField('创建时间', default=now)
def __str__(self):
return self.tid
class Meta:
ordering = ['creation_time']
verbose_name = "OwnTrackLogs"
verbose_name_plural = verbose_name
get_latest_by = 'creation_time'
================================================
FILE: owntracks/tests.py
================================================
import json
from django.test import Client, RequestFactory, TestCase
from accounts.models import BlogUser
from .models import OwnTrackLog
# Create your tests here.
class OwnTrackLogTest(TestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
def test_own_track_log(self):
o = {
'tid': 12,
'lat': 123.123,
'lon': 134.341
}
self.client.post(
'/owntracks/logtracks',
json.dumps(o),
content_type='application/json')
length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1)
o = {
'tid': 12,
'lat': 123.123
}
self.client.post(
'/owntracks/logtracks',
json.dumps(o),
content_type='application/json')
length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1)
rsp = self.client.get('/owntracks/show_maps')
self.assertEqual(rsp.status_code, 302)
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
self.client.login(username='liangliangyy1', password='liangliangyy1')
s = OwnTrackLog()
s.tid = 12
s.lon = 123.234
s.lat = 34.234
s.save()
rsp = self.client.get('/owntracks/show_dates')
self.assertEqual(rsp.status_code, 200)
rsp = self.client.get('/owntracks/show_maps')
self.assertEqual(rsp.status_code, 200)
rsp = self.client.get('/owntracks/get_datas')
self.assertEqual(rsp.status_code, 200)
rsp = self.client.get('/owntracks/get_datas?date=2018-02-26')
self.assertEqual(rsp.status_code, 200)
================================================
FILE: owntracks/urls.py
================================================
from django.urls import path
from . import views
app_name = "owntracks"
urlpatterns = [
path('owntracks/logtracks', views.manage_owntrack_log, name='logtracks'),
path('owntracks/show_maps', views.show_maps, name='show_maps'),
path('owntracks/get_datas', views.get_datas, name='get_datas'),
path('owntracks/show_dates', views.show_log_dates, name='show_dates')
]
================================================
FILE: owntracks/views.py
================================================
# Create your views here.
import datetime
import itertools
import json
import logging
from datetime import timezone
from itertools import groupby
import django
import requests
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.http import JsonResponse
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from .models import OwnTrackLog
logger = logging.getLogger(__name__)
@csrf_exempt
def manage_owntrack_log(request):
try:
s = json.loads(request.read().decode('utf-8'))
tid = s['tid']
lat = s['lat']
lon = s['lon']
logger.info(
'tid:{tid}.lat:{lat}.lon:{lon}'.format(
tid=tid, lat=lat, lon=lon))
if tid and lat and lon:
m = OwnTrackLog()
m.tid = tid
m.lat = lat
m.lon = lon
m.save()
return HttpResponse('ok')
else:
return HttpResponse('data error')
except Exception as e:
logger.error(e)
return HttpResponse('error')
@login_required
def show_maps(request):
if request.user.is_superuser:
defaultdate = str(datetime.datetime.now(timezone.utc).date())
date = request.GET.get('date', defaultdate)
context = {
'date': date
}
return render(request, 'owntracks/show_maps.html', context)
else:
from django.http import HttpResponseForbidden
return HttpResponseForbidden()
@login_required
def show_log_dates(request):
dates = OwnTrackLog.objects.values_list('creation_time', flat=True)
results = list(sorted(set(map(lambda x: x.strftime('%Y-%m-%d'), dates))))
context = {
'results': results
}
return render(request, 'owntracks/show_log_dates.html', context)
def convert_to_amap(locations):
convert_result = []
it = iter(locations)
item = list(itertools.islice(it, 30))
while item:
datas = ';'.join(
set(map(lambda x: str(x.lon) + ',' + str(x.lat), item)))
key = '8440a376dfc9743d8924bf0ad141f28e'
api = 'http://restapi.amap.com/v3/assistant/coordinate/convert'
query = {
'key': key,
'locations': datas,
'coordsys': 'gps'
}
rsp = requests.get(url=api, params=query)
result = json.loads(rsp.text)
if "locations" in result:
convert_result.append(result['locations'])
item = list(itertools.islice(it, 30))
return ";".join(convert_result)
@login_required
def get_datas(request):
now = django.utils.timezone.now().replace(tzinfo=timezone.utc)
querydate = django.utils.timezone.datetime(
now.year, now.month, now.day, 0, 0, 0)
if request.GET.get('date', None):
date = list(map(lambda x: int(x), request.GET.get('date').split('-')))
querydate = django.utils.timezone.datetime(
date[0], date[1], date[2], 0, 0, 0)
nextdate = querydate + datetime.timedelta(days=1)
models = OwnTrackLog.objects.filter(
creation_time__range=(querydate, nextdate))
result = list()
if models and len(models):
for tid, item in groupby(
sorted(models, key=lambda k: k.tid), key=lambda k: k.tid):
d = dict()
d["name"] = tid
paths = list()
# 使用高德转换后的经纬度
# locations = convert_to_amap(
# sorted(item, key=lambda x: x.creation_time))
# for i in locations.split(';'):
# paths.append(i.split(','))
# 使用GPS原始经纬度
for location in sorted(item, key=lambda x: x.creation_time):
paths.append([str(location.lon), str(location.lat)])
d["path"] = paths
result.append(d)
return JsonResponse(result, safe=False)
================================================
FILE: plugins/__init__.py
================================================
# This file makes this a Python package
================================================
FILE: plugins/article_copyright/__init__.py
================================================
# This file makes this a Python package
================================================
FILE: plugins/article_copyright/plugin.py
================================================
from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
class ArticleCopyrightPlugin(BasePlugin):
PLUGIN_NAME = '文章结尾版权声明'
PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。'
PLUGIN_VERSION = '0.2.0'
PLUGIN_AUTHOR = 'liangliangyy'
# 2. 实现 register_hooks 方法,专门用于注册钩子
def register_hooks(self):
# 在这里将插件的方法注册到指定的钩子上
hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_copyright_to_content)
def add_copyright_to_content(self, content, *args, **kwargs):
"""
这个方法会被注册到 'the_content' 过滤器钩子上。
它接收原始内容,并返回添加了版权信息的新内容。
"""
article = kwargs.get('article')
if not article:
return content
# 如果是摘要模式(首页),不添加版权声明
is_summary = kwargs.get('is_summary', False)
if is_summary:
return content
copyright_info = f"\n本文由 {article.author.username} 原创,转载请注明出处。
"
return content + copyright_info
# 3. 实例化插件。
# 这会自动调用 BasePlugin.__init__,然后 BasePlugin.__init__ 会调用我们上面定义的 register_hooks 方法。
plugin = ArticleCopyrightPlugin()
================================================
FILE: plugins/article_recommendation/__init__.py
================================================
# 文章推荐插件
================================================
FILE: plugins/article_recommendation/plugin.py
================================================
import logging
from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_DETAIL_LOAD
from blog.models import Article
logger = logging.getLogger(__name__)
class ArticleRecommendationPlugin(BasePlugin):
PLUGIN_NAME = '文章推荐'
PLUGIN_DESCRIPTION = '智能文章推荐系统,支持多位置展示'
PLUGIN_VERSION = '1.0.0'
PLUGIN_AUTHOR = 'liangliangyy'
# 支持的位置
SUPPORTED_POSITIONS = ['article_bottom']
# 各位置优先级
POSITION_PRIORITIES = {
'article_bottom': 80, # 文章底部优先级
}
# 插件配置
CONFIG = {
'article_bottom_count': 8, # 文章底部推荐数量
'sidebar_count': 5, # 侧边栏推荐数量
'enable_category_fallback': True, # 启用分类回退
'enable_popular_fallback': True, # 启用热门文章回退
}
def register_hooks(self):
"""注册钩子"""
hooks.register(ARTICLE_DETAIL_LOAD, self.on_article_detail_load)
def on_article_detail_load(self, article, context, request, *args, **kwargs):
"""文章详情页加载时的处理"""
# 可以在这里预加载推荐数据到context中
recommendations = self.get_recommendations(article)
context['article_recommendations'] = recommendations
def should_display(self, position, context, **kwargs):
"""条件显示逻辑"""
# 只在文章详情页底部显示
if position == 'article_bottom':
article = kwargs.get('article') or context.get('article')
# 检查是否有文章对象,以及是否不是索引页面
is_index = context.get('isindex', False) if hasattr(context, 'get') else False
return article is not None and not is_index
return False
def render_article_bottom_widget(self, context, **kwargs):
"""渲染文章底部推荐"""
article = kwargs.get('article') or context.get('article')
if not article:
return None
# 使用配置的数量,也可以通过kwargs覆盖
count = kwargs.get('count', self.CONFIG['article_bottom_count'])
recommendations = self.get_recommendations(article, count=count)
if not recommendations:
return None
# 将RequestContext转换为普通字典
context_dict = {}
if hasattr(context, 'flatten'):
context_dict = context.flatten()
elif hasattr(context, 'dicts'):
# 合并所有上下文字典
for d in context.dicts:
context_dict.update(d)
template_context = {
'recommendations': recommendations,
'article': article,
'title': '相关推荐',
**context_dict
}
return self.render_template('bottom_widget.html', template_context)
def render_sidebar_widget(self, context, **kwargs):
"""渲染侧边栏推荐"""
article = context.get('article')
# 使用配置的数量,也可以通过kwargs覆盖
count = kwargs.get('count', self.CONFIG['sidebar_count'])
if article:
# 文章页面,显示相关文章
recommendations = self.get_recommendations(article, count=count)
title = '相关文章'
else:
# 其他页面,显示热门文章
recommendations = self.get_popular_articles(count=count)
title = '热门推荐'
if not recommendations:
return None
# 将RequestContext转换为普通字典
context_dict = {}
if hasattr(context, 'flatten'):
context_dict = context.flatten()
elif hasattr(context, 'dicts'):
# 合并所有上下文字典
for d in context.dicts:
context_dict.update(d)
template_context = {
'recommendations': recommendations,
'title': title,
**context_dict
}
return self.render_template('sidebar_widget.html', template_context)
def get_css_files(self):
"""返回CSS文件"""
return ['css/recommendation.css']
def get_js_files(self):
"""返回JS文件"""
return ['js/recommendation.js']
def get_recommendations(self, article, count=5):
"""获取推荐文章"""
if not article:
return []
recommendations = []
# 1. 基于标签的推荐
if article.tags.exists():
tag_ids = list(article.tags.values_list('id', flat=True))
tag_based = list(Article.objects.filter(
status='p',
tags__id__in=tag_ids
).exclude(
id=article.id
).exclude(
title__isnull=True
).exclude(
title__exact=''
).distinct().order_by('-views')[:count])
recommendations.extend(tag_based)
# 2. 如果数量不够,基于分类推荐
if len(recommendations) < count and self.CONFIG['enable_category_fallback']:
needed = count - len(recommendations)
existing_ids = [r.id for r in recommendations] + [article.id]
category_based = list(Article.objects.filter(
status='p',
category=article.category
).exclude(
id__in=existing_ids
).exclude(
title__isnull=True
).exclude(
title__exact=''
).order_by('-views')[:needed])
recommendations.extend(category_based)
# 3. 如果还是不够,推荐热门文章
if len(recommendations) < count and self.CONFIG['enable_popular_fallback']:
needed = count - len(recommendations)
existing_ids = [r.id for r in recommendations] + [article.id]
popular_articles = list(Article.objects.filter(
status='p'
).exclude(
id__in=existing_ids
).exclude(
title__isnull=True
).exclude(
title__exact=''
).order_by('-views')[:needed])
recommendations.extend(popular_articles)
# 过滤掉无效的推荐
valid_recommendations = []
for rec in recommendations:
if rec.title and len(rec.title.strip()) > 0:
valid_recommendations.append(rec)
else:
logger.warning(f"过滤掉空标题文章: ID={rec.id}, 标题='{rec.title}'")
# 调试:记录推荐结果
logger.info(f"原始推荐数量: {len(recommendations)}, 有效推荐数量: {len(valid_recommendations)}")
for i, rec in enumerate(valid_recommendations):
logger.info(f"推荐 {i+1}: ID={rec.id}, 标题='{rec.title}', 长度={len(rec.title)}")
return valid_recommendations[:count]
def get_popular_articles(self, count=3):
"""获取热门文章"""
return list(Article.objects.filter(
status='p'
).order_by('-views')[:count])
# 实例化插件
plugin = ArticleRecommendationPlugin()
================================================
FILE: plugins/article_recommendation/static/article_recommendation/css/recommendation.css
================================================
/* 文章推荐插件样式 - 与网站风格保持一致 */
/* 文章底部推荐样式 */
.article-recommendations {
margin: 30px 0;
padding: 20px;
background: #fff;
border: 1px solid #e1e1e1;
border-radius: 3px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.recommendations-title {
margin: 0 0 15px 0;
font-size: 18px;
color: #444;
font-weight: bold;
padding-bottom: 8px;
border-bottom: 2px solid #21759b;
display: inline-block;
}
.recommendations-icon {
margin-right: 5px;
font-size: 16px;
}
.recommendations-grid {
display: grid;
gap: 15px;
grid-template-columns: 1fr;
margin-top: 15px;
}
.recommendation-card {
background: #fff;
border: 1px solid #e1e1e1;
border-radius: 3px;
transition: all 0.2s ease;
overflow: hidden;
}
.recommendation-card:hover {
border-color: #21759b;
box-shadow: 0 2px 5px rgba(33, 117, 155, 0.1);
}
.recommendation-link {
display: block;
padding: 15px;
text-decoration: none;
color: inherit;
}
.recommendation-title {
margin: 0 0 8px 0;
font-size: 15px;
font-weight: normal;
color: #444;
line-height: 1.4;
transition: color 0.2s ease;
}
.recommendation-card:hover .recommendation-title {
color: #21759b;
}
.recommendation-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #757575;
}
.recommendation-category {
background: #ebebeb;
color: #5e5e5e;
padding: 2px 6px;
border-radius: 2px;
font-size: 11px;
font-weight: normal;
}
.recommendation-date {
font-weight: normal;
color: #757575;
}
/* 深色模式支持 */
[data-theme="dark"] .article-recommendations {
background: #1a1a1a;
border-color: #333;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
[data-theme="dark"] .recommendations-title {
color: #e0e0e0;
border-bottom-color: #3a8fb7;
}
[data-theme="dark"] .recommendation-card {
background: #2a2a2a;
border-color: #3a3a3a;
}
[data-theme="dark"] .recommendation-card:hover {
border-color: #3a8fb7;
box-shadow: 0 2px 5px rgba(58, 143, 183, 0.2);
}
[data-theme="dark"] .recommendation-title {
color: #d0d0d0;
}
[data-theme="dark"] .recommendation-card:hover .recommendation-title {
color: #3a8fb7;
}
[data-theme="dark"] .recommendation-meta {
color: #999;
}
[data-theme="dark"] .recommendation-category {
background: #3a3a3a;
color: #b0b0b0;
}
[data-theme="dark"] .recommendation-date {
color: #999;
}
/* 侧边栏推荐样式 */
.widget_recommendations {
margin-bottom: 20px;
}
.widget_recommendations .widget-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
color: #333;
border-bottom: 2px solid #007cba;
padding-bottom: 5px;
}
.recommendations-list {
list-style: none;
padding: 0;
margin: 0;
}
.recommendations-list .recommendation-item {
padding: 8px 0;
border-bottom: 1px solid #eee;
background: none;
border: none;
border-radius: 0;
}
.recommendations-list .recommendation-item:last-child {
border-bottom: none;
}
.recommendations-list .recommendation-item a {
color: #333;
text-decoration: none;
font-size: 14px;
line-height: 1.4;
display: block;
margin-bottom: 4px;
transition: color 0.3s ease;
}
.recommendations-list .recommendation-item a:hover {
color: #007cba;
}
.recommendations-list .recommendation-meta {
font-size: 11px;
color: #999;
margin: 0;
}
.recommendations-list .recommendation-meta span {
margin-right: 10px;
}
/* 响应式设计 - 分栏显示 */
@media (min-width: 768px) {
.recommendations-grid {
grid-template-columns: repeat(2, 1fr);
gap: 15px;
}
}
@media (min-width: 1024px) {
.recommendations-grid {
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
}
@media (min-width: 1200px) {
.recommendations-grid {
grid-template-columns: repeat(4, 1fr);
gap: 15px;
}
}
================================================
FILE: plugins/article_recommendation/static/article_recommendation/js/recommendation.js
================================================
/**
* 文章推荐插件JavaScript
*/
(function() {
'use strict';
// 等待DOM加载完成
document.addEventListener('DOMContentLoaded', function() {
initRecommendations();
});
function initRecommendations() {
// 添加点击统计
trackRecommendationClicks();
// 懒加载优化(如果需要)
lazyLoadRecommendations();
}
function trackRecommendationClicks() {
const recommendationLinks = document.querySelectorAll('.recommendation-item a');
recommendationLinks.forEach(function(link) {
link.addEventListener('click', function(e) {
// 可以在这里添加点击统计逻辑
const articleTitle = this.textContent.trim();
const articleUrl = this.href;
// 发送统计数据到后端(可选)
if (typeof gtag !== 'undefined') {
gtag('event', 'click', {
'event_category': 'recommendation',
'event_label': articleTitle,
'value': 1
});
}
console.log('Recommendation clicked:', articleTitle, articleUrl);
});
});
}
function lazyLoadRecommendations() {
// 如果推荐内容很多,可以实现懒加载
const recommendationContainer = document.querySelector('.article-recommendations');
if (!recommendationContainer) {
return;
}
// 检查是否在视窗中
const observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
entry.target.classList.add('loaded');
observer.unobserve(entry.target);
}
});
}, {
threshold: 0.1
});
const recommendationItems = document.querySelectorAll('.recommendation-item');
recommendationItems.forEach(function(item) {
observer.observe(item);
});
}
// 添加一些动画效果
function addAnimations() {
const recommendationItems = document.querySelectorAll('.recommendation-item');
recommendationItems.forEach(function(item, index) {
item.style.opacity = '0';
item.style.transform = 'translateY(20px)';
item.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
setTimeout(function() {
item.style.opacity = '1';
item.style.transform = 'translateY(0)';
}, index * 100);
});
}
// 如果需要,可以在这里添加更多功能
window.ArticleRecommendation = {
init: initRecommendations,
track: trackRecommendationClicks,
animate: addAnimations
};
})();
================================================
FILE: plugins/cloudflare_cache/__init__.py
================================================
"""
Cloudflare Cache Plugin for DjangoBlog
Automatically purges Cloudflare cache when content changes.
"""
__version__ = '1.0.0'
================================================
FILE: plugins/cloudflare_cache/api.py
================================================
"""
Cloudflare API 封装
提供与Cloudflare API交互的功能,用于清除缓存。
"""
import logging
import requests
from typing import List, Dict, Optional
logger = logging.getLogger(__name__)
class CloudflareAPI:
"""Cloudflare API 客户端"""
API_BASE = "https://api.cloudflare.com/client/v4"
def __init__(self, zone_id: str, api_token: str):
"""
初始化Cloudflare API客户端
Args:
zone_id: Cloudflare Zone ID
api_token: Cloudflare API Token (需要 Zone.Cache Purge 权限)
"""
self.zone_id = zone_id
self.api_token = api_token
self.headers = {
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json'
}
def purge_urls(self, urls: List[str]) -> Dict:
"""
按URL清除缓存(精确清除)
Args:
urls: 要清除的URL列表(最多30个)
Returns:
API响应结果字典,包含 'success' 字段
Note:
- 单次请求最多清除30个URL
- URL必须包含完整的协议和域名
- 示例: https://example.com/article/123.html
"""
if not urls:
logger.warning("[CF API] No URLs to purge")
return {'success': True, 'result': {'id': 'no-op'}}
# Cloudflare限制单次最多30个URL
if len(urls) > 30:
logger.warning(f"[CF API] URLs count ({len(urls)}) exceeds limit (30), will be truncated")
urls = urls[:30]
endpoint = f"{self.API_BASE}/zones/{self.zone_id}/purge_cache"
data = {'files': urls}
try:
logger.info(f"[CF API] Purging {len(urls)} URLs from cache")
logger.debug(f"[CF API] URLs: {urls}")
response = requests.post(
endpoint,
json=data,
headers=self.headers,
timeout=10
)
result = response.json()
if result.get('success'):
logger.info(f"[CF API] Successfully purged {len(urls)} URLs")
logger.debug(f"[CF API] Response: {result}")
else:
errors = result.get('errors', [])
logger.error(f"[CF API] Failed to purge cache: {errors}")
return result
except requests.Timeout:
logger.error("[CF API] Request timeout (10s)")
return {'success': False, 'errors': [{'message': 'Request timeout'}]}
except requests.RequestException as e:
logger.error(f"[CF API] Request failed: {e}", exc_info=True)
return {'success': False, 'errors': [{'message': str(e)}]}
except Exception as e:
logger.error(f"[CF API] Unexpected error: {e}", exc_info=True)
return {'success': False, 'errors': [{'message': f'Unexpected error: {e}'}]}
def purge_all(self) -> Dict:
"""
清除所有缓存(慎用!)
Returns:
API响应结果字典
Warning:
此操作会清除Zone下的所有缓存,影响范围大,请谨慎使用!
"""
endpoint = f"{self.API_BASE}/zones/{self.zone_id}/purge_cache"
data = {'purge_everything': True}
try:
logger.warning("[CF API] Purging ALL cache - this affects the entire zone!")
response = requests.post(
endpoint,
json=data,
headers=self.headers,
timeout=10
)
result = response.json()
if result.get('success'):
logger.info("[CF API] Successfully purged all cache")
else:
errors = result.get('errors', [])
logger.error(f"[CF API] Failed to purge all cache: {errors}")
return result
except requests.RequestException as e:
logger.error(f"[CF API] Request failed: {e}", exc_info=True)
return {'success': False, 'errors': [{'message': str(e)}]}
def purge_by_tags(self, tags: List[str]) -> Dict:
"""
按缓存标签清除(需要企业版)
Args:
tags: 缓存标签列表
Returns:
API响应结果字典
Note:
此功能仅在Cloudflare企业版中可用
"""
endpoint = f"{self.API_BASE}/zones/{self.zone_id}/purge_cache"
data = {'tags': tags}
try:
logger.info(f"[CF API] Purging cache by tags: {tags}")
response = requests.post(
endpoint,
json=data,
headers=self.headers,
timeout=10
)
result = response.json()
if result.get('success'):
logger.info(f"[CF API] Successfully purged cache by tags")
else:
errors = result.get('errors', [])
logger.error(f"[CF API] Failed to purge by tags: {errors}")
return result
except requests.RequestException as e:
logger.error(f"[CF API] Request failed: {e}", exc_info=True)
return {'success': False, 'errors': [{'message': str(e)}]}
def validate_credentials(self) -> bool:
"""
验证API凭证是否有效
Returns:
True 如果凭证有效,False 否则
"""
endpoint = f"{self.API_BASE}/zones/{self.zone_id}"
try:
response = requests.get(
endpoint,
headers=self.headers,
timeout=5
)
result = response.json()
if result.get('success'):
logger.info("[CF API] Credentials validated successfully")
return True
else:
logger.error(f"[CF API] Invalid credentials: {result.get('errors')}")
return False
except Exception as e:
logger.error(f"[CF API] Failed to validate credentials: {e}")
return False
================================================
FILE: plugins/cloudflare_cache/handlers.py
================================================
"""
Django信号处理器
监听模型变更事件,触发Cloudflare缓存清除。
"""
import logging
from typing import List
from django.contrib.admin.models import LogEntry
logger = logging.getLogger(__name__)
class CloudflareCacheHandler:
"""Cloudflare缓存处理器"""
def __init__(self, config: dict):
"""
初始化处理器
Args:
config: 插件配置字典
"""
self.config = config
# 初始化Cloudflare API客户端
from .api import CloudflareAPI
self.cf_api = CloudflareAPI(
zone_id=config['zone_id'],
api_token=config['api_token']
)
logger.info("[CF Handler] Initialized with config")
def on_model_save(self, sender, instance, created, update_fields, **kwargs):
"""
Django post_save 信号处理器
Args:
sender: 发送信号的模型类
instance: 保存的模型实例
created: 是否为新建
update_fields: 更新的字段集合
**kwargs: 其他参数
"""
# 忽略日志条目
if isinstance(instance, LogEntry):
return
# 🚨 关键:忽略仅更新views字段的情况
# 浏览量更新非常频繁,不应该清除缓存
is_update_views = update_fields == {'views'}
if is_update_views:
logger.debug(f"[CF Handler] Skipping cache purge for views update: {instance}")
return
# 导入模型类(延迟导入避免循环依赖)
from blog.models import Article
from comments.models import Comment
# 收集需要清除的URL
urls_to_purge = []
if isinstance(instance, Article):
if self.config['purge_on_article_save']:
urls_to_purge = self._collect_article_urls(instance, created)
logger.info(f"[CF Handler] Article {'created' if created else 'updated'}: {instance.title}")
elif isinstance(instance, Comment):
if self.config['purge_on_comment_save'] and instance.is_enable:
urls_to_purge = self._collect_comment_urls(instance, created)
logger.info(f"[CF Handler] Comment {'created' if created else 'updated'} on article: {instance.article.title}")
# 执行缓存清除
if urls_to_purge:
self._purge_cache_batch(urls_to_purge)
def _collect_article_urls(self, article, is_new: bool) -> List[str]:
"""
收集文章相关的URL
Args:
article: 文章实例
is_new: 是否为新建文章
Returns:
需要清除的URL列表
"""
from djangoblog.utils import get_current_site
try:
site = get_current_site()
base_url = f"https://{site.domain}"
urls = []
# 1. 文章详情页(必须清除)
article_url = base_url + article.get_absolute_url()
urls.append(article_url)
logger.debug(f"[CF Handler] Added article detail: {article_url}")
# 2. 首页(如果配置启用)
if self.config['purge_home_on_article']:
home_url = base_url + '/'
urls.append(home_url)
logger.debug(f"[CF Handler] Added homepage: {home_url}")
# 3. 相关页面(如果配置启用)
if self.config['purge_related_pages']:
# 分类页
try:
category_url = base_url + article.category.get_absolute_url()
urls.append(category_url)
logger.debug(f"[CF Handler] Added category: {category_url}")
except Exception as e:
logger.warning(f"[CF Handler] Failed to get category URL: {e}")
# 标签页
try:
for tag in article.tags.all():
tag_url = base_url + tag.get_absolute_url()
urls.append(tag_url)
logger.debug(f"[CF Handler] Added tag: {tag_url}")
except Exception as e:
logger.warning(f"[CF Handler] Failed to get tag URLs: {e}")
# RSS Feeds
urls.append(base_url + '/rss/')
urls.append(base_url + '/feed/')
logger.debug(f"[CF Handler] Added RSS feeds")
# 去重
urls = list(dict.fromkeys(urls))
logger.info(f"[CF Handler] Collected {len(urls)} URLs for article: {article.title}")
return urls
except Exception as e:
logger.error(f"[CF Handler] Error collecting article URLs: {e}", exc_info=True)
return []
def _collect_comment_urls(self, comment, is_new: bool) -> List[str]:
"""
收集评论相关的URL
Args:
comment: 评论实例
is_new: 是否为新评论
Returns:
需要清除的URL列表
"""
from djangoblog.utils import get_current_site
try:
site = get_current_site()
base_url = f"https://{site.domain}"
urls = []
# 评论所在的文章页
if comment.article:
article_url = base_url + comment.article.get_absolute_url()
urls.append(article_url)
logger.debug(f"[CF Handler] Added article for comment: {article_url}")
# 如果配置启用,也清除首页(显示最新评论的情况)
if self.config.get('purge_home_on_comment', False):
urls.append(base_url + '/')
logger.info(f"[CF Handler] Collected {len(urls)} URLs for comment")
return urls
except Exception as e:
logger.error(f"[CF Handler] Error collecting comment URLs: {e}", exc_info=True)
return []
def _purge_cache_batch(self, urls: List[str]):
"""
批量清除缓存
Args:
urls: 要清除的URL列表
Note:
- Cloudflare单次请求最多30个URL
- 自动分批处理
"""
if not urls:
return
max_urls = self.config['max_urls_per_request']
try:
# 分批处理
for i in range(0, len(urls), max_urls):
batch = urls[i:i + max_urls]
logger.info(f"[CF Handler] Purging batch {i//max_urls + 1}: {len(batch)} URLs")
result = self.cf_api.purge_urls(batch)
if result.get('success'):
logger.info(f"[CF Handler] ✓ Successfully purged {len(batch)} URLs")
else:
errors = result.get('errors', [])
error_messages = [err.get('message', str(err)) for err in errors]
logger.error(f"[CF Handler] ✗ Failed to purge batch: {error_messages}")
except Exception as e:
# 缓存清除失败不应影响主流程
logger.error(f"[CF Handler] Exception during cache purge: {e}", exc_info=True)
logger.warning("[CF Handler] Cache purge failed, but main operation completed successfully")
def purge_all(self):
"""
手动清除所有缓存
Warning:
此方法会清除整个Zone的所有缓存,请谨慎使用
"""
logger.warning("[CF Handler] Manual purge all triggered")
try:
result = self.cf_api.purge_all()
if result.get('success'):
logger.info("[CF Handler] ✓ Successfully purged all cache")
return True
else:
errors = result.get('errors', [])
logger.error(f"[CF Handler] ✗ Failed to purge all: {errors}")
return False
except Exception as e:
logger.error(f"[CF Handler] Exception during purge all: {e}", exc_info=True)
return False
================================================
FILE: plugins/cloudflare_cache/plugin.py
================================================
"""
Cloudflare 缓存管理插件
自动清除Cloudflare CDN缓存,确保内容更新后用户能看到最新内容。
功能特性:
- 文章发布/修改时自动清除相关页面缓存
- 评论发布时清除文章页面缓存
- 智能过滤浏览量更新,避免频繁清除缓存
- 支持批量清除,自动处理Cloudflare的30个URL限制
- 完整的错误处理,缓存清除失败不影响主流程
- 灵活的配置选项,可按需启用/禁用功能
作者: DjangoBlog
版本: 1.0.0
"""
import os
import logging
from django.conf import settings
from django.db.models.signals import post_save
from djangoblog.plugin_manage.base_plugin import BasePlugin
logger = logging.getLogger(__name__)
class CloudflareCachePlugin(BasePlugin):
"""Cloudflare 缓存管理插件"""
# ==================== 插件元数据 ====================
PLUGIN_NAME = 'Cloudflare 缓存管理'
PLUGIN_DESCRIPTION = '自动清除Cloudflare缓存,在文章、评论更新时保持内容同步'
PLUGIN_VERSION = '1.0.0'
PLUGIN_AUTHOR = 'DjangoBlog'
# ==================== 位置配置 ====================
# 此插件不需要在页面上显示任何内容,只在后台工作
SUPPORTED_POSITIONS = []
# ==================== 插件配置 ====================
CONFIG = {
# === 基础配置 ===
'enabled': True, # 插件总开关
# Cloudflare 凭证(从环境变量或Django settings读取)
'zone_id': os.environ.get('CLOUDFLARE_ZONE_ID', ''),
'api_token': os.environ.get('CLOUDFLARE_API_TOKEN', ''),
# === 清除策略 ===
'purge_on_startup': os.environ.get('CLOUDFLARE_PURGE_ON_STARTUP', 'false').lower() in ('true', '1', 'yes'), # 应用启动时清除全站缓存
'purge_on_article_save': True, # 文章保存时清除缓存
'purge_on_comment_save': True, # 评论保存时清除缓存
# === 清除范围 ===
'purge_home_on_article': True, # 文章更新时是否清除首页
'purge_related_pages': True, # 是否清除分类页、标签页、RSS等相关页面
'purge_home_on_comment': True, # 评论更新时是否清除首页(如果首页显示最新评论则开启)
# === API 配置 ===
'max_urls_per_request': 30, # 单次请求最多清除的URL数(Cloudflare限制)
'request_timeout': 10, # API请求超时时间(秒)
}
def init_plugin(self):
"""插件初始化"""
logger.info(f"Initializing {self.PLUGIN_NAME} v{self.PLUGIN_VERSION}")
# 从Django settings读取配置(如果存在)
if hasattr(settings, 'CLOUDFLARE_CONFIG'):
cf_config = settings.CLOUDFLARE_CONFIG
self.CONFIG['zone_id'] = cf_config.get('zone_id', self.CONFIG['zone_id'])
self.CONFIG['api_token'] = cf_config.get('api_token', self.CONFIG['api_token'])
logger.info("[CF Plugin] Loaded config from Django settings")
# 验证配置
if not self._validate_config():
self.CONFIG['enabled'] = False
logger.warning("[CF Plugin] Plugin disabled due to invalid configuration")
return
logger.info("[CF Plugin] Configuration validated successfully")
# 测试API连接(可选)
if self.CONFIG.get('test_on_init', False):
self._test_api_connection()
# 启动时清除全站缓存(可选)
if self.CONFIG.get('purge_on_startup', False):
self._purge_on_startup()
def _validate_config(self) -> bool:
"""
验证配置是否有效
Returns:
True 如果配置有效,False 否则
"""
zone_id = self.CONFIG.get('zone_id', '').strip()
api_token = self.CONFIG.get('api_token', '').strip()
if not zone_id:
logger.error("[CF Plugin] CLOUDFLARE_ZONE_ID not configured")
logger.info("[CF Plugin] Please set environment variable: CLOUDFLARE_ZONE_ID")
return False
if not api_token:
logger.error("[CF Plugin] CLOUDFLARE_API_TOKEN not configured")
logger.info("[CF Plugin] Please set environment variable: CLOUDFLARE_API_TOKEN")
return False
# 基本格式验证
if len(zone_id) != 32:
logger.warning(f"[CF Plugin] Zone ID format may be invalid (length: {len(zone_id)})")
if len(api_token) < 20:
logger.warning(f"[CF Plugin] API Token format may be invalid (length: {len(api_token)})")
return True
def _test_api_connection(self):
"""测试Cloudflare API连接"""
try:
from .api import CloudflareAPI
cf_api = CloudflareAPI(
zone_id=self.CONFIG['zone_id'],
api_token=self.CONFIG['api_token']
)
if cf_api.validate_credentials():
logger.info("[CF Plugin] ✓ API connection test successful")
else:
logger.error("[CF Plugin] ✗ API connection test failed")
logger.warning("[CF Plugin] Plugin will continue, but cache purging may not work")
except Exception as e:
logger.error(f"[CF Plugin] Error testing API connection: {e}")
def _purge_on_startup(self):
"""
应用启动时清除全站缓存
使用后台线程异步执行,不阻塞启动过程
"""
import threading
def _do_purge():
"""实际执行清除的函数"""
try:
import time
# 延迟几秒,确保应用完全启动
time.sleep(3)
logger.info("[CF Plugin] 🚀 Starting startup cache purge...")
from .api import CloudflareAPI
cf_api = CloudflareAPI(
zone_id=self.CONFIG['zone_id'],
api_token=self.CONFIG['api_token']
)
result = cf_api.purge_all()
if result.get('success'):
logger.info("[CF Plugin] ✓ Successfully purged all cache on startup")
logger.info("[CF Plugin] 🎉 All Cloudflare cache cleared! Fresh start guaranteed.")
else:
errors = result.get('errors', [])
logger.error(f"[CF Plugin] ✗ Failed to purge cache on startup: {errors}")
except Exception as e:
logger.error(f"[CF Plugin] Exception during startup cache purge: {e}", exc_info=True)
# 在后台线程中执行,不阻塞应用启动
thread = threading.Thread(target=_do_purge, daemon=True, name="CloudflareCachePurgeOnStartup")
thread.start()
logger.info("[CF Plugin] Scheduled startup cache purge (will execute in background)")
def register_hooks(self):
"""注册Django信号钩子"""
if not self.CONFIG['enabled']:
logger.info("[CF Plugin] Plugin is disabled, skipping hook registration")
return
try:
# 导入模型类
from blog.models import Article
from comments.models import Comment
from .handlers import CloudflareCacheHandler
# 初始化处理器
self.handler = CloudflareCacheHandler(self.CONFIG)
# 注册Article模型的post_save信号
if self.CONFIG['purge_on_article_save']:
post_save.connect(
self.handler.on_model_save,
sender=Article,
dispatch_uid='cloudflare_cache_article_save'
)
logger.info("[CF Plugin] Registered hook: Article.post_save")
# 注册Comment模型的post_save信号
if self.CONFIG['purge_on_comment_save']:
post_save.connect(
self.handler.on_model_save,
sender=Comment,
dispatch_uid='cloudflare_cache_comment_save'
)
logger.info("[CF Plugin] Registered hook: Comment.post_save")
logger.info("[CF Plugin] All hooks registered successfully")
except ImportError as e:
logger.error(f"[CF Plugin] Failed to import dependencies: {e}")
self.CONFIG['enabled'] = False
except Exception as e:
logger.error(f"[CF Plugin] Error registering hooks: {e}", exc_info=True)
self.CONFIG['enabled'] = False
# ==================== 管理命令接口 ====================
def purge_all_cache(self):
"""
手动清除所有缓存
Returns:
bool: 是否成功
Warning:
此操作会清除整个Zone的所有缓存!
"""
if not self.CONFIG['enabled']:
logger.error("[CF Plugin] Plugin is not enabled")
return False
if hasattr(self, 'handler'):
return self.handler.purge_all()
else:
logger.error("[CF Plugin] Handler not initialized")
return False
def get_plugin_status(self) -> dict:
"""
获取插件状态
Returns:
包含插件状态信息的字典
"""
return {
'name': self.PLUGIN_NAME,
'version': self.PLUGIN_VERSION,
'enabled': self.CONFIG['enabled'],
'zone_id_configured': bool(self.CONFIG.get('zone_id')),
'api_token_configured': bool(self.CONFIG.get('api_token')),
'purge_on_article': self.CONFIG['purge_on_article_save'],
'purge_on_comment': self.CONFIG['purge_on_comment_save'],
}
# ==================== 插件实例(必需)====================
plugin = CloudflareCachePlugin()
================================================
FILE: plugins/external_links/__init__.py
================================================
# This file makes this a Python package
================================================
FILE: plugins/external_links/plugin.py
================================================
import re
from urllib.parse import urlparse
from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
class ExternalLinksPlugin(BasePlugin):
PLUGIN_NAME = '外部链接处理器'
PLUGIN_DESCRIPTION = '自动为文章中的外部链接添加 target="_blank" 和 rel="noopener noreferrer" 属性。'
PLUGIN_VERSION = '0.1.0'
PLUGIN_AUTHOR = 'liangliangyy'
def register_hooks(self):
hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.process_external_links)
def process_external_links(self, content, *args, **kwargs):
from djangoblog.utils import get_current_site
site_domain = get_current_site().domain
# 正则表达式查找所有 标签
link_pattern = re.compile(r'( ]*?\s+)?href=")([^"]*)(".*?/a>)', re.IGNORECASE)
def replacer(match):
# match.group(1) 是 ...
href = match.group(2)
# 如果链接已经有 target 属性,则不处理
if 'target=' in match.group(0).lower():
return match.group(0)
# 解析链接
parsed_url = urlparse(href)
# 如果链接是外部的 (有域名且域名不等于当前网站域名)
if parsed_url.netloc and parsed_url.netloc != site_domain:
# 添加 target 和 rel 属性
return f'{match.group(1)}{href}" target="_blank" rel="noopener noreferrer"{match.group(3)}'
# 否则返回原样
return match.group(0)
return link_pattern.sub(replacer, content)
plugin = ExternalLinksPlugin()
================================================
FILE: plugins/image_lazy_loading/__init__.py
================================================
# Image Lazy Loading Plugin
================================================
FILE: plugins/image_lazy_loading/plugin.py
================================================
import re
import hashlib
from urllib.parse import urlparse
from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
class ImageOptimizationPlugin(BasePlugin):
PLUGIN_NAME = '图片性能优化插件'
PLUGIN_DESCRIPTION = '自动为文章中的图片添加懒加载、异步解码等性能优化属性,显著提升页面加载速度。'
PLUGIN_VERSION = '1.0.0'
PLUGIN_AUTHOR = 'liangliangyy'
def __init__(self):
# 插件配置
self.config = {
'enable_lazy_loading': True, # 启用懒加载
'enable_async_decoding': True, # 启用异步解码
'add_loading_placeholder': True, # 添加加载占位符
'optimize_external_images': True, # 优化外部图片
'add_responsive_attributes': True, # 添加响应式属性
'skip_first_image': True, # 跳过第一张图片(LCP优化)
}
super().__init__()
def register_hooks(self):
hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.optimize_images)
def optimize_images(self, content, *args, **kwargs):
"""
优化文章中的图片标签
"""
if not content:
return content
# 正则表达式匹配 img 标签
img_pattern = re.compile(
r' ]*?)(?:\s*/)?>',
re.IGNORECASE | re.DOTALL
)
image_count = 0
def replace_img_tag(match):
nonlocal image_count
image_count += 1
# 获取原始属性
original_attrs = match.group(1)
# 解析现有属性
attrs = self._parse_img_attributes(original_attrs)
# 应用优化
optimized_attrs = self._apply_optimizations(attrs, image_count)
# 重构 img 标签
return self._build_img_tag(optimized_attrs)
# 替换所有 img 标签
optimized_content = img_pattern.sub(replace_img_tag, content)
return optimized_content
def _parse_img_attributes(self, attr_string):
"""
解析 img 标签的属性
"""
attrs = {}
# 正则表达式匹配属性
attr_pattern = re.compile(r'(\w+)=(["\'])(.*?)\2')
for match in attr_pattern.finditer(attr_string):
attr_name = match.group(1).lower()
attr_value = match.group(3)
attrs[attr_name] = attr_value
return attrs
def _apply_optimizations(self, attrs, image_index):
"""
应用各种图片优化
"""
# 1. 懒加载优化(跳过第一张图片以优化LCP)
if self.config['enable_lazy_loading']:
if not (self.config['skip_first_image'] and image_index == 1):
if 'loading' not in attrs:
attrs['loading'] = 'lazy'
# 2. 异步解码
if self.config['enable_async_decoding']:
if 'decoding' not in attrs:
attrs['decoding'] = 'async'
# 3. 添加样式优化
current_style = attrs.get('style', '')
# 确保图片不会超出容器
if 'max-width' not in current_style:
if current_style and not current_style.endswith(';'):
current_style += ';'
current_style += 'max-width:100%;height:auto;'
attrs['style'] = current_style
# 4. 添加 alt 属性(SEO和可访问性)
if 'alt' not in attrs:
# 尝试从图片URL生成有意义的alt文本
src = attrs.get('src', '')
if src:
# 从文件名生成alt文本
filename = src.split('/')[-1].split('.')[0]
# 移除常见的无意义字符
clean_name = re.sub(r'[0-9a-f]{8,}', '', filename) # 移除长hash
clean_name = re.sub(r'[_-]+', ' ', clean_name).strip()
attrs['alt'] = clean_name if clean_name else '文章图片'
else:
attrs['alt'] = '文章图片'
# 5. 外部图片优化
if self.config['optimize_external_images'] and 'src' in attrs:
src = attrs['src']
parsed_url = urlparse(src)
# 如果是外部图片,添加 referrerpolicy
if parsed_url.netloc and parsed_url.netloc != self._get_current_domain():
attrs['referrerpolicy'] = 'no-referrer-when-downgrade'
# 为外部图片添加crossorigin属性以支持性能监控
if 'crossorigin' not in attrs:
attrs['crossorigin'] = 'anonymous'
# 6. 响应式图片属性(如果配置启用)
if self.config['add_responsive_attributes']:
# 添加 sizes 属性(如果没有的话)
if 'sizes' not in attrs and 'srcset' not in attrs:
attrs['sizes'] = '(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
# 7. 添加图片唯一标识符用于性能追踪
if 'data-img-id' not in attrs and 'src' in attrs:
img_hash = hashlib.md5(attrs['src'].encode()).hexdigest()[:8]
attrs['data-img-id'] = f'img-{img_hash}'
# 8. 为第一张图片添加高优先级提示(LCP优化)
if image_index == 1 and self.config['skip_first_image']:
attrs['fetchpriority'] = 'high'
# 移除懒加载以确保快速加载
if 'loading' in attrs:
del attrs['loading']
return attrs
def _build_img_tag(self, attrs):
"""
重新构建 img 标签
"""
attr_strings = []
# 确保 src 属性在最前面
if 'src' in attrs:
attr_strings.append(f'src="{attrs["src"]}"')
# 添加其他属性
for key, value in attrs.items():
if key != 'src': # src 已经添加过了
attr_strings.append(f'{key}="{value}"')
return f' '
def _get_current_domain(self):
"""
获取当前网站域名
"""
try:
from djangoblog.utils import get_current_site
return get_current_site().domain
except:
return ''
# 实例化插件
plugin = ImageOptimizationPlugin()
================================================
FILE: plugins/reading_time/__init__.py
================================================
# This file makes this a Python package
================================================
FILE: plugins/reading_time/plugin.py
================================================
import math
import re
from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
class ReadingTimePlugin(BasePlugin):
PLUGIN_NAME = '阅读时间预测'
PLUGIN_DESCRIPTION = '估算文章阅读时间并显示在文章开头。'
PLUGIN_VERSION = '0.1.0'
PLUGIN_AUTHOR = 'liangliangyy'
def register_hooks(self):
hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_reading_time)
def add_reading_time(self, content, *args, **kwargs):
"""
计算阅读时间并添加到内容开头。
只在文章详情页显示,首页(文章列表页)不显示。
"""
# 检查是否为摘要模式(首页/文章列表页)
# 通过kwargs中的is_summary参数判断
is_summary = kwargs.get('is_summary', False)
if is_summary:
# 如果是摘要模式(首页),直接返回原内容,不添加阅读时间
return content
# 移除HTML标签和空白字符,以获得纯文本
clean_content = re.sub(r'<[^>]*>', '', content)
clean_content = clean_content.strip()
# 中文和英文单词混合计数的一个简单方法
# 匹配中文字符或连续的非中文字符(视为单词)
words = re.findall(r'[\u4e00-\u9fa5]|\w+', clean_content)
word_count = len(words)
# 按平均每分钟200字的速度计算
reading_speed = 200
reading_minutes = math.ceil(word_count / reading_speed)
# 如果阅读时间少于1分钟,则显示为1分钟
if reading_minutes < 1:
reading_minutes = 1
reading_time_html = f'预计阅读时间:{reading_minutes} 分钟
'
return reading_time_html + content
plugin = ReadingTimePlugin()
================================================
FILE: plugins/seo_optimizer/__init__.py
================================================
# This file makes this a Python package
================================================
FILE: plugins/seo_optimizer/plugin.py
================================================
import json
from django.utils.html import strip_tags
from django.template.defaultfilters import truncatewords
from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage import hooks
from blog.models import Article, Category, Tag
from djangoblog.utils import get_blog_setting
class SeoOptimizerPlugin(BasePlugin):
PLUGIN_NAME = 'SEO 优化器'
PLUGIN_DESCRIPTION = '为文章、页面等提供高级 SEO 优化,动态生成增强的 Open Graph 标签和 JSON-LD 结构化数据。基础 SEO(title、description、keywords)由视图层提供。'
PLUGIN_VERSION = '0.3.0'
PLUGIN_AUTHOR = 'liuangliangyy'
def register_hooks(self):
hooks.register('head_meta', self.dispatch_seo_generation)
def _get_article_seo_data(self, context, request, blog_setting):
article = context.get('article')
if not isinstance(article, Article):
return None
from django.utils.html import escape
from django.utils.text import Truncator
from djangoblog.utils import CommonMarkdown
# 处理description:markdown -> HTML -> 纯文本,彻底去除格式
html_content = CommonMarkdown.get_markdown(article.body)
description = strip_tags(html_content)
description = ' '.join(description.split())
description = Truncator(description).chars(150, truncate='...')
description_escaped = escape(description)
# 增强的 Open Graph 标签
# 使用article.get_full_url确保与canonical一致
article_url = article.get_full_url()
meta_tags = f'''
'''
for tag in article.tags.all():
meta_tags += f' '
meta_tags += f' '
# JSON-LD 结构化数据
structured_data = {
"@context": "https://schema.org",
"@type": "Article",
"mainEntityOfPage": {"@type": "WebPage", "@id": article_url},
"headline": article.title,
"description": description,
"image": request.build_absolute_uri(article.get_first_image_url()),
"datePublished": article.pub_time.isoformat(),
"dateModified": article.last_modify_time.isoformat(),
"author": {"@type": "Person", "name": article.author.username},
"publisher": {"@type": "Organization", "name": blog_setting.site_name}
}
if not structured_data.get("image"):
del structured_data["image"]
return {
"meta_tags": meta_tags,
"json_ld": structured_data
}
def _get_category_seo_data(self, context, request, blog_setting):
category_name = context.get('tag_name') # 注意:这里沿用了原有的变量名
if not category_name:
return None
category = Category.objects.filter(name=category_name).first()
if not category:
return None
# BreadcrumbList 结构化数据
breadcrumb_items = [
{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')},
{"@type": "ListItem", "position": 2, "name": category.name, "item": request.build_absolute_uri()}
]
structured_data = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": breadcrumb_items
}
return {
"meta_tags": "",
"json_ld": structured_data
}
def _get_tag_seo_data(self, context, request, blog_setting):
"""标签页面的高级SEO数据"""
tag_name = context.get('tag_name')
if not tag_name:
return None
tag = Tag.objects.filter(name=tag_name).first()
if not tag:
return None
# BreadcrumbList 结构化数据
breadcrumb_items = [
{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')},
{"@type": "ListItem", "position": 2, "name": "标签", "item": request.build_absolute_uri('/tag/')},
{"@type": "ListItem", "position": 3, "name": tag.name, "item": request.build_absolute_uri()}
]
structured_data = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": breadcrumb_items
}
return {
"meta_tags": "",
"json_ld": structured_data
}
def _get_author_seo_data(self, context, request, blog_setting):
"""作者页面的高级SEO数据"""
author_name = context.get('tag_name') # 注意:这里沿用了原有的变量名
if not author_name:
return None
# BreadcrumbList 结构化数据
breadcrumb_items = [
{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')},
{"@type": "ListItem", "position": 2, "name": "作者", "item": request.build_absolute_uri('/author/')},
{"@type": "ListItem", "position": 3, "name": author_name, "item": request.build_absolute_uri()}
]
structured_data = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": breadcrumb_items
}
return {
"meta_tags": "",
"json_ld": structured_data
}
def _get_default_seo_data(self, context, request, blog_setting):
"""首页和其他默认页面的高级SEO数据"""
structured_data = {
"@context": "https://schema.org",
"@type": "WebSite",
"name": blog_setting.site_name,
"description": blog_setting.site_description,
"url": request.build_absolute_uri('/'),
"potentialAction": {
"@type": "SearchAction",
"target": f"{request.build_absolute_uri('/search/')}?q={{search_term_string}}",
"query-input": "required name=search_term_string"
}
}
return {
"meta_tags": "",
"json_ld": structured_data
}
def dispatch_seo_generation(self, metas, context):
"""
根据页面类型分发高级SEO生成
注意:基础SEO(title、description、keywords)已由视图层提供
此处只负责生成增强的 Open Graph 标签和 JSON-LD 结构化数据
"""
request = context.get('request')
if not request:
return metas
view_name = request.resolver_match.view_name
blog_setting = get_blog_setting()
seo_data = None
if view_name == 'blog:detailbyid':
seo_data = self._get_article_seo_data(context, request, blog_setting)
elif view_name == 'blog:category_detail':
seo_data = self._get_category_seo_data(context, request, blog_setting)
elif view_name == 'blog:tag_detail':
seo_data = self._get_tag_seo_data(context, request, blog_setting)
elif view_name == 'blog:author_detail':
seo_data = self._get_author_seo_data(context, request, blog_setting)
if not seo_data:
seo_data = self._get_default_seo_data(context, request, blog_setting)
# 只生成 JSON-LD 和增强的 OG 标签
json_ld_script = f''
seo_html = f"""
{seo_data.get("meta_tags", "")}
{json_ld_script}
"""
# 将高级SEO内容追加到现有的metas内容上
return metas + seo_html
plugin = SeoOptimizerPlugin()
================================================
FILE: plugins/view_count/__init__.py
================================================
# This file makes this a Python package
================================================
FILE: plugins/view_count/plugin.py
================================================
from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage import hooks
class ViewCountPlugin(BasePlugin):
PLUGIN_NAME = '文章浏览次数统计'
PLUGIN_DESCRIPTION = '统计文章的浏览次数'
PLUGIN_VERSION = '0.1.0'
PLUGIN_AUTHOR = 'liangliangyy'
def register_hooks(self):
hooks.register('after_article_body_get', self.record_view)
def record_view(self, article, *args, **kwargs):
article.viewed()
plugin = ViewCountPlugin()
================================================
FILE: requirements.txt
================================================
bleach==6.3.0
coverage==7.13.4
Django==5.2.12
django-appconf==1.2.0
django-compressor==4.6.0
django-echarts==0.6.0
django-haystack==3.3.0
django-ipware==7.0.1
django-mdeditor==0.1.20
django-uuslug==2.0.0
elasticsearch==8.19.3
elasticsearch-dsl==8.11.0
gevent==25.9.1
greenlet==3.3.2
htmlgenerator==1.2.32
jieba==0.42.1
Jinja2==3.1.6
Markdown==3.10.2
MarkupSafe==3.0.3
mysqlclient==2.2.8
openai==2.26.0
pillow==12.1.1
prettytable==3.17.0
propcache==0.4.1
pycparser==3.0
pyecharts==2.1.0
Pygments==2.19.2
python-dateutil==2.9.0.post0
python-ipware==3.0.0
python-logstash==0.4.8
python-slugify==8.0.4
pytz==2026.1.post1
rcssmin==1.2.2
redis==7.3.0
requests==2.32.5
rjsmin==1.2.5
setuptools==82.0.1
simplejson==3.20.2
tzdata==2025.3
user-agents==2.2.0
WeRoBot==1.13.1
Whoosh==2.7.4
jsonpickle==4.1.1
beautifulsoup4==4.14.3
certifi>=2024.7.4
configobj>=5.0.9
cryptography>=43.0.1
idna>=3.7
urllib3>=2.6.3
wheel>=0.46.2
================================================
FILE: servermanager/MemcacheStorage.py
================================================
from werobot.session import SessionStorage
from werobot.utils import json_loads, json_dumps
from djangoblog.utils import cache
class MemcacheStorage(SessionStorage):
def __init__(self, prefix='ws_'):
self.prefix = prefix
self.cache = cache
@property
def is_available(self):
value = "1"
self.set('checkavaliable', value=value)
return value == self.get('checkavaliable')
def key_name(self, s):
return '{prefix}{s}'.format(prefix=self.prefix, s=s)
def get(self, id):
id = self.key_name(id)
session_json = self.cache.get(id) or '{}'
return json_loads(session_json)
def set(self, id, value):
id = self.key_name(id)
self.cache.set(id, json_dumps(value))
def delete(self, id):
id = self.key_name(id)
self.cache.delete(id)
================================================
FILE: servermanager/__init__.py
================================================
================================================
FILE: servermanager/admin.py
================================================
from django.contrib import admin
# Register your models here.
class CommandsAdmin(admin.ModelAdmin):
list_display = ('title', 'command', 'describe')
class EmailSendLogAdmin(admin.ModelAdmin):
list_display = ('title', 'emailto', 'send_result', 'creation_time')
readonly_fields = (
'title',
'emailto',
'send_result',
'creation_time',
'content')
def has_add_permission(self, request):
return False
================================================
FILE: servermanager/api/__init__.py
================================================
================================================
FILE: servermanager/api/blogapi.py
================================================
from haystack.query import SearchQuerySet
from blog.models import Article, Category
class BlogApi:
def __init__(self):
self.searchqueryset = SearchQuerySet()
self.searchqueryset.auto_query('')
self.__max_takecount__ = 8
def search_articles(self, query):
sqs = self.searchqueryset.auto_query(query)
sqs = sqs.load_all()
return sqs[:self.__max_takecount__]
def get_category_lists(self):
return Category.objects.all()
def get_category_articles(self, categoryname):
articles = Article.objects.filter(category__name=categoryname)
if articles:
return articles[:self.__max_takecount__]
return None
def get_recent_articles(self):
return Article.objects.all()[:self.__max_takecount__]
================================================
FILE: servermanager/api/commonapi.py
================================================
import logging
import os
import openai
from servermanager.models import commands
logger = logging.getLogger(__name__)
openai.api_key = os.environ.get('OPENAI_API_KEY')
if os.environ.get('HTTP_PROXY'):
openai.proxy = os.environ.get('HTTP_PROXY')
class ChatGPT:
@staticmethod
def chat(prompt):
try:
completion = openai.ChatCompletion.create(model="gpt-3.5-turbo",
messages=[{"role": "user", "content": prompt}])
return completion.choices[0].message.content
except Exception as e:
logger.error(e)
return "服务器出错了"
class CommandHandler:
def __init__(self):
self.commands = commands.objects.all()
def run(self, title):
"""
运行命令
:param title: 命令
:return: 返回命令执行结果
"""
cmd = list(
filter(
lambda x: x.title.upper() == title.upper(),
self.commands))
if cmd:
return self.__run_command__(cmd[0].command)
else:
return "未找到相关命令,请输入hepme获得帮助。"
def __run_command__(self, cmd):
try:
res = os.popen(cmd).read()
return res
except BaseException:
return '命令执行出错!'
def get_help(self):
rsp = ''
for cmd in self.commands:
rsp += '{c}:{d}\n'.format(c=cmd.title, d=cmd.describe)
return rsp
if __name__ == '__main__':
chatbot = ChatGPT()
prompt = "写一篇1000字关于AI的论文"
print(chatbot.chat(prompt))
================================================
FILE: servermanager/apps.py
================================================
from django.apps import AppConfig
class ServermanagerConfig(AppConfig):
name = 'servermanager'
================================================
FILE: servermanager/migrations/0001_initial.py
================================================
# Generated by Django 4.1.7 on 2023-03-02 07:14
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='commands',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=300, verbose_name='命令标题')),
('command', models.CharField(max_length=2000, verbose_name='命令')),
('describe', models.CharField(max_length=300, verbose_name='命令描述')),
('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(auto_now=True, verbose_name='修改时间')),
],
options={
'verbose_name': '命令',
'verbose_name_plural': '命令',
},
),
migrations.CreateModel(
name='EmailSendLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('emailto', models.CharField(max_length=300, verbose_name='收件人')),
('title', models.CharField(max_length=2000, verbose_name='邮件标题')),
('content', models.TextField(verbose_name='邮件内容')),
('send_result', models.BooleanField(default=False, verbose_name='结果')),
('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
],
options={
'verbose_name': '邮件发送log',
'verbose_name_plural': '邮件发送log',
'ordering': ['-created_time'],
},
),
]
================================================
FILE: servermanager/migrations/0002_alter_emailsendlog_options_and_more.py
================================================
# Generated by Django 4.2.5 on 2023-09-06 13:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('servermanager', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='emailsendlog',
options={'ordering': ['-creation_time'], 'verbose_name': '邮件发送log', 'verbose_name_plural': '邮件发送log'},
),
migrations.RenameField(
model_name='commands',
old_name='created_time',
new_name='creation_time',
),
migrations.RenameField(
model_name='commands',
old_name='last_mod_time',
new_name='last_modify_time',
),
migrations.RenameField(
model_name='emailsendlog',
old_name='created_time',
new_name='creation_time',
),
]
================================================
FILE: servermanager/migrations/__init__.py
================================================
================================================
FILE: servermanager/models.py
================================================
from django.db import models
# Create your models here.
class commands(models.Model):
title = models.CharField('命令标题', max_length=300)
command = models.CharField('命令', max_length=2000)
describe = models.CharField('命令描述', max_length=300)
creation_time = models.DateTimeField('创建时间', auto_now_add=True)
last_modify_time = models.DateTimeField('修改时间', auto_now=True)
def __str__(self):
return self.title
class Meta:
verbose_name = '命令'
verbose_name_plural = verbose_name
class EmailSendLog(models.Model):
emailto = models.CharField('收件人', max_length=300)
title = models.CharField('邮件标题', max_length=2000)
content = models.TextField('邮件内容')
send_result = models.BooleanField('结果', default=False)
creation_time = models.DateTimeField('创建时间', auto_now_add=True)
def __str__(self):
return self.title
class Meta:
verbose_name = '邮件发送log'
verbose_name_plural = verbose_name
ordering = ['-creation_time']
================================================
FILE: servermanager/robot.py
================================================
import logging
import os
import re
import jsonpickle
from django.conf import settings
from werobot import WeRoBot
from werobot.replies import ArticlesReply, Article
from werobot.session.filestorage import FileStorage
from djangoblog.utils import get_sha256
from servermanager.api.blogapi import BlogApi
from servermanager.api.commonapi import ChatGPT, CommandHandler
from .MemcacheStorage import MemcacheStorage
robot = WeRoBot(token=os.environ.get('DJANGO_WEROBOT_TOKEN')
or 'lylinux', enable_session=True)
memstorage = MemcacheStorage()
if memstorage.is_available:
robot.config['SESSION_STORAGE'] = memstorage
else:
if os.path.exists(os.path.join(settings.BASE_DIR, 'werobot_session')):
os.remove(os.path.join(settings.BASE_DIR, 'werobot_session'))
robot.config['SESSION_STORAGE'] = FileStorage(filename='werobot_session')
blogapi = BlogApi()
cmd_handler = CommandHandler()
logger = logging.getLogger(__name__)
def convert_to_article_reply(articles, message):
reply = ArticlesReply(message=message)
from blog.templatetags.blog_tags import truncatechars_content
for post in articles:
imgs = re.findall(r'(?:http\:|https\:)?\/\/.*\.(?:png|jpg)', post.body)
imgurl = ''
if imgs:
imgurl = imgs[0]
article = Article(
title=post.title,
description=truncatechars_content(post.body),
img=imgurl,
url=post.get_full_url()
)
reply.add_article(article)
return reply
@robot.filter(re.compile(r"^\?.*"))
def search(message, session):
s = message.content
searchstr = str(s).replace('?', '')
result = blogapi.search_articles(searchstr)
if result:
articles = list(map(lambda x: x.object, result))
reply = convert_to_article_reply(articles, message)
return reply
else:
return '没有找到相关文章。'
@robot.filter(re.compile(r'^category\s*$', re.I))
def category(message, session):
categorys = blogapi.get_category_lists()
content = ','.join(map(lambda x: x.name, categorys))
return '所有文章分类目录:' + content
@robot.filter(re.compile(r'^recent\s*$', re.I))
def recents(message, session):
articles = blogapi.get_recent_articles()
if articles:
reply = convert_to_article_reply(articles, message)
return reply
else:
return "暂时还没有文章"
@robot.filter(re.compile('^help$', re.I))
def help(message, session):
return '''欢迎关注!
默认会与图灵机器人聊天~~
你可以通过下面这些命令来获得信息
?关键字搜索文章.
如?python.
category获得文章分类目录及文章数.
category-***获得该分类目录文章
如category-python
recent获得最新文章
help获得帮助.
weather:获得天气
如weather:西安
idcard:获得身份证信息
如idcard:61048119xxxxxxxxxx
music:音乐搜索
如music:阴天快乐
PS:以上标点符号都不支持中文标点~~
'''
@robot.filter(re.compile(r'^weather\:.*$', re.I))
def weather(message, session):
return "建设中..."
@robot.filter(re.compile(r'^idcard\:.*$', re.I))
def idcard(message, session):
return "建设中..."
@robot.handler
def echo(message, session):
handler = MessageHandler(message, session)
return handler.handler()
class MessageHandler:
def __init__(self, message, session):
userid = message.source
self.message = message
self.session = session
self.userid = userid
try:
info = session[userid]
self.userinfo = jsonpickle.decode(info)
except Exception as e:
userinfo = WxUserInfo()
self.userinfo = userinfo
@property
def is_admin(self):
return self.userinfo.isAdmin
@property
def is_password_set(self):
return self.userinfo.isPasswordSet
def save_session(self):
info = jsonpickle.encode(self.userinfo)
self.session[self.userid] = info
def handler(self):
info = self.message.content
if self.userinfo.isAdmin and info.upper() == 'EXIT':
self.userinfo = WxUserInfo()
self.save_session()
return "退出成功"
if info.upper() == 'ADMIN':
self.userinfo.isAdmin = True
self.save_session()
return "输入管理员密码"
if self.userinfo.isAdmin and not self.userinfo.isPasswordSet:
passwd = settings.WXADMIN
if settings.TESTING:
passwd = '123'
if passwd.upper() == get_sha256(get_sha256(info)).upper():
self.userinfo.isPasswordSet = True
self.save_session()
return "验证通过,请输入命令或者要执行的命令代码:输入helpme获得帮助"
else:
if self.userinfo.Count >= 3:
self.userinfo = WxUserInfo()
self.save_session()
return "超过验证次数"
self.userinfo.Count += 1
self.save_session()
return "验证失败,请重新输入管理员密码:"
if self.userinfo.isAdmin and self.userinfo.isPasswordSet:
if self.userinfo.Command != '' and info.upper() == 'Y':
return cmd_handler.run(self.userinfo.Command)
else:
if info.upper() == 'HELPME':
return cmd_handler.get_help()
self.userinfo.Command = info
self.save_session()
return "确认执行: " + info + " 命令?"
return ChatGPT.chat(info)
class WxUserInfo():
def __init__(self):
self.isAdmin = False
self.isPasswordSet = False
self.Count = 0
self.Command = ''
================================================
FILE: servermanager/tests.py
================================================
from django.test import Client, RequestFactory, TestCase
from django.utils import timezone
from werobot.messages.messages import TextMessage
from accounts.models import BlogUser
from blog.models import Category, Article
from servermanager.api.commonapi import ChatGPT
from .models import commands
from .robot import MessageHandler, CommandHandler
from .robot import search, category, recents
# Create your tests here.
class ServerManagerTest(TestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
def test_chat_gpt(self):
content = ChatGPT.chat("你好")
self.assertIsNotNone(content)
def test_validate_comment(self):
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
self.client.login(username='liangliangyy1', password='liangliangyy1')
c = Category()
c.name = "categoryccc"
c.save()
article = Article()
article.title = "nicetitleccc"
article.body = "nicecontentccc"
article.author = user
article.category = c
article.type = 'a'
article.status = 'p'
article.save()
s = TextMessage([])
s.content = "nice"
rsp = search(s, None)
rsp = category(None, None)
self.assertIsNotNone(rsp)
rsp = recents(None, None)
self.assertTrue(rsp != '暂时还没有文章')
cmd = commands()
cmd.title = "test"
cmd.command = "ls"
cmd.describe = "test"
cmd.save()
cmdhandler = CommandHandler()
rsp = cmdhandler.run('test')
self.assertIsNotNone(rsp)
s.source = 'u'
s.content = 'test'
msghandler = MessageHandler(s, {})
# msghandler.userinfo.isPasswordSet = True
# msghandler.userinfo.isAdmin = True
msghandler.handler()
s.content = 'y'
msghandler.handler()
s.content = 'idcard:12321233'
msghandler.handler()
s.content = 'weather:上海'
msghandler.handler()
s.content = 'admin'
msghandler.handler()
s.content = '123'
msghandler.handler()
s.content = 'exit'
msghandler.handler()
================================================
FILE: servermanager/urls.py
================================================
from django.urls import path
from werobot.contrib.django import make_view
from .robot import robot
app_name = "servermanager"
urlpatterns = [
path(r'robot', make_view(robot)),
]
================================================
FILE: servermanager/views.py
================================================
# Create your views here.
================================================
FILE: templates/account/forget_password.html
================================================
{% extends 'share_layout/base_account.html' %}
{% load i18n %}
{% load static %}
{% block page_title %}{% trans 'Forget Password' %}{% endblock %}
{% block content %}
{% trans 'Reset Password' %}
{% trans 'Enter your email to reset password' %}
{% endblock %}
================================================
FILE: templates/account/login.html
================================================
{% extends 'share_layout/base_account.html' %}
{% load static %}
{% load i18n %}
{% block page_title %}{% trans 'Sign in' %}{% endblock %}
{% block content %}
{% endblock %}
================================================
FILE: templates/account/registration_form.html
================================================
{% extends 'share_layout/base_account.html' %}
{% load static %}
{% load i18n %}
{% block page_title %}{% trans 'Create Account' %}{% endblock %}
{% block content %}
{% endblock %}
================================================
FILE: templates/account/result.html
================================================
{% extends 'share_layout/base.html' %}
{% load i18n %}
{% block header %}
{{ title }}
{% endblock %}
{% block content %}
{% endblock %}
================================================
FILE: templates/blog/article_archives.html
================================================
{% extends 'share_layout/base.html' %}
{% load blog_tags %}
{% load cache %}
{% load i18n %}
{% block header %}
{% trans 'article archive' %} | {{ SITE_NAME }}
{% endblock %}
{% block content %}
{# Page Header #}
{% trans 'article archive' %}
共 {{ article_list|length }} 篇文章
{# Archive Timeline Card #}
{% regroup article_list by pub_time.year as year_post_group %}
{% for year in year_post_group %}
{# Year Header #}
{{ year.grouper }}
{% regroup year.list by pub_time.month as month_post_group %}
{% for month in month_post_group %}
{# Month Header #}
{{ month.grouper }} 月
{# Articles in this month #}
{% for article in month.list %}
{# Timeline dot #}
{{ article.title }}
{{ article.pub_time|date:"m-d" }}
{% if article.category %}
·
{{ article.category.name }}
{% endif %}
{% endfor %}
{% endfor %}
{% endfor %}
{# Sidebar #}
{% load_sidebar user 'i' %}
{% endblock %}
================================================
FILE: templates/blog/article_detail.html
================================================
{% extends 'share_layout/base.html' %}
{% load blog_tags %}
{% load cache %}
{% load i18n %}
{% block header %}
{{ seo_title|default:SITE_NAME }}
{% endblock %}
{% block content %}
{# Main Content #}
{# Article #}
{# Breadcrumb #}
{% if article.type == 'a' %}
{% cache 36000 breadcrumb_v3 article.pk %}
{% load_breadcrumb article %}
{% endcache %}
{% endif %}
{# Title #}
{{ article.title }}
{# Meta #}
{{ article.category.name }}
{{ article.pub_time|date:"Y-m-d" }}
{{ article.views }}
{% if article.comment_status == "o" and open_site_comment %}
{{ article.comment_set.count }}
{% endif %}
{# Content #}
{% if article.show_toc %}
{% get_markdown_toc article.body as toc %}
{% trans 'toc' %}
{{ toc|safe }}
{% endif %}
{% render_article_content article False %}
{# Tags #}
{# Prev/Next Navigation #}
{% if article.type == 'a' and next_article or prev_article %}
{% if next_article %}
上一篇
{{ next_article.title }}
{% endif %}
{% if prev_article %}
下一篇
{{ prev_article.title }}
{% endif %}
{% endif %}
{# Article bottom plugins #}
{% render_plugin_widgets 'article_bottom' article=article %}
{# Comments Section #}
{% if article.comment_status == "o" and OPEN_SITE_COMMENT %}
{% include 'comments/tags/comment_list_modern.html' %}
{% if user.is_authenticated %}
{% include 'comments/tags/post_comment_modern.html' %}
{% else %}
{% endif %}
{% endif %}
{# Sidebar - Desktop #}
{% load_sidebar user "p" %}
{# Mobile Sidebar #}
发现更多
{% load_sidebar user "p" %}
{% endblock %}
================================================
FILE: templates/blog/article_index.html
================================================
{% extends 'share_layout/base.html' %}
{% load blog_tags %}
{% load i18n %}
{% block header %}
{{ SITE_NAME }}
{% endblock %}
{% block content %}
{# Main Grid #}
{# Main Content #}
{# Title Section #}
{% if page_type == "分类目录归档" %}
分类
{{ tag_name }}
{% elif page_type == "分类标签归档" %}
标签
{{ tag_name }}
{% elif page_type == "作者文章归档" %}
作者
{{ tag_name }}
{% else %}
{% trans 'recent articles' %}
{% endif %}
{# Article List #}
{% for article in article_list %}
{% load_article_detail article True user %}
{% endfor %}
{# Pagination #}
{% if is_paginated %}
{% load_pagination_info page_obj page_type tag_name %}
{% endif %}
{# Sidebar - Desktop: fixed right column #}
{% load_sidebar user "p" %}
{# Mobile Sidebar - shown below content on small screens #}
发现更多
{% load_sidebar user "p" %}
{% endblock %}
================================================
FILE: templates/blog/error_page.html
================================================
{% extends 'share_layout/base.html' %}
{% load blog_tags %}
{% load cache %}
{% load i18n %}
{% block header %}
{% if tag_name %}
{% if statuscode == '404' %}
404 NotFound
{% elif statuscode == '403' %}
Permission Denied
{% elif statuscode == '500' %}
500 Error
{% else %}
{% endif %}
{% else %}
{{ SITE_NAME }} | {{ SITE_DESCRIPTION }}
{% endif %}
{% endblock %}
{% block content %}
{# Error code #}
{% if statuscode %}
{{ statuscode }}
{% endif %}
{# Error message #}
{{ message }}
{# Action buttons - unified style #}
{# Sidebar #}
{% load_sidebar user 'i' %}
{% endblock %}
================================================
FILE: templates/blog/links_list.html
================================================
{% extends 'share_layout/base.html' %}
{% load blog_tags %}
{% load cache %}
{% load i18n %}
{% block header %}
友情链接 | {{ SITE_NAME }}
{% endblock %}
{% block content %}
{# Page Header #}
{% trans 'links' %}
共 {{ object_list|length }} 个链接
{# Links Grid #}
{# Sidebar #}
{% load_sidebar user 'i' %}
{% endblock %}
================================================
FILE: templates/blog/tags/article_info.html
================================================
{% load blog_tags %}
{% load cache %}
{% load i18n %}
{# Top: Category & Date #}
{{ article.category.name }}
{{ article.pub_time|date:"Y-m-d" }}
{# Title #}
{# Excerpt (List page only) #}
{% if isindex %}
{% render_article_content article True %}
{% endif %}
{# Bottom: Tags & Stats #}
{# Tags #}
{% for tag in article.tags.all|slice:":3" %}
{{ tag.name }}
{% endfor %}
{# Stats #}
{{ article.views }}
{% if article.comment_status == "o" and open_site_comment %}
{{ article.comment_set.count }}
{% endif %}
================================================
FILE: templates/blog/tags/article_info_highlight.html
================================================
{% load blog_tags %}
{% load cache %}
{% load i18n %}
{% if isindex %}
{# 列表页显示搜索摘要 #}
{% if highlighted.body %}
{% for snippet in highlighted.body %}
...{{ snippet|safe }}...
{% endfor %}
{% else %}
{% render_article_content article True %}
{% endif %}
Read more
{% else %}
{# 详情页显示完整内容(不高亮,因为高亮只在搜索结果列表有意义) #}
{% if article.show_toc %}
{% get_markdown_toc article.body as toc %}
{% trans 'toc' %}:
{{ toc|safe }}
{% endif %}
{% render_article_content article False %}
{% endif %}
{% load_article_metas article user %}
{% if not isindex %}
{% render_plugin_widgets 'article_bottom' article=article %}
{% endif %}
================================================
FILE: templates/blog/tags/article_meta_info.html
================================================
{% load i18n %}
{% load blog_tags %}
================================================
FILE: templates/blog/tags/article_pagination.html
================================================
{% if page_obj.paginator.num_pages > 1 %}
{# Previous #}
{% if page_obj.has_previous and previous_url %}
{% else %}
{% endif %}
{# Page numbers #}
{% for item in page_range %}
{% if item.type == 'ellipsis' %}
...
{% elif item.is_current %}
{{ item.number }}
{% else %}
{{ item.number }}
{% endif %}
{% endfor %}
{# Next #}
{% if page_obj.has_next and next_url %}
{% else %}
{% endif %}
{% endif %}
================================================
FILE: templates/blog/tags/article_tag_list.html
================================================
{% load i18n %}
{% if article_tags_list %}
{% endif %}
================================================
FILE: templates/blog/tags/breadcrumb.html
================================================
{% for name,url in names %}
{{ name }}
{% endfor %}
{{ title }}
================================================
FILE: templates/blog/tags/sidebar.html
================================================
{% load blog_tags %}
{% load i18n %}
================================================
FILE: templates/comments/tags/comment_item.html
================================================
{% load blog_tags %}
================================================
FILE: templates/comments/tags/comment_item_modern.html
================================================
{% load blog_tags %}
================================================
FILE: templates/comments/tags/comment_item_tree.html
================================================
{% load blog_tags %}
{% query article_comments parent_comment=comment_item as cc_comments %}
{% for cc in cc_comments %}
{% with comment_item=cc template_name="comments/tags/comment_item_tree.html" %}
{% if depth >= 1 %}
{% include template_name %}
{% else %}
{% with depth=depth|add:1 %}
{% include template_name %}
{% endwith %}
{% endif %}
{% endwith %}
{% endfor %}
================================================
FILE: templates/comments/tags/comment_list.html
================================================
================================================
FILE: templates/comments/tags/comment_list_modern.html
================================================
{% load blog_tags %}
{% load comments_tags %}
{% load cache %}
================================================
FILE: templates/comments/tags/post_comment.html
================================================
================================================
FILE: templates/comments/tags/post_comment_modern.html
================================================
================================================
FILE: templates/oauth/bindsuccess.html
================================================
{% extends 'share_layout/base.html' %}
{% block header %}
{{ title }}
{% endblock %}
{% block content %}
{% endblock %}
================================================
FILE: templates/oauth/oauth_applications.html
================================================
{% load i18n %}
{% load static %}
================================================
FILE: templates/oauth/require_email.html
================================================
{% extends 'share_layout/base_account.html' %}
{% load static %}
{% block page_title %}绑定邮箱{% endblock %}
{% block content %}
{% if picture %}
{% else %}
{% endif %}
绑定您的邮箱账号
请输入您的邮箱以完成绑定
{% endblock %}
================================================
FILE: templates/owntracks/show_log_dates.html
================================================
记录日期
================================================
FILE: templates/owntracks/show_maps.html
================================================
运动轨迹
================================================
FILE: templates/plugins/article_recommendation/__init__.py
================================================
# 插件模板目录
================================================
FILE: templates/plugins/article_recommendation/bottom_widget.html
================================================
{% load i18n %}
📖 {{ title }}
{% for article in recommendations %}
{% if article.title and article.title|length > 0 %}
{% endif %}
{% endfor %}
================================================
FILE: templates/plugins/article_recommendation/sidebar_widget.html
================================================
{% load i18n %}
================================================
FILE: templates/plugins/css_includes.html
================================================
{% comment %}插件CSS文件包含模板 - 用于压缩{% endcomment %}
{% for css_file in css_files %}
{% endfor %}
================================================
FILE: templates/plugins/js_includes.html
================================================
{% comment %}插件JS文件包含模板 - 用于压缩{% endcomment %}
{% for js_file in js_files %}
{% endfor %}
================================================
FILE: templates/search/indexes/blog/article_text.txt
================================================
{{ object.title }}
{{ object.author.username }}
{{ object.body }}
================================================
FILE: templates/search/search.html
================================================
{% extends 'share_layout/base.html' %}
{% load blog_tags %}
{% load i18n %}
{% block header %}
{% if query %}
搜索:{{ query }} | {{ SITE_NAME }}
{% else %}
搜索 | {{ SITE_NAME }}
{% endif %}
{% endblock %}
{% block content %}
{% if query %}
{# Search header #}
{% if suggestion %}
已显示 "{{ suggestion }}" 的搜索结果。
仍然搜索:{{ query }}
{% else %}
搜索:{{ query }}
{% endif %}
{% endif %}
{% if query and page.object_list %}
{% for result in page.object_list %}
{% load_article_detail result.object True user query %}
{% endfor %}
{% if page.has_previous or page.has_next %}
{% if page.has_previous %}
上一页
{% else %}
{% endif %}
{% if page.has_next %}
下一页
{% else %}
{% endif %}
{% endif %}
{% else %}
{# No results #}
关键字:{{ query }} 没有找到结果
要不换个词再试试?
{% endif %}
{# Sidebar #}
{% load_sidebar request.user 'i' %}
{% endblock %}
================================================
FILE: templates/share_layout/adsense.html
================================================
{{ GOOGLE_ADSENSE_CODES }}
================================================
FILE: templates/share_layout/base.html
================================================
{% load static %}
{% load cache %}
{% load i18n %}
{% load compress %}
{% load vite_tags %}
{% load blog_tags %}
{% head_meta %}
{% block header %}
{% endblock %}
{% vite_js 'src/main.js' %}
{% compress css %}
{% block compress_css %}
{% endblock %}
{% plugin_compressed_css %}
{% endcompress %}
{% if GLOBAL_HEADER %}
{{ GLOBAL_HEADER|safe }}
{% endif %}
{% load i18n %}
{% include 'share_layout/nav.html' %}
{% block content %}
{% endblock %}
{% block sidebar %}
{% endblock %}
{% include 'share_layout/footer.html' %}
{% compress js %}
{% block compress_js %}
{% endblock %}
{% plugin_compressed_js %}
{% endcompress %}
{% block footer %}
{% endblock %}
================================================
FILE: templates/share_layout/base_account.html
================================================
{% load static %}
{% load i18n %}
{% load compress %}
{% load vite_tags %}
{{ SITE_NAME }} | {% block page_title %}Account{% endblock %}
{% vite_js 'src/main.js' %}
{% compress css %}
{% block compress_css %}{% endblock %}
{% endcompress %}
{% block content %}{% endblock %}
{# Theme toggle button #}
================================================
FILE: templates/share_layout/footer.html
================================================
{% load i18n %}
{% load blog_tags %}
================================================
FILE: templates/share_layout/nav.html
================================================
{% load blog_tags %}
{% load i18n %}
================================================
FILE: templates/share_layout/nav_node.html
================================================
================================================
FILE: templates/share_layout/nav_node_mobile.html
================================================
{% load blog_tags %}
{% query nav_category_list parent_category=node as child_categorys %}
{% if child_categorys %}
{% endif %}
{# {% query article_comments parent_comment=None as parent_comments %}#} {% for comment_item in p_comments %} {% with 0 as depth %} {% include "comments/tags/comment_item_tree.html" %} {% endwith %} {% endfor %}