diff --git a/doc/DjangoBlog界面设计说明书.md b/doc/DjangoBlog界面设计说明书.md new file mode 100644 index 0000000..fb752b9 --- /dev/null +++ b/doc/DjangoBlog界面设计说明书.md @@ -0,0 +1,211 @@ +# "DjangoBlog"软件系统的界面设计 + +组号:19 + +小组成员:张恒祺,胡振,李裕祥,杜英硕,王浩 + +## 软件系统概述 + +DjangoBlog 是一款基于 Django 框架开发的现代化博客系统,旨在为个人博主、技术团队和企业提供功能完善的内容发布平台。系统通过用户友好的界面设计和清晰的模板架构,实现了从文章发布、内容管理到读者互动的全流程服务。系统解决了传统博客系统界面单一、功能有限、维护困难等问题,为作者提供便捷的内容创作体验,同时为读者提供优质的阅读体验。 + +## 功能分析 + +系统主要实现以下核心功能模块: + +### 1. 用户端功能 + +**文章展示模块**:以列表和详情形式展示所有文章,支持分类浏览和标签筛选 + +**智能搜索模块**:支持全文搜索,提供精准的搜索结果和高亮显示 + +**文章归档模块**:按时间维度组织文章,支持年月日多级归档浏览 + +**评论互动模块**:读者评论、点赞、分享等社交功能 + +**用户认证模块**:用户注册、登录、密码重置等账户管理功能 + +### 2. 管理端功能 + +**文章管理模块**:文章的增删改查、发布状态控制、分类标签管理 + +**评论管理模块**:评论审核、回复、垃圾评论过滤 + +**数据统计模块**:访问统计、热门文章分析、读者行为分析 + +**系统设置模块**:博客信息配置、主题样式管理、SEO设置 + +## 软件主要的界面设计 + +### 1. 系统首页界面 + +**界面功能**:文章列表展示、分类导航、搜索入口 + +**界面元素**: +- 顶部导航栏:包含Logo、主导航菜单(首页、分类、归档、关于) +- 侧边栏区域:搜索框、分类列表、热门文章、标签云 +- 文章列表区:采用卡片式布局展示文章标题、摘要、发布时间、阅读量 +- 分页导航:支持多页文章浏览 + +### 2. 文章详情界面 + +**界面功能**:完整文章内容展示、评论系统、相关文章推荐 + +**界面组件**: +- 面包屑导航:显示当前位置和导航路径 +- 文章标题区:文章标题、作者信息、发布时间、阅读统计 +- 文章内容区:支持Markdown渲染、代码高亮、目录导航 +- 文章元信息:标签列表、分类信息、社交分享按钮 +- 评论区域:评论列表、评论表单、评论分页 + +### 3. 搜索界面 + +**界面功能**:全文搜索、结果展示、高级筛选 + +**界面元素**: +- 搜索框:支持关键词联想和搜索历史 +- 搜索结果:以列表形式展示,支持按相关度、时间排序 +- 高亮显示:搜索关键词在结果中高亮标记 +- 筛选选项:支持按分类、标签、时间范围筛选 + +### 4. 归档界面 + +**界面功能**:按时间浏览历史文章 + +**界面组件**: +- 时间树形结构:按年-月-日层次展示文章分布 +- 文章链接列表:每个时间节点下的文章标题链接 +- 统计信息:各时间段文章数量统计 + +**界面关系图**: +![图片描述](https://code.educoder.net/api/pqkpsr3gu/DjangoBlog/raw/src%2Fimg%2Ff5b2b176a93286ba173c053e60bb4b0d.png?ref=hz_branch) + +## 模板设计 + +### 模板架构设计 + +本系统采用Django模板引擎,基于MTV架构设计了一套完整的模板系统,实现了界面元素的高度复用和统一管理。模板系统采用分层设计理念,从基础布局到具体功能页面,形成了清晰的继承和包含关系。 + +### 模板继承关系 +![归档界面时间树结构](https://code.educoder.net/api/pqkpsr3gu/DjangoBlog/raw/src%2Fimg%2Fbb816f59d4732648c2ce554565975e06.png?ref=hz_branch) +### 模板继承机制说明 + +#### 1. 基础模板层次 + +**base.html (基础布局)** +- 提供网站整体HTML结构 +- 包含全局CSS和JavaScript资源 +- 定义主要的内容区块(header、content、footer、sidebar) + +**base_account.html (用户认证页面布局)** +- 继承base.html但采用简化布局 +- 专门用于登录、注册等认证页面 +- 移除侧边栏,专注于表单功能 + +**各功能页面模板** +- article_index.html (文章列表):展示分页文章列表 +- article_detail.html (文章详情):完整文章内容和评论系统 +- article_archives.html (文章归档):时间维度的文章组织 +- error_page.html (错误页面):友好的错误提示 +- search.html (搜索页面):搜索结果展示 + +#### 2. 模板包含关系 + +**base.html 包含关系**: +- nav.html (导航菜单):主导航和响应式菜单 +- footer.html (页脚):版权信息、友情链接、备案信息 +- adsense.html (广告):广告位和统计代码 + +**内容区块结构**: +- content (主要内容):各页面特有的内容区域 +- sidebar (侧边栏):通过sidebar.html加载辅助功能组件 + +#### 3. 模板标签系统 + +**核心模板标签 (blog_tags.py)**: +- `load_article_detail`:加载文章详情数据 +- `load_sidebar`:动态生成侧边栏内容 +- `load_breadcrumb`:生成面包屑导航 +- `load_article_metas`:加载文章元信息(作者、时间、阅读量) +- `load_pagination_info`:生成分页导航 + +**模板标签文件结构**: +``` +templates/blog/tags/ +├── article_info.html (文章基本信息展示) +├── article_meta_info.html (文章元信息展示) +├── article_pagination.html (分页控件) +├── article_tag_list.html (标签云和标签列表) +├── breadcrumb.html (面包屑导航) +└── sidebar.html (侧边栏组件集合) +``` + +#### 4. 模板功能分析 + +**基础模板 (base.html) 功能**: +- HTML5 文档结构和语义化标签 +- SEO 元标签优化(title、description、keywords) +- OpenGraph 和 Twitter Card 社交媒体支持 +- 响应式设计支持和移动端适配 +- 全局样式表和JavaScript资源管理 +- 网站头部导航和底部信息统一管理 + +**文章列表模板 (article_index.html) 特点**: +- 支持多种布局模式(列表、网格) +- 智能分页机制,优化大量数据展示 +- 多维度筛选(分类、标签、时间) +- 集成搜索功能和排序选项 +- SEO友好的URL结构和标题层次 + +**文章详情模板 (article_detail.html) 功能**: +- Markdown内容渲染和代码高亮 +- 自动生成文章目录(TOC)导航 +- 多级评论系统和回复功能 +- 相关文章推荐算法 +- 社交分享按钮和统计代码 +- 阅读进度指示器和阅读时间估算 + +**侧边栏模板 (sidebar.html) 组件**: +- 智能搜索框(支持自动补全) +- 分类树形结构和文章计数 +- 热门文章排行榜(按阅读量、评论数) +- 最近评论列表和作者回复 +- 标签云可视化展示 +- 友情链接管理和展示 +- 管理员功能快捷入口 + +### 界面设计特点 + +#### 1. 响应式设计 +- 采用移动优先的响应式设计理念 +- 支持从手机到桌面的全设备适配 +- 自适应侧边栏布局(桌面端显示,移动端可隐藏) +- 触摸友好的交互元素设计 + +#### 2. SEO 优化 +- 完整的meta标签体系和结构化数据 +- JSON-LD格式的富片段标记 +- 面包屑导航和规范的URL结构 +- 语义化HTML5标签使用 +- 图片alt属性和延迟加载 + +#### 3. 用户体验 +- 清晰的视觉层次和信息架构 +- 快速的页面加载和流畅的交互 +- 智能的内容推荐和个性化展示 +- 无障碍访问支持(ARIA标签) +- 一致的操作反馈和状态提示 + +#### 4. 性能优化 +- 模板片段缓存和数据库查询优化 +- 静态资源压缩和CDN加速 +- 图片懒加载和响应式图片 +- 渐进式加载和骨架屏技术 +- 服务端渲染和客户端增强 + +## 总结 + +DjangoBlog 系统采用了清晰的模板继承和包含关系,通过基础模板提供统一布局,通过模板标签实现组件化开发。整个系统具有良好的可维护性和扩展性,适合作为博客系统的基础框架进行二次开发。 + +界面设计注重用户体验和 SEO 优化,通过响应式布局和丰富的交互功能,为用户提供了良好的浏览体验。模板系统的模块化设计使得系统易于维护和扩展,同时为后续功能迭代提供了良好的基础架构。 + +系统的界面流转逻辑清晰,用户可以从多个入口访问内容,最终都汇聚到核心的文章详情页面。这种设计既保证了内容的可发现性,又提供了连贯的用户体验。模板标签系统的细粒度组件设计,使得界面元素的复用和定制变得简单高效。 \ No newline at end of file diff --git a/doc/第六周开源软件泛读报告.docx b/doc/第六周开源软件泛读报告.docx new file mode 100644 index 0000000..5d82733 Binary files /dev/null and b/doc/第六周开源软件泛读报告.docx differ diff --git a/doc/软件数据模型设计说明书.docx b/doc/软件数据模型设计说明书.docx new file mode 100644 index 0000000..b1bcd8a Binary files /dev/null and b/doc/软件数据模型设计说明书.docx differ diff --git a/src/.coveragerc b/src/.coveragerc deleted file mode 100644 index 9757484..0000000 --- a/src/.coveragerc +++ /dev/null @@ -1,10 +0,0 @@ -[run] -source = . -include = *.py -omit = - *migrations* - *tests* - *.html - *whoosh_cn_backend* - *settings.py* - *venv* diff --git a/src/.dockerignore b/src/.dockerignore index 2818c38..bd68a58 100644 --- a/src/.dockerignore +++ b/src/.dockerignore @@ -8,4 +8,5 @@ settings_production.py *.md docs/ logs/ -static/ \ No newline at end of file +static/ +.github/ diff --git a/src/.github/dependabot.yml b/src/.github/dependabot.yml new file mode 100644 index 0000000..a13b873 --- /dev/null +++ b/src/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/src/.github/workflows/codeql-analysis.yml b/src/.github/workflows/codeql-analysis.yml index 6b76522..52775e0 100644 --- a/src/.github/workflows/codeql-analysis.yml +++ b/src/.github/workflows/codeql-analysis.yml @@ -38,10 +38,12 @@ jobs: uses: actions/checkout@v3 - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - + uses: github/codeql-action/init@v3 + with: + languages: python + - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 \ No newline at end of file + uses: github/codeql-action/analyze@v3 \ No newline at end of file diff --git a/src/.github/workflows/deploy-master.yml b/src/.github/workflows/deploy-master.yml new file mode 100644 index 0000000..c07a326 --- /dev/null +++ b/src/.github/workflows/deploy-master.yml @@ -0,0 +1,176 @@ +name: 自动部署到生产环境 + +on: + workflow_run: + workflows: ["Django 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: '跳过测试直接部署' + required: false + default: false + type: boolean + +env: + REGISTRY: registry.cn-shenzhen.aliyuncs.com + IMAGE_NAME: liangliangyy/djangoblog + NAMESPACE: djangoblog + +jobs: + deploy: + name: 构建镜像并部署到生产环境 + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - 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 + echo "skip_tests=${{ github.event.inputs.skip_tests }}" >> $GITHUB_OUTPUT + else + echo "trigger_type=CI自动触发" >> $GITHUB_OUTPUT + echo "environment=production" >> $GITHUB_OUTPUT + echo "image_tag=latest" >> $GITHUB_OUTPUT + echo "skip_tests=false" >> $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 }}" + echo " 跳过测试: ${{ steps.deploy-params.outputs.skip_tests }}" + + - name: 设置Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 登录私有镜像仓库 + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: 提取镜像元数据 + id: meta + uses: docker/metadata-action@v5 + 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@v5 + 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.0.3 + 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 \ No newline at end of file diff --git a/src/.github/workflows/django.yml b/src/.github/workflows/django.yml index 94baea9..ebe7953 100644 --- a/src/.github/workflows/django.yml +++ b/src/.github/workflows/django.yml @@ -19,55 +19,59 @@ on: - '**/*.js' jobs: - build-normal: + test: runs-on: ubuntu-latest strategy: - max-parallel: 4 + fail-fast: false matrix: - python-version: ["3.10","3.11" ] + 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: Start 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 - - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - - name: Install Dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Run Tests - env: - DJANGO_MYSQL_PASSWORD: root - DJANGO_MYSQL_HOST: 127.0.0.1 + - name: Checkout代码 + uses: actions/checkout@v4 + + - name: 设置测试信息 + id: test-info run: | - python manage.py makemigrations - python manage.py migrate - python manage.py test - - build-with-es: - runs-on: ubuntu-latest - strategy: - max-parallel: 4 - matrix: - python-version: ["3.10","3.11" ] - - steps: - - name: Start MySQL + 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 @@ -79,58 +83,289 @@ jobs: mysql database: djangoblog mysql user: root mysql password: root - - - name: Configure sysctl limits + + # 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 - - - uses: miyataka/elasticsearch-github-actions@1 - + + - name: 启动Elasticsearch + if: matrix.elasticsearch == true + uses: miyataka/elasticsearch-github-actions@1 with: stack-version: '7.12.1' plugins: 'https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-7.12.1.zip' - - - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + + # Python环境设置 (Docker测试跳过) + - name: 设置Python ${{ matrix.python-version }} + if: steps.test-info.outputs.skip_python_setup == 'false' + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - - name: Install Dependencies + cache-dependency-path: 'requirements.txt' + + # 多层缓存策略优化 + - name: 缓存Python依赖 + if: steps.test-info.outputs.skip_python_setup == 'false' + uses: actions/cache@v4 + 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@v4 + 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: | - python -m pip install --upgrade pip + echo "📦 安装Python依赖 (Python ${{ matrix.python-version }})" + python -m pip install --upgrade pip setuptools wheel + + # 安装基础依赖 pip install -r requirements.txt - - name: Run Tests + + # 根据测试类型安装额外依赖 + 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: 127.0.0.1:9200 + DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && '127.0.0.1:9200' || '' }} run: | - python manage.py makemigrations - python manage.py migrate - coverage run manage.py test - coverage xml - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 - - docker: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Build and push - uses: docker/build-push-action@v3 + 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 && '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 && '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@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage.xml + flags: unittests + name: codecov-${{ steps.test-info.outputs.test_name }} + fail_ci_if_error: false + verbose: true + + - name: 上传覆盖率到Codecov (备用) + if: matrix.coverage == true && failure() + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + flags: unittests + name: codecov-${{ 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@v3 + + - name: 设置Docker Buildx + if: matrix.test-type == 'docker' + uses: docker/setup-buildx-action@v3 + + - name: Docker构建测试 + if: matrix.test-type == 'docker' + uses: docker/build-push-action@v5 with: context: . push: false - tags: djangoblog/djangoblog:dev + 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@v4 + 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 diff --git a/src/.github/workflows/docker.yml b/src/.github/workflows/docker.yml index a312e2f..0712f46 100644 --- a/src/.github/workflows/docker.yml +++ b/src/.github/workflows/docker.yml @@ -8,7 +8,7 @@ on: branches: - 'master' - 'dev' - + workflow_dispatch: jobs: docker: runs-on: ubuntu-latest @@ -22,19 +22,19 @@ jobs: run: | echo "DOCKER_TAG=latest" >> $GITHUB_ENV - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v5 with: context: . push: true diff --git a/src/.gitignore b/src/.gitignore index 3015816..76302b1 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -62,7 +62,6 @@ target/ # http://www.jetbrains.com/pycharm/webhelp/project.html .idea .iml -static/ # virtualenv venv/ diff --git a/src/.venv/Include/site/python3.12/greenlet/greenlet.h b/src/.venv/Include/site/python3.12/greenlet/greenlet.h deleted file mode 100644 index d02a16e..0000000 --- a/src/.venv/Include/site/python3.12/greenlet/greenlet.h +++ /dev/null @@ -1,164 +0,0 @@ -/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */ - -/* Greenlet object interface */ - -#ifndef Py_GREENLETOBJECT_H -#define Py_GREENLETOBJECT_H - - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* This is deprecated and undocumented. It does not change. */ -#define GREENLET_VERSION "1.0.0" - -#ifndef GREENLET_MODULE -#define implementation_ptr_t void* -#endif - -typedef struct _greenlet { - PyObject_HEAD - PyObject* weakreflist; - PyObject* dict; - implementation_ptr_t pimpl; -} PyGreenlet; - -#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type)) - - -/* C API functions */ - -/* Total number of symbols that are exported */ -#define PyGreenlet_API_pointers 12 - -#define PyGreenlet_Type_NUM 0 -#define PyExc_GreenletError_NUM 1 -#define PyExc_GreenletExit_NUM 2 - -#define PyGreenlet_New_NUM 3 -#define PyGreenlet_GetCurrent_NUM 4 -#define PyGreenlet_Throw_NUM 5 -#define PyGreenlet_Switch_NUM 6 -#define PyGreenlet_SetParent_NUM 7 - -#define PyGreenlet_MAIN_NUM 8 -#define PyGreenlet_STARTED_NUM 9 -#define PyGreenlet_ACTIVE_NUM 10 -#define PyGreenlet_GET_PARENT_NUM 11 - -#ifndef GREENLET_MODULE -/* This section is used by modules that uses the greenlet C API */ -static void** _PyGreenlet_API = NULL; - -# define PyGreenlet_Type \ - (*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM]) - -# define PyExc_GreenletError \ - ((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM]) - -# define PyExc_GreenletExit \ - ((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM]) - -/* - * PyGreenlet_New(PyObject *args) - * - * greenlet.greenlet(run, parent=None) - */ -# define PyGreenlet_New \ - (*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \ - _PyGreenlet_API[PyGreenlet_New_NUM]) - -/* - * PyGreenlet_GetCurrent(void) - * - * greenlet.getcurrent() - */ -# define PyGreenlet_GetCurrent \ - (*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM]) - -/* - * PyGreenlet_Throw( - * PyGreenlet *greenlet, - * PyObject *typ, - * PyObject *val, - * PyObject *tb) - * - * g.throw(...) - */ -# define PyGreenlet_Throw \ - (*(PyObject * (*)(PyGreenlet * self, \ - PyObject * typ, \ - PyObject * val, \ - PyObject * tb)) \ - _PyGreenlet_API[PyGreenlet_Throw_NUM]) - -/* - * PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args) - * - * g.switch(*args, **kwargs) - */ -# define PyGreenlet_Switch \ - (*(PyObject * \ - (*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \ - _PyGreenlet_API[PyGreenlet_Switch_NUM]) - -/* - * PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent) - * - * g.parent = new_parent - */ -# define PyGreenlet_SetParent \ - (*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \ - _PyGreenlet_API[PyGreenlet_SetParent_NUM]) - -/* - * PyGreenlet_GetParent(PyObject* greenlet) - * - * return greenlet.parent; - * - * This could return NULL even if there is no exception active. - * If it does not return NULL, you are responsible for decrementing the - * reference count. - */ -# define PyGreenlet_GetParent \ - (*(PyGreenlet* (*)(PyGreenlet*)) \ - _PyGreenlet_API[PyGreenlet_GET_PARENT_NUM]) - -/* - * deprecated, undocumented alias. - */ -# define PyGreenlet_GET_PARENT PyGreenlet_GetParent - -# define PyGreenlet_MAIN \ - (*(int (*)(PyGreenlet*)) \ - _PyGreenlet_API[PyGreenlet_MAIN_NUM]) - -# define PyGreenlet_STARTED \ - (*(int (*)(PyGreenlet*)) \ - _PyGreenlet_API[PyGreenlet_STARTED_NUM]) - -# define PyGreenlet_ACTIVE \ - (*(int (*)(PyGreenlet*)) \ - _PyGreenlet_API[PyGreenlet_ACTIVE_NUM]) - - - - -/* Macro that imports greenlet and initializes C API */ -/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we - keep the older definition to be sure older code that might have a copy of - the header still works. */ -# define PyGreenlet_Import() \ - { \ - _PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \ - } - -#endif /* GREENLET_MODULE */ - -#ifdef __cplusplus -} -#endif -#endif /* !Py_GREENLETOBJECT_H */ diff --git a/src/.venv/Scripts/Activate.ps1 b/src/.venv/Scripts/Activate.ps1 deleted file mode 100644 index 918eac3..0000000 --- a/src/.venv/Scripts/Activate.ps1 +++ /dev/null @@ -1,528 +0,0 @@ -<# -.Synopsis -Activate a Python virtual environment for the current PowerShell session. - -.Description -Pushes the python executable for a virtual environment to the front of the -$Env:PATH environment variable and sets the prompt to signify that you are -in a Python virtual environment. Makes use of the command line switches as -well as the `pyvenv.cfg` file values present in the virtual environment. - -.Parameter VenvDir -Path to the directory that contains the virtual environment to activate. The -default value for this is the parent of the directory that the Activate.ps1 -script is located within. - -.Parameter Prompt -The prompt prefix to display when this virtual environment is activated. By -default, this prompt is the name of the virtual environment folder (VenvDir) -surrounded by parentheses and followed by a single space (ie. '(.venv) '). - -.Example -Activate.ps1 -Activates the Python virtual environment that contains the Activate.ps1 script. - -.Example -Activate.ps1 -Verbose -Activates the Python virtual environment that contains the Activate.ps1 script, -and shows extra information about the activation as it executes. - -.Example -Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv -Activates the Python virtual environment located in the specified location. - -.Example -Activate.ps1 -Prompt "MyPython" -Activates the Python virtual environment that contains the Activate.ps1 script, -and prefixes the current prompt with the specified string (surrounded in -parentheses) while the virtual environment is active. - -.Notes -On Windows, it may be required to enable this Activate.ps1 script by setting the -execution policy for the user. You can do this by issuing the following PowerShell -command: - -PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser - -For more information on Execution Policies: -https://go.microsoft.com/fwlink/?LinkID=135170 - -#> -Param( - [Parameter(Mandatory = $false)] - [String] - $VenvDir, - [Parameter(Mandatory = $false)] - [String] - $Prompt -) - -<# Function declarations --------------------------------------------------- #> - -<# -.Synopsis -Remove all shell session elements added by the Activate script, including the -addition of the virtual environment's Python executable from the beginning of -the PATH variable. - -.Parameter NonDestructive -If present, do not remove this function from the global namespace for the -session. - -#> -function global:deactivate ([switch]$NonDestructive) { - # Revert to original values - - # The prior prompt: - if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { - Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt - Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT - } - - # The prior PYTHONHOME: - if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { - Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME - Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME - } - - # The prior PATH: - if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { - Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH - Remove-Item -Path Env:_OLD_VIRTUAL_PATH - } - - # Just remove the VIRTUAL_ENV altogether: - if (Test-Path -Path Env:VIRTUAL_ENV) { - Remove-Item -Path env:VIRTUAL_ENV - } - - # Just remove VIRTUAL_ENV_PROMPT altogether. - if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { - Remove-Item -Path env:VIRTUAL_ENV_PROMPT - } - - # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: - if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { - Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force - } - - # Leave deactivate function in the global namespace if requested: - if (-not $NonDestructive) { - Remove-Item -Path function:deactivate - } -} - -<# -.Description -Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the -given folder, and returns them in a map. - -For each line in the pyvenv.cfg file, if that line can be parsed into exactly -two strings separated by `=` (with any amount of whitespace surrounding the =) -then it is considered a `key = value` line. The left hand string is the key, -the right hand is the value. - -If the value starts with a `'` or a `"` then the first and last character is -stripped from the value before being captured. - -.Parameter ConfigDir -Path to the directory that contains the `pyvenv.cfg` file. -#> -function Get-PyVenvConfig( - [String] - $ConfigDir -) { - Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" - - # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). - $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue - - # An empty map will be returned if no config file is found. - $pyvenvConfig = @{ } - - if ($pyvenvConfigPath) { - - Write-Verbose "File exists, parse `key = value` lines" - $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath - - $pyvenvConfigContent | ForEach-Object { - $keyval = $PSItem -split "\s*=\s*", 2 - if ($keyval[0] -and $keyval[1]) { - $val = $keyval[1] - - # Remove extraneous quotations around a string value. - if ("'""".Contains($val.Substring(0, 1))) { - $val = $val.Substring(1, $val.Length - 2) - } - - $pyvenvConfig[$keyval[0]] = $val - Write-Verbose "Adding Key: '$($keyval[0])'='$val'" - } - } - } - return $pyvenvConfig -} - - -<# Begin Activate script --------------------------------------------------- #> - -# Determine the containing directory of this script -$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition -$VenvExecDir = Get-Item -Path $VenvExecPath - -Write-Verbose "Activation script is located in path: '$VenvExecPath'" -Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" -Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" - -# Set values required in priority: CmdLine, ConfigFile, Default -# First, get the location of the virtual environment, it might not be -# VenvExecDir if specified on the command line. -if ($VenvDir) { - Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" -} -else { - Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." - $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") - Write-Verbose "VenvDir=$VenvDir" -} - -# Next, read the `pyvenv.cfg` file to determine any required value such -# as `prompt`. -$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir - -# Next, set the prompt from the command line, or the config file, or -# just use the name of the virtual environment folder. -if ($Prompt) { - Write-Verbose "Prompt specified as argument, using '$Prompt'" -} -else { - Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" - if ($pyvenvCfg -and $pyvenvCfg['prompt']) { - Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" - $Prompt = $pyvenvCfg['prompt']; - } - else { - Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" - Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" - $Prompt = Split-Path -Path $venvDir -Leaf - } -} - -Write-Verbose "Prompt = '$Prompt'" -Write-Verbose "VenvDir='$VenvDir'" - -# Deactivate any currently active virtual environment, but leave the -# deactivate function in place. -deactivate -nondestructive - -# Now set the environment variable VIRTUAL_ENV, used by many tools to determine -# that there is an activated venv. -$env:VIRTUAL_ENV = $VenvDir - -if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { - - Write-Verbose "Setting prompt to '$Prompt'" - - # Set the prompt to include the env name - # Make sure _OLD_VIRTUAL_PROMPT is global - function global:_OLD_VIRTUAL_PROMPT { "" } - Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT - New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt - - function global:prompt { - Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " - _OLD_VIRTUAL_PROMPT - } - $env:VIRTUAL_ENV_PROMPT = $Prompt -} - -# Clear PYTHONHOME -if (Test-Path -Path Env:PYTHONHOME) { - Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME - Remove-Item -Path Env:PYTHONHOME -} - -# Add the venv to the PATH -Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH -$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" - -# SIG # Begin signature block -# MII0CQYJKoZIhvcNAQcCoIIz+jCCM/YCAQExDzANBglghkgBZQMEAgEFADB5Bgor -# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG -# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBnL745ElCYk8vk -# dBtMuQhLeWJ3ZGfzKW4DHCYzAn+QB6CCG9IwggXMMIIDtKADAgECAhBUmNLR1FsZ -# lUgTecgRwIeZMA0GCSqGSIb3DQEBDAUAMHcxCzAJBgNVBAYTAlVTMR4wHAYDVQQK -# ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xSDBGBgNVBAMTP01pY3Jvc29mdCBJZGVu -# dGl0eSBWZXJpZmljYXRpb24gUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAy -# MDAeFw0yMDA0MTYxODM2MTZaFw00NTA0MTYxODQ0NDBaMHcxCzAJBgNVBAYTAlVT -# MR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xSDBGBgNVBAMTP01pY3Jv -# c29mdCBJZGVudGl0eSBWZXJpZmljYXRpb24gUm9vdCBDZXJ0aWZpY2F0ZSBBdXRo -# b3JpdHkgMjAyMDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALORKgeD -# Bmf9np3gx8C3pOZCBH8Ppttf+9Va10Wg+3cL8IDzpm1aTXlT2KCGhFdFIMeiVPvH -# or+Kx24186IVxC9O40qFlkkN/76Z2BT2vCcH7kKbK/ULkgbk/WkTZaiRcvKYhOuD -# PQ7k13ESSCHLDe32R0m3m/nJxxe2hE//uKya13NnSYXjhr03QNAlhtTetcJtYmrV -# qXi8LW9J+eVsFBT9FMfTZRY33stuvF4pjf1imxUs1gXmuYkyM6Nix9fWUmcIxC70 -# ViueC4fM7Ke0pqrrBc0ZV6U6CwQnHJFnni1iLS8evtrAIMsEGcoz+4m+mOJyoHI1 -# vnnhnINv5G0Xb5DzPQCGdTiO0OBJmrvb0/gwytVXiGhNctO/bX9x2P29Da6SZEi3 -# W295JrXNm5UhhNHvDzI9e1eM80UHTHzgXhgONXaLbZ7LNnSrBfjgc10yVpRnlyUK -# xjU9lJfnwUSLgP3B+PR0GeUw9gb7IVc+BhyLaxWGJ0l7gpPKWeh1R+g/OPTHU3mg -# trTiXFHvvV84wRPmeAyVWi7FQFkozA8kwOy6CXcjmTimthzax7ogttc32H83rwjj -# O3HbbnMbfZlysOSGM1l0tRYAe1BtxoYT2v3EOYI9JACaYNq6lMAFUSw0rFCZE4e7 -# swWAsk0wAly4JoNdtGNz764jlU9gKL431VulAgMBAAGjVDBSMA4GA1UdDwEB/wQE -# AwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTIftJqhSobyhmYBAcnz1AQ -# T2ioojAQBgkrBgEEAYI3FQEEAwIBADANBgkqhkiG9w0BAQwFAAOCAgEAr2rd5hnn -# LZRDGU7L6VCVZKUDkQKL4jaAOxWiUsIWGbZqWl10QzD0m/9gdAmxIR6QFm3FJI9c -# Zohj9E/MffISTEAQiwGf2qnIrvKVG8+dBetJPnSgaFvlVixlHIJ+U9pW2UYXeZJF -# xBA2CFIpF8svpvJ+1Gkkih6PsHMNzBxKq7Kq7aeRYwFkIqgyuH4yKLNncy2RtNwx -# AQv3Rwqm8ddK7VZgxCwIo3tAsLx0J1KH1r6I3TeKiW5niB31yV2g/rarOoDXGpc8 -# FzYiQR6sTdWD5jw4vU8w6VSp07YEwzJ2YbuwGMUrGLPAgNW3lbBeUU0i/OxYqujY -# lLSlLu2S3ucYfCFX3VVj979tzR/SpncocMfiWzpbCNJbTsgAlrPhgzavhgplXHT2 -# 6ux6anSg8Evu75SjrFDyh+3XOjCDyft9V77l4/hByuVkrrOj7FjshZrM77nq81YY -# uVxzmq/FdxeDWds3GhhyVKVB0rYjdaNDmuV3fJZ5t0GNv+zcgKCf0Xd1WF81E+Al -# GmcLfc4l+gcK5GEh2NQc5QfGNpn0ltDGFf5Ozdeui53bFv0ExpK91IjmqaOqu/dk -# ODtfzAzQNb50GQOmxapMomE2gj4d8yu8l13bS3g7LfU772Aj6PXsCyM2la+YZr9T -# 03u4aUoqlmZpxJTG9F9urJh4iIAGXKKy7aIwggb+MIIE5qADAgECAhMzAAM/y2Wy -# WWnFfpZcAAAAAz/LMA0GCSqGSIb3DQEBDAUAMFoxCzAJBgNVBAYTAlVTMR4wHAYD -# VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKzApBgNVBAMTIk1pY3Jvc29mdCBJ -# RCBWZXJpZmllZCBDUyBBT0MgQ0EgMDEwHhcNMjUwNDA4MDEwNzI0WhcNMjUwNDEx -# MDEwNzI0WjB8MQswCQYDVQQGEwJVUzEPMA0GA1UECBMGT3JlZ29uMRIwEAYDVQQH -# EwlCZWF2ZXJ0b24xIzAhBgNVBAoTGlB5dGhvbiBTb2Z0d2FyZSBGb3VuZGF0aW9u -# MSMwIQYDVQQDExpQeXRob24gU29mdHdhcmUgRm91bmRhdGlvbjCCAaIwDQYJKoZI -# hvcNAQEBBQADggGPADCCAYoCggGBAI0elXEcbTdGLOszMU2fzimHGM9Y4EjwFgC2 -# iGPdieHc0dK1DyEIdtnvjKxnG/KICC3J2MrhePGzMEkie3yQjx05B5leG0q8YoGU -# m9z9K67V6k3DSXX0vQe9FbaNVuyXed31MEf/qek7Zo4ELxu8n/LO3ibURBLRHNoW -# Dz9zr4DcU+hha0bdIL6SnKMLwHqRj59gtFFEPqXcOVO7kobkzQS3O1T5KNL/zGuW -# UGQln7fS4YI9bj24bfrSeG/QzLgChVYScxnUgjAANfT1+SnSxrT4/esMtfbcvfID -# BIvOWk+FPPj9IQWsAMEG/LLG4cF/pQ/TozUXKx362GJBbe6paTM/RCUTcffd83h2 -# bXo9vXO/roZYk6H0ecd2h2FFzLUQn/0i4RQQSOp6zt1eDf28h6F8ev+YYKcChph8 -# iRt32bJPcLQVbUzhehzT4C0pz6oAqPz8s0BGvlj1G6r4CY1Cs2YiMU09/Fl64pWf -# IsA/ReaYj6yNsgQZNUcvzobK2mTxMwIDAQABo4ICGTCCAhUwDAYDVR0TAQH/BAIw -# ADAOBgNVHQ8BAf8EBAMCB4AwPAYDVR0lBDUwMwYKKwYBBAGCN2EBAAYIKwYBBQUH -# AwMGGysGAQQBgjdhgqKNuwqmkohkgZH0oEWCk/3hbzAdBgNVHQ4EFgQU4Y4Xr/Xn -# zEXblXrNC0ZLdaPEJYUwHwYDVR0jBBgwFoAU6IPEM9fcnwycdpoKptTfh6ZeWO4w -# ZwYDVR0fBGAwXjBcoFqgWIZWaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9w -# cy9jcmwvTWljcm9zb2Z0JTIwSUQlMjBWZXJpZmllZCUyMENTJTIwQU9DJTIwQ0El -# MjAwMS5jcmwwgaUGCCsGAQUFBwEBBIGYMIGVMGQGCCsGAQUFBzAChlhodHRwOi8v -# d3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRzL01pY3Jvc29mdCUyMElEJTIw -# VmVyaWZpZWQlMjBDUyUyMEFPQyUyMENBJTIwMDEuY3J0MC0GCCsGAQUFBzABhiFo -# dHRwOi8vb25lb2NzcC5taWNyb3NvZnQuY29tL29jc3AwZgYDVR0gBF8wXTBRBgwr -# BgEEAYI3TIN9AQEwQTA/BggrBgEFBQcCARYzaHR0cDovL3d3dy5taWNyb3NvZnQu -# Y29tL3BraW9wcy9Eb2NzL1JlcG9zaXRvcnkuaHRtMAgGBmeBDAEEATANBgkqhkiG -# 9w0BAQwFAAOCAgEAKTeVGPXsDKqQLe1OuKx6K6q711FPxNQyLOOqeenH8zybHwNo -# k05cMk39HQ7u+R9BQIL0bWexb7wa3XeKaX06p7aY/OQs+ycvUi/fC6RGlaLWmQ9D -# YhZn2TBz5znimvSf3P+aidCuXeDU5c8GpBFog6fjEa/k+n7TILi0spuYZ4yC9R48 -# R63/VvpLi2SqxfJbx5n92bY6driNzAntjoravF25BSejXVrdzefbnqbQnZPB39g8 -# XHygGPb0912fIuNKPLQa/uCnmYdXJnPb0ZgMxxA8fyxvL2Q30Qf5xpFDssPDElvD -# DoAbvR24CWvuHbu+CMMr2SJUpX4RRvDioO7JeB6wZb+64MXyPUSSf6QwkKNsHPIa -# e9tSfREh86sYn5bOA0Wd+Igk0RpA5jDRTu3GgPOPWbm1PU+VoeqThtHt6R3l17pr -# aQ5wIuuLXgxi1K4ZWgtvXw8BtIXfZz24qCtoo0+3kEGUpEHBgkF1SClbRb8uAzx+ -# 0ROGniLPJRU20Xfn7CgipeKLcNn33JPFwQHk1zpbGS0090mi0erOQCz0S47YdHmm -# RJcbkNIL9DeNAglTZ/TFxrYUM1NRS1Cp4e63MgBKcWh9VJNokInzzmS+bofZz+u1 -# mm8YNtiJjdT8fmizXdUEk68EXQhOs0+HBNvc9nMRK6R28MZu/J+PaUcPL84wggda -# MIIFQqADAgECAhMzAAAABzeMW6HZW4zUAAAAAAAHMA0GCSqGSIb3DQEBDAUAMGMx -# CzAJBgNVBAYTAlVTMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xNDAy -# BgNVBAMTK01pY3Jvc29mdCBJRCBWZXJpZmllZCBDb2RlIFNpZ25pbmcgUENBIDIw -# MjEwHhcNMjEwNDEzMTczMTU0WhcNMjYwNDEzMTczMTU0WjBaMQswCQYDVQQGEwJV -# UzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSswKQYDVQQDEyJNaWNy -# b3NvZnQgSUQgVmVyaWZpZWQgQ1MgQU9DIENBIDAxMIICIjANBgkqhkiG9w0BAQEF -# AAOCAg8AMIICCgKCAgEAt/fAAygHxbo+jxA04hNI8bz+EqbWvSu9dRgAawjCZau1 -# Y54IQal5ArpJWi8cIj0WA+mpwix8iTRguq9JELZvTMo2Z1U6AtE1Tn3mvq3mywZ9 -# SexVd+rPOTr+uda6GVgwLA80LhRf82AvrSwxmZpCH/laT08dn7+Gt0cXYVNKJORm -# 1hSrAjjDQiZ1Jiq/SqiDoHN6PGmT5hXKs22E79MeFWYB4y0UlNqW0Z2LPNua8k0r -# bERdiNS+nTP/xsESZUnrbmyXZaHvcyEKYK85WBz3Sr6Et8Vlbdid/pjBpcHI+Hyt -# oaUAGE6rSWqmh7/aEZeDDUkz9uMKOGasIgYnenUk5E0b2U//bQqDv3qdhj9UJYWA -# DNYC/3i3ixcW1VELaU+wTqXTxLAFelCi/lRHSjaWipDeE/TbBb0zTCiLnc9nmOjZ -# PKlutMNho91wxo4itcJoIk2bPot9t+AV+UwNaDRIbcEaQaBycl9pcYwWmf0bJ4IF -# n/CmYMVG1ekCBxByyRNkFkHmuMXLX6PMXcveE46jMr9syC3M8JHRddR4zVjd/FxB -# nS5HOro3pg6StuEPshrp7I/Kk1cTG8yOWl8aqf6OJeAVyG4lyJ9V+ZxClYmaU5yv -# tKYKk1FLBnEBfDWw+UAzQV0vcLp6AVx2Fc8n0vpoyudr3SwZmckJuz7R+S79BzMC -# AwEAAaOCAg4wggIKMA4GA1UdDwEB/wQEAwIBhjAQBgkrBgEEAYI3FQEEAwIBADAd -# BgNVHQ4EFgQU6IPEM9fcnwycdpoKptTfh6ZeWO4wVAYDVR0gBE0wSzBJBgRVHSAA -# MEEwPwYIKwYBBQUHAgEWM2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMv -# RG9jcy9SZXBvc2l0b3J5Lmh0bTAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTAS -# BgNVHRMBAf8ECDAGAQH/AgEAMB8GA1UdIwQYMBaAFNlBKbAPD2Ns72nX9c0pnqRI -# ajDmMHAGA1UdHwRpMGcwZaBjoGGGX2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9w -# a2lvcHMvY3JsL01pY3Jvc29mdCUyMElEJTIwVmVyaWZpZWQlMjBDb2RlJTIwU2ln -# bmluZyUyMFBDQSUyMDIwMjEuY3JsMIGuBggrBgEFBQcBAQSBoTCBnjBtBggrBgEF -# BQcwAoZhaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jZXJ0cy9NaWNy -# b3NvZnQlMjBJRCUyMFZlcmlmaWVkJTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAy -# MDIxLmNydDAtBggrBgEFBQcwAYYhaHR0cDovL29uZW9jc3AubWljcm9zb2Z0LmNv -# bS9vY3NwMA0GCSqGSIb3DQEBDAUAA4ICAQB3/utLItkwLTp4Nfh99vrbpSsL8NwP -# Ij2+TBnZGL3C8etTGYs+HZUxNG+rNeZa+Rzu9oEcAZJDiGjEWytzMavD6Bih3nEW -# FsIW4aGh4gB4n/pRPeeVrK4i1LG7jJ3kPLRhNOHZiLUQtmrF4V6IxtUFjvBnijaZ -# 9oIxsSSQP8iHMjP92pjQrHBFWHGDbkmx+yO6Ian3QN3YmbdfewzSvnQmKbkiTibJ -# gcJ1L0TZ7BwmsDvm+0XRsPOfFgnzhLVqZdEyWww10bflOeBKqkb3SaCNQTz8nsha -# UZhrxVU5qNgYjaaDQQm+P2SEpBF7RolEC3lllfuL4AOGCtoNdPOWrx9vBZTXAVdT -# E2r0IDk8+5y1kLGTLKzmNFn6kVCc5BddM7xoDWQ4aUoCRXcsBeRhsclk7kVXP+zJ -# GPOXwjUJbnz2Kt9iF/8B6FDO4blGuGrogMpyXkuwCC2Z4XcfyMjPDhqZYAPGGTUI -# NMtFbau5RtGG1DOWE9edCahtuPMDgByfPixvhy3sn7zUHgIC/YsOTMxVuMQi/bga -# memo/VNKZrsZaS0nzmOxKpg9qDefj5fJ9gIHXcp2F0OHcVwe3KnEXa8kqzMDfrRl -# /wwKrNSFn3p7g0b44Ad1ONDmWt61MLQvF54LG62i6ffhTCeoFT9Z9pbUo2gxlyTF -# g7Bm0fgOlnRfGDCCB54wggWGoAMCAQICEzMAAAAHh6M0o3uljhwAAAAAAAcwDQYJ -# KoZIhvcNAQEMBQAwdzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBD -# b3Jwb3JhdGlvbjFIMEYGA1UEAxM/TWljcm9zb2Z0IElkZW50aXR5IFZlcmlmaWNh -# dGlvbiBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDIwMB4XDTIxMDQwMTIw -# MDUyMFoXDTM2MDQwMTIwMTUyMFowYzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1p -# Y3Jvc29mdCBDb3Jwb3JhdGlvbjE0MDIGA1UEAxMrTWljcm9zb2Z0IElEIFZlcmlm -# aWVkIENvZGUgU2lnbmluZyBQQ0EgMjAyMTCCAiIwDQYJKoZIhvcNAQEBBQADggIP -# ADCCAgoCggIBALLwwK8ZiCji3VR6TElsaQhVCbRS/3pK+MHrJSj3Zxd3KU3rlfL3 -# qrZilYKJNqztA9OQacr1AwoNcHbKBLbsQAhBnIB34zxf52bDpIO3NJlfIaTE/xrw -# eLoQ71lzCHkD7A4As1Bs076Iu+mA6cQzsYYH/Cbl1icwQ6C65rU4V9NQhNUwgrx9 -# rGQ//h890Q8JdjLLw0nV+ayQ2Fbkd242o9kH82RZsH3HEyqjAB5a8+Ae2nPIPc8s -# ZU6ZE7iRrRZywRmrKDp5+TcmJX9MRff241UaOBs4NmHOyke8oU1TYrkxh+YeHgfW -# o5tTgkoSMoayqoDpHOLJs+qG8Tvh8SnifW2Jj3+ii11TS8/FGngEaNAWrbyfNrC6 -# 9oKpRQXY9bGH6jn9NEJv9weFxhTwyvx9OJLXmRGbAUXN1U9nf4lXezky6Uh/cgjk -# Vd6CGUAf0K+Jw+GE/5VpIVbcNr9rNE50Sbmy/4RTCEGvOq3GhjITbCa4crCzTTHg -# YYjHs1NbOc6brH+eKpWLtr+bGecy9CrwQyx7S/BfYJ+ozst7+yZtG2wR461uckFu -# 0t+gCwLdN0A6cFtSRtR8bvxVFyWwTtgMMFRuBa3vmUOTnfKLsLefRaQcVTgRnzeL -# zdpt32cdYKp+dhr2ogc+qM6K4CBI5/j4VFyC4QFeUP2YAidLtvpXRRo3AgMBAAGj -# ggI1MIICMTAOBgNVHQ8BAf8EBAMCAYYwEAYJKwYBBAGCNxUBBAMCAQAwHQYDVR0O -# BBYEFNlBKbAPD2Ns72nX9c0pnqRIajDmMFQGA1UdIARNMEswSQYEVR0gADBBMD8G -# CCsGAQUFBwIBFjNodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL0RvY3Mv -# UmVwb3NpdG9yeS5odG0wGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBDAEEwDwYDVR0T -# AQH/BAUwAwEB/zAfBgNVHSMEGDAWgBTIftJqhSobyhmYBAcnz1AQT2ioojCBhAYD -# VR0fBH0wezB5oHegdYZzaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9j -# cmwvTWljcm9zb2Z0JTIwSWRlbnRpdHklMjBWZXJpZmljYXRpb24lMjBSb290JTIw -# Q2VydGlmaWNhdGUlMjBBdXRob3JpdHklMjAyMDIwLmNybDCBwwYIKwYBBQUHAQEE -# gbYwgbMwgYEGCCsGAQUFBzAChnVodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtp -# b3BzL2NlcnRzL01pY3Jvc29mdCUyMElkZW50aXR5JTIwVmVyaWZpY2F0aW9uJTIw -# Um9vdCUyMENlcnRpZmljYXRlJTIwQXV0aG9yaXR5JTIwMjAyMC5jcnQwLQYIKwYB -# BQUHMAGGIWh0dHA6Ly9vbmVvY3NwLm1pY3Jvc29mdC5jb20vb2NzcDANBgkqhkiG -# 9w0BAQwFAAOCAgEAfyUqnv7Uq+rdZgrbVyNMul5skONbhls5fccPlmIbzi+OwVdP -# Q4H55v7VOInnmezQEeW4LqK0wja+fBznANbXLB0KrdMCbHQpbLvG6UA/Xv2pfpVI -# E1CRFfNF4XKO8XYEa3oW8oVH+KZHgIQRIwAbyFKQ9iyj4aOWeAzwk+f9E5StNp5T -# 8FG7/VEURIVWArbAzPt9ThVN3w1fAZkF7+YU9kbq1bCR2YD+MtunSQ1Rft6XG7b4 -# e0ejRA7mB2IoX5hNh3UEauY0byxNRG+fT2MCEhQl9g2i2fs6VOG19CNep7SquKaB -# jhWmirYyANb0RJSLWjinMLXNOAga10n8i9jqeprzSMU5ODmrMCJE12xS/NWShg/t -# uLjAsKP6SzYZ+1Ry358ZTFcx0FS/mx2vSoU8s8HRvy+rnXqyUJ9HBqS0DErVLjQw -# K8VtsBdekBmdTbQVoCgPCqr+PDPB3xajYnzevs7eidBsM71PINK2BoE2UfMwxCCX -# 3mccFgx6UsQeRSdVVVNSyALQe6PT12418xon2iDGE81OGCreLzDcMAZnrUAx4XQL -# Uz6ZTl65yPUiOh3k7Yww94lDf+8oG2oZmDh5O1Qe38E+M3vhKwmzIeoB1dVLlz4i -# 3IpaDcR+iuGjH2TdaC1ZOmBXiCRKJLj4DT2uhJ04ji+tHD6n58vhavFIrmcxgheN -# MIIXiQIBATBxMFoxCzAJBgNVBAYTAlVTMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29y -# cG9yYXRpb24xKzApBgNVBAMTIk1pY3Jvc29mdCBJRCBWZXJpZmllZCBDUyBBT0Mg -# Q0EgMDECEzMAAz/LZbJZacV+llwAAAADP8swDQYJYIZIAWUDBAIBBQCggcowGQYJ -# KoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwGCisGAQQB -# gjcCARUwLwYJKoZIhvcNAQkEMSIEIGcBno/ti9PCrR9sXrajsTvlHQvGxbk63JiI -# URJByQuGMF4GCisGAQQBgjcCAQwxUDBOoEiARgBCAHUAaQBsAHQAOgAgAFIAZQBs -# AGUAYQBzAGUAXwB2ADMALgAxADIALgAxADAAXwAyADAAMgA1ADAANAAwADgALgAw -# ADKhAoAAMA0GCSqGSIb3DQEBAQUABIIBgE9xMVem4h5iAbvBzmB1pTdA4LYNkvd/ -# hSbYmJRt5oJqBR0RGbUmcfYAgTlhdb/S84aGvI3N62I8qeMApnH89q+UF0i8p6+U -# Qza6Mu1cAHCq0NkHH6+N8g7nIfe5Cn+BBCBJ6kuYfQm9bx1JwEm5/yVCwG9I6+XV -# 3WonOeA8djuZFfB9OIW6N9ubX7X+nYqWaeT6w6/lDs8mL+s0Fumy4mJ8B15pd9mr -# N6dIRFokzhuALq6G0USKFzYf3qJQ4GyCos/Luez3cr8sE/78ds6vah5IlLP6qXMM -# ETwAdoymIYSm3Dly3lflodd4d7/nkMhfHITOxSUDoBbCP6MO1rhChX591rJy/omK -# 0RdM9ZpMl6VXHhzZ+lB8U/6j7xJGlxJSJHet7HFEuTnJEjY9dDy2bUgzk0vK1Rs2 -# l7VLOP3X87p9iVz5vDAOQB0fcsMDJvhIzJlmIb5z2uZ6hqD4UZdTDMLIBWe9H7Kv -# rhmGDPHPRboFKtTrKoKcWaf4fJJ2NUtYlKGCFKAwghScBgorBgEEAYI3AwMBMYIU -# jDCCFIgGCSqGSIb3DQEHAqCCFHkwghR1AgEDMQ8wDQYJYIZIAWUDBAIBBQAwggFh -# BgsqhkiG9w0BCRABBKCCAVAEggFMMIIBSAIBAQYKKwYBBAGEWQoDATAxMA0GCWCG -# SAFlAwQCAQUABCAY3nVyqXzzboHwsVGd+j5FjG9eaMv+O3mJKpX+3EJ43AIGZ9gU -# uyvYGBMyMDI1MDQwODEyNDEyMi40MTNaMASAAgH0oIHgpIHdMIHaMQswCQYDVQQG -# EwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwG -# A1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQg -# QW1lcmljYSBPcGVyYXRpb25zMSYwJAYDVQQLEx1UaGFsZXMgVFNTIEVTTjozREE1 -# LTk2M0ItRTFGNDE1MDMGA1UEAxMsTWljcm9zb2Z0IFB1YmxpYyBSU0EgVGltZSBT -# dGFtcGluZyBBdXRob3JpdHmggg8gMIIHgjCCBWqgAwIBAgITMwAAAAXlzw//Zi7J -# hwAAAAAABTANBgkqhkiG9w0BAQwFADB3MQswCQYDVQQGEwJVUzEeMBwGA1UEChMV -# TWljcm9zb2Z0IENvcnBvcmF0aW9uMUgwRgYDVQQDEz9NaWNyb3NvZnQgSWRlbnRp -# dHkgVmVyaWZpY2F0aW9uIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMjAw -# HhcNMjAxMTE5MjAzMjMxWhcNMzUxMTE5MjA0MjMxWjBhMQswCQYDVQQGEwJVUzEe -# MBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3Nv -# ZnQgUHVibGljIFJTQSBUaW1lc3RhbXBpbmcgQ0EgMjAyMDCCAiIwDQYJKoZIhvcN -# AQEBBQADggIPADCCAgoCggIBAJ5851Jj/eDFnwV9Y7UGIqMcHtfnlzPREwW9ZUZH -# d5HBXXBvf7KrQ5cMSqFSHGqg2/qJhYqOQxwuEQXG8kB41wsDJP5d0zmLYKAY8Zxv -# 3lYkuLDsfMuIEqvGYOPURAH+Ybl4SJEESnt0MbPEoKdNihwM5xGv0rGofJ1qOYST -# Ncc55EbBT7uq3wx3mXhtVmtcCEr5ZKTkKKE1CxZvNPWdGWJUPC6e4uRfWHIhZcgC -# sJ+sozf5EeH5KrlFnxpjKKTavwfFP6XaGZGWUG8TZaiTogRoAlqcevbiqioUz1Yt -# 4FRK53P6ovnUfANjIgM9JDdJ4e0qiDRm5sOTiEQtBLGd9Vhd1MadxoGcHrRCsS5r -# O9yhv2fjJHrmlQ0EIXmp4DhDBieKUGR+eZ4CNE3ctW4uvSDQVeSp9h1SaPV8UWEf -# yTxgGjOsRpeexIveR1MPTVf7gt8hY64XNPO6iyUGsEgt8c2PxF87E+CO7A28TpjN -# q5eLiiunhKbq0XbjkNoU5JhtYUrlmAbpxRjb9tSreDdtACpm3rkpxp7AQndnI0Sh -# u/fk1/rE3oWsDqMX3jjv40e8KN5YsJBnczyWB4JyeeFMW3JBfdeAKhzohFe8U5w9 -# WuvcP1E8cIxLoKSDzCCBOu0hWdjzKNu8Y5SwB1lt5dQhABYyzR3dxEO/T1K/BVF3 -# rV69AgMBAAGjggIbMIICFzAOBgNVHQ8BAf8EBAMCAYYwEAYJKwYBBAGCNxUBBAMC -# AQAwHQYDVR0OBBYEFGtpKDo1L0hjQM972K9J6T7ZPdshMFQGA1UdIARNMEswSQYE -# VR0gADBBMD8GCCsGAQUFBwIBFjNodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtp -# b3BzL0RvY3MvUmVwb3NpdG9yeS5odG0wEwYDVR0lBAwwCgYIKwYBBQUHAwgwGQYJ -# KwYBBAGCNxQCBAweCgBTAHUAYgBDAEEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSME -# GDAWgBTIftJqhSobyhmYBAcnz1AQT2ioojCBhAYDVR0fBH0wezB5oHegdYZzaHR0 -# cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jcmwvTWljcm9zb2Z0JTIwSWRl -# bnRpdHklMjBWZXJpZmljYXRpb24lMjBSb290JTIwQ2VydGlmaWNhdGUlMjBBdXRo -# b3JpdHklMjAyMDIwLmNybDCBlAYIKwYBBQUHAQEEgYcwgYQwgYEGCCsGAQUFBzAC -# hnVodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRzL01pY3Jvc29m -# dCUyMElkZW50aXR5JTIwVmVyaWZpY2F0aW9uJTIwUm9vdCUyMENlcnRpZmljYXRl -# JTIwQXV0aG9yaXR5JTIwMjAyMC5jcnQwDQYJKoZIhvcNAQEMBQADggIBAF+Idsd+ -# bbVaFXXnTHho+k7h2ESZJRWluLE0Oa/pO+4ge/XEizXvhs0Y7+KVYyb4nHlugBes -# nFqBGEdC2IWmtKMyS1OWIviwpnK3aL5JedwzbeBF7POyg6IGG/XhhJ3UqWeWTO+C -# zb1c2NP5zyEh89F72u9UIw+IfvM9lzDmc2O2END7MPnrcjWdQnrLn1Ntday7JSyr -# DvBdmgbNnCKNZPmhzoa8PccOiQljjTW6GePe5sGFuRHzdFt8y+bN2neF7Zu8hTO1 -# I64XNGqst8S+w+RUdie8fXC1jKu3m9KGIqF4aldrYBamyh3g4nJPj/LR2CBaLyD+ -# 2BuGZCVmoNR/dSpRCxlot0i79dKOChmoONqbMI8m04uLaEHAv4qwKHQ1vBzbV/nG -# 89LDKbRSSvijmwJwxRxLLpMQ/u4xXxFfR4f/gksSkbJp7oqLwliDm/h+w0aJ/U5c -# cnYhYb7vPKNMN+SZDWycU5ODIRfyoGl59BsXR/HpRGtiJquOYGmvA/pk5vC1lcnb -# eMrcWD/26ozePQ/TWfNXKBOmkFpvPE8CH+EeGGWzqTCjdAsno2jzTeNSxlx3glDG -# Jgcdz5D/AAxw9Sdgq/+rY7jjgs7X6fqPTXPmaCAJKVHAP19oEjJIBwD1LyHbaEgB -# xFCogYSOiUIr0Xqcr1nJfiWG2GwYe6ZoAF1bMIIHljCCBX6gAwIBAgITMwAAAEYX -# 5HV6yv3a5QAAAAAARjANBgkqhkiG9w0BAQwFADBhMQswCQYDVQQGEwJVUzEeMBwG -# A1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQg -# UHVibGljIFJTQSBUaW1lc3RhbXBpbmcgQ0EgMjAyMDAeFw0yNDExMjYxODQ4NDla -# Fw0yNTExMTkxODQ4NDlaMIHaMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu -# Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv -# cmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMSYw -# JAYDVQQLEx1UaGFsZXMgVFNTIEVTTjozREE1LTk2M0ItRTFGNDE1MDMGA1UEAxMs -# TWljcm9zb2Z0IFB1YmxpYyBSU0EgVGltZSBTdGFtcGluZyBBdXRob3JpdHkwggIi -# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCwlXzoj/MNL1BfnV+gg4d0fZum -# 1HdUJidSNTcDzpHJvmIBqH566zBYcV0TyN7+3qOnJjpoTx6JBMgNYnL5BmTX9Hrm -# X0WdNMLf74u7NtBSuAD2sf6n2qUUrz7i8f7r0JiZixKJnkvA/1akLHppQMDCug1o -# C0AYjd753b5vy1vWdrHXE9hL71BZe5DCq5/4LBny8aOQZlzvjewgONkiZm+Sfctk -# Jjh9LxdkDlq5EvGE6YU0uC37XF7qkHvIksD2+XgBP0lEMfmPJo2fI9FwIA9YMX7K -# IINEM5OY6nkvKryM9s5bK6LV4z48NYpiI1xvH15YDps+19nHCtKMVTZdB4cYhA0d -# VqJ7dAu4VcxUwD1AEcMxWbIOR1z6OFkVY9GX5oH8k17d9t35PWfn0XuxW4SG/rim -# gtFgpE/shRsy5nMCbHyeCdW0He1plrYQqTsSHP2n/lz2DCgIlnx+uvPLVf5+JG/1 -# d1i/LdwbC2WH6UEEJyZIl3a0YwM4rdzoR+P4dO9I/2oWOxXCYqFytYdCy9ljELUw -# byLjrjRddteR8QTxrCfadKpKfFY6Ak/HNZPUHaAPak3baOIvV7Q8axo3DWQy2ib3 -# zXV6hMPNt1v90pv+q9daQdwUzUrgcbwThdrRhWHwlRIVg2sR668HPn4/8l9ikGok -# rL6gAmVxNswEZ9awCwIDAQABo4IByzCCAccwHQYDVR0OBBYEFBE20NSvdrC6Z6cm -# 6RPGP8YbqIrxMB8GA1UdIwQYMBaAFGtpKDo1L0hjQM972K9J6T7ZPdshMGwGA1Ud -# HwRlMGMwYaBfoF2GW2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY3Js -# L01pY3Jvc29mdCUyMFB1YmxpYyUyMFJTQSUyMFRpbWVzdGFtcGluZyUyMENBJTIw -# MjAyMC5jcmwweQYIKwYBBQUHAQEEbTBrMGkGCCsGAQUFBzAChl1odHRwOi8vd3d3 -# Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRzL01pY3Jvc29mdCUyMFB1YmxpYyUy -# MFJTQSUyMFRpbWVzdGFtcGluZyUyMENBJTIwMjAyMC5jcnQwDAYDVR0TAQH/BAIw -# ADAWBgNVHSUBAf8EDDAKBggrBgEFBQcDCDAOBgNVHQ8BAf8EBAMCB4AwZgYDVR0g -# BF8wXTBRBgwrBgEEAYI3TIN9AQEwQTA/BggrBgEFBQcCARYzaHR0cDovL3d3dy5t -# aWNyb3NvZnQuY29tL3BraW9wcy9Eb2NzL1JlcG9zaXRvcnkuaHRtMAgGBmeBDAEE -# AjANBgkqhkiG9w0BAQwFAAOCAgEAFIW5L+gGzX4gyHorS33YKXuK9iC91iZTpm30 -# x/EdHG6U8NAu2qityxjZVq6MDq300gspG0ntzLYqVhjfku7iNzE78k6tNgFCr9wv -# GkIHeK+Q2RAO9/s5R8rhNC+lywOB+6K5Zi0kfO0agVXf7Nk2O6F6D9AEzNLijG+c -# Oe5Ef2F5l4ZsVSkLFCI5jELC+r4KnNZjunc+qvjSz2DkNsXfrjFhyk+K7v7U7+JF -# Z8kZ58yFuxEX0cxDKpJLxiNh/ODCOL2UxYkhyfI3AR0EhfxX9QZHVgxyZwnavR35 -# FxqLSiGTeAJsK7YN3bIxyuP6eCcnkX8TMdpu9kPD97sHnM7po0UQDrjaN7etviLD -# xnax2nemdvJW3BewOLFrD1nSnd7ZHdPGPB3oWTCaK9/3XwQERLi3Xj+HZc89RP50 -# Nt7h7+3G6oq2kXYNidI9iWd+gL+lvkQZH9YTIfBCLWjvuXvUUUU+AvFI00Utqrvd -# rIdqCFaqE9HHQgSfXeQ53xLWdMCztUP/YnMXiJxNBkc6UE2px/o6+/LXJDIpwIXR -# 4HSodLfkfsNQl6FFrJ1xsOYGSHvcFkH8389RmUvrjr1NBbdesc4Bu4kox+3cabOZ -# c1zm89G+1RRL2tReFzSMlYSGO3iKn3GGXmQiRmFlBb3CpbUVQz+fgxVMfeL0j4Lm -# KQfT1jIxggPUMIID0AIBATB4MGExCzAJBgNVBAYTAlVTMR4wHAYDVQQKExVNaWNy -# b3NvZnQgQ29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBQdWJsaWMgUlNB -# IFRpbWVzdGFtcGluZyBDQSAyMDIwAhMzAAAARhfkdXrK/drlAAAAAABGMA0GCWCG -# SAFlAwQCAQUAoIIBLTAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwLwYJKoZI -# hvcNAQkEMSIEIHgwQkiMhul6IrfEKmPaCFR+R91oZOlPqVgP/9PPcfn+MIHdBgsq -# hkiG9w0BCRACLzGBzTCByjCBxzCBoAQgEid2SJpUPj5xQm73M4vqDmVh1QR6TiuT -# UVkL3P8Wis4wfDBlpGMwYTELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29m -# dCBDb3Jwb3JhdGlvbjEyMDAGA1UEAxMpTWljcm9zb2Z0IFB1YmxpYyBSU0EgVGlt -# ZXN0YW1waW5nIENBIDIwMjACEzMAAABGF+R1esr92uUAAAAAAEYwIgQgVp6I1YBM -# Mni0rCuD57vEK/tzWZypHqWFikWLFVY11RwwDQYJKoZIhvcNAQELBQAEggIAnRBH -# voM5+wbJp+aOwrrL8fi8Rv/eFV820Nhr+jMny73UscN60OWdcdcZDbjDlnDX1KEP -# sNcEOFvaruHHrF4kDK8N0yemElNz63IgqhUoGoXXQKT2RgVg7T/kiQJH7zuaEjgB -# YNniAZdXXJJ1C+uv2ZQzkGIEVIEA6pB5/xo4kFhrfkOrdGzqL8HXT/RZQDMn5Uzk -# W+Sl2JmsyYBS4sgI9Ay3qT5nv+frzngbWlqx1dre21uj37Fgk5mWHJEdmY1nqTTd -# 25j6oDLGPC8AS9wtgZBXggemKAXwyeOFFahXUFN7X7cbwTALy5aWjE/rqp+N5J7M -# +YApl3aknUZ13KTXz9pfAF0uhmZimngvBHjijyctleF8HUP2RNAhS/l68OqW7oKi -# Dqvb7tSHJbcnYkxo7dUq6ppfN51ah61ZsyMVG6SaH015+5QO1k50ohXcFff2GOuZ -# d3Z9JOoAjIkeiVTNeRlPDlHtS0CSYu4ZKsWsst+0VY2R9rJBeoii9Xa0oiIggkYL -# 1pHAPH0B1uLlvFcI6B+fAXe0OiCJodbO5lk8ZpvCG5WWYbjzp2c3B8PZGSBgEpSf -# KYlVavvBAvaJCORUO7j8PyzzDINuzQorP9+i399ORjOnqeC92Cb0V12LcoqqtJaf -# 7oSB86VOI0lfHnPUlLWvoiLHrFR5PsYkltOuPqU= -# SIG # End signature block diff --git a/src/.venv/Scripts/activate b/src/.venv/Scripts/activate deleted file mode 100644 index 603e51f..0000000 --- a/src/.venv/Scripts/activate +++ /dev/null @@ -1,76 +0,0 @@ -# This file must be used with "source bin/activate" *from bash* -# You cannot run it directly - -deactivate () { - # reset old environment variables - if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then - PATH="${_OLD_VIRTUAL_PATH:-}" - export PATH - unset _OLD_VIRTUAL_PATH - fi - if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then - PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" - export PYTHONHOME - unset _OLD_VIRTUAL_PYTHONHOME - fi - - # Call hash to forget past locations. Without forgetting - # past locations the $PATH changes we made may not be respected. - # See "man bash" for more details. hash is usually a builtin of your shell - hash -r 2> /dev/null - - if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then - PS1="${_OLD_VIRTUAL_PS1:-}" - export PS1 - unset _OLD_VIRTUAL_PS1 - fi - - unset VIRTUAL_ENV - unset VIRTUAL_ENV_PROMPT - if [ ! "${1:-}" = "nondestructive" ] ; then - # Self destruct! - unset -f deactivate - fi -} - -# unset irrelevant variables -deactivate nondestructive - -# on Windows, a path can contain colons and backslashes and has to be converted: -case "$(uname)" in - CYGWIN*|MSYS*|MINGW*) - # transform D:\path\to\venv to /d/path/to/venv on MSYS and MINGW - # and to /cygdrive/d/path/to/venv on Cygwin - VIRTUAL_ENV=$(cygpath 'D:\pycharm\djangoProject\DjangoBlog-master\.venv') - export VIRTUAL_ENV - ;; - *) - # use the path as-is - export VIRTUAL_ENV='D:\pycharm\djangoProject\DjangoBlog-master\.venv' - ;; -esac - -_OLD_VIRTUAL_PATH="$PATH" -PATH="$VIRTUAL_ENV/"Scripts":$PATH" -export PATH - -VIRTUAL_ENV_PROMPT='(.venv) ' -export VIRTUAL_ENV_PROMPT - -# unset PYTHONHOME if set -# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) -# could use `if (set -u; : $PYTHONHOME) ;` in bash -if [ -n "${PYTHONHOME:-}" ] ; then - _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" - unset PYTHONHOME -fi - -if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then - _OLD_VIRTUAL_PS1="${PS1:-}" - PS1="("'(.venv) '") ${PS1:-}" - export PS1 -fi - -# Call hash to forget past commands. Without forgetting -# past commands the $PATH changes we made may not be respected -hash -r 2> /dev/null diff --git a/src/.venv/Scripts/activate.bat b/src/.venv/Scripts/activate.bat deleted file mode 100644 index f0a9764..0000000 --- a/src/.venv/Scripts/activate.bat +++ /dev/null @@ -1,34 +0,0 @@ -@echo off - -rem This file is UTF-8 encoded, so we need to update the current code page while executing it -for /f "tokens=2 delims=:." %%a in ('"%SystemRoot%\System32\chcp.com"') do ( - set _OLD_CODEPAGE=%%a -) -if defined _OLD_CODEPAGE ( - "%SystemRoot%\System32\chcp.com" 65001 > nul -) - -set "VIRTUAL_ENV=D:\pycharm\djangoProject\DjangoBlog-master\.venv" - -if not defined PROMPT set PROMPT=$P$G - -if defined _OLD_VIRTUAL_PROMPT set PROMPT=%_OLD_VIRTUAL_PROMPT% -if defined _OLD_VIRTUAL_PYTHONHOME set PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME% - -set _OLD_VIRTUAL_PROMPT=%PROMPT% -set PROMPT=(.venv) %PROMPT% - -if defined PYTHONHOME set _OLD_VIRTUAL_PYTHONHOME=%PYTHONHOME% -set PYTHONHOME= - -if defined _OLD_VIRTUAL_PATH set PATH=%_OLD_VIRTUAL_PATH% -if not defined _OLD_VIRTUAL_PATH set _OLD_VIRTUAL_PATH=%PATH% - -set "PATH=%VIRTUAL_ENV%\Scripts;%PATH%" -set "VIRTUAL_ENV_PROMPT=(.venv) " - -:END -if defined _OLD_CODEPAGE ( - "%SystemRoot%\System32\chcp.com" %_OLD_CODEPAGE% > nul - set _OLD_CODEPAGE= -) diff --git a/src/.venv/Scripts/bottle.exe b/src/.venv/Scripts/bottle.exe deleted file mode 100644 index 430cd24..0000000 Binary files a/src/.venv/Scripts/bottle.exe and /dev/null differ diff --git a/src/.venv/Scripts/bottle.py b/src/.venv/Scripts/bottle.py deleted file mode 100644 index 0f9ba30..0000000 --- a/src/.venv/Scripts/bottle.py +++ /dev/null @@ -1,4680 +0,0 @@ -#!D:\pycharm\djangoProject\DjangoBlog-master\.venv\Scripts\python.exe -# -*- coding: utf-8 -*- -""" -Bottle is a fast and simple micro-framework for small web applications. It -offers request dispatching (Routes) with URL parameter support, templates, -a built-in HTTP Server and adapters for many third party WSGI/HTTP-server and -template engines - all in a single file and with no dependencies other than the -Python Standard Library. - -Homepage and documentation: http://bottlepy.org/ - -Copyright (c) 2009-2024, Marcel Hellkamp. -License: MIT (see LICENSE for details) -""" - -from __future__ import print_function -import sys - -__author__ = 'Marcel Hellkamp' -__version__ = '0.13.2' -__license__ = 'MIT' - -############################################################################### -# Command-line interface ###################################################### -############################################################################### -# INFO: Some server adapters need to monkey-patch std-lib modules before they -# are imported. This is why some of the command-line handling is done here, but -# the actual call to _main() is at the end of the file. - - -def _cli_parse(args): # pragma: no coverage - from argparse import ArgumentParser - - parser = ArgumentParser(prog=args[0], usage="%(prog)s [options] package.module:app") - opt = parser.add_argument - opt("--version", action="store_true", help="show version number.") - opt("-b", "--bind", metavar="ADDRESS", help="bind socket to ADDRESS.") - opt("-s", "--server", default='wsgiref', help="use SERVER as backend.") - opt("-p", "--plugin", action="append", help="install additional plugin/s.") - opt("-c", "--conf", action="append", metavar="FILE", - help="load config values from FILE.") - opt("-C", "--param", action="append", metavar="NAME=VALUE", - help="override config values.") - opt("--debug", action="store_true", help="start server in debug mode.") - opt("--reload", action="store_true", help="auto-reload on file changes.") - opt('app', help='WSGI app entry point.', nargs='?') - - cli_args = parser.parse_args(args[1:]) - - return cli_args, parser - - -def _cli_patch(cli_args): # pragma: no coverage - parsed_args, _ = _cli_parse(cli_args) - opts = parsed_args - if opts.server: - if opts.server.startswith('gevent'): - import gevent.monkey - gevent.monkey.patch_all() - elif opts.server.startswith('eventlet'): - import eventlet - eventlet.monkey_patch() - - -if __name__ == '__main__': - _cli_patch(sys.argv) - -############################################################################### -# Imports and Python 2/3 unification ########################################## -############################################################################### - -import base64, calendar, email.utils, functools, hmac, itertools,\ - mimetypes, os, re, tempfile, threading, time, warnings, weakref, hashlib - -from types import FunctionType -from datetime import date as datedate, datetime, timedelta -from tempfile import NamedTemporaryFile -from traceback import format_exc, print_exc -from unicodedata import normalize - -try: - from ujson import dumps as json_dumps, loads as json_lds -except ImportError: - from json import dumps as json_dumps, loads as json_lds - -py = sys.version_info -py3k = py.major > 2 - -# Lots of stdlib and builtin differences. -if py3k: - import http.client as httplib - import _thread as thread - from urllib.parse import urljoin, SplitResult as UrlSplitResult - from urllib.parse import urlencode, quote as urlquote, unquote as urlunquote - urlunquote = functools.partial(urlunquote, encoding='latin1') - from http.cookies import SimpleCookie, Morsel, CookieError - from collections.abc import MutableMapping as DictMixin - from types import ModuleType as new_module - import pickle - from io import BytesIO - import configparser - from datetime import timezone - UTC = timezone.utc - # getfullargspec was deprecated in 3.5 and un-deprecated in 3.6 - # getargspec was deprecated in 3.0 and removed in 3.11 - from inspect import getfullargspec - def getargspec(func): - spec = getfullargspec(func) - kwargs = makelist(spec[0]) + makelist(spec.kwonlyargs) - return kwargs, spec[1], spec[2], spec[3] - - basestring = str - unicode = str - json_loads = lambda s: json_lds(touni(s)) - callable = lambda x: hasattr(x, '__call__') - imap = map - - def _raise(*a): - raise a[0](a[1]).with_traceback(a[2]) -else: # 2.x - warnings.warn("Python 2 support will be dropped in Bottle 0.14", DeprecationWarning) - import httplib - import thread - from urlparse import urljoin, SplitResult as UrlSplitResult - from urllib import urlencode, quote as urlquote, unquote as urlunquote - from Cookie import SimpleCookie, Morsel, CookieError - from itertools import imap - import cPickle as pickle - from imp import new_module - from StringIO import StringIO as BytesIO - import ConfigParser as configparser - from collections import MutableMapping as DictMixin - from inspect import getargspec - from datetime import tzinfo - - class _UTC(tzinfo): - def utcoffset(self, dt): return timedelta(0) - def tzname(self, dt): return "UTC" - def dst(self, dt): return timedelta(0) - UTC = _UTC() - - unicode = unicode - json_loads = json_lds - - exec(compile('def _raise(*a): raise a[0], a[1], a[2]', '', 'exec')) - -# Some helpers for string/byte handling -def tob(s, enc='utf8'): - if isinstance(s, unicode): - return s.encode(enc) - return b'' if s is None else bytes(s) - - -def touni(s, enc='utf8', err='strict'): - if isinstance(s, bytes): - return s.decode(enc, err) - return unicode("" if s is None else s) - - -tonat = touni if py3k else tob - - -def _stderr(*args): - try: - print(*args, file=sys.stderr) - except (IOError, AttributeError): - pass # Some environments do not allow printing (mod_wsgi) - - -# A bug in functools causes it to break if the wrapper is an instance method -def update_wrapper(wrapper, wrapped, *a, **ka): - try: - functools.update_wrapper(wrapper, wrapped, *a, **ka) - except AttributeError: - pass - -# These helpers are used at module level and need to be defined first. -# And yes, I know PEP-8, but sometimes a lower-case classname makes more sense. - - -def depr(major, minor, cause, fix, stacklevel=3): - text = "Warning: Use of deprecated feature or API. (Deprecated in Bottle-%d.%d)\n"\ - "Cause: %s\n"\ - "Fix: %s\n" % (major, minor, cause, fix) - if DEBUG == 'strict': - raise DeprecationWarning(text) - warnings.warn(text, DeprecationWarning, stacklevel=stacklevel) - return DeprecationWarning(text) - - -def makelist(data): # This is just too handy - if isinstance(data, (tuple, list, set, dict)): - return list(data) - elif data: - return [data] - else: - return [] - - -class DictProperty(object): - """ Property that maps to a key in a local dict-like attribute. """ - - def __init__(self, attr, key=None, read_only=False): - self.attr, self.key, self.read_only = attr, key, read_only - - def __call__(self, func): - functools.update_wrapper(self, func, updated=[]) - self.getter, self.key = func, self.key or func.__name__ - return self - - def __get__(self, obj, cls): - if obj is None: return self - key, storage = self.key, getattr(obj, self.attr) - if key not in storage: storage[key] = self.getter(obj) - return storage[key] - - def __set__(self, obj, value): - if self.read_only: raise AttributeError("Read-Only property.") - getattr(obj, self.attr)[self.key] = value - - def __delete__(self, obj): - if self.read_only: raise AttributeError("Read-Only property.") - del getattr(obj, self.attr)[self.key] - - -class cached_property(object): - """ A property that is only computed once per instance and then replaces - itself with an ordinary attribute. Deleting the attribute resets the - property. """ - - def __init__(self, func): - update_wrapper(self, func) - self.func = func - - def __get__(self, obj, cls): - if obj is None: return self - value = obj.__dict__[self.func.__name__] = self.func(obj) - return value - - -class lazy_attribute(object): - """ A property that caches itself to the class object. """ - - def __init__(self, func): - functools.update_wrapper(self, func, updated=[]) - self.getter = func - - def __get__(self, obj, cls): - value = self.getter(cls) - setattr(cls, self.__name__, value) - return value - - -############################################################################### -# Exceptions and Events ####################################################### -############################################################################### - - -class BottleException(Exception): - """ A base class for exceptions used by bottle. """ - pass - -############################################################################### -# Routing ###################################################################### -############################################################################### - - -class RouteError(BottleException): - """ This is a base class for all routing related exceptions """ - - -class RouteReset(BottleException): - """ If raised by a plugin or request handler, the route is reset and all - plugins are re-applied. """ - - -class RouterUnknownModeError(RouteError): - - pass - - -class RouteSyntaxError(RouteError): - """ The route parser found something not supported by this router. """ - - -class RouteBuildError(RouteError): - """ The route could not be built. """ - - -def _re_flatten(p): - """ Turn all capturing groups in a regular expression pattern into - non-capturing groups. """ - if '(' not in p: - return p - return re.sub(r'(\\*)(\(\?P<[^>]+>|\((?!\?))', lambda m: m.group(0) if - len(m.group(1)) % 2 else m.group(1) + '(?:', p) - - -class Router(object): - """ A Router is an ordered collection of route->target pairs. It is used to - efficiently match WSGI requests against a number of routes and return - the first target that satisfies the request. The target may be anything, - usually a string, ID or callable object. A route consists of a path-rule - and a HTTP method. - - The path-rule is either a static path (e.g. `/contact`) or a dynamic - path that contains wildcards (e.g. `/wiki/`). The wildcard syntax - and details on the matching order are described in docs:`routing`. - """ - - default_pattern = '[^/]+' - default_filter = 're' - - #: The current CPython regexp implementation does not allow more - #: than 99 matching groups per regular expression. - _MAX_GROUPS_PER_PATTERN = 99 - - def __init__(self, strict=False): - self.rules = [] # All rules in order - self._groups = {} # index of regexes to find them in dyna_routes - self.builder = {} # Data structure for the url builder - self.static = {} # Search structure for static routes - self.dyna_routes = {} - self.dyna_regexes = {} # Search structure for dynamic routes - #: If true, static routes are no longer checked first. - self.strict_order = strict - self.filters = { - 're': lambda conf: (_re_flatten(conf or self.default_pattern), - None, None), - 'int': lambda conf: (r'-?\d+', int, lambda x: str(int(x))), - 'float': lambda conf: (r'-?[\d.]+', float, lambda x: str(float(x))), - 'path': lambda conf: (r'.+?', None, None) - } - - def add_filter(self, name, func): - """ Add a filter. The provided function is called with the configuration - string as parameter and must return a (regexp, to_python, to_url) tuple. - The first element is a string, the last two are callables or None. """ - self.filters[name] = func - - rule_syntax = re.compile('(\\\\*)' - '(?:(?::([a-zA-Z_][a-zA-Z_0-9]*)?()(?:#(.*?)#)?)' - '|(?:<([a-zA-Z_][a-zA-Z_0-9]*)?(?::([a-zA-Z_]*)' - '(?::((?:\\\\.|[^\\\\>])+)?)?)?>))') - - def _itertokens(self, rule): - offset, prefix = 0, '' - for match in self.rule_syntax.finditer(rule): - prefix += rule[offset:match.start()] - g = match.groups() - if g[2] is not None: - depr(0, 13, "Use of old route syntax.", - "Use instead of :name in routes.", - stacklevel=4) - if len(g[0]) % 2: # Escaped wildcard - prefix += match.group(0)[len(g[0]):] - offset = match.end() - continue - if prefix: - yield prefix, None, None - name, filtr, conf = g[4:7] if g[2] is None else g[1:4] - yield name, filtr or 'default', conf or None - offset, prefix = match.end(), '' - if offset <= len(rule) or prefix: - yield prefix + rule[offset:], None, None - - def add(self, rule, method, target, name=None): - """ Add a new rule or replace the target for an existing rule. """ - anons = 0 # Number of anonymous wildcards found - keys = [] # Names of keys - pattern = '' # Regular expression pattern with named groups - filters = [] # Lists of wildcard input filters - builder = [] # Data structure for the URL builder - is_static = True - - for key, mode, conf in self._itertokens(rule): - if mode: - is_static = False - if mode == 'default': mode = self.default_filter - mask, in_filter, out_filter = self.filters[mode](conf) - if not key: - pattern += '(?:%s)' % mask - key = 'anon%d' % anons - anons += 1 - else: - pattern += '(?P<%s>%s)' % (key, mask) - keys.append(key) - if in_filter: filters.append((key, in_filter)) - builder.append((key, out_filter or str)) - elif key: - pattern += re.escape(key) - builder.append((None, key)) - - self.builder[rule] = builder - if name: self.builder[name] = builder - - if is_static and not self.strict_order: - self.static.setdefault(method, {}) - self.static[method][self.build(rule)] = (target, None) - return - - try: - re_pattern = re.compile('^(%s)$' % pattern) - re_match = re_pattern.match - except re.error as e: - raise RouteSyntaxError("Could not add Route: %s (%s)" % (rule, e)) - - if filters: - - def getargs(path): - url_args = re_match(path).groupdict() - for name, wildcard_filter in filters: - try: - url_args[name] = wildcard_filter(url_args[name]) - except ValueError: - raise HTTPError(400, 'Path has wrong format.') - return url_args - elif re_pattern.groupindex: - - def getargs(path): - return re_match(path).groupdict() - else: - getargs = None - - flatpat = _re_flatten(pattern) - whole_rule = (rule, flatpat, target, getargs) - - if (flatpat, method) in self._groups: - if DEBUG: - msg = 'Route <%s %s> overwrites a previously defined route' - warnings.warn(msg % (method, rule), RuntimeWarning, stacklevel=3) - self.dyna_routes[method][ - self._groups[flatpat, method]] = whole_rule - else: - self.dyna_routes.setdefault(method, []).append(whole_rule) - self._groups[flatpat, method] = len(self.dyna_routes[method]) - 1 - - self._compile(method) - - def _compile(self, method): - all_rules = self.dyna_routes[method] - comborules = self.dyna_regexes[method] = [] - maxgroups = self._MAX_GROUPS_PER_PATTERN - for x in range(0, len(all_rules), maxgroups): - some = all_rules[x:x + maxgroups] - combined = (flatpat for (_, flatpat, _, _) in some) - combined = '|'.join('(^%s$)' % flatpat for flatpat in combined) - combined = re.compile(combined).match - rules = [(target, getargs) for (_, _, target, getargs) in some] - comborules.append((combined, rules)) - - def build(self, _name, *anons, **query): - """ Build an URL by filling the wildcards in a rule. """ - builder = self.builder.get(_name) - if not builder: - raise RouteBuildError("No route with that name.", _name) - try: - for i, value in enumerate(anons): - query['anon%d' % i] = value - url = ''.join([f(query.pop(n)) if n else f for (n, f) in builder]) - return url if not query else url + '?' + urlencode(query) - except KeyError as E: - raise RouteBuildError('Missing URL argument: %r' % E.args[0]) - - def match(self, environ): - """ Return a (target, url_args) tuple or raise HTTPError(400/404/405). """ - verb = environ['REQUEST_METHOD'].upper() - path = environ['PATH_INFO'] or '/' - - methods = ('PROXY', 'HEAD', 'GET', 'ANY') if verb == 'HEAD' else ('PROXY', verb, 'ANY') - - for method in methods: - if method in self.static and path in self.static[method]: - target, getargs = self.static[method][path] - return target, getargs(path) if getargs else {} - elif method in self.dyna_regexes: - for combined, rules in self.dyna_regexes[method]: - match = combined(path) - if match: - target, getargs = rules[match.lastindex - 1] - return target, getargs(path) if getargs else {} - - # No matching route found. Collect alternative methods for 405 response - allowed = set([]) - nocheck = set(methods) - for method in set(self.static) - nocheck: - if path in self.static[method]: - allowed.add(method) - for method in set(self.dyna_regexes) - allowed - nocheck: - for combined, rules in self.dyna_regexes[method]: - match = combined(path) - if match: - allowed.add(method) - if allowed: - allow_header = ",".join(sorted(allowed)) - raise HTTPError(405, "Method not allowed.", Allow=allow_header) - - # No matching route and no alternative method found. We give up - raise HTTPError(404, "Not found: " + repr(path)) - - -class Route(object): - """ This class wraps a route callback along with route specific metadata and - configuration and applies Plugins on demand. It is also responsible for - turning an URL path rule into a regular expression usable by the Router. - """ - - def __init__(self, app, rule, method, callback, - name=None, - plugins=None, - skiplist=None, **config): - #: The application this route is installed to. - self.app = app - #: The path-rule string (e.g. ``/wiki/``). - self.rule = rule - #: The HTTP method as a string (e.g. ``GET``). - self.method = method - #: The original callback with no plugins applied. Useful for introspection. - self.callback = callback - #: The name of the route (if specified) or ``None``. - self.name = name or None - #: A list of route-specific plugins (see :meth:`Bottle.route`). - self.plugins = plugins or [] - #: A list of plugins to not apply to this route (see :meth:`Bottle.route`). - self.skiplist = skiplist or [] - #: Additional keyword arguments passed to the :meth:`Bottle.route` - #: decorator are stored in this dictionary. Used for route-specific - #: plugin configuration and meta-data. - self.config = app.config._make_overlay() - self.config.load_dict(config) - - @cached_property - def call(self): - """ The route callback with all plugins applied. This property is - created on demand and then cached to speed up subsequent requests.""" - return self._make_callback() - - def reset(self): - """ Forget any cached values. The next time :attr:`call` is accessed, - all plugins are re-applied. """ - self.__dict__.pop('call', None) - - def prepare(self): - """ Do all on-demand work immediately (useful for debugging).""" - self.call - - def all_plugins(self): - """ Yield all Plugins affecting this route. """ - unique = set() - for p in reversed(self.app.plugins + self.plugins): - if True in self.skiplist: break - name = getattr(p, 'name', False) - if name and (name in self.skiplist or name in unique): continue - if p in self.skiplist or type(p) in self.skiplist: continue - if name: unique.add(name) - yield p - - def _make_callback(self): - callback = self.callback - for plugin in self.all_plugins(): - try: - if hasattr(plugin, 'apply'): - callback = plugin.apply(callback, self) - else: - callback = plugin(callback) - except RouteReset: # Try again with changed configuration. - return self._make_callback() - if callback is not self.callback: - update_wrapper(callback, self.callback) - return callback - - def get_undecorated_callback(self): - """ Return the callback. If the callback is a decorated function, try to - recover the original function. """ - func = self.callback - func = getattr(func, '__func__' if py3k else 'im_func', func) - closure_attr = '__closure__' if py3k else 'func_closure' - while hasattr(func, closure_attr) and getattr(func, closure_attr): - attributes = getattr(func, closure_attr) - func = attributes[0].cell_contents - - # in case of decorators with multiple arguments - if not isinstance(func, FunctionType): - # pick first FunctionType instance from multiple arguments - func = filter(lambda x: isinstance(x, FunctionType), - map(lambda x: x.cell_contents, attributes)) - func = list(func)[0] # py3 support - return func - - def get_callback_args(self): - """ Return a list of argument names the callback (most likely) accepts - as keyword arguments. If the callback is a decorated function, try - to recover the original function before inspection. """ - return getargspec(self.get_undecorated_callback())[0] - - def get_config(self, key, default=None): - """ Lookup a config field and return its value, first checking the - route.config, then route.app.config.""" - depr(0, 13, "Route.get_config() is deprecated.", - "The Route.config property already includes values from the" - " application config for missing keys. Access it directly.") - return self.config.get(key, default) - - def __repr__(self): - cb = self.get_undecorated_callback() - return '<%s %s -> %s:%s>' % (self.method, self.rule, cb.__module__, cb.__name__) - -############################################################################### -# Application Object ########################################################### -############################################################################### - - -class Bottle(object): - """ Each Bottle object represents a single, distinct web application and - consists of routes, callbacks, plugins, resources and configuration. - Instances are callable WSGI applications. - - :param catchall: If true (default), handle all exceptions. Turn off to - let debugging middleware handle exceptions. - """ - - @lazy_attribute - def _global_config(cls): - cfg = ConfigDict() - cfg.meta_set('catchall', 'validate', bool) - return cfg - - def __init__(self, **kwargs): - #: A :class:`ConfigDict` for app specific configuration. - self.config = self._global_config._make_overlay() - self.config._add_change_listener( - functools.partial(self.trigger_hook, 'config')) - - self.config.update({ - "catchall": True - }) - - if kwargs.get('catchall') is False: - depr(0, 13, "Bottle(catchall) keyword argument.", - "The 'catchall' setting is now part of the app " - "configuration. Fix: `app.config['catchall'] = False`") - self.config['catchall'] = False - if kwargs.get('autojson') is False: - depr(0, 13, "Bottle(autojson) keyword argument.", - "The 'autojson' setting is now part of the app " - "configuration. Fix: `app.config['json.enable'] = False`") - self.config['json.disable'] = True - - self._mounts = [] - - #: A :class:`ResourceManager` for application files - self.resources = ResourceManager() - - self.routes = [] # List of installed :class:`Route` instances. - self.router = Router() # Maps requests to :class:`Route` instances. - self.error_handler = {} - - # Core plugins - self.plugins = [] # List of installed plugins. - self.install(JSONPlugin()) - self.install(TemplatePlugin()) - - #: If true, most exceptions are caught and returned as :exc:`HTTPError` - catchall = DictProperty('config', 'catchall') - - __hook_names = 'before_request', 'after_request', 'app_reset', 'config' - __hook_reversed = {'after_request'} - - @cached_property - def _hooks(self): - return dict((name, []) for name in self.__hook_names) - - def add_hook(self, name, func): - """ Attach a callback to a hook. Three hooks are currently implemented: - - before_request - Executed once before each request. The request context is - available, but no routing has happened yet. - after_request - Executed once after each request regardless of its outcome. - app_reset - Called whenever :meth:`Bottle.reset` is called. - """ - if name in self.__hook_reversed: - self._hooks[name].insert(0, func) - else: - self._hooks[name].append(func) - - def remove_hook(self, name, func): - """ Remove a callback from a hook. """ - if name in self._hooks and func in self._hooks[name]: - self._hooks[name].remove(func) - return True - - def trigger_hook(self, __name, *args, **kwargs): - """ Trigger a hook and return a list of results. """ - return [hook(*args, **kwargs) for hook in self._hooks[__name][:]] - - def hook(self, name): - """ Return a decorator that attaches a callback to a hook. See - :meth:`add_hook` for details.""" - - def decorator(func): - self.add_hook(name, func) - return func - - return decorator - - def _mount_wsgi(self, prefix, app, **options): - segments = [p for p in prefix.split('/') if p] - if not segments: - raise ValueError('WSGI applications cannot be mounted to "/".') - path_depth = len(segments) - - def mountpoint_wrapper(): - try: - request.path_shift(path_depth) - rs = HTTPResponse([]) - - def start_response(status, headerlist, exc_info=None): - if exc_info: - _raise(*exc_info) - if py3k: - # Errors here mean that the mounted WSGI app did not - # follow PEP-3333 (which requires latin1) or used a - # pre-encoding other than utf8 :/ - status = status.encode('latin1').decode('utf8') - headerlist = [(k, v.encode('latin1').decode('utf8')) - for (k, v) in headerlist] - rs.status = status - for name, value in headerlist: - rs.add_header(name, value) - return rs.body.append - - body = app(request.environ, start_response) - rs.body = itertools.chain(rs.body, body) if rs.body else body - return rs - finally: - request.path_shift(-path_depth) - - options.setdefault('skip', True) - options.setdefault('method', 'PROXY') - options.setdefault('mountpoint', {'prefix': prefix, 'target': app}) - options['callback'] = mountpoint_wrapper - - self.route('/%s/<:re:.*>' % '/'.join(segments), **options) - if not prefix.endswith('/'): - self.route('/' + '/'.join(segments), **options) - - def _mount_app(self, prefix, app, **options): - if app in self._mounts or '_mount.app' in app.config: - depr(0, 13, "Application mounted multiple times. Falling back to WSGI mount.", - "Clone application before mounting to a different location.") - return self._mount_wsgi(prefix, app, **options) - - if options: - depr(0, 13, "Unsupported mount options. Falling back to WSGI mount.", - "Do not specify any route options when mounting bottle application.") - return self._mount_wsgi(prefix, app, **options) - - if not prefix.endswith("/"): - depr(0, 13, "Prefix must end in '/'. Falling back to WSGI mount.", - "Consider adding an explicit redirect from '/prefix' to '/prefix/' in the parent application.") - return self._mount_wsgi(prefix, app, **options) - - self._mounts.append(app) - app.config['_mount.prefix'] = prefix - app.config['_mount.app'] = self - for route in app.routes: - route.rule = prefix + route.rule.lstrip('/') - self.add_route(route) - - def mount(self, prefix, app, **options): - """ Mount an application (:class:`Bottle` or plain WSGI) to a specific - URL prefix. Example:: - - parent_app.mount('/prefix/', child_app) - - :param prefix: path prefix or `mount-point`. - :param app: an instance of :class:`Bottle` or a WSGI application. - - Plugins from the parent application are not applied to the routes - of the mounted child application. If you need plugins in the child - application, install them separately. - - While it is possible to use path wildcards within the prefix path - (:class:`Bottle` childs only), it is highly discouraged. - - The prefix path must end with a slash. If you want to access the - root of the child application via `/prefix` in addition to - `/prefix/`, consider adding a route with a 307 redirect to the - parent application. - """ - - if not prefix.startswith('/'): - raise ValueError("Prefix must start with '/'") - - if isinstance(app, Bottle): - return self._mount_app(prefix, app, **options) - else: - return self._mount_wsgi(prefix, app, **options) - - def merge(self, routes): - """ Merge the routes of another :class:`Bottle` application or a list of - :class:`Route` objects into this application. The routes keep their - 'owner', meaning that the :data:`Route.app` attribute is not - changed. """ - if isinstance(routes, Bottle): - routes = routes.routes - for route in routes: - self.add_route(route) - - def install(self, plugin): - """ Add a plugin to the list of plugins and prepare it for being - applied to all routes of this application. A plugin may be a simple - decorator or an object that implements the :class:`Plugin` API. - """ - if hasattr(plugin, 'setup'): plugin.setup(self) - if not callable(plugin) and not hasattr(plugin, 'apply'): - raise TypeError("Plugins must be callable or implement .apply()") - self.plugins.append(plugin) - self.reset() - return plugin - - def uninstall(self, plugin): - """ Uninstall plugins. Pass an instance to remove a specific plugin, a type - object to remove all plugins that match that type, a string to remove - all plugins with a matching ``name`` attribute or ``True`` to remove all - plugins. Return the list of removed plugins. """ - removed, remove = [], plugin - for i, plugin in list(enumerate(self.plugins))[::-1]: - if remove is True or remove is plugin or remove is type(plugin) \ - or getattr(plugin, 'name', True) == remove: - removed.append(plugin) - del self.plugins[i] - if hasattr(plugin, 'close'): plugin.close() - if removed: self.reset() - return removed - - def reset(self, route=None): - """ Reset all routes (force plugins to be re-applied) and clear all - caches. If an ID or route object is given, only that specific route - is affected. """ - if route is None: routes = self.routes - elif isinstance(route, Route): routes = [route] - else: routes = [self.routes[route]] - for route in routes: - route.reset() - if DEBUG: - for route in routes: - route.prepare() - self.trigger_hook('app_reset') - - def close(self): - """ Close the application and all installed plugins. """ - for plugin in self.plugins: - if hasattr(plugin, 'close'): plugin.close() - - def run(self, **kwargs): - """ Calls :func:`run` with the same parameters. """ - run(self, **kwargs) - - def match(self, environ): - """ Search for a matching route and return a (:class:`Route`, urlargs) - tuple. The second value is a dictionary with parameters extracted - from the URL. Raise :exc:`HTTPError` (404/405) on a non-match.""" - return self.router.match(environ) - - def get_url(self, routename, **kargs): - """ Return a string that matches a named route """ - scriptname = request.environ.get('SCRIPT_NAME', '').strip('/') + '/' - location = self.router.build(routename, **kargs).lstrip('/') - return urljoin(urljoin('/', scriptname), location) - - def add_route(self, route): - """ Add a route object, but do not change the :data:`Route.app` - attribute.""" - self.routes.append(route) - self.router.add(route.rule, route.method, route, name=route.name) - if DEBUG: route.prepare() - - def route(self, - path=None, - method='GET', - callback=None, - name=None, - apply=None, - skip=None, **config): - """ A decorator to bind a function to a request URL. Example:: - - @app.route('/hello/') - def hello(name): - return 'Hello %s' % name - - The ```` part is a wildcard. See :class:`Router` for syntax - details. - - :param path: Request path or a list of paths to listen to. If no - path is specified, it is automatically generated from the - signature of the function. - :param method: HTTP method (`GET`, `POST`, `PUT`, ...) or a list of - methods to listen to. (default: `GET`) - :param callback: An optional shortcut to avoid the decorator - syntax. ``route(..., callback=func)`` equals ``route(...)(func)`` - :param name: The name for this route. (default: None) - :param apply: A decorator or plugin or a list of plugins. These are - applied to the route callback in addition to installed plugins. - :param skip: A list of plugins, plugin classes or names. Matching - plugins are not installed to this route. ``True`` skips all. - - Any additional keyword arguments are stored as route-specific - configuration and passed to plugins (see :meth:`Plugin.apply`). - """ - if callable(path): path, callback = None, path - plugins = makelist(apply) - skiplist = makelist(skip) - - def decorator(callback): - if isinstance(callback, basestring): callback = load(callback) - for rule in makelist(path) or yieldroutes(callback): - for verb in makelist(method): - verb = verb.upper() - route = Route(self, rule, verb, callback, - name=name, - plugins=plugins, - skiplist=skiplist, **config) - self.add_route(route) - return callback - - return decorator(callback) if callback else decorator - - def get(self, path=None, method='GET', **options): - """ Equals :meth:`route`. """ - return self.route(path, method, **options) - - def post(self, path=None, method='POST', **options): - """ Equals :meth:`route` with a ``POST`` method parameter. """ - return self.route(path, method, **options) - - def put(self, path=None, method='PUT', **options): - """ Equals :meth:`route` with a ``PUT`` method parameter. """ - return self.route(path, method, **options) - - def delete(self, path=None, method='DELETE', **options): - """ Equals :meth:`route` with a ``DELETE`` method parameter. """ - return self.route(path, method, **options) - - def patch(self, path=None, method='PATCH', **options): - """ Equals :meth:`route` with a ``PATCH`` method parameter. """ - return self.route(path, method, **options) - - def error(self, code=500, callback=None): - """ Register an output handler for a HTTP error code. Can - be used as a decorator or called directly :: - - def error_handler_500(error): - return 'error_handler_500' - - app.error(code=500, callback=error_handler_500) - - @app.error(404) - def error_handler_404(error): - return 'error_handler_404' - - """ - - def decorator(callback): - if isinstance(callback, basestring): callback = load(callback) - self.error_handler[int(code)] = callback - return callback - - return decorator(callback) if callback else decorator - - def default_error_handler(self, res): - return tob(template(ERROR_PAGE_TEMPLATE, e=res, template_settings=dict(name='__ERROR_PAGE_TEMPLATE'))) - - def _handle(self, environ): - path = environ['bottle.raw_path'] = environ['PATH_INFO'] - if py3k: - environ['PATH_INFO'] = path.encode('latin1').decode('utf8', 'ignore') - - environ['bottle.app'] = self - request.bind(environ) - response.bind() - - try: - while True: # Remove in 0.14 together with RouteReset - out = None - try: - self.trigger_hook('before_request') - route, args = self.router.match(environ) - environ['route.handle'] = route - environ['bottle.route'] = route - environ['route.url_args'] = args - out = route.call(**args) - break - except HTTPResponse as E: - out = E - break - except RouteReset: - depr(0, 13, "RouteReset exception deprecated", - "Call route.call() after route.reset() and " - "return the result.") - route.reset() - continue - finally: - if isinstance(out, HTTPResponse): - out.apply(response) - try: - self.trigger_hook('after_request') - except HTTPResponse as E: - out = E - out.apply(response) - except (KeyboardInterrupt, SystemExit, MemoryError): - raise - except Exception as E: - if not self.catchall: raise - stacktrace = format_exc() - environ['wsgi.errors'].write(stacktrace) - environ['wsgi.errors'].flush() - environ['bottle.exc_info'] = sys.exc_info() - out = HTTPError(500, "Internal Server Error", E, stacktrace) - out.apply(response) - - return out - - def _cast(self, out, peek=None): - """ Try to convert the parameter into something WSGI compatible and set - correct HTTP headers when possible. - Support: False, str, unicode, dict, HTTPResponse, HTTPError, file-like, - iterable of strings and iterable of unicodes - """ - - # Empty output is done here - if not out: - if 'Content-Length' not in response: - response['Content-Length'] = 0 - return [] - # Join lists of byte or unicode strings. Mixed lists are NOT supported - if isinstance(out, (tuple, list))\ - and isinstance(out[0], (bytes, unicode)): - out = out[0][0:0].join(out) # b'abc'[0:0] -> b'' - # Encode unicode strings - if isinstance(out, unicode): - out = out.encode(response.charset) - # Byte Strings are just returned - if isinstance(out, bytes): - if 'Content-Length' not in response: - response['Content-Length'] = len(out) - return [out] - # HTTPError or HTTPException (recursive, because they may wrap anything) - # TODO: Handle these explicitly in handle() or make them iterable. - if isinstance(out, HTTPError): - out.apply(response) - out = self.error_handler.get(out.status_code, - self.default_error_handler)(out) - return self._cast(out) - if isinstance(out, HTTPResponse): - out.apply(response) - return self._cast(out.body) - - # File-like objects. - if hasattr(out, 'read'): - if 'wsgi.file_wrapper' in request.environ: - return request.environ['wsgi.file_wrapper'](out) - elif hasattr(out, 'close') or not hasattr(out, '__iter__'): - return WSGIFileWrapper(out) - - # Handle Iterables. We peek into them to detect their inner type. - try: - iout = iter(out) - first = next(iout) - while not first: - first = next(iout) - except StopIteration: - return self._cast('') - except HTTPResponse as E: - first = E - except (KeyboardInterrupt, SystemExit, MemoryError): - raise - except Exception as error: - if not self.catchall: raise - first = HTTPError(500, 'Unhandled exception', error, format_exc()) - - # These are the inner types allowed in iterator or generator objects. - if isinstance(first, HTTPResponse): - return self._cast(first) - elif isinstance(first, bytes): - new_iter = itertools.chain([first], iout) - elif isinstance(first, unicode): - encoder = lambda x: x.encode(response.charset) - new_iter = imap(encoder, itertools.chain([first], iout)) - else: - msg = 'Unsupported response type: %s' % type(first) - return self._cast(HTTPError(500, msg)) - if hasattr(out, 'close'): - new_iter = _closeiter(new_iter, out.close) - return new_iter - - def wsgi(self, environ, start_response): - """ The bottle WSGI-interface. """ - try: - out = self._cast(self._handle(environ)) - # rfc2616 section 4.3 - if response._status_code in (100, 101, 204, 304)\ - or environ['REQUEST_METHOD'] == 'HEAD': - if hasattr(out, 'close'): out.close() - out = [] - exc_info = environ.get('bottle.exc_info') - if exc_info is not None: - del environ['bottle.exc_info'] - start_response(response._wsgi_status_line(), response.headerlist, exc_info) - return out - except (KeyboardInterrupt, SystemExit, MemoryError): - raise - except Exception as E: - if not self.catchall: raise - err = '

Critical error while processing request: %s

' \ - % html_escape(environ.get('PATH_INFO', '/')) - if DEBUG: - err += '

Error:

\n
\n%s\n
\n' \ - '

Traceback:

\n
\n%s\n
\n' \ - % (html_escape(repr(E)), html_escape(format_exc())) - environ['wsgi.errors'].write(err) - environ['wsgi.errors'].flush() - headers = [('Content-Type', 'text/html; charset=UTF-8')] - start_response('500 INTERNAL SERVER ERROR', headers, sys.exc_info()) - return [tob(err)] - - def __call__(self, environ, start_response): - """ Each instance of :class:'Bottle' is a WSGI application. """ - return self.wsgi(environ, start_response) - - def __enter__(self): - """ Use this application as default for all module-level shortcuts. """ - default_app.push(self) - return self - - def __exit__(self, exc_type, exc_value, traceback): - default_app.pop() - - def __setattr__(self, name, value): - if name in self.__dict__: - raise AttributeError("Attribute %s already defined. Plugin conflict?" % name) - object.__setattr__(self, name, value) - -############################################################################### -# HTTP and WSGI Tools ########################################################## -############################################################################### - - -class BaseRequest(object): - """ A wrapper for WSGI environment dictionaries that adds a lot of - convenient access methods and properties. Most of them are read-only. - - Adding new attributes to a request actually adds them to the environ - dictionary (as 'bottle.request.ext.'). This is the recommended - way to store and access request-specific data. - """ - - __slots__ = ('environ', ) - - #: Maximum size of memory buffer for :attr:`body` in bytes. - MEMFILE_MAX = 102400 - - def __init__(self, environ=None): - """ Wrap a WSGI environ dictionary. """ - #: The wrapped WSGI environ dictionary. This is the only real attribute. - #: All other attributes actually are read-only properties. - self.environ = {} if environ is None else environ - self.environ['bottle.request'] = self - - @DictProperty('environ', 'bottle.app', read_only=True) - def app(self): - """ Bottle application handling this request. """ - raise RuntimeError('This request is not connected to an application.') - - @DictProperty('environ', 'bottle.route', read_only=True) - def route(self): - """ The bottle :class:`Route` object that matches this request. """ - raise RuntimeError('This request is not connected to a route.') - - @DictProperty('environ', 'route.url_args', read_only=True) - def url_args(self): - """ The arguments extracted from the URL. """ - raise RuntimeError('This request is not connected to a route.') - - @property - def path(self): - """ The value of ``PATH_INFO`` with exactly one prefixed slash (to fix - broken clients and avoid the "empty path" edge case). """ - return '/' + self.environ.get('PATH_INFO', '').lstrip('/') - - @property - def method(self): - """ The ``REQUEST_METHOD`` value as an uppercase string. """ - return self.environ.get('REQUEST_METHOD', 'GET').upper() - - @DictProperty('environ', 'bottle.request.headers', read_only=True) - def headers(self): - """ A :class:`WSGIHeaderDict` that provides case-insensitive access to - HTTP request headers. """ - return WSGIHeaderDict(self.environ) - - def get_header(self, name, default=None): - """ Return the value of a request header, or a given default value. """ - return self.headers.get(name, default) - - @DictProperty('environ', 'bottle.request.cookies', read_only=True) - def cookies(self): - """ Cookies parsed into a :class:`FormsDict`. Signed cookies are NOT - decoded. Use :meth:`get_cookie` if you expect signed cookies. """ - cookies = SimpleCookie(self.environ.get('HTTP_COOKIE', '')).values() - return FormsDict((c.key, c.value) for c in cookies) - - def get_cookie(self, key, default=None, secret=None, digestmod=hashlib.sha256): - """ Return the content of a cookie. To read a `Signed Cookie`, the - `secret` must match the one used to create the cookie (see - :meth:`BaseResponse.set_cookie`). If anything goes wrong (missing - cookie or wrong signature), return a default value. """ - value = self.cookies.get(key) - if secret: - # See BaseResponse.set_cookie for details on signed cookies. - if value and value.startswith('!') and '?' in value: - sig, msg = map(tob, value[1:].split('?', 1)) - hash = hmac.new(tob(secret), msg, digestmod=digestmod).digest() - if _lscmp(sig, base64.b64encode(hash)): - dst = pickle.loads(base64.b64decode(msg)) - if dst and dst[0] == key: - return dst[1] - return default - return value or default - - @DictProperty('environ', 'bottle.request.query', read_only=True) - def query(self): - """ The :attr:`query_string` parsed into a :class:`FormsDict`. These - values are sometimes called "URL arguments" or "GET parameters", but - not to be confused with "URL wildcards" as they are provided by the - :class:`Router`. """ - get = self.environ['bottle.get'] = FormsDict() - pairs = _parse_qsl(self.environ.get('QUERY_STRING', '')) - for key, value in pairs: - get[key] = value - return get - - @DictProperty('environ', 'bottle.request.forms', read_only=True) - def forms(self): - """ Form values parsed from an `url-encoded` or `multipart/form-data` - encoded POST or PUT request body. The result is returned as a - :class:`FormsDict`. All keys and values are strings. File uploads - are stored separately in :attr:`files`. """ - forms = FormsDict() - forms.recode_unicode = self.POST.recode_unicode - for name, item in self.POST.allitems(): - if not isinstance(item, FileUpload): - forms[name] = item - return forms - - @DictProperty('environ', 'bottle.request.params', read_only=True) - def params(self): - """ A :class:`FormsDict` with the combined values of :attr:`query` and - :attr:`forms`. File uploads are stored in :attr:`files`. """ - params = FormsDict() - for key, value in self.query.allitems(): - params[key] = value - for key, value in self.forms.allitems(): - params[key] = value - return params - - @DictProperty('environ', 'bottle.request.files', read_only=True) - def files(self): - """ File uploads parsed from `multipart/form-data` encoded POST or PUT - request body. The values are instances of :class:`FileUpload`. - - """ - files = FormsDict() - files.recode_unicode = self.POST.recode_unicode - for name, item in self.POST.allitems(): - if isinstance(item, FileUpload): - files[name] = item - return files - - @DictProperty('environ', 'bottle.request.json', read_only=True) - def json(self): - """ If the ``Content-Type`` header is ``application/json`` or - ``application/json-rpc``, this property holds the parsed content - of the request body. Only requests smaller than :attr:`MEMFILE_MAX` - are processed to avoid memory exhaustion. - Invalid JSON raises a 400 error response. - """ - ctype = self.environ.get('CONTENT_TYPE', '').lower().split(';')[0] - if ctype in ('application/json', 'application/json-rpc'): - b = self._get_body_string(self.MEMFILE_MAX) - if not b: - return None - try: - return json_loads(b) - except (ValueError, TypeError): - raise HTTPError(400, 'Invalid JSON') - return None - - def _iter_body(self, read, bufsize): - maxread = max(0, self.content_length) - while maxread: - part = read(min(maxread, bufsize)) - if not part: break - yield part - maxread -= len(part) - - @staticmethod - def _iter_chunked(read, bufsize): - err = HTTPError(400, 'Error while parsing chunked transfer body.') - rn, sem, bs = tob('\r\n'), tob(';'), tob('') - while True: - header = read(1) - while header[-2:] != rn: - c = read(1) - header += c - if not c: raise err - if len(header) > bufsize: raise err - size, _, _ = header.partition(sem) - try: - maxread = int(tonat(size.strip()), 16) - except ValueError: - raise err - if maxread == 0: break - buff = bs - while maxread > 0: - if not buff: - buff = read(min(maxread, bufsize)) - part, buff = buff[:maxread], buff[maxread:] - if not part: raise err - yield part - maxread -= len(part) - if read(2) != rn: - raise err - - @DictProperty('environ', 'bottle.request.body', read_only=True) - def _body(self): - try: - read_func = self.environ['wsgi.input'].read - except KeyError: - self.environ['wsgi.input'] = BytesIO() - return self.environ['wsgi.input'] - body_iter = self._iter_chunked if self.chunked else self._iter_body - body, body_size, is_temp_file = BytesIO(), 0, False - for part in body_iter(read_func, self.MEMFILE_MAX): - body.write(part) - body_size += len(part) - if not is_temp_file and body_size > self.MEMFILE_MAX: - body, tmp = NamedTemporaryFile(mode='w+b'), body - body.write(tmp.getvalue()) - del tmp - is_temp_file = True - self.environ['wsgi.input'] = body - body.seek(0) - return body - - def _get_body_string(self, maxread): - """ Read body into a string. Raise HTTPError(413) on requests that are - too large. """ - if self.content_length > maxread: - raise HTTPError(413, 'Request entity too large') - data = self.body.read(maxread + 1) - if len(data) > maxread: - raise HTTPError(413, 'Request entity too large') - return data - - @property - def body(self): - """ The HTTP request body as a seek-able file-like object. Depending on - :attr:`MEMFILE_MAX`, this is either a temporary file or a - :class:`io.BytesIO` instance. Accessing this property for the first - time reads and replaces the ``wsgi.input`` environ variable. - Subsequent accesses just do a `seek(0)` on the file object. """ - self._body.seek(0) - return self._body - - @property - def chunked(self): - """ True if Chunked transfer encoding was. """ - return 'chunked' in self.environ.get( - 'HTTP_TRANSFER_ENCODING', '').lower() - - #: An alias for :attr:`query`. - GET = query - - @DictProperty('environ', 'bottle.request.post', read_only=True) - def POST(self): - """ The values of :attr:`forms` and :attr:`files` combined into a single - :class:`FormsDict`. Values are either strings (form values) or - instances of :class:`FileUpload`. - """ - post = FormsDict() - content_type = self.environ.get('CONTENT_TYPE', '') - content_type, options = _parse_http_header(content_type)[0] - # We default to application/x-www-form-urlencoded for everything that - # is not multipart and take the fast path (also: 3.1 workaround) - if not content_type.startswith('multipart/'): - body = tonat(self._get_body_string(self.MEMFILE_MAX), 'latin1') - for key, value in _parse_qsl(body): - post[key] = value - return post - - post.recode_unicode = False - charset = options.get("charset", "utf8") - boundary = options.get("boundary") - if not boundary: - raise MultipartError("Invalid content type header, missing boundary") - parser = _MultipartParser(self.body, boundary, self.content_length, - mem_limit=self.MEMFILE_MAX, memfile_limit=self.MEMFILE_MAX, - charset=charset) - - for part in parser.parse(): - if not part.filename and part.is_buffered(): - post[part.name] = tonat(part.value, 'utf8') - else: - post[part.name] = FileUpload(part.file, part.name, - part.filename, part.headerlist) - - return post - - @property - def url(self): - """ The full request URI including hostname and scheme. If your app - lives behind a reverse proxy or load balancer and you get confusing - results, make sure that the ``X-Forwarded-Host`` header is set - correctly. """ - return self.urlparts.geturl() - - @DictProperty('environ', 'bottle.request.urlparts', read_only=True) - def urlparts(self): - """ The :attr:`url` string as an :class:`urlparse.SplitResult` tuple. - The tuple contains (scheme, host, path, query_string and fragment), - but the fragment is always empty because it is not visible to the - server. """ - env = self.environ - http = env.get('HTTP_X_FORWARDED_PROTO') \ - or env.get('wsgi.url_scheme', 'http') - host = env.get('HTTP_X_FORWARDED_HOST') or env.get('HTTP_HOST') - if not host: - # HTTP 1.1 requires a Host-header. This is for HTTP/1.0 clients. - host = env.get('SERVER_NAME', '127.0.0.1') - port = env.get('SERVER_PORT') - if port and port != ('80' if http == 'http' else '443'): - host += ':' + port - path = urlquote(self.fullpath) - return UrlSplitResult(http, host, path, env.get('QUERY_STRING'), '') - - @property - def fullpath(self): - """ Request path including :attr:`script_name` (if present). """ - return urljoin(self.script_name, self.path.lstrip('/')) - - @property - def query_string(self): - """ The raw :attr:`query` part of the URL (everything in between ``?`` - and ``#``) as a string. """ - return self.environ.get('QUERY_STRING', '') - - @property - def script_name(self): - """ The initial portion of the URL's `path` that was removed by a higher - level (server or routing middleware) before the application was - called. This script path is returned with leading and tailing - slashes. """ - script_name = self.environ.get('SCRIPT_NAME', '').strip('/') - return '/' + script_name + '/' if script_name else '/' - - def path_shift(self, shift=1): - """ Shift path segments from :attr:`path` to :attr:`script_name` and - vice versa. - - :param shift: The number of path segments to shift. May be negative - to change the shift direction. (default: 1) - """ - script, path = path_shift(self.environ.get('SCRIPT_NAME', '/'), self.path, shift) - self['SCRIPT_NAME'], self['PATH_INFO'] = script, path - - @property - def content_length(self): - """ The request body length as an integer. The client is responsible to - set this header. Otherwise, the real length of the body is unknown - and -1 is returned. In this case, :attr:`body` will be empty. """ - return int(self.environ.get('CONTENT_LENGTH') or -1) - - @property - def content_type(self): - """ The Content-Type header as a lowercase-string (default: empty). """ - return self.environ.get('CONTENT_TYPE', '').lower() - - @property - def is_xhr(self): - """ True if the request was triggered by a XMLHttpRequest. This only - works with JavaScript libraries that support the `X-Requested-With` - header (most of the popular libraries do). """ - requested_with = self.environ.get('HTTP_X_REQUESTED_WITH', '') - return requested_with.lower() == 'xmlhttprequest' - - @property - def is_ajax(self): - """ Alias for :attr:`is_xhr`. "Ajax" is not the right term. """ - return self.is_xhr - - @property - def auth(self): - """ HTTP authentication data as a (user, password) tuple. This - implementation currently supports basic (not digest) authentication - only. If the authentication happened at a higher level (e.g. in the - front web-server or a middleware), the password field is None, but - the user field is looked up from the ``REMOTE_USER`` environ - variable. On any errors, None is returned. """ - basic = parse_auth(self.environ.get('HTTP_AUTHORIZATION', '')) - if basic: return basic - ruser = self.environ.get('REMOTE_USER') - if ruser: return (ruser, None) - return None - - @property - def remote_route(self): - """ A list of all IPs that were involved in this request, starting with - the client IP and followed by zero or more proxies. This does only - work if all proxies support the ```X-Forwarded-For`` header. Note - that this information can be forged by malicious clients. """ - proxy = self.environ.get('HTTP_X_FORWARDED_FOR') - if proxy: return [ip.strip() for ip in proxy.split(',')] - remote = self.environ.get('REMOTE_ADDR') - return [remote] if remote else [] - - @property - def remote_addr(self): - """ The client IP as a string. Note that this information can be forged - by malicious clients. """ - route = self.remote_route - return route[0] if route else None - - def copy(self): - """ Return a new :class:`Request` with a shallow :attr:`environ` copy. """ - return Request(self.environ.copy()) - - def get(self, value, default=None): - return self.environ.get(value, default) - - def __getitem__(self, key): - return self.environ[key] - - def __delitem__(self, key): - self[key] = "" - del (self.environ[key]) - - def __iter__(self): - return iter(self.environ) - - def __len__(self): - return len(self.environ) - - def keys(self): - return self.environ.keys() - - def __setitem__(self, key, value): - """ Change an environ value and clear all caches that depend on it. """ - - if self.environ.get('bottle.request.readonly'): - raise KeyError('The environ dictionary is read-only.') - - self.environ[key] = value - todelete = () - - if key == 'wsgi.input': - todelete = ('body', 'forms', 'files', 'params', 'post', 'json') - elif key == 'QUERY_STRING': - todelete = ('query', 'params') - elif key.startswith('HTTP_'): - todelete = ('headers', 'cookies') - - for key in todelete: - self.environ.pop('bottle.request.' + key, None) - - def __repr__(self): - return '<%s: %s %s>' % (self.__class__.__name__, self.method, self.url) - - def __getattr__(self, name): - """ Search in self.environ for additional user defined attributes. """ - try: - var = self.environ['bottle.request.ext.%s' % name] - return var.__get__(self) if hasattr(var, '__get__') else var - except KeyError: - raise AttributeError('Attribute %r not defined.' % name) - - def __setattr__(self, name, value): - """ Define new attributes that are local to the bound request environment. """ - if name == 'environ': return object.__setattr__(self, name, value) - key = 'bottle.request.ext.%s' % name - if hasattr(self, name): - raise AttributeError("Attribute already defined: %s" % name) - self.environ[key] = value - - def __delattr__(self, name): - try: - del self.environ['bottle.request.ext.%s' % name] - except KeyError: - raise AttributeError("Attribute not defined: %s" % name) - - -def _hkey(key): - if '\n' in key or '\r' in key or '\0' in key: - raise ValueError("Header names must not contain control characters: %r" % key) - return key.title().replace('_', '-') - - -def _hval(value): - value = tonat(value) - if '\n' in value or '\r' in value or '\0' in value: - raise ValueError("Header value must not contain control characters: %r" % value) - return value - - -class HeaderProperty(object): - def __init__(self, name, reader=None, writer=None, default=''): - self.name, self.default = name, default - self.reader, self.writer = reader, writer - self.__doc__ = 'Current value of the %r header.' % name.title() - - def __get__(self, obj, _): - if obj is None: return self - value = obj.get_header(self.name, self.default) - return self.reader(value) if self.reader else value - - def __set__(self, obj, value): - obj[self.name] = self.writer(value) if self.writer else value - - def __delete__(self, obj): - del obj[self.name] - - -class BaseResponse(object): - """ Storage class for a response body as well as headers and cookies. - - This class does support dict-like case-insensitive item-access to - headers, but is NOT a dict. Most notably, iterating over a response - yields parts of the body and not the headers. - """ - - default_status = 200 - default_content_type = 'text/html; charset=UTF-8' - - # Header denylist for specific response codes - # (rfc2616 section 10.2.3 and 10.3.5) - bad_headers = { - 204: frozenset(('Content-Type', 'Content-Length')), - 304: frozenset(('Allow', 'Content-Encoding', 'Content-Language', - 'Content-Length', 'Content-Range', 'Content-Type', - 'Content-Md5', 'Last-Modified')) - } - - def __init__(self, body='', status=None, headers=None, **more_headers): - """ Create a new response object. - - :param body: The response body as one of the supported types. - :param status: Either an HTTP status code (e.g. 200) or a status line - including the reason phrase (e.g. '200 OK'). - :param headers: A dictionary or a list of name-value pairs. - - Additional keyword arguments are added to the list of headers. - Underscores in the header name are replaced with dashes. - """ - self._cookies = None - self._headers = {} - self.body = body - self.status = status or self.default_status - if headers: - if isinstance(headers, dict): - headers = headers.items() - for name, value in headers: - self.add_header(name, value) - if more_headers: - for name, value in more_headers.items(): - self.add_header(name, value) - - def copy(self, cls=None): - """ Returns a copy of self. """ - cls = cls or BaseResponse - assert issubclass(cls, BaseResponse) - copy = cls() - copy.status = self.status - copy._headers = dict((k, v[:]) for (k, v) in self._headers.items()) - if self._cookies: - cookies = copy._cookies = SimpleCookie() - for k,v in self._cookies.items(): - cookies[k] = v.value - cookies[k].update(v) # also copy cookie attributes - return copy - - def __iter__(self): - return iter(self.body) - - def close(self): - if hasattr(self.body, 'close'): - self.body.close() - - @property - def status_line(self): - """ The HTTP status line as a string (e.g. ``404 Not Found``).""" - return self._status_line - - @property - def status_code(self): - """ The HTTP status code as an integer (e.g. 404).""" - return self._status_code - - def _set_status(self, status): - if isinstance(status, int): - code, status = status, _HTTP_STATUS_LINES.get(status) - elif ' ' in status: - if '\n' in status or '\r' in status or '\0' in status: - raise ValueError('Status line must not include control chars.') - status = status.strip() - code = int(status.split()[0]) - else: - raise ValueError('String status line without a reason phrase.') - if not 100 <= code <= 999: - raise ValueError('Status code out of range.') - self._status_code = code - self._status_line = str(status or ('%d Unknown' % code)) - - def _get_status(self): - return self._status_line - - status = property( - _get_status, _set_status, None, - ''' A writeable property to change the HTTP response status. It accepts - either a numeric code (100-999) or a string with a custom reason - phrase (e.g. "404 Brain not found"). Both :data:`status_line` and - :data:`status_code` are updated accordingly. The return value is - always a status string. ''') - del _get_status, _set_status - - @property - def headers(self): - """ An instance of :class:`HeaderDict`, a case-insensitive dict-like - view on the response headers. """ - hdict = HeaderDict() - hdict.dict = self._headers - return hdict - - def __contains__(self, name): - return _hkey(name) in self._headers - - def __delitem__(self, name): - del self._headers[_hkey(name)] - - def __getitem__(self, name): - return self._headers[_hkey(name)][-1] - - def __setitem__(self, name, value): - self._headers[_hkey(name)] = [_hval(value)] - - def get_header(self, name, default=None): - """ Return the value of a previously defined header. If there is no - header with that name, return a default value. """ - return self._headers.get(_hkey(name), [default])[-1] - - def set_header(self, name, value): - """ Create a new response header, replacing any previously defined - headers with the same name. """ - self._headers[_hkey(name)] = [_hval(value)] - - def add_header(self, name, value): - """ Add an additional response header, not removing duplicates. """ - self._headers.setdefault(_hkey(name), []).append(_hval(value)) - - def iter_headers(self): - """ Yield (header, value) tuples, skipping headers that are not - allowed with the current response status code. """ - return self.headerlist - - def _wsgi_status_line(self): - """ WSGI conform status line (latin1-encodeable) """ - if py3k: - return self._status_line.encode('utf8').decode('latin1') - return self._status_line - - @property - def headerlist(self): - """ WSGI conform list of (header, value) tuples. """ - out = [] - headers = list(self._headers.items()) - if 'Content-Type' not in self._headers: - headers.append(('Content-Type', [self.default_content_type])) - if self._status_code in self.bad_headers: - bad_headers = self.bad_headers[self._status_code] - headers = [h for h in headers if h[0] not in bad_headers] - out += [(name, val) for (name, vals) in headers for val in vals] - if self._cookies: - for c in self._cookies.values(): - out.append(('Set-Cookie', _hval(c.OutputString()))) - if py3k: - out = [(k, v.encode('utf8').decode('latin1')) for (k, v) in out] - return out - - content_type = HeaderProperty('Content-Type') - content_length = HeaderProperty('Content-Length', reader=int, default=-1) - expires = HeaderProperty( - 'Expires', - reader=lambda x: datetime.fromtimestamp(parse_date(x), UTC), - writer=lambda x: http_date(x)) - - @property - def charset(self, default='UTF-8'): - """ Return the charset specified in the content-type header (default: utf8). """ - if 'charset=' in self.content_type: - return self.content_type.split('charset=')[-1].split(';')[0].strip() - return default - - def set_cookie(self, name, value, secret=None, digestmod=hashlib.sha256, **options): - """ Create a new cookie or replace an old one. If the `secret` parameter is - set, create a `Signed Cookie` (described below). - - :param name: the name of the cookie. - :param value: the value of the cookie. - :param secret: a signature key required for signed cookies. - - Additionally, this method accepts all RFC 2109 attributes that are - supported by :class:`cookie.Morsel`, including: - - :param maxage: maximum age in seconds. (default: None) - :param expires: a datetime object or UNIX timestamp. (default: None) - :param domain: the domain that is allowed to read the cookie. - (default: current domain) - :param path: limits the cookie to a given path (default: current path) - :param secure: limit the cookie to HTTPS connections (default: off). - :param httponly: prevents client-side javascript to read this cookie - (default: off, requires Python 2.6 or newer). - :param samesite: Control or disable third-party use for this cookie. - Possible values: `lax`, `strict` or `none` (default). - - If neither `expires` nor `maxage` is set (default), the cookie will - expire at the end of the browser session (as soon as the browser - window is closed). - - Signed cookies may store any pickle-able object and are - cryptographically signed to prevent manipulation. Keep in mind that - cookies are limited to 4kb in most browsers. - - Warning: Pickle is a potentially dangerous format. If an attacker - gains access to the secret key, he could forge cookies that execute - code on server side if unpickled. Using pickle is discouraged and - support for it will be removed in later versions of bottle. - - Warning: Signed cookies are not encrypted (the client can still see - the content) and not copy-protected (the client can restore an old - cookie). The main intention is to make pickling and unpickling - save, not to store secret information at client side. - """ - if not self._cookies: - self._cookies = SimpleCookie() - - # Monkey-patch Cookie lib to support 'SameSite' parameter - # https://tools.ietf.org/html/draft-west-first-party-cookies-07#section-4.1 - if py < (3, 8, 0): - Morsel._reserved.setdefault('samesite', 'SameSite') - - if secret: - if not isinstance(value, basestring): - depr(0, 13, "Pickling of arbitrary objects into cookies is " - "deprecated.", "Only store strings in cookies. " - "JSON strings are fine, too.") - encoded = base64.b64encode(pickle.dumps([name, value], -1)) - sig = base64.b64encode(hmac.new(tob(secret), encoded, - digestmod=digestmod).digest()) - value = touni(tob('!') + sig + tob('?') + encoded) - elif not isinstance(value, basestring): - raise TypeError('Secret key required for non-string cookies.') - - # Cookie size plus options must not exceed 4kb. - if len(name) + len(value) > 3800: - raise ValueError('Content does not fit into a cookie.') - - self._cookies[name] = value - - for key, value in options.items(): - if key in ('max_age', 'maxage'): # 'maxage' variant added in 0.13 - key = 'max-age' - if isinstance(value, timedelta): - value = value.seconds + value.days * 24 * 3600 - if key == 'expires': - value = http_date(value) - if key in ('same_site', 'samesite'): # 'samesite' variant added in 0.13 - key, value = 'samesite', (value or "none").lower() - if value not in ('lax', 'strict', 'none'): - raise CookieError("Invalid value for SameSite") - if key in ('secure', 'httponly') and not value: - continue - self._cookies[name][key] = value - - def delete_cookie(self, key, **kwargs): - """ Delete a cookie. Be sure to use the same `domain` and `path` - settings as used to create the cookie. """ - kwargs['max_age'] = -1 - kwargs['expires'] = 0 - self.set_cookie(key, '', **kwargs) - - def __repr__(self): - out = '' - for name, value in self.headerlist: - out += '%s: %s\n' % (name.title(), value.strip()) - return out - - -def _local_property(): - ls = threading.local() - - def fget(_): - try: - return ls.var - except AttributeError: - raise RuntimeError("Request context not initialized.") - - def fset(_, value): - ls.var = value - - def fdel(_): - del ls.var - - return property(fget, fset, fdel, 'Thread-local property') - - -class LocalRequest(BaseRequest): - """ A thread-local subclass of :class:`BaseRequest` with a different - set of attributes for each thread. There is usually only one global - instance of this class (:data:`request`). If accessed during a - request/response cycle, this instance always refers to the *current* - request (even on a multithreaded server). """ - bind = BaseRequest.__init__ - environ = _local_property() - - -class LocalResponse(BaseResponse): - """ A thread-local subclass of :class:`BaseResponse` with a different - set of attributes for each thread. There is usually only one global - instance of this class (:data:`response`). Its attributes are used - to build the HTTP response at the end of the request/response cycle. - """ - bind = BaseResponse.__init__ - _status_line = _local_property() - _status_code = _local_property() - _cookies = _local_property() - _headers = _local_property() - body = _local_property() - - -Request = BaseRequest -Response = BaseResponse - - -class HTTPResponse(Response, BottleException): - """ A subclass of :class:`Response` that can be raised or returned from request - handlers to short-curcuit request processing and override changes made to the - global :data:`request` object. This bypasses error handlers, even if the status - code indicates an error. Return or raise :class:`HTTPError` to trigger error - handlers. - """ - - def __init__(self, body='', status=None, headers=None, **more_headers): - super(HTTPResponse, self).__init__(body, status, headers, **more_headers) - - def apply(self, other): - """ Copy the state of this response to a different :class:`Response` object. """ - other._status_code = self._status_code - other._status_line = self._status_line - other._headers = self._headers - other._cookies = self._cookies - other.body = self.body - - -class HTTPError(HTTPResponse): - """ A subclass of :class:`HTTPResponse` that triggers error handlers. """ - - default_status = 500 - - def __init__(self, - status=None, - body=None, - exception=None, - traceback=None, **more_headers): - self.exception = exception - self.traceback = traceback - super(HTTPError, self).__init__(body, status, **more_headers) - -############################################################################### -# Plugins ###################################################################### -############################################################################### - - -class PluginError(BottleException): - pass - - -class JSONPlugin(object): - name = 'json' - api = 2 - - def __init__(self, json_dumps=json_dumps): - self.json_dumps = json_dumps - - def setup(self, app): - app.config._define('json.enable', default=True, validate=bool, - help="Enable or disable automatic dict->json filter.") - app.config._define('json.ascii', default=False, validate=bool, - help="Use only 7-bit ASCII characters in output.") - app.config._define('json.indent', default=True, validate=bool, - help="Add whitespace to make json more readable.") - app.config._define('json.dump_func', default=None, - help="If defined, use this function to transform" - " dict into json. The other options no longer" - " apply.") - - def apply(self, callback, route): - dumps = self.json_dumps - if not self.json_dumps: return callback - - @functools.wraps(callback) - def wrapper(*a, **ka): - try: - rv = callback(*a, **ka) - except HTTPResponse as resp: - rv = resp - - if isinstance(rv, dict): - #Attempt to serialize, raises exception on failure - json_response = dumps(rv) - #Set content type only if serialization successful - response.content_type = 'application/json' - return json_response - elif isinstance(rv, HTTPResponse) and isinstance(rv.body, dict): - rv.body = dumps(rv.body) - rv.content_type = 'application/json' - return rv - - return wrapper - - -class TemplatePlugin(object): - """ This plugin applies the :func:`view` decorator to all routes with a - `template` config parameter. If the parameter is a tuple, the second - element must be a dict with additional options (e.g. `template_engine`) - or default variables for the template. """ - name = 'template' - api = 2 - - def setup(self, app): - app.tpl = self - - def apply(self, callback, route): - conf = route.config.get('template') - if isinstance(conf, (tuple, list)) and len(conf) == 2: - return view(conf[0], **conf[1])(callback) - elif isinstance(conf, str): - return view(conf)(callback) - else: - return callback - - -#: Not a plugin, but part of the plugin API. TODO: Find a better place. -class _ImportRedirect(object): - def __init__(self, name, impmask): - """ Create a virtual package that redirects imports (see PEP 302). """ - self.name = name - self.impmask = impmask - self.module = sys.modules.setdefault(name, new_module(name)) - self.module.__dict__.update({ - '__file__': __file__, - '__path__': [], - '__all__': [], - '__loader__': self - }) - sys.meta_path.append(self) - - def find_spec(self, fullname, path, target=None): - if '.' not in fullname: return - if fullname.rsplit('.', 1)[0] != self.name: return - from importlib.util import spec_from_loader - return spec_from_loader(fullname, self) - - def find_module(self, fullname, path=None): - if '.' not in fullname: return - if fullname.rsplit('.', 1)[0] != self.name: return - return self - - def create_module(self, spec): - return self.load_module(spec.name) - - def exec_module(self, module): - pass # This probably breaks importlib.reload() :/ - - def load_module(self, fullname): - if fullname in sys.modules: return sys.modules[fullname] - modname = fullname.rsplit('.', 1)[1] - realname = self.impmask % modname - __import__(realname) - module = sys.modules[fullname] = sys.modules[realname] - setattr(self.module, modname, module) - module.__loader__ = self - return module - -############################################################################### -# Common Utilities ############################################################# -############################################################################### - - -class MultiDict(DictMixin): - """ This dict stores multiple values per key, but behaves exactly like a - normal dict in that it returns only the newest value for any given key. - There are special methods available to access the full list of values. - """ - - def __init__(self, *a, **k): - self.dict = dict((k, [v]) for (k, v) in dict(*a, **k).items()) - - def __len__(self): - return len(self.dict) - - def __iter__(self): - return iter(self.dict) - - def __contains__(self, key): - return key in self.dict - - def __delitem__(self, key): - del self.dict[key] - - def __getitem__(self, key): - return self.dict[key][-1] - - def __setitem__(self, key, value): - self.append(key, value) - - def keys(self): - return self.dict.keys() - - if py3k: - - def values(self): - return (v[-1] for v in self.dict.values()) - - def items(self): - return ((k, v[-1]) for k, v in self.dict.items()) - - def allitems(self): - return ((k, v) for k, vl in self.dict.items() for v in vl) - - iterkeys = keys - itervalues = values - iteritems = items - iterallitems = allitems - - else: - - def values(self): - return [v[-1] for v in self.dict.values()] - - def items(self): - return [(k, v[-1]) for k, v in self.dict.items()] - - def iterkeys(self): - return self.dict.iterkeys() - - def itervalues(self): - return (v[-1] for v in self.dict.itervalues()) - - def iteritems(self): - return ((k, v[-1]) for k, v in self.dict.iteritems()) - - def iterallitems(self): - return ((k, v) for k, vl in self.dict.iteritems() for v in vl) - - def allitems(self): - return [(k, v) for k, vl in self.dict.iteritems() for v in vl] - - def get(self, key, default=None, index=-1, type=None): - """ Return the most recent value for a key. - - :param default: The default value to be returned if the key is not - present or the type conversion fails. - :param index: An index for the list of available values. - :param type: If defined, this callable is used to cast the value - into a specific type. Exception are suppressed and result in - the default value to be returned. - """ - try: - val = self.dict[key][index] - return type(val) if type else val - except Exception: - pass - return default - - def append(self, key, value): - """ Add a new value to the list of values for this key. """ - self.dict.setdefault(key, []).append(value) - - def replace(self, key, value): - """ Replace the list of values with a single value. """ - self.dict[key] = [value] - - def getall(self, key): - """ Return a (possibly empty) list of values for a key. """ - return self.dict.get(key) or [] - - #: Aliases for WTForms to mimic other multi-dict APIs (Django) - getone = get - getlist = getall - - -class FormsDict(MultiDict): - """ This :class:`MultiDict` subclass is used to store request form data. - Additionally to the normal dict-like item access methods (which return - unmodified data as native strings), this container also supports - attribute-like access to its values. Attributes are automatically de- - or recoded to match :attr:`input_encoding` (default: 'utf8'). Missing - attributes default to an empty string. """ - - #: Encoding used for attribute values. - input_encoding = 'utf8' - #: If true (default), unicode strings are first encoded with `latin1` - #: and then decoded to match :attr:`input_encoding`. - recode_unicode = True - - def _fix(self, s, encoding=None): - if isinstance(s, unicode) and self.recode_unicode: # Python 3 WSGI - return s.encode('latin1').decode(encoding or self.input_encoding) - elif isinstance(s, bytes): # Python 2 WSGI - return s.decode(encoding or self.input_encoding) - else: - return s - - def decode(self, encoding=None): - """ Returns a copy with all keys and values de- or recoded to match - :attr:`input_encoding`. Some libraries (e.g. WTForms) want a - unicode dictionary. """ - copy = FormsDict() - enc = copy.input_encoding = encoding or self.input_encoding - copy.recode_unicode = False - for key, value in self.allitems(): - copy.append(self._fix(key, enc), self._fix(value, enc)) - return copy - - def getunicode(self, name, default=None, encoding=None): - """ Return the value as a unicode string, or the default. """ - try: - return self._fix(self[name], encoding) - except (UnicodeError, KeyError): - return default - - def __getattr__(self, name, default=unicode()): - # Without this guard, pickle generates a cryptic TypeError: - if name.startswith('__') and name.endswith('__'): - return super(FormsDict, self).__getattr__(name) - return self.getunicode(name, default=default) - -class HeaderDict(MultiDict): - """ A case-insensitive version of :class:`MultiDict` that defaults to - replace the old value instead of appending it. """ - - def __init__(self, *a, **ka): - self.dict = {} - if a or ka: self.update(*a, **ka) - - def __contains__(self, key): - return _hkey(key) in self.dict - - def __delitem__(self, key): - del self.dict[_hkey(key)] - - def __getitem__(self, key): - return self.dict[_hkey(key)][-1] - - def __setitem__(self, key, value): - self.dict[_hkey(key)] = [_hval(value)] - - def append(self, key, value): - self.dict.setdefault(_hkey(key), []).append(_hval(value)) - - def replace(self, key, value): - self.dict[_hkey(key)] = [_hval(value)] - - def getall(self, key): - return self.dict.get(_hkey(key)) or [] - - def get(self, key, default=None, index=-1): - return MultiDict.get(self, _hkey(key), default, index) - - def filter(self, names): - for name in (_hkey(n) for n in names): - if name in self.dict: - del self.dict[name] - - -class WSGIHeaderDict(DictMixin): - """ This dict-like class wraps a WSGI environ dict and provides convenient - access to HTTP_* fields. Keys and values are native strings - (2.x bytes or 3.x unicode) and keys are case-insensitive. If the WSGI - environment contains non-native string values, these are de- or encoded - using a lossless 'latin1' character set. - - The API will remain stable even on changes to the relevant PEPs. - Currently PEP 333, 444 and 3333 are supported. (PEP 444 is the only one - that uses non-native strings.) - """ - #: List of keys that do not have a ``HTTP_`` prefix. - cgikeys = ('CONTENT_TYPE', 'CONTENT_LENGTH') - - def __init__(self, environ): - self.environ = environ - - def _ekey(self, key): - """ Translate header field name to CGI/WSGI environ key. """ - key = key.replace('-', '_').upper() - if key in self.cgikeys: - return key - return 'HTTP_' + key - - def raw(self, key, default=None): - """ Return the header value as is (may be bytes or unicode). """ - return self.environ.get(self._ekey(key), default) - - def __getitem__(self, key): - val = self.environ[self._ekey(key)] - if py3k: - if isinstance(val, unicode): - val = val.encode('latin1').decode('utf8') - else: - val = val.decode('utf8') - return val - - def __setitem__(self, key, value): - raise TypeError("%s is read-only." % self.__class__) - - def __delitem__(self, key): - raise TypeError("%s is read-only." % self.__class__) - - def __iter__(self): - for key in self.environ: - if key[:5] == 'HTTP_': - yield _hkey(key[5:]) - elif key in self.cgikeys: - yield _hkey(key) - - def keys(self): - return [x for x in self] - - def __len__(self): - return len(self.keys()) - - def __contains__(self, key): - return self._ekey(key) in self.environ - -_UNSET = object() - -class ConfigDict(dict): - """ A dict-like configuration storage with additional support for - namespaces, validators, meta-data and overlays. - - This dict-like class is heavily optimized for read access. - Read-only methods and item access should be as fast as a native dict. - """ - - __slots__ = ('_meta', '_change_listener', '_overlays', '_virtual_keys', '_source', '__weakref__') - - def __init__(self): - self._meta = {} - self._change_listener = [] - #: Weak references of overlays that need to be kept in sync. - self._overlays = [] - #: Config that is the source for this overlay. - self._source = None - #: Keys of values copied from the source (values we do not own) - self._virtual_keys = set() - - def load_module(self, name, squash=True): - """Load values from a Python module. - - Import a python module by name and add all upper-case module-level - variables to this config dict. - - :param name: Module name to import and load. - :param squash: If true (default), nested dicts are assumed to - represent namespaces and flattened (see :meth:`load_dict`). - """ - config_obj = load(name) - obj = {key: getattr(config_obj, key) - for key in dir(config_obj) if key.isupper()} - - if squash: - self.load_dict(obj) - else: - self.update(obj) - return self - - def load_config(self, filename, **options): - """ Load values from ``*.ini`` style config files using configparser. - - INI style sections (e.g. ``[section]``) are used as namespace for - all keys within that section. Both section and key names may contain - dots as namespace separators and are converted to lower-case. - - The special sections ``[bottle]`` and ``[ROOT]`` refer to the root - namespace and the ``[DEFAULT]`` section defines default values for all - other sections. - - :param filename: The path of a config file, or a list of paths. - :param options: All keyword parameters are passed to the underlying - :class:`python:configparser.ConfigParser` constructor call. - - """ - options.setdefault('allow_no_value', True) - if py3k: - options.setdefault('interpolation', - configparser.ExtendedInterpolation()) - conf = configparser.ConfigParser(**options) - conf.read(filename) - for section in conf.sections(): - for key in conf.options(section): - value = conf.get(section, key) - if section not in ('bottle', 'ROOT'): - key = section + '.' + key - self[key.lower()] = value - return self - - def load_dict(self, source, namespace=''): - """ Load values from a dictionary structure. Nesting can be used to - represent namespaces. - - >>> c = ConfigDict() - >>> c.load_dict({'some': {'namespace': {'key': 'value'} } }) - {'some.namespace.key': 'value'} - """ - for key, value in source.items(): - if isinstance(key, basestring): - nskey = (namespace + '.' + key).strip('.') - if isinstance(value, dict): - self.load_dict(value, namespace=nskey) - else: - self[nskey] = value - else: - raise TypeError('Key has type %r (not a string)' % type(key)) - return self - - def update(self, *a, **ka): - """ If the first parameter is a string, all keys are prefixed with this - namespace. Apart from that it works just as the usual dict.update(). - - >>> c = ConfigDict() - >>> c.update('some.namespace', key='value') - """ - prefix = '' - if a and isinstance(a[0], basestring): - prefix = a[0].strip('.') + '.' - a = a[1:] - for key, value in dict(*a, **ka).items(): - self[prefix + key] = value - - def setdefault(self, key, value=None): - if key not in self: - self[key] = value - return self[key] - - def __setitem__(self, key, value): - if not isinstance(key, basestring): - raise TypeError('Key has type %r (not a string)' % type(key)) - - self._virtual_keys.discard(key) - - value = self.meta_get(key, 'filter', lambda x: x)(value) - if key in self and self[key] is value: - return - - self._on_change(key, value) - dict.__setitem__(self, key, value) - - for overlay in self._iter_overlays(): - overlay._set_virtual(key, value) - - def __delitem__(self, key): - if key not in self: - raise KeyError(key) - if key in self._virtual_keys: - raise KeyError("Virtual keys cannot be deleted: %s" % key) - - if self._source and key in self._source: - # Not virtual, but present in source -> Restore virtual value - dict.__delitem__(self, key) - self._set_virtual(key, self._source[key]) - else: # not virtual, not present in source. This is OUR value - self._on_change(key, None) - dict.__delitem__(self, key) - for overlay in self._iter_overlays(): - overlay._delete_virtual(key) - - def _set_virtual(self, key, value): - """ Recursively set or update virtual keys. """ - if key in self and key not in self._virtual_keys: - return # Do nothing for non-virtual keys. - - self._virtual_keys.add(key) - if key in self and self[key] is not value: - self._on_change(key, value) - dict.__setitem__(self, key, value) - for overlay in self._iter_overlays(): - overlay._set_virtual(key, value) - - def _delete_virtual(self, key): - """ Recursively delete virtual entry. """ - if key not in self._virtual_keys: - return # Do nothing for non-virtual keys. - - if key in self: - self._on_change(key, None) - dict.__delitem__(self, key) - self._virtual_keys.discard(key) - for overlay in self._iter_overlays(): - overlay._delete_virtual(key) - - def _on_change(self, key, value): - for cb in self._change_listener: - if cb(self, key, value): - return True - - def _add_change_listener(self, func): - self._change_listener.append(func) - return func - - def meta_get(self, key, metafield, default=None): - """ Return the value of a meta field for a key. """ - return self._meta.get(key, {}).get(metafield, default) - - def meta_set(self, key, metafield, value): - """ Set the meta field for a key to a new value. - - Meta-fields are shared between all members of an overlay tree. - """ - self._meta.setdefault(key, {})[metafield] = value - - def meta_list(self, key): - """ Return an iterable of meta field names defined for a key. """ - return self._meta.get(key, {}).keys() - - def _define(self, key, default=_UNSET, help=_UNSET, validate=_UNSET): - """ (Unstable) Shortcut for plugins to define own config parameters. """ - if default is not _UNSET: - self.setdefault(key, default) - if help is not _UNSET: - self.meta_set(key, 'help', help) - if validate is not _UNSET: - self.meta_set(key, 'validate', validate) - - def _iter_overlays(self): - for ref in self._overlays: - overlay = ref() - if overlay is not None: - yield overlay - - def _make_overlay(self): - """ (Unstable) Create a new overlay that acts like a chained map: Values - missing in the overlay are copied from the source map. Both maps - share the same meta entries. - - Entries that were copied from the source are called 'virtual'. You - can not delete virtual keys, but overwrite them, which turns them - into non-virtual entries. Setting keys on an overlay never affects - its source, but may affect any number of child overlays. - - Other than collections.ChainMap or most other implementations, this - approach does not resolve missing keys on demand, but instead - actively copies all values from the source to the overlay and keeps - track of virtual and non-virtual keys internally. This removes any - lookup-overhead. Read-access is as fast as a build-in dict for both - virtual and non-virtual keys. - - Changes are propagated recursively and depth-first. A failing - on-change handler in an overlay stops the propagation of virtual - values and may result in an partly updated tree. Take extra care - here and make sure that on-change handlers never fail. - - Used by Route.config - """ - # Cleanup dead references - self._overlays[:] = [ref for ref in self._overlays if ref() is not None] - - overlay = ConfigDict() - overlay._meta = self._meta - overlay._source = self - self._overlays.append(weakref.ref(overlay)) - for key in self: - overlay._set_virtual(key, self[key]) - return overlay - - - - -class AppStack(list): - """ A stack-like list. Calling it returns the head of the stack. """ - - def __call__(self): - """ Return the current default application. """ - return self.default - - def push(self, value=None): - """ Add a new :class:`Bottle` instance to the stack """ - if not isinstance(value, Bottle): - value = Bottle() - self.append(value) - return value - new_app = push - - @property - def default(self): - try: - return self[-1] - except IndexError: - return self.push() - - -class WSGIFileWrapper(object): - def __init__(self, fp, buffer_size=1024 * 64): - self.fp, self.buffer_size = fp, buffer_size - for attr in 'fileno', 'close', 'read', 'readlines', 'tell', 'seek': - if hasattr(fp, attr): setattr(self, attr, getattr(fp, attr)) - - def __iter__(self): - buff, read = self.buffer_size, self.read - part = read(buff) - while part: - yield part - part = read(buff) - - -class _closeiter(object): - """ This only exists to be able to attach a .close method to iterators that - do not support attribute assignment (most of itertools). """ - - def __init__(self, iterator, close=None): - self.iterator = iterator - self.close_callbacks = makelist(close) - - def __iter__(self): - return iter(self.iterator) - - def close(self): - for func in self.close_callbacks: - func() - - -class ResourceManager(object): - """ This class manages a list of search paths and helps to find and open - application-bound resources (files). - - :param base: default value for :meth:`add_path` calls. - :param opener: callable used to open resources. - :param cachemode: controls which lookups are cached. One of 'all', - 'found' or 'none'. - """ - - def __init__(self, base='./', opener=open, cachemode='all'): - self.opener = opener - self.base = base - self.cachemode = cachemode - - #: A list of search paths. See :meth:`add_path` for details. - self.path = [] - #: A cache for resolved paths. ``res.cache.clear()`` clears the cache. - self.cache = {} - - def add_path(self, path, base=None, index=None, create=False): - """ Add a new path to the list of search paths. Return False if the - path does not exist. - - :param path: The new search path. Relative paths are turned into - an absolute and normalized form. If the path looks like a file - (not ending in `/`), the filename is stripped off. - :param base: Path used to absolutize relative search paths. - Defaults to :attr:`base` which defaults to ``os.getcwd()``. - :param index: Position within the list of search paths. Defaults - to last index (appends to the list). - - The `base` parameter makes it easy to reference files installed - along with a python module or package:: - - res.add_path('./resources/', __file__) - """ - base = os.path.abspath(os.path.dirname(base or self.base)) - path = os.path.abspath(os.path.join(base, os.path.dirname(path))) - path += os.sep - if path in self.path: - self.path.remove(path) - if create and not os.path.isdir(path): - os.makedirs(path) - if index is None: - self.path.append(path) - else: - self.path.insert(index, path) - self.cache.clear() - return os.path.exists(path) - - def __iter__(self): - """ Iterate over all existing files in all registered paths. """ - search = self.path[:] - while search: - path = search.pop() - if not os.path.isdir(path): continue - for name in os.listdir(path): - full = os.path.join(path, name) - if os.path.isdir(full): search.append(full) - else: yield full - - def lookup(self, name): - """ Search for a resource and return an absolute file path, or `None`. - - The :attr:`path` list is searched in order. The first match is - returned. Symlinks are followed. The result is cached to speed up - future lookups. """ - if name not in self.cache or DEBUG: - for path in self.path: - fpath = os.path.join(path, name) - if os.path.isfile(fpath): - if self.cachemode in ('all', 'found'): - self.cache[name] = fpath - return fpath - if self.cachemode == 'all': - self.cache[name] = None - return self.cache[name] - - def open(self, name, mode='r', *args, **kwargs): - """ Find a resource and return a file object, or raise IOError. """ - fname = self.lookup(name) - if not fname: raise IOError("Resource %r not found." % name) - return self.opener(fname, mode=mode, *args, **kwargs) - - -class FileUpload(object): - def __init__(self, fileobj, name, filename, headers=None): - """ Wrapper for a single file uploaded via ``multipart/form-data``. """ - #: Open file(-like) object (BytesIO buffer or temporary file) - self.file = fileobj - #: Name of the upload form field - self.name = name - #: Raw filename as sent by the client (may contain unsafe characters) - self.raw_filename = filename - #: A :class:`HeaderDict` with additional headers (e.g. content-type) - self.headers = HeaderDict(headers) if headers else HeaderDict() - - content_type = HeaderProperty('Content-Type') - content_length = HeaderProperty('Content-Length', reader=int, default=-1) - - def get_header(self, name, default=None): - """ Return the value of a header within the multipart part. """ - return self.headers.get(name, default) - - @cached_property - def filename(self): - """ Name of the file on the client file system, but normalized to ensure - file system compatibility. An empty filename is returned as 'empty'. - - Only ASCII letters, digits, dashes, underscores and dots are - allowed in the final filename. Accents are removed, if possible. - Whitespace is replaced by a single dash. Leading or tailing dots - or dashes are removed. The filename is limited to 255 characters. - """ - fname = self.raw_filename - if not isinstance(fname, unicode): - fname = fname.decode('utf8', 'ignore') - fname = normalize('NFKD', fname) - fname = fname.encode('ASCII', 'ignore').decode('ASCII') - fname = os.path.basename(fname.replace('\\', os.path.sep)) - fname = re.sub(r'[^a-zA-Z0-9-_.\s]', '', fname).strip() - fname = re.sub(r'[-\s]+', '-', fname).strip('.-') - return fname[:255] or 'empty' - - def _copy_file(self, fp, chunk_size=2 ** 16): - read, write, offset = self.file.read, fp.write, self.file.tell() - while 1: - buf = read(chunk_size) - if not buf: break - write(buf) - self.file.seek(offset) - - def save(self, destination, overwrite=False, chunk_size=2 ** 16): - """ Save file to disk or copy its content to an open file(-like) object. - If *destination* is a directory, :attr:`filename` is added to the - path. Existing files are not overwritten by default (IOError). - - :param destination: File path, directory or file(-like) object. - :param overwrite: If True, replace existing files. (default: False) - :param chunk_size: Bytes to read at a time. (default: 64kb) - """ - if isinstance(destination, basestring): # Except file-likes here - if os.path.isdir(destination): - destination = os.path.join(destination, self.filename) - if not overwrite and os.path.exists(destination): - raise IOError('File exists.') - with open(destination, 'wb') as fp: - self._copy_file(fp, chunk_size) - else: - self._copy_file(destination, chunk_size) - -############################################################################### -# Application Helper ########################################################### -############################################################################### - - -def abort(code=500, text='Unknown Error.'): - """ Aborts execution and causes a HTTP error. """ - raise HTTPError(code, text) - - -def redirect(url, code=None): - """ Aborts execution and causes a 303 or 302 redirect, depending on - the HTTP protocol version. """ - if not code: - code = 303 if request.get('SERVER_PROTOCOL') == "HTTP/1.1" else 302 - res = response.copy(cls=HTTPResponse) - res.status = code - res.body = "" - res.set_header('Location', urljoin(request.url, url)) - raise res - - -def _rangeiter(fp, offset, limit, bufsize=1024 * 1024): - """ Yield chunks from a range in a file. """ - fp.seek(offset) - while limit > 0: - part = fp.read(min(limit, bufsize)) - if not part: - break - limit -= len(part) - yield part - - -def static_file(filename, root, - mimetype=True, - download=False, - charset='UTF-8', - etag=None, - headers=None): - """ Open a file in a safe way and return an instance of :exc:`HTTPResponse` - that can be sent back to the client. - - :param filename: Name or path of the file to send, relative to ``root``. - :param root: Root path for file lookups. Should be an absolute directory - path. - :param mimetype: Provide the content-type header (default: guess from - file extension) - :param download: If True, ask the browser to open a `Save as...` dialog - instead of opening the file with the associated program. You can - specify a custom filename as a string. If not specified, the - original filename is used (default: False). - :param charset: The charset for files with a ``text/*`` mime-type. - (default: UTF-8) - :param etag: Provide a pre-computed ETag header. If set to ``False``, - ETag handling is disabled. (default: auto-generate ETag header) - :param headers: Additional headers dict to add to the response. - - While checking user input is always a good idea, this function provides - additional protection against malicious ``filename`` parameters from - breaking out of the ``root`` directory and leaking sensitive information - to an attacker. - - Read-protected files or files outside of the ``root`` directory are - answered with ``403 Access Denied``. Missing files result in a - ``404 Not Found`` response. Conditional requests (``If-Modified-Since``, - ``If-None-Match``) are answered with ``304 Not Modified`` whenever - possible. ``HEAD`` and ``Range`` requests (used by download managers to - check or continue partial downloads) are also handled automatically. - """ - - root = os.path.join(os.path.abspath(root), '') - filename = os.path.abspath(os.path.join(root, filename.strip('/\\'))) - headers = headers.copy() if headers else {} - getenv = request.environ.get - - if not filename.startswith(root): - return HTTPError(403, "Access denied.") - if not os.path.exists(filename) or not os.path.isfile(filename): - return HTTPError(404, "File does not exist.") - if not os.access(filename, os.R_OK): - return HTTPError(403, "You do not have permission to access this file.") - - if mimetype is True: - name = download if isinstance(download, str) else filename - mimetype, encoding = mimetypes.guess_type(name) - if encoding == 'gzip': - mimetype = 'application/gzip' - elif encoding: # e.g. bzip2 -> application/x-bzip2 - mimetype = 'application/x-' + encoding - - if charset and mimetype and 'charset=' not in mimetype \ - and (mimetype[:5] == 'text/' or mimetype == 'application/javascript'): - mimetype += '; charset=%s' % charset - - if mimetype: - headers['Content-Type'] = mimetype - - if download is True: - download = os.path.basename(filename) - - if download: - download = download.replace('"','') - headers['Content-Disposition'] = 'attachment; filename="%s"' % download - - stats = os.stat(filename) - headers['Content-Length'] = clen = stats.st_size - headers['Last-Modified'] = email.utils.formatdate(stats.st_mtime, usegmt=True) - headers['Date'] = email.utils.formatdate(time.time(), usegmt=True) - - if etag is None: - etag = '%d:%d:%d:%d:%s' % (stats.st_dev, stats.st_ino, stats.st_mtime, - clen, filename) - etag = hashlib.sha1(tob(etag)).hexdigest() - - if etag: - headers['ETag'] = etag - check = getenv('HTTP_IF_NONE_MATCH') - if check and check == etag: - return HTTPResponse(status=304, **headers) - - ims = getenv('HTTP_IF_MODIFIED_SINCE') - if ims: - ims = parse_date(ims.split(";")[0].strip()) - if ims is not None and ims >= int(stats.st_mtime): - return HTTPResponse(status=304, **headers) - - body = '' if request.method == 'HEAD' else open(filename, 'rb') - - headers["Accept-Ranges"] = "bytes" - range_header = getenv('HTTP_RANGE') - if range_header: - ranges = list(parse_range_header(range_header, clen)) - if not ranges: - return HTTPError(416, "Requested Range Not Satisfiable") - offset, end = ranges[0] - rlen = end - offset - headers["Content-Range"] = "bytes %d-%d/%d" % (offset, end - 1, clen) - headers["Content-Length"] = str(rlen) - if body: body = _closeiter(_rangeiter(body, offset, rlen), body.close) - return HTTPResponse(body, status=206, **headers) - return HTTPResponse(body, **headers) - -############################################################################### -# HTTP Utilities and MISC (TODO) ############################################### -############################################################################### - - -def debug(mode=True): - """ Change the debug level. - There is only one debug level supported at the moment.""" - global DEBUG - if mode: warnings.simplefilter('default') - DEBUG = bool(mode) - - -def http_date(value): - if isinstance(value, basestring): - return value - if isinstance(value, datetime): - # aware datetime.datetime is converted to UTC time - # naive datetime.datetime is treated as UTC time - value = value.utctimetuple() - elif isinstance(value, datedate): - # datetime.date is naive, and is treated as UTC time - value = value.timetuple() - if not isinstance(value, (int, float)): - # convert struct_time in UTC to UNIX timestamp - value = calendar.timegm(value) - return email.utils.formatdate(value, usegmt=True) - - -def parse_date(ims): - """ Parse rfc1123, rfc850 and asctime timestamps and return UTC epoch. """ - try: - ts = email.utils.parsedate_tz(ims) - return calendar.timegm(ts[:8] + (0, )) - (ts[9] or 0) - except (TypeError, ValueError, IndexError, OverflowError): - return None - - -def parse_auth(header): - """ Parse rfc2617 HTTP authentication header string (basic) and return (user,pass) tuple or None""" - try: - method, data = header.split(None, 1) - if method.lower() == 'basic': - user, pwd = touni(base64.b64decode(tob(data))).split(':', 1) - return user, pwd - except (KeyError, ValueError): - return None - - -def parse_range_header(header, maxlen=0): - """ Yield (start, end) ranges parsed from a HTTP Range header. Skip - unsatisfiable ranges. The end index is non-inclusive.""" - if not header or header[:6] != 'bytes=': return - ranges = [r.split('-', 1) for r in header[6:].split(',') if '-' in r] - for start, end in ranges: - try: - if not start: # bytes=-100 -> last 100 bytes - start, end = max(0, maxlen - int(end)), maxlen - elif not end: # bytes=100- -> all but the first 99 bytes - start, end = int(start), maxlen - else: # bytes=100-200 -> bytes 100-200 (inclusive) - start, end = int(start), min(int(end) + 1, maxlen) - if 0 <= start < end <= maxlen: - yield start, end - except ValueError: - pass - - -#: Header tokenizer used by _parse_http_header() -_hsplit = re.compile('(?:(?:"((?:[^"\\\\]|\\\\.)*)")|([^;,=]+))([;,=]?)').findall - -def _parse_http_header(h): - """ Parses a typical multi-valued and parametrised HTTP header (e.g. Accept headers) and returns a list of values - and parameters. For non-standard or broken input, this implementation may return partial results. - :param h: A header string (e.g. ``text/html,text/plain;q=0.9,*/*;q=0.8``) - :return: List of (value, params) tuples. The second element is a (possibly empty) dict. - """ - values = [] - if '"' not in h: # INFO: Fast path without regexp (~2x faster) - for value in h.split(','): - parts = value.split(';') - values.append((parts[0].strip(), {})) - for attr in parts[1:]: - name, value = attr.split('=', 1) - values[-1][1][name.strip().lower()] = value.strip() - else: - lop, key, attrs = ',', None, {} - for quoted, plain, tok in _hsplit(h): - value = plain.strip() if plain else quoted.replace('\\"', '"') - if lop == ',': - attrs = {} - values.append((value, attrs)) - elif lop == ';': - if tok == '=': - key = value - else: - attrs[value.strip().lower()] = '' - elif lop == '=' and key: - attrs[key.strip().lower()] = value - key = None - lop = tok - return values - - -def _parse_qsl(qs): - r = [] - for pair in qs.split('&'): - if not pair: continue - nv = pair.split('=', 1) - if len(nv) != 2: nv.append('') - key = urlunquote(nv[0].replace('+', ' ')) - value = urlunquote(nv[1].replace('+', ' ')) - r.append((key, value)) - return r - - -def _lscmp(a, b): - """ Compares two strings in a cryptographically safe way: - Runtime is not affected by length of common prefix. """ - return not sum(0 if x == y else 1 - for x, y in zip(a, b)) and len(a) == len(b) - - -def cookie_encode(data, key, digestmod=None): - """ Encode and sign a pickle-able object. Return a (byte) string """ - depr(0, 13, "cookie_encode() will be removed soon.", - "Do not use this API directly.") - digestmod = digestmod or hashlib.sha256 - msg = base64.b64encode(pickle.dumps(data, -1)) - sig = base64.b64encode(hmac.new(tob(key), msg, digestmod=digestmod).digest()) - return tob('!') + sig + tob('?') + msg - - -def cookie_decode(data, key, digestmod=None): - """ Verify and decode an encoded string. Return an object or None.""" - depr(0, 13, "cookie_decode() will be removed soon.", - "Do not use this API directly.") - data = tob(data) - if cookie_is_encoded(data): - sig, msg = data.split(tob('?'), 1) - digestmod = digestmod or hashlib.sha256 - hashed = hmac.new(tob(key), msg, digestmod=digestmod).digest() - if _lscmp(sig[1:], base64.b64encode(hashed)): - return pickle.loads(base64.b64decode(msg)) - return None - - -def cookie_is_encoded(data): - """ Return True if the argument looks like a encoded cookie.""" - depr(0, 13, "cookie_is_encoded() will be removed soon.", - "Do not use this API directly.") - return bool(data.startswith(tob('!')) and tob('?') in data) - - -def html_escape(string): - """ Escape HTML special characters ``&<>`` and quotes ``'"``. """ - return string.replace('&', '&').replace('<', '<').replace('>', '>')\ - .replace('"', '"').replace("'", ''') - - -def html_quote(string): - """ Escape and quote a string to be used as an HTTP attribute.""" - return '"%s"' % html_escape(string).replace('\n', ' ')\ - .replace('\r', ' ').replace('\t', ' ') - - -def yieldroutes(func): - """ Return a generator for routes that match the signature (name, args) - of the func parameter. This may yield more than one route if the function - takes optional keyword arguments. The output is best described by example:: - - a() -> '/a' - b(x, y) -> '/b//' - c(x, y=5) -> '/c/' and '/c//' - d(x=5, y=6) -> '/d' and '/d/' and '/d//' - """ - path = '/' + func.__name__.replace('__', '/').lstrip('/') - spec = getargspec(func) - argc = len(spec[0]) - len(spec[3] or []) - path += ('/<%s>' * argc) % tuple(spec[0][:argc]) - yield path - for arg in spec[0][argc:]: - path += '/<%s>' % arg - yield path - - -def path_shift(script_name, path_info, shift=1): - """ Shift path fragments from PATH_INFO to SCRIPT_NAME and vice versa. - - :return: The modified paths. - :param script_name: The SCRIPT_NAME path. - :param script_name: The PATH_INFO path. - :param shift: The number of path fragments to shift. May be negative to - change the shift direction. (default: 1) - """ - if shift == 0: return script_name, path_info - pathlist = path_info.strip('/').split('/') - scriptlist = script_name.strip('/').split('/') - if pathlist and pathlist[0] == '': pathlist = [] - if scriptlist and scriptlist[0] == '': scriptlist = [] - if 0 < shift <= len(pathlist): - moved = pathlist[:shift] - scriptlist = scriptlist + moved - pathlist = pathlist[shift:] - elif 0 > shift >= -len(scriptlist): - moved = scriptlist[shift:] - pathlist = moved + pathlist - scriptlist = scriptlist[:shift] - else: - empty = 'SCRIPT_NAME' if shift < 0 else 'PATH_INFO' - raise AssertionError("Cannot shift. Nothing left from %s" % empty) - new_script_name = '/' + '/'.join(scriptlist) - new_path_info = '/' + '/'.join(pathlist) - if path_info.endswith('/') and pathlist: new_path_info += '/' - return new_script_name, new_path_info - - -def auth_basic(check, realm="private", text="Access denied"): - """ Callback decorator to require HTTP auth (basic). - TODO: Add route(check_auth=...) parameter. """ - - def decorator(func): - - @functools.wraps(func) - def wrapper(*a, **ka): - user, password = request.auth or (None, None) - if user is None or not check(user, password): - err = HTTPError(401, text) - err.add_header('WWW-Authenticate', 'Basic realm="%s"' % realm) - return err - return func(*a, **ka) - - return wrapper - - return decorator - -# Shortcuts for common Bottle methods. -# They all refer to the current default application. - - -def make_default_app_wrapper(name): - """ Return a callable that relays calls to the current default app. """ - - @functools.wraps(getattr(Bottle, name)) - def wrapper(*a, **ka): - return getattr(app(), name)(*a, **ka) - - return wrapper - - -route = make_default_app_wrapper('route') -get = make_default_app_wrapper('get') -post = make_default_app_wrapper('post') -put = make_default_app_wrapper('put') -delete = make_default_app_wrapper('delete') -patch = make_default_app_wrapper('patch') -error = make_default_app_wrapper('error') -mount = make_default_app_wrapper('mount') -hook = make_default_app_wrapper('hook') -install = make_default_app_wrapper('install') -uninstall = make_default_app_wrapper('uninstall') -url = make_default_app_wrapper('get_url') - - -############################################################################### -# Multipart Handling ########################################################### -############################################################################### -# cgi.FieldStorage was deprecated in Python 3.11 and removed in 3.13 -# This implementation is based on https://github.com/defnull/multipart/ - - -class MultipartError(HTTPError): - def __init__(self, msg): - HTTPError.__init__(self, 400, "MultipartError: " + msg) - - -class _MultipartParser(object): - def __init__( - self, - stream, - boundary, - content_length=-1, - disk_limit=2 ** 30, - mem_limit=2 ** 20, - memfile_limit=2 ** 18, - buffer_size=2 ** 16, - charset="latin1", - ): - self.stream = stream - self.boundary = boundary - self.content_length = content_length - self.disk_limit = disk_limit - self.memfile_limit = memfile_limit - self.mem_limit = min(mem_limit, self.disk_limit) - self.buffer_size = min(buffer_size, self.mem_limit) - self.charset = charset - - if not boundary: - raise MultipartError("No boundary.") - - if self.buffer_size - 6 < len(boundary): # "--boundary--\r\n" - raise MultipartError("Boundary does not fit into buffer_size.") - - def _lineiter(self): - """ Iterate over a binary file-like object (crlf terminated) line by - line. Each line is returned as a (line, crlf) tuple. Lines larger - than buffer_size are split into chunks where all but the last chunk - has an empty string instead of crlf. Maximum chunk size is twice the - buffer size. - """ - - read = self.stream.read - maxread, maxbuf = self.content_length, self.buffer_size - partial = b"" # Contains the last (partial) line - - while True: - chunk = read(maxbuf if maxread < 0 else min(maxbuf, maxread)) - maxread -= len(chunk) - if not chunk: - if partial: - yield partial, b'' - break - - if partial: - chunk = partial + chunk - - scanpos = 0 - while True: - i = chunk.find(b'\r\n', scanpos) - if i >= 0: - yield chunk[scanpos:i], b'\r\n' - scanpos = i + 2 - else: # CRLF not found - partial = chunk[scanpos:] if scanpos else chunk - break - - if len(partial) > maxbuf: - yield partial[:-1], b"" - partial = partial[-1:] - - def parse(self): - """ Return a MultiPart iterator. Can only be called once. """ - - lines, line = self._lineiter(), "" - separator = b"--" + tob(self.boundary) - terminator = separator + b"--" - mem_used, disk_used = 0, 0 # Track used resources to prevent DoS - is_tail = False # True if the last line was incomplete (cutted) - - # Consume first boundary. Ignore any preamble, as required by RFC - # 2046, section 5.1.1. - for line, nl in lines: - if line in (separator, terminator): - break - else: - raise MultipartError("Stream does not contain boundary") - - # First line is termainating boundary -> empty multipart stream - if line == terminator: - for _ in lines: - raise MultipartError("Found data after empty multipart stream") - return - - part_options = { - "buffer_size": self.buffer_size, - "memfile_limit": self.memfile_limit, - "charset": self.charset, - } - part = _MultipartPart(**part_options) - - for line, nl in lines: - if not is_tail and (line == separator or line == terminator): - part.finish() - if part.is_buffered(): - mem_used += part.size - else: - disk_used += part.size - yield part - if line == terminator: - break - part = _MultipartPart(**part_options) - else: - is_tail = not nl # The next line continues this one - try: - part.feed(line, nl) - if part.is_buffered(): - if part.size + mem_used > self.mem_limit: - raise MultipartError("Memory limit reached.") - elif part.size + disk_used > self.disk_limit: - raise MultipartError("Disk limit reached.") - except MultipartError: - part.close() - raise - else: - part.close() - - if line != terminator: - raise MultipartError("Unexpected end of multipart stream.") - - -class _MultipartPart(object): - def __init__(self, buffer_size=2 ** 16, memfile_limit=2 ** 18, charset="latin1"): - self.headerlist = [] - self.headers = None - self.file = False - self.size = 0 - self._buf = b"" - self.disposition = None - self.name = None - self.filename = None - self.content_type = None - self.charset = charset - self.memfile_limit = memfile_limit - self.buffer_size = buffer_size - - def feed(self, line, nl=""): - if self.file: - return self.write_body(line, nl) - return self.write_header(line, nl) - - def write_header(self, line, nl): - line = line.decode(self.charset) - - if not nl: - raise MultipartError("Unexpected end of line in header.") - - if not line.strip(): # blank line -> end of header segment - self.finish_header() - elif line[0] in " \t" and self.headerlist: - name, value = self.headerlist.pop() - self.headerlist.append((name, value + line.strip())) - else: - if ":" not in line: - raise MultipartError("Syntax error in header: No colon.") - - name, value = line.split(":", 1) - self.headerlist.append((name.strip(), value.strip())) - - def write_body(self, line, nl): - if not line and not nl: - return # This does not even flush the buffer - - self.size += len(line) + len(self._buf) - self.file.write(self._buf + line) - self._buf = nl - - if self.content_length > 0 and self.size > self.content_length: - raise MultipartError("Size of body exceeds Content-Length header.") - - if self.size > self.memfile_limit and isinstance(self.file, BytesIO): - self.file, old = NamedTemporaryFile(mode="w+b"), self.file - old.seek(0) - - copied, maxcopy, chunksize = 0, self.size, self.buffer_size - read, write = old.read, self.file.write - while copied < maxcopy: - chunk = read(min(chunksize, maxcopy - copied)) - write(chunk) - copied += len(chunk) - - def finish_header(self): - self.file = BytesIO() - self.headers = HeaderDict(self.headerlist) - content_disposition = self.headers.get("Content-Disposition") - content_type = self.headers.get("Content-Type") - - if not content_disposition: - raise MultipartError("Content-Disposition header is missing.") - - self.disposition, self.options = _parse_http_header(content_disposition)[0] - self.name = self.options.get("name") - if "filename" in self.options: - self.filename = self.options.get("filename") - if self.filename[1:3] == ":\\" or self.filename[:2] == "\\\\": - self.filename = self.filename.split("\\")[-1] # ie6 bug - - self.content_type, options = _parse_http_header(content_type)[0] if content_type else (None, {}) - self.charset = options.get("charset") or self.charset - - self.content_length = int(self.headers.get("Content-Length", "-1")) - - def finish(self): - if not self.file: - raise MultipartError("Incomplete part: Header section not closed.") - self.file.seek(0) - - def is_buffered(self): - """ Return true if the data is fully buffered in memory.""" - return isinstance(self.file, BytesIO) - - @property - def value(self): - """ Data decoded with the specified charset """ - - return self.raw.decode(self.charset) - - @property - def raw(self): - """ Data without decoding """ - pos = self.file.tell() - self.file.seek(0) - - try: - return self.file.read() - finally: - self.file.seek(pos) - - def close(self): - if self.file: - self.file.close() - self.file = False - -############################################################################### -# Server Adapter ############################################################### -############################################################################### - -# Before you edit or add a server adapter, please read: -# - https://github.com/bottlepy/bottle/pull/647#issuecomment-60152870 -# - https://github.com/bottlepy/bottle/pull/865#issuecomment-242795341 - -class ServerAdapter(object): - quiet = False - - def __init__(self, host='127.0.0.1', port=8080, **options): - self.options = options - self.host = host - self.port = int(port) - - def run(self, handler): # pragma: no cover - pass - - def __repr__(self): - args = ', '.join('%s=%s' % (k, repr(v)) - for k, v in self.options.items()) - return "%s(%s)" % (self.__class__.__name__, args) - - -class CGIServer(ServerAdapter): - quiet = True - - def run(self, handler): # pragma: no cover - from wsgiref.handlers import CGIHandler - - def fixed_environ(environ, start_response): - environ.setdefault('PATH_INFO', '') - return handler(environ, start_response) - - CGIHandler().run(fixed_environ) - - -class FlupFCGIServer(ServerAdapter): - def run(self, handler): # pragma: no cover - import flup.server.fcgi - self.options.setdefault('bindAddress', (self.host, self.port)) - flup.server.fcgi.WSGIServer(handler, **self.options).run() - - -class WSGIRefServer(ServerAdapter): - def run(self, app): # pragma: no cover - from wsgiref.simple_server import make_server - from wsgiref.simple_server import WSGIRequestHandler, WSGIServer - import socket - - class FixedHandler(WSGIRequestHandler): - def address_string(self): # Prevent reverse DNS lookups please. - return self.client_address[0] - - def log_request(*args, **kw): - if not self.quiet: - return WSGIRequestHandler.log_request(*args, **kw) - - handler_cls = self.options.get('handler_class', FixedHandler) - server_cls = self.options.get('server_class', WSGIServer) - - if ':' in self.host: # Fix wsgiref for IPv6 addresses. - if getattr(server_cls, 'address_family') == socket.AF_INET: - - class server_cls(server_cls): - address_family = socket.AF_INET6 - - self.srv = make_server(self.host, self.port, app, server_cls, - handler_cls) - self.port = self.srv.server_port # update port actual port (0 means random) - try: - self.srv.serve_forever() - except KeyboardInterrupt: - self.srv.server_close() # Prevent ResourceWarning: unclosed socket - raise - - -class CherryPyServer(ServerAdapter): - def run(self, handler): # pragma: no cover - depr(0, 13, "The wsgi server part of cherrypy was split into a new " - "project called 'cheroot'.", "Use the 'cheroot' server " - "adapter instead of cherrypy.") - from cherrypy import wsgiserver # This will fail for CherryPy >= 9 - - self.options['bind_addr'] = (self.host, self.port) - self.options['wsgi_app'] = handler - - certfile = self.options.get('certfile') - if certfile: - del self.options['certfile'] - keyfile = self.options.get('keyfile') - if keyfile: - del self.options['keyfile'] - - server = wsgiserver.CherryPyWSGIServer(**self.options) - if certfile: - server.ssl_certificate = certfile - if keyfile: - server.ssl_private_key = keyfile - - try: - server.start() - finally: - server.stop() - - -class CherootServer(ServerAdapter): - def run(self, handler): # pragma: no cover - from cheroot import wsgi - from cheroot.ssl import builtin - self.options['bind_addr'] = (self.host, self.port) - self.options['wsgi_app'] = handler - certfile = self.options.pop('certfile', None) - keyfile = self.options.pop('keyfile', None) - chainfile = self.options.pop('chainfile', None) - server = wsgi.Server(**self.options) - if certfile and keyfile: - server.ssl_adapter = builtin.BuiltinSSLAdapter( - certfile, keyfile, chainfile) - try: - server.start() - finally: - server.stop() - - -class WaitressServer(ServerAdapter): - def run(self, handler): - from waitress import serve - serve(handler, host=self.host, port=self.port, _quiet=self.quiet, **self.options) - - -class PasteServer(ServerAdapter): - def run(self, handler): # pragma: no cover - from paste import httpserver - from paste.translogger import TransLogger - handler = TransLogger(handler, setup_console_handler=(not self.quiet)) - httpserver.serve(handler, - host=self.host, - port=str(self.port), **self.options) - - -class MeinheldServer(ServerAdapter): - def run(self, handler): - from meinheld import server - server.listen((self.host, self.port)) - server.run(handler) - - -class FapwsServer(ServerAdapter): - """ Extremely fast webserver using libev. See https://github.com/william-os4y/fapws3 """ - - def run(self, handler): # pragma: no cover - depr(0, 13, "fapws3 is not maintained and support will be dropped.") - import fapws._evwsgi as evwsgi - from fapws import base, config - port = self.port - if float(config.SERVER_IDENT[-2:]) > 0.4: - # fapws3 silently changed its API in 0.5 - port = str(port) - evwsgi.start(self.host, port) - # fapws3 never releases the GIL. Complain upstream. I tried. No luck. - if 'BOTTLE_CHILD' in os.environ and not self.quiet: - _stderr("WARNING: Auto-reloading does not work with Fapws3.") - _stderr(" (Fapws3 breaks python thread support)") - evwsgi.set_base_module(base) - - def app(environ, start_response): - environ['wsgi.multiprocess'] = False - return handler(environ, start_response) - - evwsgi.wsgi_cb(('', app)) - evwsgi.run() - - -class TornadoServer(ServerAdapter): - """ The super hyped asynchronous server by facebook. Untested. """ - - def run(self, handler): # pragma: no cover - import tornado.wsgi, tornado.httpserver, tornado.ioloop - container = tornado.wsgi.WSGIContainer(handler) - server = tornado.httpserver.HTTPServer(container) - server.listen(port=self.port, address=self.host) - tornado.ioloop.IOLoop.instance().start() - - -class AppEngineServer(ServerAdapter): - """ Adapter for Google App Engine. """ - quiet = True - - def run(self, handler): - depr(0, 13, "AppEngineServer no longer required", - "Configure your application directly in your app.yaml") - from google.appengine.ext.webapp import util - # A main() function in the handler script enables 'App Caching'. - # Lets makes sure it is there. This _really_ improves performance. - module = sys.modules.get('__main__') - if module and not hasattr(module, 'main'): - module.main = lambda: util.run_wsgi_app(handler) - util.run_wsgi_app(handler) - - -class TwistedServer(ServerAdapter): - """ Untested. """ - - def run(self, handler): - from twisted.web import server, wsgi - from twisted.python.threadpool import ThreadPool - from twisted.internet import reactor - thread_pool = ThreadPool() - thread_pool.start() - reactor.addSystemEventTrigger('after', 'shutdown', thread_pool.stop) - factory = server.Site(wsgi.WSGIResource(reactor, thread_pool, handler)) - reactor.listenTCP(self.port, factory, interface=self.host) - if not reactor.running: - reactor.run() - - -class DieselServer(ServerAdapter): - """ Untested. """ - - def run(self, handler): - depr(0, 13, "Diesel is not tested or supported and will be removed.") - from diesel.protocols.wsgi import WSGIApplication - app = WSGIApplication(handler, port=self.port) - app.run() - - -class GeventServer(ServerAdapter): - """ Untested. Options: - - * See gevent.wsgi.WSGIServer() documentation for more options. - """ - - def run(self, handler): - from gevent import pywsgi, local - if not isinstance(threading.local(), local.local): - msg = "Bottle requires gevent.monkey.patch_all() (before import)" - raise RuntimeError(msg) - if self.quiet: - self.options['log'] = None - address = (self.host, self.port) - server = pywsgi.WSGIServer(address, handler, **self.options) - if 'BOTTLE_CHILD' in os.environ: - import signal - signal.signal(signal.SIGINT, lambda s, f: server.stop()) - server.serve_forever() - - -class GunicornServer(ServerAdapter): - """ Untested. See http://gunicorn.org/configure.html for options. """ - - def run(self, handler): - from gunicorn.app.base import BaseApplication - - if self.host.startswith("unix:"): - config = {'bind': self.host} - else: - config = {'bind': "%s:%d" % (self.host, self.port)} - - config.update(self.options) - - class GunicornApplication(BaseApplication): - def load_config(self): - for key, value in config.items(): - self.cfg.set(key, value) - - def load(self): - return handler - - GunicornApplication().run() - - -class EventletServer(ServerAdapter): - """ Untested. Options: - - * `backlog` adjust the eventlet backlog parameter which is the maximum - number of queued connections. Should be at least 1; the maximum - value is system-dependent. - * `family`: (default is 2) socket family, optional. See socket - documentation for available families. - """ - - def run(self, handler): - from eventlet import wsgi, listen, patcher - if not patcher.is_monkey_patched(os): - msg = "Bottle requires eventlet.monkey_patch() (before import)" - raise RuntimeError(msg) - socket_args = {} - for arg in ('backlog', 'family'): - try: - socket_args[arg] = self.options.pop(arg) - except KeyError: - pass - address = (self.host, self.port) - try: - wsgi.server(listen(address, **socket_args), handler, - log_output=(not self.quiet)) - except TypeError: - # Fallback, if we have old version of eventlet - wsgi.server(listen(address), handler) - - -class BjoernServer(ServerAdapter): - """ Fast server written in C: https://github.com/jonashaag/bjoern """ - - def run(self, handler): - from bjoern import run - run(handler, self.host, self.port, reuse_port=True) - -class AsyncioServerAdapter(ServerAdapter): - """ Extend ServerAdapter for adding custom event loop """ - def get_event_loop(self): - pass - -class AiohttpServer(AsyncioServerAdapter): - """ Asynchronous HTTP client/server framework for asyncio - https://pypi.python.org/pypi/aiohttp/ - https://pypi.org/project/aiohttp-wsgi/ - """ - - def get_event_loop(self): - import asyncio - return asyncio.new_event_loop() - - def run(self, handler): - import asyncio - from aiohttp_wsgi.wsgi import serve - self.loop = self.get_event_loop() - asyncio.set_event_loop(self.loop) - - if 'BOTTLE_CHILD' in os.environ: - import signal - signal.signal(signal.SIGINT, lambda s, f: self.loop.stop()) - - serve(handler, host=self.host, port=self.port) - - -class AiohttpUVLoopServer(AiohttpServer): - """uvloop - https://github.com/MagicStack/uvloop - """ - def get_event_loop(self): - import uvloop - return uvloop.new_event_loop() - -class AutoServer(ServerAdapter): - """ Untested. """ - adapters = [WaitressServer, PasteServer, TwistedServer, CherryPyServer, - CherootServer, WSGIRefServer] - - def run(self, handler): - for sa in self.adapters: - try: - return sa(self.host, self.port, **self.options).run(handler) - except ImportError: - pass - - -server_names = { - 'cgi': CGIServer, - 'flup': FlupFCGIServer, - 'wsgiref': WSGIRefServer, - 'waitress': WaitressServer, - 'cherrypy': CherryPyServer, - 'cheroot': CherootServer, - 'paste': PasteServer, - 'fapws3': FapwsServer, - 'tornado': TornadoServer, - 'gae': AppEngineServer, - 'twisted': TwistedServer, - 'diesel': DieselServer, - 'meinheld': MeinheldServer, - 'gunicorn': GunicornServer, - 'eventlet': EventletServer, - 'gevent': GeventServer, - 'bjoern': BjoernServer, - 'aiohttp': AiohttpServer, - 'uvloop': AiohttpUVLoopServer, - 'auto': AutoServer, -} - -############################################################################### -# Application Control ########################################################## -############################################################################### - - -def load(target, **namespace): - """ Import a module or fetch an object from a module. - - * ``package.module`` returns `module` as a module object. - * ``pack.mod:name`` returns the module variable `name` from `pack.mod`. - * ``pack.mod:func()`` calls `pack.mod.func()` and returns the result. - - The last form accepts not only function calls, but any type of - expression. Keyword arguments passed to this function are available as - local variables. Example: ``import_string('re:compile(x)', x='[a-z]')`` - """ - module, target = target.split(":", 1) if ':' in target else (target, None) - if module not in sys.modules: __import__(module) - if not target: return sys.modules[module] - if target.isalnum(): return getattr(sys.modules[module], target) - package_name = module.split('.')[0] - namespace[package_name] = sys.modules[package_name] - return eval('%s.%s' % (module, target), namespace) - - -def load_app(target): - """ Load a bottle application from a module and make sure that the import - does not affect the current default application, but returns a separate - application object. See :func:`load` for the target parameter. """ - global NORUN - NORUN, nr_old = True, NORUN - tmp = default_app.push() # Create a new "default application" - try: - rv = load(target) # Import the target module - return rv if callable(rv) else tmp - finally: - default_app.remove(tmp) # Remove the temporary added default application - NORUN = nr_old - - -_debug = debug - - -def run(app=None, - server='wsgiref', - host='127.0.0.1', - port=8080, - interval=1, - reloader=False, - quiet=False, - plugins=None, - debug=None, - config=None, **kargs): - """ Start a server instance. This method blocks until the server terminates. - - :param app: WSGI application or target string supported by - :func:`load_app`. (default: :func:`default_app`) - :param server: Server adapter to use. See :data:`server_names` keys - for valid names or pass a :class:`ServerAdapter` subclass. - (default: `wsgiref`) - :param host: Server address to bind to. Pass ``0.0.0.0`` to listens on - all interfaces including the external one. (default: 127.0.0.1) - :param port: Server port to bind to. Values below 1024 require root - privileges. (default: 8080) - :param reloader: Start auto-reloading server? (default: False) - :param interval: Auto-reloader interval in seconds (default: 1) - :param quiet: Suppress output to stdout and stderr? (default: False) - :param options: Options passed to the server adapter. - """ - if NORUN: return - if reloader and not os.environ.get('BOTTLE_CHILD'): - import subprocess - fd, lockfile = tempfile.mkstemp(prefix='bottle.', suffix='.lock') - environ = os.environ.copy() - environ['BOTTLE_CHILD'] = 'true' - environ['BOTTLE_LOCKFILE'] = lockfile - args = [sys.executable] + sys.argv - # If a package was loaded with `python -m`, then `sys.argv` needs to be - # restored to the original value, or imports might break. See #1336 - if getattr(sys.modules.get('__main__'), '__package__', None): - args[1:1] = ["-m", sys.modules['__main__'].__package__] - - try: - os.close(fd) # We never write to this file - while os.path.exists(lockfile): - p = subprocess.Popen(args, env=environ) - while p.poll() is None: - os.utime(lockfile, None) # Tell child we are still alive - time.sleep(interval) - if p.returncode == 3: # Child wants to be restarted - continue - sys.exit(p.returncode) - except KeyboardInterrupt: - pass - finally: - if os.path.exists(lockfile): - os.unlink(lockfile) - return - - try: - if debug is not None: _debug(debug) - app = app or default_app() - if isinstance(app, basestring): - app = load_app(app) - if not callable(app): - raise ValueError("Application is not callable: %r" % app) - - for plugin in plugins or []: - if isinstance(plugin, basestring): - plugin = load(plugin) - app.install(plugin) - - if config: - app.config.update(config) - - if server in server_names: - server = server_names.get(server) - if isinstance(server, basestring): - server = load(server) - if isinstance(server, type): - server = server(host=host, port=port, **kargs) - if not isinstance(server, ServerAdapter): - raise ValueError("Unknown or unsupported server: %r" % server) - - server.quiet = server.quiet or quiet - if not server.quiet: - _stderr("Bottle v%s server starting up (using %s)..." % - (__version__, repr(server))) - if server.host.startswith("unix:"): - _stderr("Listening on %s" % server.host) - else: - _stderr("Listening on http://%s:%d/" % - (server.host, server.port)) - _stderr("Hit Ctrl-C to quit.\n") - - if reloader: - lockfile = os.environ.get('BOTTLE_LOCKFILE') - bgcheck = FileCheckerThread(lockfile, interval) - with bgcheck: - server.run(app) - if bgcheck.status == 'reload': - sys.exit(3) - else: - server.run(app) - except KeyboardInterrupt: - pass - except (SystemExit, MemoryError): - raise - except: - if not reloader: raise - if not getattr(server, 'quiet', quiet): - print_exc() - time.sleep(interval) - sys.exit(3) - - -class FileCheckerThread(threading.Thread): - """ Interrupt main-thread as soon as a changed module file is detected, - the lockfile gets deleted or gets too old. """ - - def __init__(self, lockfile, interval): - threading.Thread.__init__(self) - self.daemon = True - self.lockfile, self.interval = lockfile, interval - #: Is one of 'reload', 'error' or 'exit' - self.status = None - - def run(self): - exists = os.path.exists - mtime = lambda p: os.stat(p).st_mtime - files = dict() - - for module in list(sys.modules.values()): - path = getattr(module, '__file__', '') or '' - if path[-4:] in ('.pyo', '.pyc'): path = path[:-1] - if path and exists(path): files[path] = mtime(path) - - while not self.status: - if not exists(self.lockfile)\ - or mtime(self.lockfile) < time.time() - self.interval - 5: - self.status = 'error' - thread.interrupt_main() - for path, lmtime in list(files.items()): - if not exists(path) or mtime(path) > lmtime: - self.status = 'reload' - thread.interrupt_main() - break - time.sleep(self.interval) - - def __enter__(self): - self.start() - - def __exit__(self, exc_type, *_): - if not self.status: self.status = 'exit' # silent exit - self.join() - return exc_type is not None and issubclass(exc_type, KeyboardInterrupt) - -############################################################################### -# Template Adapters ############################################################ -############################################################################### - - -class TemplateError(BottleException): - pass - - -class BaseTemplate(object): - """ Base class and minimal API for template adapters """ - extensions = ['tpl', 'html', 'thtml', 'stpl'] - settings = {} #used in prepare() - defaults = {} #used in render() - - def __init__(self, - source=None, - name=None, - lookup=None, - encoding='utf8', **settings): - """ Create a new template. - If the source parameter (str or buffer) is missing, the name argument - is used to guess a template filename. Subclasses can assume that - self.source and/or self.filename are set. Both are strings. - The lookup, encoding and settings parameters are stored as instance - variables. - The lookup parameter stores a list containing directory paths. - The encoding parameter should be used to decode byte strings or files. - The settings parameter contains a dict for engine-specific settings. - """ - self.name = name - self.source = source.read() if hasattr(source, 'read') else source - self.filename = source.filename if hasattr(source, 'filename') else None - self.lookup = [os.path.abspath(x) for x in lookup] if lookup else [] - self.encoding = encoding - self.settings = self.settings.copy() # Copy from class variable - self.settings.update(settings) # Apply - if not self.source and self.name: - self.filename = self.search(self.name, self.lookup) - if not self.filename: - raise TemplateError('Template %s not found.' % repr(name)) - if not self.source and not self.filename: - raise TemplateError('No template specified.') - self.prepare(**self.settings) - - @classmethod - def search(cls, name, lookup=None): - """ Search name in all directories specified in lookup. - First without, then with common extensions. Return first hit. """ - if not lookup: - raise depr(0, 12, "Empty template lookup path.", "Configure a template lookup path.") - - if os.path.isabs(name): - raise depr(0, 12, "Use of absolute path for template name.", - "Refer to templates with names or paths relative to the lookup path.") - - for spath in lookup: - spath = os.path.abspath(spath) + os.sep - fname = os.path.abspath(os.path.join(spath, name)) - if not fname.startswith(spath): continue - if os.path.isfile(fname): return fname - for ext in cls.extensions: - if os.path.isfile('%s.%s' % (fname, ext)): - return '%s.%s' % (fname, ext) - - @classmethod - def global_config(cls, key, *args): - """ This reads or sets the global settings stored in class.settings. """ - if args: - cls.settings = cls.settings.copy() # Make settings local to class - cls.settings[key] = args[0] - else: - return cls.settings[key] - - def prepare(self, **options): - """ Run preparations (parsing, caching, ...). - It should be possible to call this again to refresh a template or to - update settings. - """ - raise NotImplementedError - - def render(self, *args, **kwargs): - """ Render the template with the specified local variables and return - a single byte or unicode string. If it is a byte string, the encoding - must match self.encoding. This method must be thread-safe! - Local variables may be provided in dictionaries (args) - or directly, as keywords (kwargs). - """ - raise NotImplementedError - - -class MakoTemplate(BaseTemplate): - def prepare(self, **options): - from mako.template import Template - from mako.lookup import TemplateLookup - options.update({'input_encoding': self.encoding}) - options.setdefault('format_exceptions', bool(DEBUG)) - lookup = TemplateLookup(directories=self.lookup, **options) - if self.source: - self.tpl = Template(self.source, lookup=lookup, **options) - else: - self.tpl = Template(uri=self.name, - filename=self.filename, - lookup=lookup, **options) - - def render(self, *args, **kwargs): - for dictarg in args: - kwargs.update(dictarg) - _defaults = self.defaults.copy() - _defaults.update(kwargs) - return self.tpl.render(**_defaults) - - -class CheetahTemplate(BaseTemplate): - def prepare(self, **options): - from Cheetah.Template import Template - self.context = threading.local() - self.context.vars = {} - options['searchList'] = [self.context.vars] - if self.source: - self.tpl = Template(source=self.source, **options) - else: - self.tpl = Template(file=self.filename, **options) - - def render(self, *args, **kwargs): - for dictarg in args: - kwargs.update(dictarg) - self.context.vars.update(self.defaults) - self.context.vars.update(kwargs) - out = str(self.tpl) - self.context.vars.clear() - return out - - -class Jinja2Template(BaseTemplate): - def prepare(self, filters=None, tests=None, globals={}, **kwargs): - from jinja2 import Environment, FunctionLoader - self.env = Environment(loader=FunctionLoader(self.loader), **kwargs) - if filters: self.env.filters.update(filters) - if tests: self.env.tests.update(tests) - if globals: self.env.globals.update(globals) - if self.source: - self.tpl = self.env.from_string(self.source) - else: - self.tpl = self.env.get_template(self.name) - - def render(self, *args, **kwargs): - for dictarg in args: - kwargs.update(dictarg) - _defaults = self.defaults.copy() - _defaults.update(kwargs) - return self.tpl.render(**_defaults) - - def loader(self, name): - if name == self.filename: - fname = name - else: - fname = self.search(name, self.lookup) - if not fname: return - with open(fname, "rb") as f: - return (f.read().decode(self.encoding), fname, lambda: False) - - -class SimpleTemplate(BaseTemplate): - def prepare(self, - escape_func=html_escape, - noescape=False, - syntax=None, **ka): - self.cache = {} - enc = self.encoding - self._str = lambda x: touni(x, enc) - self._escape = lambda x: escape_func(touni(x, enc)) - self.syntax = syntax - if noescape: - self._str, self._escape = self._escape, self._str - - @cached_property - def co(self): - return compile(self.code, self.filename or '', 'exec') - - @cached_property - def code(self): - source = self.source - if not source: - with open(self.filename, 'rb') as f: - source = f.read() - try: - source, encoding = touni(source), 'utf8' - except UnicodeError: - raise depr(0, 11, 'Unsupported template encodings.', 'Use utf-8 for templates.') - parser = StplParser(source, encoding=encoding, syntax=self.syntax) - code = parser.translate() - self.encoding = parser.encoding - return code - - def _rebase(self, _env, _name=None, **kwargs): - _env['_rebase'] = (_name, kwargs) - - def _include(self, _env, _name=None, **kwargs): - env = _env.copy() - env.update(kwargs) - if _name not in self.cache: - self.cache[_name] = self.__class__(name=_name, lookup=self.lookup, syntax=self.syntax) - return self.cache[_name].execute(env['_stdout'], env) - - def execute(self, _stdout, kwargs): - env = self.defaults.copy() - env.update(kwargs) - env.update({ - '_stdout': _stdout, - '_printlist': _stdout.extend, - 'include': functools.partial(self._include, env), - 'rebase': functools.partial(self._rebase, env), - '_rebase': None, - '_str': self._str, - '_escape': self._escape, - 'get': env.get, - 'setdefault': env.setdefault, - 'defined': env.__contains__ - }) - exec(self.co, env) - if env.get('_rebase'): - subtpl, rargs = env.pop('_rebase') - rargs['base'] = ''.join(_stdout) #copy stdout - del _stdout[:] # clear stdout - return self._include(env, subtpl, **rargs) - return env - - def render(self, *args, **kwargs): - """ Render the template using keyword arguments as local variables. """ - env = {} - stdout = [] - for dictarg in args: - env.update(dictarg) - env.update(kwargs) - self.execute(stdout, env) - return ''.join(stdout) - - -class StplSyntaxError(TemplateError): - pass - - -class StplParser(object): - """ Parser for stpl templates. """ - _re_cache = {} #: Cache for compiled re patterns - - # This huge pile of voodoo magic splits python code into 8 different tokens. - # We use the verbose (?x) regex mode to make this more manageable - - _re_tok = r'''( - [urbURB]* - (?: ''(?!') - |""(?!") - |'{6} - |"{6} - |'(?:[^\\']|\\.)+?' - |"(?:[^\\"]|\\.)+?" - |'{3}(?:[^\\]|\\.|\n)+?'{3} - |"{3}(?:[^\\]|\\.|\n)+?"{3} - ) - )''' - - _re_inl = _re_tok.replace(r'|\n', '') # We re-use this string pattern later - - _re_tok += r''' - # 2: Comments (until end of line, but not the newline itself) - |(\#.*) - - # 3: Open and close (4) grouping tokens - |([\[\{\(]) - |([\]\}\)]) - - # 5,6: Keywords that start or continue a python block (only start of line) - |^([\ \t]*(?:if|for|while|with|try|def|class)\b) - |^([\ \t]*(?:elif|else|except|finally)\b) - - # 7: Our special 'end' keyword (but only if it stands alone) - |((?:^|;)[\ \t]*end[\ \t]*(?=(?:%(block_close)s[\ \t]*)?\r?$|;|\#)) - - # 8: A customizable end-of-code-block template token (only end of line) - |(%(block_close)s[\ \t]*(?=\r?$)) - - # 9: And finally, a single newline. The 10th token is 'everything else' - |(\r?\n) - ''' - - # Match the start tokens of code areas in a template - _re_split = r'''(?m)^[ \t]*(\\?)((%(line_start)s)|(%(block_start)s))''' - # Match inline statements (may contain python strings) - _re_inl = r'''%%(inline_start)s((?:%s|[^'"\n])*?)%%(inline_end)s''' % _re_inl - - # add the flag in front of the regexp to avoid Deprecation warning (see Issue #949) - # verbose and dot-matches-newline mode - _re_tok = '(?mx)' + _re_tok - _re_inl = '(?mx)' + _re_inl - - - default_syntax = '<% %> % {{ }}' - - def __init__(self, source, syntax=None, encoding='utf8'): - self.source, self.encoding = touni(source, encoding), encoding - self.set_syntax(syntax or self.default_syntax) - self.code_buffer, self.text_buffer = [], [] - self.lineno, self.offset = 1, 0 - self.indent, self.indent_mod = 0, 0 - self.paren_depth = 0 - - def get_syntax(self): - """ Tokens as a space separated string (default: <% %> % {{ }}) """ - return self._syntax - - def set_syntax(self, syntax): - self._syntax = syntax - self._tokens = syntax.split() - if syntax not in self._re_cache: - names = 'block_start block_close line_start inline_start inline_end' - etokens = map(re.escape, self._tokens) - pattern_vars = dict(zip(names.split(), etokens)) - patterns = (self._re_split, self._re_tok, self._re_inl) - patterns = [re.compile(p % pattern_vars) for p in patterns] - self._re_cache[syntax] = patterns - self.re_split, self.re_tok, self.re_inl = self._re_cache[syntax] - - syntax = property(get_syntax, set_syntax) - - def translate(self): - if self.offset: raise RuntimeError('Parser is a one time instance.') - while True: - m = self.re_split.search(self.source, pos=self.offset) - if m: - text = self.source[self.offset:m.start()] - self.text_buffer.append(text) - self.offset = m.end() - if m.group(1): # Escape syntax - line, sep, _ = self.source[self.offset:].partition('\n') - self.text_buffer.append(self.source[m.start():m.start(1)] + - m.group(2) + line + sep) - self.offset += len(line + sep) - continue - self.flush_text() - self.offset += self.read_code(self.source[self.offset:], - multiline=bool(m.group(4))) - else: - break - self.text_buffer.append(self.source[self.offset:]) - self.flush_text() - return ''.join(self.code_buffer) - - def read_code(self, pysource, multiline): - code_line, comment = '', '' - offset = 0 - while True: - m = self.re_tok.search(pysource, pos=offset) - if not m: - code_line += pysource[offset:] - offset = len(pysource) - self.write_code(code_line.strip(), comment) - break - code_line += pysource[offset:m.start()] - offset = m.end() - _str, _com, _po, _pc, _blk1, _blk2, _end, _cend, _nl = m.groups() - if self.paren_depth > 0 and (_blk1 or _blk2): # a if b else c - code_line += _blk1 or _blk2 - continue - if _str: # Python string - code_line += _str - elif _com: # Python comment (up to EOL) - comment = _com - if multiline and _com.strip().endswith(self._tokens[1]): - multiline = False # Allow end-of-block in comments - elif _po: # open parenthesis - self.paren_depth += 1 - code_line += _po - elif _pc: # close parenthesis - if self.paren_depth > 0: - # we could check for matching parentheses here, but it's - # easier to leave that to python - just check counts - self.paren_depth -= 1 - code_line += _pc - elif _blk1: # Start-block keyword (if/for/while/def/try/...) - code_line = _blk1 - self.indent += 1 - self.indent_mod -= 1 - elif _blk2: # Continue-block keyword (else/elif/except/...) - code_line = _blk2 - self.indent_mod -= 1 - elif _cend: # The end-code-block template token (usually '%>') - if multiline: multiline = False - else: code_line += _cend - elif _end: - self.indent -= 1 - self.indent_mod += 1 - else: # \n - self.write_code(code_line.strip(), comment) - self.lineno += 1 - code_line, comment, self.indent_mod = '', '', 0 - if not multiline: - break - - return offset - - def flush_text(self): - text = ''.join(self.text_buffer) - del self.text_buffer[:] - if not text: return - parts, pos, nl = [], 0, '\\\n' + ' ' * self.indent - for m in self.re_inl.finditer(text): - prefix, pos = text[pos:m.start()], m.end() - if prefix: - parts.append(nl.join(map(repr, prefix.splitlines(True)))) - if prefix.endswith('\n'): parts[-1] += nl - parts.append(self.process_inline(m.group(1).strip())) - if pos < len(text): - prefix = text[pos:] - lines = prefix.splitlines(True) - if lines[-1].endswith('\\\\\n'): lines[-1] = lines[-1][:-3] - elif lines[-1].endswith('\\\\\r\n'): lines[-1] = lines[-1][:-4] - parts.append(nl.join(map(repr, lines))) - code = '_printlist((%s,))' % ', '.join(parts) - self.lineno += code.count('\n') + 1 - self.write_code(code) - - @staticmethod - def process_inline(chunk): - if chunk[0] == '!': return '_str(%s)' % chunk[1:] - return '_escape(%s)' % chunk - - def write_code(self, line, comment=''): - code = ' ' * (self.indent + self.indent_mod) - code += line.lstrip() + comment + '\n' - self.code_buffer.append(code) - - -def template(*args, **kwargs): - """ - Get a rendered template as a string iterator. - You can use a name, a filename or a template string as first parameter. - Template rendering arguments can be passed as dictionaries - or directly (as keyword arguments). - """ - tpl = args[0] if args else None - for dictarg in args[1:]: - kwargs.update(dictarg) - adapter = kwargs.pop('template_adapter', SimpleTemplate) - lookup = kwargs.pop('template_lookup', TEMPLATE_PATH) - tplid = (id(lookup), tpl) - if tplid not in TEMPLATES or DEBUG: - settings = kwargs.pop('template_settings', {}) - if isinstance(tpl, adapter): - TEMPLATES[tplid] = tpl - if settings: TEMPLATES[tplid].prepare(**settings) - elif "\n" in tpl or "{" in tpl or "%" in tpl or '$' in tpl: - TEMPLATES[tplid] = adapter(source=tpl, lookup=lookup, **settings) - else: - TEMPLATES[tplid] = adapter(name=tpl, lookup=lookup, **settings) - if not TEMPLATES[tplid]: - abort(500, 'Template (%s) not found' % tpl) - return TEMPLATES[tplid].render(kwargs) - - -mako_template = functools.partial(template, template_adapter=MakoTemplate) -cheetah_template = functools.partial(template, - template_adapter=CheetahTemplate) -jinja2_template = functools.partial(template, template_adapter=Jinja2Template) - - -def view(tpl_name, **defaults): - """ Decorator: renders a template for a handler. - The handler can control its behavior like that: - - - return a dict of template vars to fill out the template - - return something other than a dict and the view decorator will not - process the template, but return the handler result as is. - This includes returning a HTTPResponse(dict) to get, - for instance, JSON with autojson or other castfilters. - """ - - def decorator(func): - - @functools.wraps(func) - def wrapper(*args, **kwargs): - result = func(*args, **kwargs) - if isinstance(result, (dict, DictMixin)): - tplvars = defaults.copy() - tplvars.update(result) - return template(tpl_name, **tplvars) - elif result is None: - return template(tpl_name, **defaults) - return result - - return wrapper - - return decorator - - -mako_view = functools.partial(view, template_adapter=MakoTemplate) -cheetah_view = functools.partial(view, template_adapter=CheetahTemplate) -jinja2_view = functools.partial(view, template_adapter=Jinja2Template) - -############################################################################### -# Constants and Globals ######################################################## -############################################################################### - -TEMPLATE_PATH = ['./', './views/'] -TEMPLATES = {} -DEBUG = False -NORUN = False # If set, run() does nothing. Used by load_app() - -#: A dict to map HTTP status codes (e.g. 404) to phrases (e.g. 'Not Found') -HTTP_CODES = httplib.responses.copy() -HTTP_CODES[418] = "I'm a teapot" # RFC 2324 -HTTP_CODES[428] = "Precondition Required" -HTTP_CODES[429] = "Too Many Requests" -HTTP_CODES[431] = "Request Header Fields Too Large" -HTTP_CODES[451] = "Unavailable For Legal Reasons" # RFC 7725 -HTTP_CODES[511] = "Network Authentication Required" -_HTTP_STATUS_LINES = dict((k, '%d %s' % (k, v)) - for (k, v) in HTTP_CODES.items()) - -#: The default template used for error pages. Override with @error() -ERROR_PAGE_TEMPLATE = """ -%%try: - %%from %s import DEBUG, request - - - - Error: {{e.status}} - - - -

Error: {{e.status}}

-

Sorry, the requested URL {{repr(request.url)}} - caused an error:

-
{{e.body}}
- %%if DEBUG and e.exception: -

Exception:

- %%try: - %%exc = repr(e.exception) - %%except: - %%exc = '' %% type(e.exception).__name__ - %%end -
{{exc}}
- %%end - %%if DEBUG and e.traceback: -

Traceback:

-
{{e.traceback}}
- %%end - - -%%except ImportError: - ImportError: Could not generate the error page. Please add bottle to - the import path. -%%end -""" % __name__ - -#: A thread-safe instance of :class:`LocalRequest`. If accessed from within a -#: request callback, this instance always refers to the *current* request -#: (even on a multi-threaded server). -request = LocalRequest() - -#: A thread-safe instance of :class:`LocalResponse`. It is used to change the -#: HTTP response for the *current* request. -response = LocalResponse() - -#: A thread-safe namespace. Not used by Bottle. -local = threading.local() - -# Initialize app stack (create first empty Bottle app now deferred until needed) -# BC: 0.6.4 and needed for run() -apps = app = default_app = AppStack() - -#: A virtual package that redirects import statements. -#: Example: ``import bottle.ext.sqlite`` actually imports `bottle_sqlite`. -ext = _ImportRedirect('bottle.ext' if __name__ == '__main__' else - __name__ + ".ext", 'bottle_%s').module - - -def _main(argv): # pragma: no coverage - args, parser = _cli_parse(argv) - - def _cli_error(cli_msg): - parser.print_help() - _stderr('\nError: %s\n' % cli_msg) - sys.exit(1) - - if args.version: - print('Bottle %s' % __version__) - sys.exit(0) - if not args.app: - _cli_error("No application entry point specified.") - - sys.path.insert(0, '.') - sys.modules.setdefault('bottle', sys.modules['__main__']) - - host, port = (args.bind or 'localhost'), 8080 - if ':' in host and host.rfind(']') < host.rfind(':'): - host, port = host.rsplit(':', 1) - host = host.strip('[]') - - config = ConfigDict() - - for cfile in args.conf or []: - try: - if cfile.endswith('.json'): - with open(cfile, 'rb') as fp: - config.load_dict(json_loads(fp.read())) - else: - config.load_config(cfile) - except configparser.Error as parse_error: - _cli_error(parse_error) - except IOError: - _cli_error("Unable to read config file %r" % cfile) - except (UnicodeError, TypeError, ValueError) as error: - _cli_error("Unable to parse config file %r: %s" % (cfile, error)) - - for cval in args.param or []: - if '=' in cval: - config.update((cval.split('=', 1),)) - else: - config[cval] = True - - run(args.app, - host=host, - port=int(port), - server=args.server, - reloader=args.reload, - plugins=args.plugin, - debug=args.debug, - config=config) - - -def main(): - _main(sys.argv) - - -if __name__ == '__main__': # pragma: no coverage - main() diff --git a/src/.venv/Scripts/convertfromhtml.exe b/src/.venv/Scripts/convertfromhtml.exe deleted file mode 100644 index 10fa7a9..0000000 Binary files a/src/.venv/Scripts/convertfromhtml.exe and /dev/null differ diff --git a/src/.venv/Scripts/coverage-3.12.exe b/src/.venv/Scripts/coverage-3.12.exe deleted file mode 100644 index 3524508..0000000 Binary files a/src/.venv/Scripts/coverage-3.12.exe and /dev/null differ diff --git a/src/.venv/Scripts/coverage.exe b/src/.venv/Scripts/coverage.exe deleted file mode 100644 index 3524508..0000000 Binary files a/src/.venv/Scripts/coverage.exe and /dev/null differ diff --git a/src/.venv/Scripts/coverage3.exe b/src/.venv/Scripts/coverage3.exe deleted file mode 100644 index 3524508..0000000 Binary files a/src/.venv/Scripts/coverage3.exe and /dev/null differ diff --git a/src/.venv/Scripts/deactivate.bat b/src/.venv/Scripts/deactivate.bat deleted file mode 100644 index 62a39a7..0000000 --- a/src/.venv/Scripts/deactivate.bat +++ /dev/null @@ -1,22 +0,0 @@ -@echo off - -if defined _OLD_VIRTUAL_PROMPT ( - set "PROMPT=%_OLD_VIRTUAL_PROMPT%" -) -set _OLD_VIRTUAL_PROMPT= - -if defined _OLD_VIRTUAL_PYTHONHOME ( - set "PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%" - set _OLD_VIRTUAL_PYTHONHOME= -) - -if defined _OLD_VIRTUAL_PATH ( - set "PATH=%_OLD_VIRTUAL_PATH%" -) - -set _OLD_VIRTUAL_PATH= - -set VIRTUAL_ENV= -set VIRTUAL_ENV_PROMPT= - -:END diff --git a/src/.venv/Scripts/django-admin.exe b/src/.venv/Scripts/django-admin.exe deleted file mode 100644 index c1d7aa5..0000000 Binary files a/src/.venv/Scripts/django-admin.exe and /dev/null differ diff --git a/src/.venv/Scripts/markdown_py.exe b/src/.venv/Scripts/markdown_py.exe deleted file mode 100644 index f770471..0000000 Binary files a/src/.venv/Scripts/markdown_py.exe and /dev/null differ diff --git a/src/.venv/Scripts/normalizer.exe b/src/.venv/Scripts/normalizer.exe deleted file mode 100644 index c1a7362..0000000 Binary files a/src/.venv/Scripts/normalizer.exe and /dev/null differ diff --git a/src/.venv/Scripts/openai.exe b/src/.venv/Scripts/openai.exe deleted file mode 100644 index 47f7458..0000000 Binary files a/src/.venv/Scripts/openai.exe and /dev/null differ diff --git a/src/.venv/Scripts/pip.exe b/src/.venv/Scripts/pip.exe deleted file mode 100644 index caf2e56..0000000 Binary files a/src/.venv/Scripts/pip.exe and /dev/null differ diff --git a/src/.venv/Scripts/pip3.12.exe b/src/.venv/Scripts/pip3.12.exe deleted file mode 100644 index caf2e56..0000000 Binary files a/src/.venv/Scripts/pip3.12.exe and /dev/null differ diff --git a/src/.venv/Scripts/pip3.exe b/src/.venv/Scripts/pip3.exe deleted file mode 100644 index caf2e56..0000000 Binary files a/src/.venv/Scripts/pip3.exe and /dev/null differ diff --git a/src/.venv/Scripts/pygmentize.exe b/src/.venv/Scripts/pygmentize.exe deleted file mode 100644 index 9ebb0f5..0000000 Binary files a/src/.venv/Scripts/pygmentize.exe and /dev/null differ diff --git a/src/.venv/Scripts/python.exe b/src/.venv/Scripts/python.exe deleted file mode 100644 index ba0cd04..0000000 Binary files a/src/.venv/Scripts/python.exe and /dev/null differ diff --git a/src/.venv/Scripts/pythonw.exe b/src/.venv/Scripts/pythonw.exe deleted file mode 100644 index 68b3cfe..0000000 Binary files a/src/.venv/Scripts/pythonw.exe and /dev/null differ diff --git a/src/.venv/Scripts/slugify.exe b/src/.venv/Scripts/slugify.exe deleted file mode 100644 index d939198..0000000 Binary files a/src/.venv/Scripts/slugify.exe and /dev/null differ diff --git a/src/.venv/Scripts/sqlformat.exe b/src/.venv/Scripts/sqlformat.exe deleted file mode 100644 index f7b8720..0000000 Binary files a/src/.venv/Scripts/sqlformat.exe and /dev/null differ diff --git a/src/.venv/Scripts/tqdm.exe b/src/.venv/Scripts/tqdm.exe deleted file mode 100644 index e84779e..0000000 Binary files a/src/.venv/Scripts/tqdm.exe and /dev/null differ diff --git a/src/.venv/pyvenv.cfg b/src/.venv/pyvenv.cfg deleted file mode 100644 index 59f67b2..0000000 --- a/src/.venv/pyvenv.cfg +++ /dev/null @@ -1,5 +0,0 @@ -home = C:\Users\86137\AppData\Local\Programs\Python\Python312 -include-system-site-packages = false -version = 3.12.10 -executable = C:\Users\86137\AppData\Local\Programs\Python\Python312\python.exe -command = C:\Users\86137\AppData\Local\Programs\Python\Python312\python.exe -m venv D:\pycharm\djangoProject\DjangoBlog-master\.venv diff --git a/src/accounts/admin.py b/src/accounts/admin.py index 32e483c..42f2919 100644 --- a/src/accounts/admin.py +++ b/src/accounts/admin.py @@ -47,6 +47,13 @@ class BlogUserChangeForm(UserChangeForm): class BlogUserAdmin(UserAdmin): form = BlogUserChangeForm add_form = BlogUserCreationForm + # Django 5.x 兼容性:覆盖add_fieldsets以排除usable_password字段 + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('username', 'password1', 'password2'), + }), + ) list_display = ( 'id', 'nickname', @@ -57,3 +64,4 @@ class BlogUserAdmin(UserAdmin): 'source') list_display_links = ('id', 'username') ordering = ('-id',) + search_fields = ('username', 'nickname', 'email') diff --git a/src/blog/admin.py b/src/blog/admin.py index 46c3420..c146e95 100644 --- a/src/blog/admin.py +++ b/src/blog/admin.py @@ -6,9 +6,9 @@ from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ # Register your models here. -from .models import Article - +from .models import Article, Category, Tag, Links, SideBar, BlogSettings +#zhq: 文章表单类 - 可用于定制文章编辑表单 class ArticleForm(forms.ModelForm): # body = forms.CharField(widget=AdminPagedownWidget()) @@ -16,29 +16,29 @@ class ArticleForm(forms.ModelForm): model = Article fields = '__all__' - +#zhq: Admin动作函数 - 批量发布文章 def makr_article_publish(modeladmin, request, queryset): queryset.update(status='p') - +#zhq: Admin动作函数 - 批量设为草稿 def draft_article(modeladmin, request, queryset): queryset.update(status='d') - +#zhq: Admin动作函数 - 批量关闭评论 def close_article_commentstatus(modeladmin, request, queryset): queryset.update(comment_status='c') - +#zhq: Admin动作函数 - 批量开启评论 def open_article_commentstatus(modeladmin, request, queryset): queryset.update(comment_status='o') - +#zhq: 设置动作的描述信息 makr_article_publish.short_description = _('Publish selected articles') draft_article.short_description = _('Draft selected articles') close_article_commentstatus.short_description = _('Close article comments') open_article_commentstatus.short_description = _('Open article comments') - +#zhq: 文章管理类 - 定制文章在Admin中的显示和行为 class ArticlelAdmin(admin.ModelAdmin): list_per_page = 20 search_fields = ('body', 'title') @@ -52,9 +52,11 @@ class ArticlelAdmin(admin.ModelAdmin): 'views', 'status', 'type', - 'article_order') + 'article_order', + 'version_count') # 添加版本数量显示 list_display_links = ('id', 'title') list_filter = ('status', 'type', 'category') + date_hierarchy = 'creation_time' filter_horizontal = ('tags',) exclude = ('creation_time', 'last_modify_time') view_on_site = True @@ -62,16 +64,30 @@ class ArticlelAdmin(admin.ModelAdmin): makr_article_publish, draft_article, close_article_commentstatus, - open_article_commentstatus] + open_article_commentstatus] #zhq: 注册批量动作 + raw_id_fields = ('author', 'category',) def link_to_category(self, obj): + # zhq: 自定义方法,生成分类的管理后台链接 info = (obj.category._meta.app_label, obj.category._meta.model_name) link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,)) return format_html(u'%s' % (link, obj.category.name)) link_to_category.short_description = _('category') + def version_count(self, obj): + """显示文章版本数量和链接""" + from blog.models_version import ArticleVersion + count = ArticleVersion.objects.filter(article=obj).count() + if count > 0: + url = reverse('admin:blog_articleversion_changelist') + f'?article__id__exact={obj.id}' + return format_html('{} 个版本', url, count) + return '无版本' + + version_count.short_description = _('Versions') + def get_form(self, request, obj=None, **kwargs): + # zhq: 限制作者字段只能选择超级用户 form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs) form.base_fields['author'].queryset = get_user_model( ).objects.filter(is_superuser=True) @@ -81,6 +97,7 @@ class ArticlelAdmin(admin.ModelAdmin): super(ArticlelAdmin, self).save_model(request, obj, form, change) def get_view_on_site_url(self, obj=None): + # zhq: 获取文章在前台的访问链接 if obj: url = obj.get_full_url() return url @@ -89,24 +106,37 @@ class ArticlelAdmin(admin.ModelAdmin): site = get_current_site().domain return site + class Media: + """引入草稿自动保存的 JavaScript""" + js = ('blog/js/article-draft-autosave.js',) +#zhq: 标签管理类 - 简化管理界面 class TagAdmin(admin.ModelAdmin): exclude = ('slug', 'last_mod_time', 'creation_time') - +#zhq: 分类管理类 - 显示父级分类信息 class CategoryAdmin(admin.ModelAdmin): list_display = ('name', 'parent_category', 'index') exclude = ('slug', 'last_mod_time', 'creation_time') - +#zhq: 友情链接管理类 class LinksAdmin(admin.ModelAdmin): exclude = ('last_mod_time', 'creation_time') - +#zhq: 侧边栏管理类 class SideBarAdmin(admin.ModelAdmin): list_display = ('name', 'content', 'is_enable', 'sequence') exclude = ('last_mod_time', 'creation_time') - +#zhq: 博客设置管理类 - 使用默认管理界面 class BlogSettingsAdmin(admin.ModelAdmin): pass + +# 导入文章版本管理(导入模块以触发装饰器注册) +import blog.admin_version +# 导入文章草稿管理(导入模块以触发装饰器注册) +import blog.admin_draft +# 导入社交功能管理(导入模块以触发装饰器注册) +import blog.admin_social +# 导入多媒体管理(导入模块以触发装饰器注册) +import blog.admin_media diff --git a/src/blog/admin_draft.py b/src/blog/admin_draft.py new file mode 100644 index 0000000..cbd6b38 --- /dev/null +++ b/src/blog/admin_draft.py @@ -0,0 +1,110 @@ +# 文章草稿 Admin 管理界面 + +from django.contrib import admin +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ +from django.urls import reverse + +from blog.models_draft import ArticleDraft + + +@admin.register(ArticleDraft) +class ArticleDraftAdmin(admin.ModelAdmin): + """文章草稿管理界面""" + + list_display = [ + 'id', + 'title_display', + 'author', + 'article_link', + 'preview', + 'last_update_time', + 'is_published', + 'action_buttons' + ] + + list_filter = [ + 'is_published', + 'author', + 'last_update_time', + 'creation_time' + ] + + search_fields = [ + 'title', + 'body', + 'author__username' + ] + + readonly_fields = [ + 'author', + 'article', + 'creation_time', + 'last_update_time', + 'session_id' + ] + + fields = [ + 'author', + 'article', + 'title', + 'body', + 'category_id', + 'tags_data', + 'status', + 'comment_status', + 'type', + 'session_id', + 'is_published', + 'creation_time', + 'last_update_time' + ] + + list_per_page = 50 + + def has_add_permission(self, request): + """禁止手动添加草稿""" + return False + + def title_display(self, obj): + """显示标题""" + title = obj.title or '(无标题)' + if len(title) > 40: + return title[:37] + '...' + return title + + title_display.short_description = '标题' + + def article_link(self, obj): + """文章链接""" + if obj.article: + url = reverse('admin:blog_article_change', args=[obj.article.id]) + return format_html('{}', url, obj.article.title) + return '-' + + article_link.short_description = '文章' + + def preview(self, obj): + """预览文本""" + preview_text = obj.get_preview_text(30) + return format_html('{}', preview_text) + + preview.short_description = '预览' + + def action_buttons(self, obj): + """操作按钮""" + if not obj.is_published: + apply_url = f'/blog/api/draft/apply/' + delete_url = f'/blog/api/draft/delete/' + + return format_html( + ' ' + '', + obj.id, obj.id + ) + return '已发布' + + action_buttons.short_description = '操作' + + class Media: + js = ('admin/js/draft_actions.js',) diff --git a/src/blog/admin_media.py b/src/blog/admin_media.py new file mode 100644 index 0000000..9d83156 --- /dev/null +++ b/src/blog/admin_media.py @@ -0,0 +1,210 @@ +# 多媒体管理 Admin 界面 + +from django.contrib import admin +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ +from django.urls import reverse + +from blog.models_media import MediaFile, MediaFolder, MediaFileFolder + + +@admin.register(MediaFile) +class MediaFileAdmin(admin.ModelAdmin): + """媒体文件管理界面""" + + list_display = [ + 'id', + 'thumbnail_preview', + 'original_filename', + 'file_type', + 'file_size_display', + 'uploader_link', + 'upload_time', + 'reference_count', + 'is_public' + ] + + list_filter = [ + 'file_type', + 'is_public', + 'upload_time' + ] + + search_fields = [ + 'original_filename', + 'description', + 'uploader__username' + ] + + readonly_fields = [ + 'stored_filename', + 'file_hash', + 'file_size', + 'mime_type', + 'file_path', + 'thumbnail_path', + 'width', + 'height', + 'upload_time', + 'thumbnail_preview_large' + ] + + fieldsets = ( + ('基本信息', { + 'fields': ('original_filename', 'file_type', 'description', 'is_public') + }), + ('文件详情', { + 'fields': ('stored_filename', 'file_size', 'file_hash', 'mime_type', 'file_path') + }), + ('图片信息', { + 'fields': ('width', 'height', 'thumbnail_path', 'thumbnail_preview_large'), + 'classes': ('collapse',) + }), + ('用户信息', { + 'fields': ('uploader', 'upload_time', 'reference_count') + }), + ) + + list_per_page = 50 + + def thumbnail_preview(self, obj): + """缩略图预览(列表)""" + if obj.is_image(): + return format_html( + '', + obj.get_thumbnail_url() + ) + return '📄' + + thumbnail_preview.short_description = '预览' + + def thumbnail_preview_large(self, obj): + """缩略图预览(详情)""" + if obj.is_image(): + return format_html( + '', + obj.get_absolute_url() + ) + return '非图片文件' + + thumbnail_preview_large.short_description = '图片预览' + + def file_size_display(self, obj): + """文件大小显示""" + size = obj.file_size + for unit in ['B', 'KB', 'MB', 'GB']: + if size < 1024.0: + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} TB" + + file_size_display.short_description = '文件大小' + + def uploader_link(self, obj): + """上传者链接""" + if obj.uploader: + url = reverse('admin:accounts_bloguser_change', args=[obj.uploader.id]) + return format_html('{}', url, obj.uploader.username) + return '-' + + uploader_link.short_description = '上传者' + + def has_add_permission(self, request): + """禁止手动添加(应通过上传功能)""" + return False + + +@admin.register(MediaFolder) +class MediaFolderAdmin(admin.ModelAdmin): + """媒体文件夹管理界面""" + + list_display = [ + 'id', + 'name', + 'full_path_display', + 'owner_link', + 'files_count_display', + 'created_time' + ] + + list_filter = [ + 'created_time' + ] + + search_fields = [ + 'name', + 'description', + 'owner__username' + ] + + readonly_fields = [ + 'created_time', + 'full_path_display' + ] + + fieldsets = ( + ('文件夹信息', { + 'fields': ('name', 'parent', 'owner', 'description') + }), + ('详细信息', { + 'fields': ('created_time', 'full_path_display') + }), + ) + + def full_path_display(self, obj): + """完整路径显示""" + return obj.get_full_path() + + full_path_display.short_description = '完整路径' + + def owner_link(self, obj): + """所有者链接""" + url = reverse('admin:accounts_bloguser_change', args=[obj.owner.id]) + return format_html('{}', url, obj.owner.username) + + owner_link.short_description = '所有者' + + def files_count_display(self, obj): + """文件数量显示""" + return obj.file_relations.count() + + files_count_display.short_description = '文件数量' + + +@admin.register(MediaFileFolder) +class MediaFileFolderAdmin(admin.ModelAdmin): + """媒体文件-文件夹关联管理界面""" + + list_display = [ + 'id', + 'file_link', + 'folder_link', + 'added_time' + ] + + list_filter = [ + 'added_time' + ] + + search_fields = [ + 'file__original_filename', + 'folder__name' + ] + + readonly_fields = [ + 'added_time' + ] + + def file_link(self, obj): + """文件链接""" + url = reverse('admin:blog_mediafile_change', args=[obj.file.id]) + return format_html('{}', url, obj.file.original_filename) + + file_link.short_description = '文件' + + def folder_link(self, obj): + """文件夹链接""" + url = reverse('admin:blog_mediafolder_change', args=[obj.folder.id]) + return format_html('{}', url, obj.folder.name) + + folder_link.short_description = '文件夹' diff --git a/src/blog/admin_social.py b/src/blog/admin_social.py new file mode 100644 index 0000000..a0cde17 --- /dev/null +++ b/src/blog/admin_social.py @@ -0,0 +1,168 @@ +# 用户关注和收藏 Admin 管理界面 + +from django.contrib import admin +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ +from django.urls import reverse + +from blog.models_social import UserFollow, ArticleFavorite, ArticleLike + + +@admin.register(UserFollow) +class UserFollowAdmin(admin.ModelAdmin): + """用户关注管理界面""" + + list_display = [ + 'id', + 'follower_link', + 'following_link', + 'creation_time' + ] + + list_filter = [ + 'creation_time' + ] + + search_fields = [ + 'follower__username', + 'following__username' + ] + + readonly_fields = [ + 'follower', + 'following', + 'creation_time' + ] + + list_per_page = 50 + + def has_add_permission(self, request): + """禁止手动添加""" + return False + + def follower_link(self, obj): + """关注者链接""" + url = reverse('admin:accounts_bloguser_change', args=[obj.follower.id]) + return format_html('{}', url, obj.follower.username) + + follower_link.short_description = '关注者' + + def following_link(self, obj): + """被关注者链接""" + url = reverse('admin:accounts_bloguser_change', args=[obj.following.id]) + return format_html('{}', url, obj.following.username) + + following_link.short_description = '被关注者' + + +@admin.register(ArticleFavorite) +class ArticleFavoriteAdmin(admin.ModelAdmin): + """文章收藏管理界面""" + + list_display = [ + 'id', + 'user_link', + 'article_link', + 'note_display', + 'creation_time' + ] + + list_filter = [ + 'creation_time' + ] + + search_fields = [ + 'user__username', + 'article__title', + 'note' + ] + + readonly_fields = [ + 'user', + 'article', + 'creation_time' + ] + + list_per_page = 50 + + def has_add_permission(self, request): + """禁止手动添加""" + return False + + def user_link(self, obj): + """用户链接""" + url = reverse('admin:accounts_bloguser_change', args=[obj.user.id]) + return format_html('{}', url, obj.user.username) + + user_link.short_description = '用户' + + def article_link(self, obj): + """文章链接""" + url = reverse('admin:blog_article_change', args=[obj.article.id]) + title = obj.article.title + if len(title) > 50: + title = title[:47] + '...' + return format_html('{}', url, title) + + article_link.short_description = '文章' + + def note_display(self, obj): + """显示备注""" + if obj.note: + if len(obj.note) > 30: + return obj.note[:27] + '...' + return obj.note + return '-' + + note_display.short_description = '备注' + + +@admin.register(ArticleLike) +class ArticleLikeAdmin(admin.ModelAdmin): + """文章点赞管理界面""" + + list_display = [ + 'id', + 'user_link', + 'article_link', + 'creation_time' + ] + + list_filter = [ + 'creation_time' + ] + + search_fields = [ + 'user__username', + 'article__title' + ] + + readonly_fields = [ + 'user', + 'article', + 'creation_time' + ] + + list_per_page = 50 + + def has_add_permission(self, request): + """禁止手动添加""" + return False + + def user_link(self, obj): + """用户链接""" + url = reverse('admin:accounts_bloguser_change', args=[obj.user.id]) + return format_html('{}', url, obj.user.username) + + user_link.short_description = '用户' + + def article_link(self, obj): + """文章链接""" + url = reverse('admin:blog_article_change', args=[obj.article.id]) + title = obj.article.title + if len(title) > 50: + title = title[:47] + '...' + return format_html('{}', url, title) + + article_link.short_description = '文章' + diff --git a/src/blog/admin_version.py b/src/blog/admin_version.py new file mode 100644 index 0000000..f430f63 --- /dev/null +++ b/src/blog/admin_version.py @@ -0,0 +1,217 @@ +# 文章版本管理 Admin 界面 + +from django.contrib import admin +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ +from django.urls import reverse +from django.shortcuts import redirect, render +from django.contrib import messages +from django.template.response import TemplateResponse + +from blog.models_version import ArticleVersion + + +@admin.register(ArticleVersion) +class ArticleVersionAdmin(admin.ModelAdmin): + """文章版本管理界面""" + + list_display = [ + 'version_number', + 'article_link', + 'title_display', + 'created_by', + 'creation_time', + 'change_summary_display', + 'is_auto_save', + 'action_buttons' + ] + + list_filter = [ + 'is_auto_save', + 'creation_time', + 'created_by' + ] + + search_fields = [ + 'title', + 'change_summary', + 'article__title' + ] + + readonly_fields = [ + 'article', + 'version_number', + 'title', + 'body', + 'pub_time', + 'status', + 'comment_status', + 'type', + 'category_id', + 'category_name', + 'created_by', + 'creation_time', + 'is_auto_save' + ] + + fields = [ + 'article', + 'version_number', + 'title', + 'body', + 'pub_time', + 'status', + 'comment_status', + 'type', + 'category_name', + 'created_by', + 'creation_time', + 'change_summary', + 'is_auto_save' + ] + + list_per_page = 50 + + def has_add_permission(self, request): + """禁止手动添加版本""" + return False + + def has_delete_permission(self, request, obj=None): + """禁止删除版本(保持历史完整性)""" + return False + + def article_link(self, obj): + """文章链接""" + url = reverse('admin:blog_article_change', args=[obj.article.id]) + return format_html('{}', url, obj.article.title) + + article_link.short_description = '文章' + + def title_display(self, obj): + """显示标题(截断)""" + if len(obj.title) > 50: + return obj.title[:47] + '...' + return obj.title + + title_display.short_description = '标题' + + def change_summary_display(self, obj): + """显示变更说明""" + if obj.change_summary: + return obj.change_summary + return format_html('自动保存') + + change_summary_display.short_description = '变更说明' + + def action_buttons(self, obj): + """操作按钮""" + restore_url = reverse('admin:restore_article_version', args=[obj.id]) + compare_url = reverse('admin:compare_article_version', args=[obj.id]) + + return format_html( + '恢复此版本 ' + '对比', + restore_url, + compare_url + ) + + action_buttons.short_description = '操作' + + def get_urls(self): + """添加自定义URL""" + from django.urls import path + urls = super().get_urls() + custom_urls = [ + path( + '/restore/', + self.admin_site.admin_view(self.restore_version), + name='restore_article_version', + ), + path( + '/compare/', + self.admin_site.admin_view(self.compare_version), + name='compare_article_version', + ), + ] + return custom_urls + urls + + def restore_version(self, request, version_id): + """恢复版本""" + try: + version = ArticleVersion.objects.get(id=version_id) + except ArticleVersion.DoesNotExist: + messages.error(request, '版本不存在') + return redirect('admin:blog_articleversion_changelist') + + if request.method == 'POST': + # 在恢复前先保存当前版本 + ArticleVersion.create_version( + article=version.article, + user=request.user, + change_summary=f'恢复前的版本(准备恢复到 v{version.version_number})', + is_auto_save=False + ) + + if version.restore_to_article(): + messages.success( + request, + f'已成功将文章恢复到版本 v{version.version_number}' + ) + return redirect('admin:blog_article_change', version.article.id) + else: + messages.error(request, '恢复版本失败') + return redirect('admin:blog_articleversion_changelist') + + # 显示确认页面 + context = { + **self.admin_site.each_context(request), + 'version': version, + 'current': version.article, + 'opts': self.model._meta, + 'title': f'恢复版本 v{version.version_number}', + } + return TemplateResponse( + request, + 'admin/blog/articleversion/restore_confirmation.html', + context + ) + + def compare_version(self, request, version_id): + """对比版本""" + try: + version = ArticleVersion.objects.get(id=version_id) + except ArticleVersion.DoesNotExist: + messages.error(request, '版本不存在') + return redirect('admin:blog_articleversion_changelist') + + current = version.article + diff = version.get_diff_with_current() + + # 生成正文差异(如果有变化) + body_diff_html = None + if diff['body_changed']: + import difflib + d = difflib.HtmlDiff() + body_diff_html = d.make_table( + version.body.splitlines(), + current.body.splitlines(), + f'版本 v{version.version_number}', + '当前版本', + context=True, + numlines=3 + ) + + context = { + **self.admin_site.each_context(request), + 'version': version, + 'current': current, + 'diff': diff, + 'body_diff_html': body_diff_html, + 'opts': self.model._meta, + 'title': f'对比版本 v{version.version_number}', + } + return TemplateResponse( + request, + 'admin/blog/articleversion/compare.html', + context + ) diff --git a/src/blog/apps.py b/src/blog/apps.py index 7930587..6336f0c 100644 --- a/src/blog/apps.py +++ b/src/blog/apps.py @@ -1,5 +1,20 @@ from django.apps import AppConfig - +#zhq: 博客应用配置类 - 继承自Django的AppConfig基类 class BlogConfig(AppConfig): - name = 'blog' + name = 'blog' #zhq: 应用名称,对应INSTALLED_APPS中的名称 + default_auto_field = 'django.db.models.BigAutoField' + + def ready(self): + """ + 应用启动时执行的初始化操作 + 导入所有Admin模块以确保它们被注册 + """ + # 导入Admin模块以触发注册 + try: + from blog import admin_version # noqa + from blog import admin_draft # noqa + from blog import admin_social # noqa + from blog import admin_media # noqa + except ImportError: + pass diff --git a/src/blog/context_processors.py b/src/blog/context_processors.py index 73e3088..6b24a31 100644 --- a/src/blog/context_processors.py +++ b/src/blog/context_processors.py @@ -7,7 +7,7 @@ from .models import Category, Article logger = logging.getLogger(__name__) - +#zhq: SEO上下文处理器 - 为所有模板提供SEO相关变量和导航数据 def seo_processor(requests): key = 'seo_processor' value = cache.get(key) @@ -15,29 +15,32 @@ def seo_processor(requests): return value else: logger.info('set processor cache.') + # zhq: 获取博客全局设置 setting = get_blog_setting() + # zhq: 构建上下文数据字典 value = { - 'SITE_NAME': setting.site_name, - 'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense, - 'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes, - 'SITE_SEO_DESCRIPTION': setting.site_seo_description, - 'SITE_DESCRIPTION': setting.site_description, - 'SITE_KEYWORDS': setting.site_keywords, - 'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/', - 'ARTICLE_SUB_LENGTH': setting.article_sub_length, - 'nav_category_list': Category.objects.all(), + 'SITE_NAME': setting.site_name,#zhq: 网站名称 + 'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,#zhq: 是否显示Google广告 + 'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,#zhq: Google广告代码 + 'SITE_SEO_DESCRIPTION': setting.site_seo_description,#zhq: SEO描述 + 'SITE_DESCRIPTION': setting.site_description,#zhq: 网站描述 + 'SITE_KEYWORDS': setting.site_keywords,#zhq: 网站关键词 + 'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/',#zhq: 网站基础URL + 'ARTICLE_SUB_LENGTH': setting.article_sub_length,#zhq: 文章摘要长度 + 'nav_category_list': Category.objects.all(),#zhq: 导航分类列表 'nav_pages': Article.objects.filter( - type='p', - status='p'), - 'OPEN_SITE_COMMENT': setting.open_site_comment, - 'BEIAN_CODE': setting.beian_code, - 'ANALYTICS_CODE': setting.analytics_code, - "BEIAN_CODE_GONGAN": setting.gongan_beiancode, - "SHOW_GONGAN_CODE": setting.show_gongan_code, - "CURRENT_YEAR": timezone.now().year, - "GLOBAL_HEADER": setting.global_header, - "GLOBAL_FOOTER": setting.global_footer, - "COMMENT_NEED_REVIEW": setting.comment_need_review, + type='p',#zhq: 页面类型 + status='p'),#zhq: 发布状态 + 'OPEN_SITE_COMMENT': setting.open_site_comment,#zhq: 是否开启全站评论 + 'BEIAN_CODE': setting.beian_code,#zhq: ICP备案号 + 'ANALYTICS_CODE': setting.analytics_code,#zhq: 网站统计代码 + "BEIAN_CODE_GONGAN": setting.gongan_beiancode,#zhq: 公安备案号 + "SHOW_GONGAN_CODE": setting.show_gongan_code,#zhq: 是否显示公安备案号 + "CURRENT_YEAR": timezone.now().year,#zhq: 当前年份 + "GLOBAL_HEADER": setting.global_header,#zhq: 全局头部HTML + "GLOBAL_FOOTER": setting.global_footer,#zhq: 全局尾部HTML + "COMMENT_NEED_REVIEW": setting.comment_need_review,#zhq: 评论是否需要审核 } + # zhq: 缓存上下文数据10小时,提高性能 cache.set(key, value, 60 * 60 * 10) return value diff --git a/src/blog/forms.py b/src/blog/forms.py index 715be76..7d703a1 100644 --- a/src/blog/forms.py +++ b/src/blog/forms.py @@ -5,15 +5,18 @@ from haystack.forms import SearchForm logger = logging.getLogger(__name__) - +#zhq: 博客搜索表单类 - 继承自Haystack的SearchForm class BlogSearchForm(SearchForm): querydata = forms.CharField(required=True) def search(self): + #zhq: 执行搜索操作,调用父类的search方法 datas = super(BlogSearchForm, self).search() + # zhq: 如果表单验证失败,返回空结果 if not self.is_valid(): return self.no_query_found() - + + # zhq: 记录搜索关键词到日志 if self.cleaned_data['querydata']: logger.info(self.cleaned_data['querydata']) return datas diff --git a/src/blog/management/commands/init_canteen_data.py b/src/blog/management/commands/init_canteen_data.py new file mode 100644 index 0000000..3887833 --- /dev/null +++ b/src/blog/management/commands/init_canteen_data.py @@ -0,0 +1,90 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import make_password +from django.core.management.base import BaseCommand + +from blog.models import Article, Tag, Category + + +class Command(BaseCommand): + help = 'Initialize canteen ordering themed test data' + + def handle(self, *args, **options): + user = get_user_model().objects.get_or_create( + email='canteen@test.com', username='食堂管理员', password=make_password('canteen!Q@W#2025'))[0] + + # 顶级分类:校园食堂 + canteen_category = Category.objects.get_or_create( + name='校园食堂', parent_category=None, defaults={"index": 100} + )[0] + + # 二级分类:各食堂 + east_canteen = Category.objects.get_or_create( + name='东区食堂', parent_category=canteen_category, defaults={"index": 90} + )[0] + west_canteen = Category.objects.get_or_create( + name='西区食堂', parent_category=canteen_category, defaults={"index": 80} + )[0] + + # 三级分类:档口 + stalls = [ + Category.objects.get_or_create(name='川味档口', parent_category=east_canteen, defaults={"index": 70})[0], + Category.objects.get_or_create(name='面点档口', parent_category=east_canteen, defaults={"index": 60})[0], + Category.objects.get_or_create(name='简餐档口', parent_category=west_canteen, defaults={"index": 50})[0], + Category.objects.get_or_create(name='风味小吃', parent_category=west_canteen, defaults={"index": 40})[0], + ] + + # 标签:口味与饮食偏好 + base_tags = [ + Tag.objects.get_or_create(name='辣味')[0], + Tag.objects.get_or_create(name='清淡')[0], + Tag.objects.get_or_create(name='无辣')[0], + Tag.objects.get_or_create(name='主食')[0], + Tag.objects.get_or_create(name='素食可选')[0], + ] + + # 示例菜品以文章承载:标题为菜名,正文为描述/价格 + dish_specs = [ + ("宫保鸡丁套餐", "包含米饭+素菜,价格¥18,可加辣"), + ("红烧牛肉面", "大份牛肉足量,价格¥22,清淡口可选"), + ("番茄鸡蛋盖浇饭", "酸甜开胃,价格¥15,老少皆宜"), + ("麻辣香锅", "自选配菜,称重计价,约¥20-30/份"), + ("素什锦炒面", "少油少盐,价格¥14,素食友好"), + ("鸡排饭", "现炸鸡排+配菜,价格¥19,无辣版本可选"), + ("牛肉卷饼", "现烙饼皮,价格¥12,可加蛋+2"), + ("酸辣粉", "地道红薯粉,价格¥13,可微辣/中特/特辣"), + ] + + tag_cycle = base_tags * 4 + idx = 0 + for stall in stalls: + for dish_title, dish_body in dish_specs: + article = Article.objects.get_or_create( + category=stall, + title=f'{stall.name}-{dish_title}', + defaults={ + 'body': f'{dish_body}\n\n口味标签:{tag_cycle[idx % len(tag_cycle)].name}', + 'author': user, + 'status': 'p', + 'comment_status': 'o', + 'type': 'a', + 'article_order': 0, + 'show_toc': False, + } + )[0] + # 绑定两个标签 + article.tags.add(tag_cycle[idx % len(tag_cycle)]) + article.tags.add(tag_cycle[(idx + 1) % len(tag_cycle)]) + article.save() + idx += 1 + + from djangoblog.utils import cache + cache.clear() + self.stdout.write(self.style.SUCCESS('Initialized canteen ordering themed test data.')) + + + + + + + + diff --git a/src/blog/middleware.py b/src/blog/middleware.py index 94dd70c..8ad1e54 100644 --- a/src/blog/middleware.py +++ b/src/blog/middleware.py @@ -9,6 +9,7 @@ from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager logger = logging.getLogger(__name__) +#zhq: 在线中间件类 - 用于监控页面加载时间和用户访问信息 class OnlineMiddleware(object): def __init__(self, get_response=None): self.get_response = get_response @@ -16,16 +17,20 @@ class OnlineMiddleware(object): def __call__(self, request): ''' page render time ''' + # zhq: 记录请求开始时间 start_time = time.time() response = self.get_response(request) + #zhq: 获取客户端IP地址和User-Agent信息 http_user_agent = request.META.get('HTTP_USER_AGENT', '') ip, _ = get_client_ip(request) user_agent = parse(http_user_agent) if not response.streaming: try: + #zhq: 计算页面渲染时间 cast_time = time.time() - start_time + # zhq: 如果启用了Elasticsearch,记录性能数据 if ELASTICSEARCH_ENABLED: - time_taken = round((cast_time) * 1000, 2) + time_taken = round((cast_time) * 1000, 2) #zhq: 转换为毫秒并保留2位小数 url = request.path from django.utils import timezone ElaspedTimeDocumentManager.create( @@ -34,8 +39,9 @@ class OnlineMiddleware(object): log_datetime=timezone.now(), useragent=user_agent, ip=ip) + # zhq: 在响应内容中替换加载时间占位符 response.content = response.content.replace( - b'', str.encode(str(cast_time)[:5])) + b'', str.encode(str(cast_time)[:5])) #zhq: 保留前5位字符 except Exception as e: logger.error("Error OnlineMiddleware: %s" % e) diff --git a/src/blog/migrations/0002_add_performance_indexes.py b/src/blog/migrations/0002_add_performance_indexes.py new file mode 100644 index 0000000..43db885 --- /dev/null +++ b/src/blog/migrations/0002_add_performance_indexes.py @@ -0,0 +1,45 @@ +# 性能优化:为常用查询字段添加数据库索引 +# Generated manually for performance optimization + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0001_initial'), + ] + + operations = [ + # 为 Article 模型添加索引 + migrations.AddIndex( + model_name='article', + index=models.Index(fields=['status', 'type'], name='article_status_type_idx'), + ), + migrations.AddIndex( + model_name='article', + index=models.Index(fields=['status', 'pub_time'], name='article_status_pubtime_idx'), + ), + migrations.AddIndex( + model_name='article', + index=models.Index(fields=['-pub_time'], name='article_pubtime_desc_idx'), + ), + migrations.AddIndex( + model_name='article', + index=models.Index(fields=['article_order', '-pub_time'], name='article_order_pubtime_idx'), + ), + + # 为 Category 添加 slug 索引(用于URL查找) + migrations.AlterField( + model_name='category', + name='slug', + field=models.SlugField(default='no-slug', max_length=60, blank=True, db_index=True), + ), + + # 为 Tag 添加 slug 索引(用于URL查找) + migrations.AlterField( + model_name='tag', + name='slug', + field=models.SlugField(default='no-slug', max_length=60, blank=True, db_index=True), + ), + ] diff --git a/src/blog/migrations/0003_add_article_version.py b/src/blog/migrations/0003_add_article_version.py new file mode 100644 index 0000000..8f7c0f0 --- /dev/null +++ b/src/blog/migrations/0003_add_article_version.py @@ -0,0 +1,55 @@ +# 文章版本管理功能 - 数据库迁移 +# Generated manually for article version management feature + +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', '0002_add_performance_indexes'), + ] + + operations = [ + migrations.CreateModel( + name='ArticleVersion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version_number', models.PositiveIntegerField(default=1, verbose_name='version number')), + ('title', models.CharField(max_length=200, verbose_name='title')), + ('body', models.TextField(verbose_name='body')), + ('pub_time', models.DateTimeField(verbose_name='publish time')), + ('status', models.CharField(max_length=1, verbose_name='status')), + ('comment_status', models.CharField(max_length=1, verbose_name='comment status')), + ('type', models.CharField(max_length=1, verbose_name='type')), + ('category_id', models.IntegerField(verbose_name='category id')), + ('category_name', models.CharField(max_length=30, verbose_name='category name')), + ('creation_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='creation time')), + ('change_summary', models.CharField(blank=True, default='', max_length=200, verbose_name='change summary')), + ('is_auto_save', models.BooleanField(default=True, verbose_name='is auto save')), + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='blog.Article', verbose_name='article')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_versions_created', to=settings.AUTH_USER_MODEL, verbose_name='created by')), + ], + options={ + 'verbose_name': 'article version', + 'verbose_name_plural': 'article versions', + 'ordering': ['-version_number'], + }, + ), + migrations.AddIndex( + model_name='articleversion', + index=models.Index(fields=['article', '-version_number'], name='version_article_num_idx'), + ), + migrations.AddIndex( + model_name='articleversion', + index=models.Index(fields=['creation_time'], name='version_creation_time_idx'), + ), + migrations.AlterUniqueTogether( + name='articleversion', + unique_together={('article', 'version_number')}, + ), + ] diff --git a/src/blog/migrations/0004_add_article_draft.py b/src/blog/migrations/0004_add_article_draft.py new file mode 100644 index 0000000..2bc2981 --- /dev/null +++ b/src/blog/migrations/0004_add_article_draft.py @@ -0,0 +1,54 @@ +# 文章草稿功能 - 数据库迁移 +# Generated manually for article draft auto-save feature + +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', '0003_add_article_version'), + ] + + operations = [ + migrations.CreateModel( + name='ArticleDraft', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(blank=True, default='', max_length=200, verbose_name='title')), + ('body', models.TextField(blank=True, default='', verbose_name='body')), + ('category_id', models.IntegerField(blank=True, null=True, verbose_name='category id')), + ('tags_data', models.JSONField(blank=True, default=list, verbose_name='tags data')), + ('status', models.CharField(default='d', max_length=1, verbose_name='status')), + ('comment_status', models.CharField(default='o', max_length=1, verbose_name='comment status')), + ('type', models.CharField(default='a', max_length=1, verbose_name='type')), + ('creation_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='creation time')), + ('last_update_time', models.DateTimeField(auto_now=True, db_index=True, verbose_name='last update time')), + ('session_id', models.CharField(blank=True, default='', max_length=64, verbose_name='session id')), + ('is_published', models.BooleanField(default=False, verbose_name='is published')), + ('article', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='drafts', to='blog.Article', verbose_name='article')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_drafts', to=settings.AUTH_USER_MODEL, verbose_name='author')), + ], + options={ + 'verbose_name': 'article draft', + 'verbose_name_plural': 'article drafts', + 'ordering': ['-last_update_time'], + }, + ), + migrations.AddIndex( + model_name='articledraft', + index=models.Index(fields=['author', '-last_update_time'], name='draft_author_time_idx'), + ), + migrations.AddIndex( + model_name='articledraft', + index=models.Index(fields=['article', '-last_update_time'], name='draft_article_time_idx'), + ), + migrations.AddIndex( + model_name='articledraft', + index=models.Index(fields=['is_published', '-last_update_time'], name='draft_published_time_idx'), + ), + ] diff --git a/src/blog/migrations/0005_add_social_features.py b/src/blog/migrations/0005_add_social_features.py new file mode 100644 index 0000000..c10159f --- /dev/null +++ b/src/blog/migrations/0005_add_social_features.py @@ -0,0 +1,78 @@ +# 用户关注和收藏功能 - 数据库迁移 +# Generated manually for social features + +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', '0004_add_article_draft'), + ] + + operations = [ + # 创建用户关注模型 + migrations.CreateModel( + name='UserFollow', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='creation time')), + ('follower', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='following', to=settings.AUTH_USER_MODEL, verbose_name='follower')), + ('following', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='followers', to=settings.AUTH_USER_MODEL, verbose_name='following')), + ], + options={ + 'verbose_name': 'user follow', + 'verbose_name_plural': 'user follows', + 'ordering': ['-creation_time'], + }, + ), + + # 创建文章收藏模型 + migrations.CreateModel( + name='ArticleFavorite', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='creation time')), + ('note', models.CharField(blank=True, default='', max_length=200, verbose_name='note')), + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by', to='blog.Article', verbose_name='article')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'article favorite', + 'verbose_name_plural': 'article favorites', + 'ordering': ['-creation_time'], + }, + ), + + # 添加索引 + migrations.AddIndex( + model_name='userfollow', + index=models.Index(fields=['follower', '-creation_time'], name='follow_follower_time_idx'), + ), + migrations.AddIndex( + model_name='userfollow', + index=models.Index(fields=['following', '-creation_time'], name='follow_following_time_idx'), + ), + migrations.AddIndex( + model_name='articlefavorite', + index=models.Index(fields=['user', '-creation_time'], name='favorite_user_time_idx'), + ), + migrations.AddIndex( + model_name='articlefavorite', + index=models.Index(fields=['article', '-creation_time'], name='favorite_article_time_idx'), + ), + + # 添加唯一约束 + migrations.AlterUniqueTogether( + name='userfollow', + unique_together={('follower', 'following')}, + ), + migrations.AlterUniqueTogether( + name='articlefavorite', + unique_together={('user', 'article')}, + ), + ] diff --git a/src/blog/migrations/0006_add_article_like.py b/src/blog/migrations/0006_add_article_like.py new file mode 100644 index 0000000..35a158e --- /dev/null +++ b/src/blog/migrations/0006_add_article_like.py @@ -0,0 +1,43 @@ +# Generated migration for ArticleLike model + +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_add_social_features'), + ] + + operations = [ + migrations.CreateModel( + name='ArticleLike', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='creation time')), + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='liked_by', to='blog.article', verbose_name='article')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'article like', + 'verbose_name_plural': 'article likes', + 'ordering': ['-creation_time'], + }, + ), + migrations.AddIndex( + model_name='articlelike', + index=models.Index(fields=['user', '-creation_time'], name='like_user_time_idx'), + ), + migrations.AddIndex( + model_name='articlelike', + index=models.Index(fields=['article', '-creation_time'], name='like_article_time_idx'), + ), + migrations.AlterUniqueTogether( + name='articlelike', + unique_together={('user', 'article')}, + ), + ] diff --git a/src/blog/migrations/0007_add_media_management.py b/src/blog/migrations/0007_add_media_management.py new file mode 100644 index 0000000..3db8e16 --- /dev/null +++ b/src/blog/migrations/0007_add_media_management.py @@ -0,0 +1,101 @@ +# Generated migration for Media Management System + +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', '0006_add_article_like'), + ] + + operations = [ + # 创建 MediaFile 模型 + migrations.CreateModel( + name='MediaFile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('original_filename', models.CharField(max_length=255, verbose_name='original filename')), + ('stored_filename', models.CharField(max_length=255, unique=True, verbose_name='stored filename')), + ('file_type', models.CharField(choices=[('image', 'Image'), ('file', 'File')], max_length=10, verbose_name='file type')), + ('file_size', models.BigIntegerField(verbose_name='file size')), + ('file_hash', models.CharField(db_index=True, max_length=32, verbose_name='file hash')), + ('mime_type', models.CharField(max_length=100, verbose_name='MIME type')), + ('file_path', models.CharField(max_length=500, verbose_name='file path')), + ('upload_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='upload time')), + ('width', models.IntegerField(blank=True, null=True, verbose_name='width')), + ('height', models.IntegerField(blank=True, null=True, verbose_name='height')), + ('thumbnail_path', models.CharField(blank=True, max_length=500, verbose_name='thumbnail path')), + ('description', models.TextField(blank=True, verbose_name='description')), + ('is_public', models.BooleanField(default=True, verbose_name='is public')), + ('reference_count', models.IntegerField(default=0, verbose_name='reference count')), + ('uploader', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_files', to=settings.AUTH_USER_MODEL, verbose_name='uploader')), + ], + options={ + 'verbose_name': 'media file', + 'verbose_name_plural': 'media files', + 'ordering': ['-upload_time'], + }, + ), + + # 创建 MediaFolder 模型 + migrations.CreateModel( + name='MediaFolder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='folder name')), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created time')), + ('description', models.TextField(blank=True, verbose_name='description')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_folders', to=settings.AUTH_USER_MODEL, verbose_name='owner')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subfolders', to='blog.mediafolder', verbose_name='parent folder')), + ], + options={ + 'verbose_name': 'media folder', + 'verbose_name_plural': 'media folders', + 'ordering': ['name'], + }, + ), + + # 创建 MediaFileFolder 模型 + migrations.CreateModel( + name='MediaFileFolder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('added_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='added time')), + ('file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='folder_relations', to='blog.mediafile')), + ('folder', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='file_relations', to='blog.mediafolder')), + ], + options={ + 'verbose_name': 'media file folder relation', + 'verbose_name_plural': 'media file folder relations', + }, + ), + + # 添加索引 + migrations.AddIndex( + model_name='mediafile', + index=models.Index(fields=['file_type', '-upload_time'], name='media_type_time_idx'), + ), + migrations.AddIndex( + model_name='mediafile', + index=models.Index(fields=['uploader', '-upload_time'], name='media_uploader_time_idx'), + ), + migrations.AddIndex( + model_name='mediafile', + index=models.Index(fields=['file_hash'], name='media_hash_idx'), + ), + + # 添加唯一约束 + migrations.AlterUniqueTogether( + name='mediafolder', + unique_together={('name', 'parent', 'owner')}, + ), + migrations.AlterUniqueTogether( + name='mediafilefolder', + unique_together={('file', 'folder')}, + ), + ] diff --git a/src/blog/migrations/0008_merge_20251124_0221.py b/src/blog/migrations/0008_merge_20251124_0221.py new file mode 100644 index 0000000..d73fb5a --- /dev/null +++ b/src/blog/migrations/0008_merge_20251124_0221.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.7 on 2025-11-24 02:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0006_alter_blogsettings_options'), + ('blog', '0007_add_media_management'), + ] + + operations = [ + ] diff --git a/src/blog/migrations/0009_alter_articledraft_options_and_more.py b/src/blog/migrations/0009_alter_articledraft_options_and_more.py new file mode 100644 index 0000000..3925ef6 --- /dev/null +++ b/src/blog/migrations/0009_alter_articledraft_options_and_more.py @@ -0,0 +1,359 @@ +# Generated by Django 5.2.7 on 2025-11-25 13:02 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0008_merge_20251124_0221'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='articledraft', + options={'ordering': ['-last_update_time'], 'verbose_name': '文章草稿', 'verbose_name_plural': '文章草稿'}, + ), + migrations.AlterModelOptions( + name='articlefavorite', + options={'ordering': ['-creation_time'], 'verbose_name': '文章收藏', 'verbose_name_plural': '文章收藏'}, + ), + migrations.AlterModelOptions( + name='articlelike', + options={'ordering': ['-creation_time'], 'verbose_name': '文章点赞', 'verbose_name_plural': '文章点赞'}, + ), + migrations.AlterModelOptions( + name='articleversion', + options={'ordering': ['-version_number'], 'verbose_name': '文章版本', 'verbose_name_plural': '文章版本'}, + ), + migrations.AlterModelOptions( + name='mediafile', + options={'ordering': ['-upload_time'], 'verbose_name': '媒体文件', 'verbose_name_plural': '媒体文件'}, + ), + migrations.AlterModelOptions( + name='mediafilefolder', + options={'verbose_name': '文件-文件夹关联', 'verbose_name_plural': '文件-文件夹关联'}, + ), + migrations.AlterModelOptions( + name='mediafolder', + options={'ordering': ['name'], 'verbose_name': '媒体文件夹', 'verbose_name_plural': '媒体文件夹'}, + ), + migrations.AlterModelOptions( + name='userfollow', + options={'ordering': ['-creation_time'], 'verbose_name': '用户关注', 'verbose_name_plural': '用户关注'}, + ), + migrations.AlterField( + model_name='articledraft', + name='article', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='drafts', to='blog.article', verbose_name='文章'), + ), + migrations.AlterField( + model_name='articledraft', + name='author', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_drafts', to=settings.AUTH_USER_MODEL, verbose_name='作者'), + ), + migrations.AlterField( + model_name='articledraft', + name='body', + field=models.TextField(blank=True, default='', verbose_name='正文'), + ), + migrations.AlterField( + model_name='articledraft', + name='category_id', + field=models.IntegerField(blank=True, null=True, verbose_name='分类ID'), + ), + migrations.AlterField( + model_name='articledraft', + name='comment_status', + field=models.CharField(default='o', max_length=1, verbose_name='评论状态'), + ), + migrations.AlterField( + model_name='articledraft', + name='creation_time', + field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='创建时间'), + ), + migrations.AlterField( + model_name='articledraft', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='articledraft', + name='is_published', + field=models.BooleanField(default=False, verbose_name='已发布'), + ), + migrations.AlterField( + model_name='articledraft', + name='last_update_time', + field=models.DateTimeField(auto_now=True, db_index=True, verbose_name='最后更新时间'), + ), + migrations.AlterField( + model_name='articledraft', + name='session_id', + field=models.CharField(blank=True, default='', max_length=64, verbose_name='会话ID'), + ), + migrations.AlterField( + model_name='articledraft', + name='status', + field=models.CharField(default='d', max_length=1, verbose_name='状态'), + ), + migrations.AlterField( + model_name='articledraft', + name='tags_data', + field=models.JSONField(blank=True, default=list, verbose_name='标签数据'), + ), + migrations.AlterField( + model_name='articledraft', + name='title', + field=models.CharField(blank=True, default='', max_length=200, verbose_name='标题'), + ), + migrations.AlterField( + model_name='articledraft', + name='type', + field=models.CharField(default='a', max_length=1, verbose_name='类型'), + ), + migrations.AlterField( + model_name='articlefavorite', + name='article', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by', to='blog.article', verbose_name='文章'), + ), + migrations.AlterField( + model_name='articlefavorite', + name='creation_time', + field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='创建时间'), + ), + migrations.AlterField( + model_name='articlefavorite', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='articlefavorite', + name='note', + field=models.CharField(blank=True, default='', max_length=200, verbose_name='备注'), + ), + migrations.AlterField( + model_name='articlefavorite', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to=settings.AUTH_USER_MODEL, verbose_name='用户'), + ), + migrations.AlterField( + model_name='articlelike', + name='article', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='liked_by', to='blog.article', verbose_name='文章'), + ), + migrations.AlterField( + model_name='articlelike', + name='creation_time', + field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='创建时间'), + ), + migrations.AlterField( + model_name='articlelike', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to=settings.AUTH_USER_MODEL, verbose_name='用户'), + ), + migrations.AlterField( + model_name='articleversion', + name='article', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='blog.article', verbose_name='文章'), + ), + migrations.AlterField( + model_name='articleversion', + name='body', + field=models.TextField(verbose_name='正文'), + ), + migrations.AlterField( + model_name='articleversion', + name='category_id', + field=models.IntegerField(verbose_name='分类ID'), + ), + migrations.AlterField( + model_name='articleversion', + name='category_name', + field=models.CharField(max_length=30, verbose_name='分类名称'), + ), + migrations.AlterField( + model_name='articleversion', + name='change_summary', + field=models.CharField(blank=True, default='', max_length=200, verbose_name='变更说明'), + ), + migrations.AlterField( + model_name='articleversion', + name='comment_status', + field=models.CharField(max_length=1, verbose_name='评论状态'), + ), + migrations.AlterField( + model_name='articleversion', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_versions_created', to=settings.AUTH_USER_MODEL, verbose_name='创建者'), + ), + migrations.AlterField( + model_name='articleversion', + name='creation_time', + field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='创建时间'), + ), + migrations.AlterField( + model_name='articleversion', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='articleversion', + name='is_auto_save', + field=models.BooleanField(default=True, verbose_name='自动保存'), + ), + migrations.AlterField( + model_name='articleversion', + name='pub_time', + field=models.DateTimeField(verbose_name='发布时间'), + ), + migrations.AlterField( + model_name='articleversion', + name='status', + field=models.CharField(max_length=1, verbose_name='状态'), + ), + migrations.AlterField( + model_name='articleversion', + name='title', + field=models.CharField(max_length=200, verbose_name='标题'), + ), + migrations.AlterField( + model_name='articleversion', + name='type', + field=models.CharField(max_length=1, verbose_name='类型'), + ), + migrations.AlterField( + model_name='articleversion', + name='version_number', + field=models.PositiveIntegerField(default=1, verbose_name='版本号'), + ), + migrations.AlterField( + model_name='mediafile', + name='description', + field=models.TextField(blank=True, verbose_name='描述'), + ), + migrations.AlterField( + model_name='mediafile', + name='file_hash', + field=models.CharField(db_index=True, max_length=32, verbose_name='文件哈希'), + ), + migrations.AlterField( + model_name='mediafile', + name='file_path', + field=models.CharField(max_length=500, verbose_name='文件路径'), + ), + migrations.AlterField( + model_name='mediafile', + name='file_size', + field=models.BigIntegerField(verbose_name='文件大小'), + ), + migrations.AlterField( + model_name='mediafile', + name='file_type', + field=models.CharField(choices=[('image', '图片'), ('file', '文件')], max_length=10, verbose_name='文件类型'), + ), + migrations.AlterField( + model_name='mediafile', + name='height', + field=models.IntegerField(blank=True, null=True, verbose_name='高度'), + ), + migrations.AlterField( + model_name='mediafile', + name='is_public', + field=models.BooleanField(default=True, verbose_name='是否公开'), + ), + migrations.AlterField( + model_name='mediafile', + name='mime_type', + field=models.CharField(max_length=100, verbose_name='MIME类型'), + ), + migrations.AlterField( + model_name='mediafile', + name='original_filename', + field=models.CharField(max_length=255, verbose_name='原始文件名'), + ), + migrations.AlterField( + model_name='mediafile', + name='reference_count', + field=models.IntegerField(default=0, verbose_name='引用次数'), + ), + migrations.AlterField( + model_name='mediafile', + name='stored_filename', + field=models.CharField(max_length=255, unique=True, verbose_name='存储文件名'), + ), + migrations.AlterField( + model_name='mediafile', + name='thumbnail_path', + field=models.CharField(blank=True, max_length=500, verbose_name='缩略图路径'), + ), + migrations.AlterField( + model_name='mediafile', + name='upload_time', + field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='上传时间'), + ), + migrations.AlterField( + model_name='mediafile', + name='uploader', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_files', to=settings.AUTH_USER_MODEL, verbose_name='上传者'), + ), + migrations.AlterField( + model_name='mediafile', + name='width', + field=models.IntegerField(blank=True, null=True, verbose_name='宽度'), + ), + migrations.AlterField( + model_name='mediafilefolder', + name='added_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='添加时间'), + ), + migrations.AlterField( + model_name='mediafolder', + name='created_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间'), + ), + migrations.AlterField( + model_name='mediafolder', + name='description', + field=models.TextField(blank=True, verbose_name='描述'), + ), + migrations.AlterField( + model_name='mediafolder', + name='name', + field=models.CharField(max_length=100, verbose_name='文件夹名称'), + ), + migrations.AlterField( + model_name='mediafolder', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_folders', to=settings.AUTH_USER_MODEL, verbose_name='所有者'), + ), + migrations.AlterField( + model_name='mediafolder', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subfolders', to='blog.mediafolder', verbose_name='父文件夹'), + ), + migrations.AlterField( + model_name='userfollow', + name='creation_time', + field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='创建时间'), + ), + migrations.AlterField( + model_name='userfollow', + name='follower', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='following', to=settings.AUTH_USER_MODEL, verbose_name='关注者'), + ), + migrations.AlterField( + model_name='userfollow', + name='following', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='followers', to=settings.AUTH_USER_MODEL, verbose_name='被关注者'), + ), + migrations.AlterField( + model_name='userfollow', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/src/blog/models.py b/src/blog/models.py index 083788b..06fb0cd 100644 --- a/src/blog/models.py +++ b/src/blog/models.py @@ -16,27 +16,28 @@ from djangoblog.utils import get_current_site logger = logging.getLogger(__name__) - +#zhq: 链接显示类型选择类 - 定义链接在不同页面的显示方式 class LinkShowType(models.TextChoices): - I = ('i', _('index')) - L = ('l', _('list')) - P = ('p', _('post')) - A = ('a', _('all')) - S = ('s', _('slide')) - + I = ('i', _('index'))#zhq: 首页显示 + L = ('l', _('list'))#zhq: 列表页显示 + P = ('p', _('post'))#zhq: 文章页显示 + A = ('a', _('all'))#zhq: 所有页面显示 + S = ('s', _('slide'))#zhq: 幻灯片显示 +#zhq: 基础模型抽象类 - 所有模型的基类,包含公共字段和方法 class BaseModel(models.Model): id = models.AutoField(primary_key=True) creation_time = models.DateTimeField(_('creation time'), default=now) last_modify_time = models.DateTimeField(_('modify time'), default=now) - def save(self, *args, **kwargs): + def save(self, *args, **kwargs):#zhq: 特殊处理文章浏览量的更新,避免触发其他字段的保存逻辑 is_update_views = isinstance( self, Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views'] if is_update_views: Article.objects.filter(pk=self.pk).update(views=self.views) else: + # zhq: 自动生成slug字段,用于SEO友好的URL if 'slug' in self.__dict__: slug = getattr( self, 'title') if 'title' in self.__dict__ else getattr( @@ -45,6 +46,7 @@ class BaseModel(models.Model): super().save(*args, **kwargs) def get_full_url(self): + # zhq: 获取对象的完整URL地址 site = get_current_site().domain url = "https://{site}{path}".format(site=site, path=self.get_absolute_url()) @@ -55,9 +57,10 @@ class BaseModel(models.Model): @abstractmethod def get_absolute_url(self): + # zhq: 抽象方法,子类必须实现获取绝对URL的方法 pass - +#zhq: 文章模型 - 博客的核心内容模型 class Article(BaseModel): """文章""" STATUS_CHOICES = ( @@ -112,10 +115,17 @@ class Article(BaseModel): return self.title class Meta: - ordering = ['-article_order', '-pub_time'] + ordering = ['-article_order', '-pub_time']#zhq: 按排序和发布时间降序排列 verbose_name = _('article') verbose_name_plural = verbose_name get_latest_by = 'id' + # 性能优化:添加组合索引,提升常用查询性能 + indexes = [ + models.Index(fields=['status', 'type'], name='article_status_type_idx'), + models.Index(fields=['status', 'pub_time'], name='article_status_pubtime_idx'), + models.Index(fields=['-pub_time'], name='article_pubtime_desc_idx'), + models.Index(fields=['article_order', '-pub_time'], name='article_order_pubtime_idx'), + ] def get_absolute_url(self): return reverse('blog:detailbyid', kwargs={ @@ -127,44 +137,77 @@ class Article(BaseModel): @cache_decorator(60 * 60 * 10) def get_category_tree(self): + # zhq: 获取文章所属分类的完整树形结构 tree = self.category.get_category_tree() names = list(map(lambda c: (c.name, c.get_absolute_url()), tree)) return names def save(self, *args, **kwargs): + # 判断是否为新创建的文章 + is_new = self.pk is None + # 检查是否需要创建版本(不是只更新浏览量) + should_create_version = not ('update_fields' in kwargs and + kwargs.get('update_fields') == ['views']) + super().save(*args, **kwargs) + # 如果需要创建版本且不是新文章,则创建版本记录 + if should_create_version and not is_new: + try: + from blog.models_version import ArticleVersion + # 创建版本记录 + ArticleVersion.create_version( + article=self, + user=self.author, + change_summary=kwargs.get('change_summary', ''), + is_auto_save=True + ) + except Exception as e: + logger.error(f"Failed to create article version: {e}") + def viewed(self): + # zhq: 增加文章浏览量 self.views += 1 self.save(update_fields=['views']) def comment_list(self): + # zhq: 获取文章的评论列表,带缓存功能 + # 性能优化:预加载评论的作者和父评论,避免 N+1 查询 cache_key = 'article_comments_{id}'.format(id=self.id) value = cache.get(cache_key) if value: logger.info('get article comments:{id}'.format(id=self.id)) return value else: - comments = self.comment_set.filter(is_enable=True).order_by('-id') + comments = self.comment_set.filter(is_enable=True) \ + .select_related('author', 'parent_comment') \ + .order_by('-id') cache.set(cache_key, comments, 60 * 100) logger.info('set article comments:{id}'.format(id=self.id)) return comments def get_admin_url(self): + # zhq: 获取文章在Admin后台的编辑链接 info = (self._meta.app_label, self._meta.model_name) return reverse('admin:%s_%s_change' % info, args=(self.pk,)) @cache_decorator(expiration=60 * 100) def next_article(self): # 下一篇 + # 性能优化:预加载关联对象 return Article.objects.filter( - id__gt=self.id, status='p').order_by('id').first() + id__gt=self.id, status='p') \ + .select_related('author', 'category') \ + .order_by('id').first() @cache_decorator(expiration=60 * 100) def prev_article(self): # 前一篇 - return Article.objects.filter(id__lt=self.id, status='p').first() + # 性能优化:预加载关联对象 + return Article.objects.filter(id__lt=self.id, status='p') \ + .select_related('author', 'category') \ + .first() def get_first_image_url(self): """ @@ -176,7 +219,7 @@ class Article(BaseModel): return match.group(1) return "" - +#zhq: 分类模型 - 支持多级分类结构 class Category(BaseModel): """文章分类""" name = models.CharField(_('category name'), max_length=30, unique=True) @@ -185,8 +228,8 @@ class Category(BaseModel): verbose_name=_('parent category'), blank=True, null=True, - on_delete=models.CASCADE) - slug = models.SlugField(default='no-slug', max_length=60, blank=True) + on_delete=models.CASCADE) #zhq: 自关联,支持多级分类 + slug = models.SlugField(default='no-slug', max_length=60, blank=True, db_index=True) index = models.IntegerField(default=0, verbose_name=_('index')) class Meta: @@ -239,11 +282,11 @@ class Category(BaseModel): parse(self) return categorys - +#zhq: 标签模型 - 简单的标签管理 class Tag(BaseModel): """文章标签""" name = models.CharField(_('tag name'), max_length=30, unique=True) - slug = models.SlugField(default='no-slug', max_length=60, blank=True) + slug = models.SlugField(default='no-slug', max_length=60, blank=True, db_index=True) def __str__(self): return self.name @@ -253,6 +296,7 @@ class Tag(BaseModel): @cache_decorator(60 * 60 * 10) def get_article_count(self): + # zhq: 获取该标签下的文章数量 return Article.objects.filter(tags__name=self.name).distinct().count() class Meta: @@ -260,32 +304,32 @@ class Tag(BaseModel): verbose_name = _('tag') verbose_name_plural = verbose_name - +#zhq: 友情链接模型 class Links(models.Model): """友情链接""" name = models.CharField(_('link name'), max_length=30, unique=True) link = models.URLField(_('link')) - sequence = models.IntegerField(_('order'), unique=True) + sequence = models.IntegerField(_('order'), unique=True) #zhq: 链接显示顺序 is_enable = models.BooleanField( _('is show'), default=True, blank=False, null=False) show_type = models.CharField( _('show type'), max_length=1, choices=LinkShowType.choices, - default=LinkShowType.I) + default=LinkShowType.I) #zhq: 链接显示类型 creation_time = models.DateTimeField(_('creation time'), default=now) last_mod_time = models.DateTimeField(_('modify time'), default=now) class Meta: - ordering = ['sequence'] + ordering = ['sequence'] #zhq: 按顺序排列 verbose_name = _('link') verbose_name_plural = verbose_name def __str__(self): return self.name - +#zhq: 侧边栏模型 - 支持自定义HTML内容 class SideBar(models.Model): """侧边栏,可以展示一些html内容""" name = models.CharField(_('title'), max_length=100) @@ -303,7 +347,7 @@ class SideBar(models.Model): def __str__(self): return self.name - +#zhq: 博客设置模型 - 单例模式,存储全局配置 class BlogSettings(models.Model): """blog的配置""" site_name = models.CharField( @@ -326,11 +370,11 @@ class BlogSettings(models.Model): null=False, blank=False, default='') - article_sub_length = models.IntegerField(_('article sub length'), default=300) - sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10) - sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5) - article_comment_count = models.IntegerField(_('article comment count'), default=5) - show_google_adsense = models.BooleanField(_('show adsense'), default=False) + article_sub_length = models.IntegerField(_('article sub length'), default=300) #zhq: 文章摘要长度 + sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10) #zhq: 侧边栏文章数量 + sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5) #zhq: 侧边栏评论数量 + article_comment_count = models.IntegerField(_('article comment count'), default=5) #zhq: 文章页评论数量 + show_google_adsense = models.BooleanField(_('show adsense'), default=False) #zhq:是否显示Google广告 google_adsense_codes = models.TextField( _('adsense code'), max_length=2000, null=True, blank=True, default='') open_site_comment = models.BooleanField(_('open site comment'), default=True) @@ -341,13 +385,13 @@ class BlogSettings(models.Model): max_length=2000, null=True, blank=True, - default='') + default='') #zhq: ICP备案号 analytics_code = models.TextField( "网站统计代码", max_length=1000, null=False, blank=False, - default='') + default='') #zhq: 网站统计代码 show_gongan_code = models.BooleanField( '是否显示公安备案号', default=False, null=False) gongan_beiancode = models.TextField( @@ -357,7 +401,7 @@ class BlogSettings(models.Model): blank=True, default='') comment_need_review = models.BooleanField( - '评论是否需要审核', default=False, null=False) + '评论是否需要审核', default=False, null=False) #zhq: 评论审核开关 class Meta: verbose_name = _('Website configuration') @@ -367,6 +411,7 @@ class BlogSettings(models.Model): return self.site_name def clean(self): + # zhq: 确保配置表只有一条记录(单例模式) if BlogSettings.objects.exclude(id=self.id).count(): raise ValidationError(_('There can only be one configuration')) @@ -374,3 +419,6 @@ class BlogSettings(models.Model): super().save(*args, **kwargs) from djangoblog.utils import cache cache.clear() + +# 导入多媒体管理模型 +from blog.models_media import MediaFile, MediaFolder, MediaFileFolder diff --git a/src/blog/models_draft.py b/src/blog/models_draft.py new file mode 100644 index 0000000..1a3c438 --- /dev/null +++ b/src/blog/models_draft.py @@ -0,0 +1,242 @@ +# 文章草稿自动保存模型 +# 用于在编辑文章时自动保存草稿,防止内容丢失 + +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, Category + + +class ArticleDraft(models.Model): + """ + 文章草稿模型 + 用于自动保存编辑中的文章,防止内容丢失 + """ + # 关联的文章(可为空,表示新建文章的草稿) + article = models.ForeignKey( + Article, + verbose_name='文章', + on_delete=models.CASCADE, + related_name='drafts', + null=True, + blank=True + ) + + # 草稿创建者 + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name='作者', + on_delete=models.CASCADE, + related_name='article_drafts' + ) + + # 草稿标题 + title = models.CharField('标题', max_length=200, blank=True, default='') + + # 草稿正文 + body = models.TextField('正文', blank=True, default='') + + # 分类ID(保存时的分类) + category_id = models.IntegerField('分类ID', null=True, blank=True) + + # 标签(JSON 格式保存标签ID列表) + tags_data = models.JSONField('标签数据', default=list, blank=True) + + # 状态 + status = models.CharField('状态', max_length=1, default='d') + + # 评论状态 + comment_status = models.CharField('评论状态', max_length=1, default='o') + + # 类型 + type = models.CharField('类型', max_length=1, default='a') + + # 创建时间 + creation_time = models.DateTimeField('创建时间', default=now, db_index=True) + + # 最后更新时间 + last_update_time = models.DateTimeField('最后更新时间', auto_now=True, db_index=True) + + # 草稿会话ID(用于区分不同的编辑会话) + session_id = models.CharField('会话ID', max_length=64, blank=True, default='') + + # 是否已发布(草稿应用到文章后标记为已发布) + is_published = models.BooleanField('已发布', default=False) + + class Meta: + ordering = ['-last_update_time'] + verbose_name = '文章草稿' + verbose_name_plural = '文章草稿' + indexes = [ + models.Index(fields=['author', '-last_update_time'], name='draft_author_time_idx'), + models.Index(fields=['article', '-last_update_time'], name='draft_article_time_idx'), + models.Index(fields=['is_published', '-last_update_time'], name='draft_published_time_idx'), + ] + + def __str__(self): + title = self.title or '(无标题)' + return f"{title} - {self.author.username} - {self.last_update_time.strftime('%Y-%m-%d %H:%M')}" + + @classmethod + def save_draft(cls, user, title='', body='', article_id=None, category_id=None, + tags_data=None, status='d', comment_status='o', type='a', session_id=''): + """ + 保存或更新草稿 + + Args: + user: 用户对象 + title: 文章标题 + body: 文章正文 + article_id: 文章ID(如果是编辑现有文章) + category_id: 分类ID + tags_data: 标签数据列表 + status: 文章状态 + comment_status: 评论状态 + type: 文章类型 + session_id: 会话ID + + Returns: + ArticleDraft 实例 + """ + if tags_data is None: + tags_data = [] + + # 查找是否存在相同会话的草稿 + if session_id: + draft = cls.objects.filter( + author=user, + session_id=session_id, + is_published=False + ).first() + elif article_id: + # 如果是编辑现有文章,查找该文章的最新未发布草稿 + draft = cls.objects.filter( + author=user, + article_id=article_id, + is_published=False + ).first() + else: + # 新建文章,查找最新的未关联文章的草稿 + draft = cls.objects.filter( + author=user, + article__isnull=True, + is_published=False + ).first() + + # 更新或创建草稿 + if draft: + draft.title = title + draft.body = body + draft.category_id = category_id + draft.tags_data = tags_data + draft.status = status + draft.comment_status = comment_status + draft.type = type + if session_id: + draft.session_id = session_id + draft.save() + else: + article = None + if article_id: + try: + article = Article.objects.get(id=article_id) + except Article.DoesNotExist: + pass + + draft = cls.objects.create( + author=user, + article=article, + title=title, + body=body, + category_id=category_id, + tags_data=tags_data, + status=status, + comment_status=comment_status, + type=type, + session_id=session_id + ) + + return draft + + def apply_to_article(self, article=None): + """ + 将草稿应用到文章 + + Args: + article: Article 实例(如果为空则使用关联的文章) + + Returns: + Article 实例 + """ + if article is None: + article = self.article + + if article is None: + # 创建新文章 + from blog.models import Category + category = None + if self.category_id: + try: + category = Category.objects.get(id=self.category_id) + except Category.DoesNotExist: + # 如果分类不存在,使用第一个分类 + category = Category.objects.first() + + if category is None: + raise ValueError("必须指定文章分类") + + article = Article.objects.create( + title=self.title, + body=self.body, + author=self.author, + category=category, + status=self.status, + comment_status=self.comment_status, + type=self.type + ) + else: + # 更新现有文章 + article.title = self.title + article.body = self.body + if self.category_id: + try: + from blog.models import Category + category = Category.objects.get(id=self.category_id) + article.category = category + except Category.DoesNotExist: + pass + article.status = self.status + article.comment_status = self.comment_status + article.type = self.type + article.save() + + # 应用标签 + if self.tags_data: + from blog.models import Tag + tags = Tag.objects.filter(id__in=self.tags_data) + article.tags.set(tags) + + # 标记草稿为已发布 + self.is_published = True + self.article = article + self.save() + + return article + + def get_preview_text(self, length=100): + """ + 获取草稿预览文本 + + Args: + length: 预览文本长度 + + Returns: + str: 预览文本 + """ + if self.body: + if len(self.body) > length: + return self.body[:length] + '...' + return self.body + return '(空草稿)' diff --git a/src/blog/models_media.py b/src/blog/models_media.py new file mode 100644 index 0000000..387ada1 --- /dev/null +++ b/src/blog/models_media.py @@ -0,0 +1,268 @@ +# 多媒体管理模型 +# 提供图片、文件的上传、管理和优化功能 + +import os +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 PIL import Image +import hashlib + + +class MediaFile(models.Model): + """ + 媒体文件模型 + 存储所有上传的图片和文件信息 + """ + FILE_TYPE_CHOICES = [ + ('image', '图片'), + ('file', '文件'), + ] + + # 文件名(原始文件名) + original_filename = models.CharField('原始文件名', max_length=255) + + # 存储文件名(实际存储的唯一文件名) + stored_filename = models.CharField('存储文件名', max_length=255, unique=True) + + # 文件类型(图片或普通文件) + file_type = models.CharField('文件类型', max_length=10, choices=FILE_TYPE_CHOICES) + + # 文件大小(字节) + file_size = models.BigIntegerField('文件大小') + + # 文件MD5哈希(用于去重) + file_hash = models.CharField('文件哈希', max_length=32, db_index=True) + + # MIME类型 + mime_type = models.CharField('MIME类型', max_length=100) + + # 文件路径(相对路径) + file_path = models.CharField('文件路径', max_length=500) + + # 上传用户 + uploader = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name='上传者', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='uploaded_files' + ) + + # 上传时间 + upload_time = models.DateTimeField('上传时间', default=now, db_index=True) + + # 图片专属字段 + # 图片宽度 + width = models.IntegerField('宽度', null=True, blank=True) + + # 图片高度 + height = models.IntegerField('高度', null=True, blank=True) + + # 缩略图路径 + thumbnail_path = models.CharField('缩略图路径', max_length=500, blank=True) + + # 描述/备注 + description = models.TextField('描述', blank=True) + + # 是否公开(私有文件只有上传者可访问) + is_public = models.BooleanField('是否公开', default=True) + + # 引用计数(被多少篇文章引用) + reference_count = models.IntegerField('引用次数', default=0) + + class Meta: + ordering = ['-upload_time'] + verbose_name = '媒体文件' + verbose_name_plural = '媒体文件' + indexes = [ + models.Index(fields=['file_type', '-upload_time'], name='media_type_time_idx'), + models.Index(fields=['uploader', '-upload_time'], name='media_uploader_time_idx'), + models.Index(fields=['file_hash'], name='media_hash_idx'), + ] + + def __str__(self): + return self.original_filename + + def get_absolute_url(self): + """获取文件的访问URL""" + from django.templatetags.static import static + return static(self.file_path) + + def get_thumbnail_url(self): + """获取缩略图URL""" + if self.thumbnail_path: + from django.templatetags.static import static + return static(self.thumbnail_path) + return self.get_absolute_url() + + def get_file_extension(self): + """获取文件扩展名""" + return os.path.splitext(self.original_filename)[1].lower() + + def is_image(self): + """判断是否为图片""" + return self.file_type == 'image' + + def delete(self, *args, **kwargs): + """删除文件时同时删除物理文件""" + # 删除主文件 + full_path = os.path.join(settings.STATICFILES, self.file_path) + if os.path.exists(full_path): + try: + os.remove(full_path) + except Exception as e: + import logging + logging.error(f"删除文件失败 {full_path}: {e}") + + # 删除缩略图 + if self.thumbnail_path: + thumb_path = os.path.join(settings.STATICFILES, self.thumbnail_path) + if os.path.exists(thumb_path): + try: + os.remove(thumb_path) + except Exception as e: + import logging + logging.error(f"删除缩略图失败 {thumb_path}: {e}") + + super().delete(*args, **kwargs) + + @classmethod + def get_file_hash(cls, file_content): + """计算文件MD5哈希""" + md5 = hashlib.md5() + for chunk in file_content.chunks(): + md5.update(chunk) + return md5.hexdigest() + + @classmethod + def check_duplicate(cls, file_hash): + """检查是否已存在相同文件""" + return cls.objects.filter(file_hash=file_hash).first() + + def generate_thumbnail(self, max_size=(300, 300)): + """生成缩略图""" + if not self.is_image(): + return False + + try: + full_path = os.path.join(settings.STATICFILES, self.file_path) + if not os.path.exists(full_path): + return False + + # 打开图片 + img = Image.open(full_path) + + # 转换RGBA为RGB(处理PNG透明背景) + if img.mode in ('RGBA', 'LA', 'P'): + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + + # 生成缩略图 + img.thumbnail(max_size, Image.Resampling.LANCZOS) + + # 保存缩略图 + thumb_filename = f"thumb_{self.stored_filename}" + thumb_dir = os.path.dirname(full_path) + thumb_path = os.path.join(thumb_dir, thumb_filename) + + img.save(thumb_path, quality=85, optimize=True) + + # 更新缩略图路径 + self.thumbnail_path = os.path.join( + os.path.dirname(self.file_path), + thumb_filename + ) + self.save(update_fields=['thumbnail_path']) + + return True + + except Exception as e: + import logging + logging.error(f"生成缩略图失败: {e}", exc_info=True) + return False + + +class MediaFolder(models.Model): + """ + 媒体文件夹模型 + 用于组织和分类媒体文件 + """ + # 文件夹名称 + name = models.CharField('文件夹名称', max_length=100) + + # 父文件夹(支持嵌套) + parent = models.ForeignKey( + 'self', + verbose_name='父文件夹', + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='subfolders' + ) + + # 所有者 + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name='所有者', + on_delete=models.CASCADE, + related_name='media_folders' + ) + + # 创建时间 + created_time = models.DateTimeField('创建时间', default=now) + + # 描述 + description = models.TextField('描述', blank=True) + + class Meta: + ordering = ['name'] + verbose_name = '媒体文件夹' + verbose_name_plural = '媒体文件夹' + unique_together = [['name', 'parent', 'owner']] + + def __str__(self): + return self.get_full_path() + + def get_full_path(self): + """获取完整路径""" + if self.parent: + return f"{self.parent.get_full_path()}/{self.name}" + return self.name + + def get_files_count(self): + """获取文件夹中的文件数量""" + return self.files.count() + + +class MediaFileFolder(models.Model): + """ + 媒体文件和文件夹的关联表 + 支持一个文件属于多个文件夹 + """ + file = models.ForeignKey( + MediaFile, + on_delete=models.CASCADE, + related_name='folder_relations' + ) + + folder = models.ForeignKey( + MediaFolder, + on_delete=models.CASCADE, + related_name='file_relations' + ) + + added_time = models.DateTimeField('添加时间', default=now) + + class Meta: + unique_together = [['file', 'folder']] + verbose_name = '文件-文件夹关联' + verbose_name_plural = '文件-文件夹关联' + + def __str__(self): + return f"{self.file.original_filename} -> {self.folder.name}" diff --git a/src/blog/models_social.py b/src/blog/models_social.py new file mode 100644 index 0000000..8f39ff2 --- /dev/null +++ b/src/blog/models_social.py @@ -0,0 +1,401 @@ +# 用户关注和收藏功能模型 +# 实现用户间的关注关系和文章收藏功能 + +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 + + +class UserFollow(models.Model): + """ + 用户关注模型 + 记录用户之间的关注关系 + """ + # 关注者(谁关注了别人) + follower = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name='关注者', + on_delete=models.CASCADE, + related_name='following' + ) + + # 被关注者(被谁关注) + following = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name='被关注者', + on_delete=models.CASCADE, + related_name='followers' + ) + + # 关注时间 + creation_time = models.DateTimeField('创建时间', default=now, db_index=True) + + class Meta: + ordering = ['-creation_time'] + verbose_name = '用户关注' + verbose_name_plural = '用户关注' + unique_together = [['follower', 'following']] + indexes = [ + models.Index(fields=['follower', '-creation_time'], name='follow_follower_time_idx'), + models.Index(fields=['following', '-creation_time'], name='follow_following_time_idx'), + ] + + def __str__(self): + return f"{self.follower.username} -> {self.following.username}" + + def clean(self): + """验证:不能关注自己""" + from django.core.exceptions import ValidationError + if self.follower == self.following: + raise ValidationError(_('Cannot follow yourself')) + + @classmethod + def is_following(cls, follower, following): + """ + 检查是否已关注 + + Args: + follower: 关注者用户对象 + following: 被关注者用户对象 + + Returns: + bool: 是否已关注 + """ + return cls.objects.filter(follower=follower, following=following).exists() + + @classmethod + def follow(cls, follower, following): + """ + 关注用户 + + Args: + follower: 关注者用户对象 + following: 被关注者用户对象 + + Returns: + UserFollow 实例或 None + """ + if follower == following: + return None + + follow, created = cls.objects.get_or_create( + follower=follower, + following=following + ) + return follow if created else None + + @classmethod + def unfollow(cls, follower, following): + """ + 取消关注 + + Args: + follower: 关注者用户对象 + following: 被关注者用户对象 + + Returns: + bool: 是否成功取消关注 + """ + deleted_count, _ = cls.objects.filter( + follower=follower, + following=following + ).delete() + return deleted_count > 0 + + @classmethod + def get_following_list(cls, user, limit=None): + """ + 获取用户关注的人列表 + + Args: + user: 用户对象 + limit: 返回数量限制 + + Returns: + QuerySet: 关注的用户列表 + """ + queryset = cls.objects.filter(follower=user).select_related('following') + if limit: + queryset = queryset[:limit] + return [f.following for f in queryset] + + @classmethod + def get_followers_list(cls, user, limit=None): + """ + 获取用户的粉丝列表 + + Args: + user: 用户对象 + limit: 返回数量限制 + + Returns: + QuerySet: 粉丝用户列表 + """ + queryset = cls.objects.filter(following=user).select_related('follower') + if limit: + queryset = queryset[:limit] + return [f.follower for f in queryset] + + @classmethod + def get_following_count(cls, user): + """获取关注数量""" + return cls.objects.filter(follower=user).count() + + @classmethod + def get_followers_count(cls, user): + """获取粉丝数量""" + return cls.objects.filter(following=user).count() + + +class ArticleFavorite(models.Model): + """ + 文章收藏模型 + 记录用户收藏的文章 + """ + # 用户 + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name='用户', + on_delete=models.CASCADE, + related_name='favorites' + ) + + # 文章 + article = models.ForeignKey( + Article, + verbose_name='文章', + on_delete=models.CASCADE, + related_name='favorited_by' + ) + + # 收藏时间 + creation_time = models.DateTimeField('创建时间', default=now, db_index=True) + + # 收藏备注(可选) + note = models.CharField('备注', max_length=200, blank=True, default='') + + class Meta: + ordering = ['-creation_time'] + verbose_name = '文章收藏' + verbose_name_plural = '文章收藏' + unique_together = [['user', 'article']] + indexes = [ + models.Index(fields=['user', '-creation_time'], name='favorite_user_time_idx'), + models.Index(fields=['article', '-creation_time'], name='favorite_article_time_idx'), + ] + + def __str__(self): + return f"{self.user.username} -> {self.article.title}" + + @classmethod + def is_favorited(cls, user, article): + """ + 检查是否已收藏 + + Args: + user: 用户对象 + article: 文章对象 + + Returns: + bool: 是否已收藏 + """ + return cls.objects.filter(user=user, article=article).exists() + + @classmethod + def add_favorite(cls, user, article, note=''): + """ + 收藏文章 + + Args: + user: 用户对象 + article: 文章对象 + note: 收藏备注 + + Returns: + ArticleFavorite 实例或 None + """ + favorite, created = cls.objects.get_or_create( + user=user, + article=article, + defaults={'note': note} + ) + return favorite if created else None + + @classmethod + def remove_favorite(cls, user, article): + """ + 取消收藏 + + Args: + user: 用户对象 + article: 文章对象 + + Returns: + bool: 是否成功取消收藏 + """ + deleted_count, _ = cls.objects.filter( + user=user, + article=article + ).delete() + return deleted_count > 0 + + @classmethod + def get_user_favorites(cls, user, limit=None): + """ + 获取用户的收藏列表 + + Args: + user: 用户对象 + limit: 返回数量限制 + + Returns: + QuerySet: 收藏的文章列表 + """ + queryset = cls.objects.filter(user=user).select_related('article', 'article__author', 'article__category') + if limit: + queryset = queryset[:limit] + return queryset + + @classmethod + def get_favorite_count(cls, user): + """获取用户收藏数量""" + return cls.objects.filter(user=user).count() + + @classmethod + def get_article_favorite_count(cls, article): + """获取文章被收藏次数""" + return cls.objects.filter(article=article).count() + + +class ArticleLike(models.Model): + """ + 文章点赞模型 + 记录用户对文章的点赞 + """ + # 用户 + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name='用户', + on_delete=models.CASCADE, + related_name='likes' + ) + + # 文章 + article = models.ForeignKey( + Article, + verbose_name='文章', + on_delete=models.CASCADE, + related_name='liked_by' + ) + + # 点赞时间 + creation_time = models.DateTimeField('创建时间', default=now, db_index=True) + + class Meta: + ordering = ['-creation_time'] + verbose_name = '文章点赞' + verbose_name_plural = '文章点赞' + unique_together = [['user', 'article']] + indexes = [ + models.Index(fields=['user', '-creation_time'], name='like_user_time_idx'), + models.Index(fields=['article', '-creation_time'], name='like_article_time_idx'), + ] + + def __str__(self): + return f"{self.user.username} 👍 {self.article.title}" + + @classmethod + def is_liked(cls, user, article): + """ + 检查是否已点赞 + + Args: + user: 用户对象 + article: 文章对象 + + Returns: + bool: 是否已点赞 + """ + return cls.objects.filter(user=user, article=article).exists() + + @classmethod + def add_like(cls, user, article): + """ + 点赞文章 + + Args: + user: 用户对象 + article: 文章对象 + + Returns: + ArticleLike 实例或 None + """ + like, created = cls.objects.get_or_create( + user=user, + article=article + ) + return like if created else None + + @classmethod + def remove_like(cls, user, article): + """ + 取消点赞 + + Args: + user: 用户对象 + article: 文章对象 + + Returns: + bool: 是否成功取消点赞 + """ + deleted_count, _ = cls.objects.filter( + user=user, + article=article + ).delete() + return deleted_count > 0 + + @classmethod + def get_article_like_count(cls, article): + """ + 获取文章点赞数 + + Args: + article: 文章对象 + + Returns: + int: 点赞数 + """ + return cls.objects.filter(article=article).count() + + @classmethod + def get_user_like_count(cls, user): + """ + 获取用户点赞数 + + Args: + user: 用户对象 + + Returns: + int: 用户点赞的文章数量 + """ + return cls.objects.filter(user=user).count() + + @classmethod + def get_user_likes(cls, user, limit=None): + """ + 获取用户点赞的文章列表 + + Args: + user: 用户对象 + limit: 返回数量限制 + + Returns: + QuerySet: 点赞的文章列表 + """ + queryset = cls.objects.filter(user=user).select_related('article', 'article__author', 'article__category') + if limit: + queryset = queryset[:limit] + return queryset diff --git a/src/blog/models_version.py b/src/blog/models_version.py new file mode 100644 index 0000000..d7cc533 --- /dev/null +++ b/src/blog/models_version.py @@ -0,0 +1,170 @@ +# 文章版本管理模型 +# 用于追踪文章的修改历史,支持版本对比和回滚 + +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 + + +class ArticleVersion(models.Model): + """ + 文章版本模型 + 每次文章保存时自动创建版本记录 + """ + # 关联的文章 + article = models.ForeignKey( + Article, + verbose_name='文章', + on_delete=models.CASCADE, + related_name='versions' + ) + + # 版本号(自动递增) + version_number = models.PositiveIntegerField('版本号', default=1) + + # 文章标题(保存时的标题) + title = models.CharField('标题', max_length=200) + + # 文章正文(保存时的内容) + body = models.TextField('正文') + + # 发布时间 + pub_time = models.DateTimeField('发布时间') + + # 状态 + status = models.CharField('状态', max_length=1) + + # 评论状态 + comment_status = models.CharField('评论状态', max_length=1) + + # 类型 + type = models.CharField('类型', max_length=1) + + # 分类(保存时的分类ID) + category_id = models.IntegerField('分类ID') + category_name = models.CharField('分类名称', max_length=30) + + # 创建版本的用户 + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name='创建者', + on_delete=models.SET_NULL, + null=True, + related_name='article_versions_created' + ) + + # 版本创建时间 + creation_time = models.DateTimeField('创建时间', default=now, db_index=True) + + # 变更说明(可选) + change_summary = models.CharField( + '变更说明', + max_length=200, + blank=True, + default='' + ) + + # 是否为自动保存 + is_auto_save = models.BooleanField('自动保存', default=True) + + class Meta: + ordering = ['-version_number'] + verbose_name = '文章版本' + verbose_name_plural = '文章版本' + unique_together = [['article', 'version_number']] + indexes = [ + models.Index(fields=['article', '-version_number'], name='version_article_num_idx'), + models.Index(fields=['creation_time'], name='version_creation_time_idx'), + ] + + def __str__(self): + return f"{self.article.title} - v{self.version_number}" + + @classmethod + def create_version(cls, article, user=None, change_summary='', is_auto_save=True): + """ + 创建文章版本 + + Args: + article: Article 实例 + user: 创建版本的用户 + change_summary: 变更说明 + is_auto_save: 是否为自动保存 + + Returns: + ArticleVersion 实例 + """ + # 获取最新版本号 + latest_version = cls.objects.filter(article=article).first() + version_number = (latest_version.version_number + 1) if latest_version else 1 + + # 创建版本记录 + version = cls.objects.create( + article=article, + version_number=version_number, + title=article.title, + body=article.body, + pub_time=article.pub_time, + status=article.status, + comment_status=article.comment_status, + type=article.type, + category_id=article.category_id, + category_name=article.category.name, + created_by=user or article.author, + change_summary=change_summary, + is_auto_save=is_auto_save + ) + + return version + + def restore_to_article(self): + """ + 将此版本恢复到文章 + + Returns: + bool: 是否成功恢复 + """ + try: + article = self.article + article.title = self.title + article.body = self.body + article.pub_time = self.pub_time + article.status = self.status + article.comment_status = self.comment_status + article.type = self.type + + # 尝试恢复分类(如果分类ID仍存在) + from blog.models import Category + try: + category = Category.objects.get(id=self.category_id) + article.category = category + except Category.DoesNotExist: + # 如果原分类已删除,保持当前分类不变 + pass + + article.save() + return True + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to restore version {self.id}: {e}") + return False + + def get_diff_with_current(self): + """ + 获取与当前文章的差异 + + Returns: + dict: 包含差异信息的字典 + """ + article = self.article + diff = { + 'title_changed': self.title != article.title, + 'body_changed': self.body != article.body, + 'status_changed': self.status != article.status, + 'category_changed': self.category_id != article.category_id, + } + return diff diff --git a/src/blog/rate_limit.py b/src/blog/rate_limit.py new file mode 100644 index 0000000..488327d --- /dev/null +++ b/src/blog/rate_limit.py @@ -0,0 +1,316 @@ +# API 速率限制 +# 提供灵活的API请求频率限制功能 +# 支持按IP、按用户、按API端点限制 + +import logging +import time +from functools import wraps +from django.core.cache import cache +from django.http import JsonResponse +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class RateLimitExceeded(Exception): + """速率限制超出异常""" + pass + + +class RateLimiter: + """速率限制器""" + + # 默认限制配置 + DEFAULT_LIMITS = { + 'default': {'requests': 100, 'window': 60}, # 100次/分钟 + 'strict': {'requests': 10, 'window': 60}, # 10次/分钟 + 'loose': {'requests': 1000, 'window': 60}, # 1000次/分钟 + } + + @classmethod + def get_client_ip(cls, request): + """获取客户端IP地址""" + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0].strip() + else: + ip = request.META.get('REMOTE_ADDR', '') + return ip + + @classmethod + def get_cache_key(cls, identifier, scope='default'): + """ + 生成缓存键 + + Args: + identifier: 标识符(IP、用户ID等) + scope: 作用域(API端点名称等) + + Returns: + str: 缓存键 + """ + return f"rate_limit:{scope}:{identifier}" + + @classmethod + def is_rate_limited(cls, identifier, limit_config, scope='default'): + """ + 检查是否超过速率限制 + + Args: + identifier: 标识符 + limit_config: 限制配置 {'requests': 100, 'window': 60} + scope: 作用域 + + Returns: + tuple: (是否限制, 剩余请求数, 重置时间) + """ + requests_limit = limit_config['requests'] + time_window = limit_config['window'] + + cache_key = cls.get_cache_key(identifier, scope) + + # 获取当前窗口的请求记录 + current_time = int(time.time()) + window_start = current_time - time_window + + # 使用列表存储请求时间戳 + request_times = cache.get(cache_key, []) + + # 过滤掉窗口外的请求 + request_times = [t for t in request_times if t > window_start] + + # 检查是否超限 + if len(request_times) >= requests_limit: + # 计算重置时间 + oldest_request = min(request_times) + reset_time = oldest_request + time_window + remaining = 0 + is_limited = True + else: + # 添加当前请求 + request_times.append(current_time) + remaining = requests_limit - len(request_times) + reset_time = current_time + time_window + is_limited = False + + # 更新缓存 + cache.set(cache_key, request_times, time_window + 10) + + return is_limited, remaining, reset_time + + @classmethod + def get_limit_config(cls, limit_name='default'): + """ + 获取限制配置 + + Args: + limit_name: 限制名称或自定义配置 + + Returns: + dict: 限制配置 + """ + if isinstance(limit_name, dict): + return limit_name + + # 从设置中获取 + custom_limits = getattr(settings, 'RATE_LIMITS', {}) + + if limit_name in custom_limits: + return custom_limits[limit_name] + + if limit_name in cls.DEFAULT_LIMITS: + return cls.DEFAULT_LIMITS[limit_name] + + # 默认限制 + return cls.DEFAULT_LIMITS['default'] + + +def rate_limit(limit='default', key_func=None, scope=None): + """ + API速率限制装饰器 + + Args: + limit: 限制配置名称或字典 {'requests': 100, 'window': 60} + key_func: 自定义键函数 func(request) -> str + scope: 作用域名称(默认使用视图函数名) + + 使用示例: + @rate_limit(limit='strict') + def my_api_view(request): + ... + + @rate_limit(limit={'requests': 50, 'window': 60}) + def another_view(request): + ... + + @rate_limit(key_func=lambda req: req.user.id, scope='user_actions') + def user_api(request): + ... + """ + def decorator(view_func): + @wraps(view_func) + def wrapped_view(request, *args, **kwargs): + # 获取限制配置 + limit_config = RateLimiter.get_limit_config(limit) + + # 确定作用域 + view_scope = scope or view_func.__name__ + + # 确定标识符 + if key_func: + identifier = key_func(request) + elif request.user.is_authenticated: + # 已登录用户使用用户ID + identifier = f"user_{request.user.id}" + else: + # 未登录用户使用IP + identifier = f"ip_{RateLimiter.get_client_ip(request)}" + + # 检查速率限制 + is_limited, remaining, reset_time = RateLimiter.is_rate_limited( + identifier, + limit_config, + view_scope + ) + + # 添加速率限制头 + response_headers = { + 'X-RateLimit-Limit': str(limit_config['requests']), + 'X-RateLimit-Remaining': str(remaining), + 'X-RateLimit-Reset': str(reset_time), + } + + if is_limited: + # 超过限制 + retry_after = reset_time - int(time.time()) + + logger.warning( + f"Rate limit exceeded for {identifier} " + f"on {view_scope}: {limit_config}" + ) + + response = JsonResponse({ + 'success': False, + 'error': 'rate_limit_exceeded', + 'message': f'请求过于频繁,请在 {retry_after} 秒后重试', + 'retry_after': retry_after + }, status=429) + + response_headers['Retry-After'] = str(retry_after) + + # 设置响应头 + for header, value in response_headers.items(): + response[header] = value + + return response + + # 执行视图 + response = view_func(request, *args, **kwargs) + + # 添加速率限制头到响应 + for header, value in response_headers.items(): + response[header] = value + + return response + + return wrapped_view + return decorator + + +def ip_rate_limit(limit='default', scope=None): + """ + 基于IP的速率限制装饰器 + + Args: + limit: 限制配置 + scope: 作用域 + + 示例: + @ip_rate_limit(limit='strict') + def api_view(request): + ... + """ + return rate_limit( + limit=limit, + key_func=lambda req: f"ip_{RateLimiter.get_client_ip(req)}", + scope=scope + ) + + +def user_rate_limit(limit='default', scope=None): + """ + 基于用户的速率限制装饰器 + + Args: + limit: 限制配置 + scope: 作用域 + + 示例: + @user_rate_limit(limit={'requests': 50, 'window': 3600}) + def api_view(request): + ... + """ + def key_func(request): + if request.user.is_authenticated: + return f"user_{request.user.id}" + # 未登录用户降级到IP限制 + return f"ip_{RateLimiter.get_client_ip(request)}" + + return rate_limit( + limit=limit, + key_func=key_func, + scope=scope + ) + + +# ==================== 中间件 ==================== + +class RateLimitMiddleware: + """ + 全局速率限制中间件 + + 在 settings.py 中配置: + MIDDLEWARE = [ + ... + 'blog.rate_limit.RateLimitMiddleware', + ] + + RATE_LIMIT_ENABLED = True + GLOBAL_RATE_LIMIT = {'requests': 1000, 'window': 60} + """ + + def __init__(self, get_response): + self.get_response = get_response + self.enabled = getattr(settings, 'RATE_LIMIT_ENABLED', False) + self.limit = getattr(settings, 'GLOBAL_RATE_LIMIT', {'requests': 1000, 'window': 60}) + + def __call__(self, request): + if self.enabled and request.path.startswith('/blog/api/'): + # 只对API请求进行全局限制 + identifier = f"global_ip_{RateLimiter.get_client_ip(request)}" + + is_limited, remaining, reset_time = RateLimiter.is_rate_limited( + identifier, + self.limit, + 'global' + ) + + if is_limited: + retry_after = reset_time - int(time.time()) + + response = JsonResponse({ + 'success': False, + 'error': 'rate_limit_exceeded', + 'message': '全局请求频率超限,请稍后再试', + 'retry_after': retry_after + }, status=429) + + response['X-RateLimit-Limit'] = str(self.limit['requests']) + response['X-RateLimit-Remaining'] = '0' + response['X-RateLimit-Reset'] = str(reset_time) + response['Retry-After'] = str(retry_after) + + return response + + response = self.get_response(request) + return response diff --git a/src/blog/static/account/css/account.css b/src/blog/static/account/css/account.css new file mode 100644 index 0000000..7d4cec7 --- /dev/null +++ b/src/blog/static/account/css/account.css @@ -0,0 +1,9 @@ +.button { + border: none; + padding: 4px 80px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + margin: 4px 2px; +} \ No newline at end of file diff --git a/src/blog/static/account/js/account.js b/src/blog/static/account/js/account.js new file mode 100644 index 0000000..f1a8771 --- /dev/null +++ b/src/blog/static/account/js/account.js @@ -0,0 +1,47 @@ +let wait = 60; + +function time(o) { + if (wait == 0) { + o.removeAttribute("disabled"); + o.value = "获取验证码"; + wait = 60 + return false + } else { + o.setAttribute("disabled", true); + o.value = "重新发送(" + wait + ")"; + wait--; + setTimeout(function () { + time(o) + }, + 1000) + } +} + +document.getElementById("btn").onclick = function () { + let id_email = $("#id_email") + let token = $("*[name='csrfmiddlewaretoken']").val() + let ts = this + let myErr = $("#myErr") + $.ajax( + { + url: "/forget_password_code/", + type: "POST", + data: { + "email": id_email.val(), + "csrfmiddlewaretoken": token + }, + success: function (result) { + if (result != "ok") { + myErr.remove() + id_email.after("
  • " + result + "
") + return + } + myErr.remove() + time(ts) + }, + error: function (e) { + alert("发送失败,请重试") + } + } + ); +} diff --git a/src/blog/static/assets/css/bootstrap.min.css b/src/blog/static/assets/css/bootstrap.min.css new file mode 100644 index 0000000..ed3905e --- /dev/null +++ b/src/blog/static/assets/css/bootstrap.min.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap v3.3.7 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0;font-size:2em}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;-webkit-box-shadow:none!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-plus:before{content:"\002b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:focus,a:hover{color:#23527c;text-decoration:underline}a:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{display:inline-block;max-width:100%;height:auto;padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role=button]{cursor:pointer}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{padding:.2em;background-color:#fcf8e3}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:focus,a.text-primary:hover{color:#286090}.text-success{color:#3c763d}a.text-success:focus,a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:focus,a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:focus,a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:focus,a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:focus,a.bg-primary:hover{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ol,ul{margin-top:0;margin-bottom:10px}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;margin-left:-5px;list-style:none}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}dl{margin-top:0;margin-bottom:20px}dd,dt{line-height:1.42857143}dt{font-weight:700}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;overflow:hidden;clear:left;text-align:right;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[data-original-title],abbr[title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote .small:before,blockquote footer:before,blockquote small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;text-align:right;border-right:5px solid #eee;border-left:0}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:''}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;font-weight:700;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{margin-right:-15px;margin-left:-15px}.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*=col-]{position:static;display:table-column;float:none}table td[class*=col-],table th[class*=col-]{position:static;display:table-cell;float:none}.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{min-height:.01%;overflow-x:auto}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:700}input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=file]:focus,input[type=checkbox]:focus,input[type=radio]:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control::-ms-expand{background-color:transparent;border:0}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type=search]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=time].form-control,input[type=datetime-local].form-control,input[type=month].form-control{line-height:34px}.input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{line-height:30px}.input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox label,.radio label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-top:4px\9;margin-left:-20px}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;font-weight:400;vertical-align:middle;cursor:pointer}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{cursor:not-allowed}.checkbox.disabled label,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .radio label{cursor:not-allowed}.form-control-static{min-height:34px;padding-top:7px;padding-bottom:7px;margin-bottom:0}.form-control-static.input-lg,.form-control-static.input-sm{padding-right:0;padding-left:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.has-success .form-control-feedback{color:#3c763d}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.form-horizontal .control-label{padding-top:7px;margin-bottom:0;text-align:right}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:11px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:400;line-height:1.42857143;text-align:center;white-space:nowrap;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-image:none;border:1px solid transparent;border-radius:4px}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#333;text-decoration:none}.btn.active,.btn:active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none;opacity:.65}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default.focus,.btn-default:focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.dropdown-toggle.btn-default.focus,.open>.dropdown-toggle.btn-default:focus,.open>.dropdown-toggle.btn-default:hover{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled.focus,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled].focus,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.dropdown-toggle.btn-primary.focus,.open>.dropdown-toggle.btn-primary:focus,.open>.dropdown-toggle.btn-primary:hover{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled.focus,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled].focus,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.dropdown-toggle.btn-success.focus,.open>.dropdown-toggle.btn-success:focus,.open>.dropdown-toggle.btn-success:hover{color:#fff;background-color:#398439;border-color:#255625}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled.focus,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled].focus,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.dropdown-toggle.btn-info.focus,.open>.dropdown-toggle.btn-info:focus,.open>.dropdown-toggle.btn-info:hover{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled.focus,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled].focus,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.dropdown-toggle.btn-warning.focus,.open>.dropdown-toggle.btn-warning:focus,.open>.dropdown-toggle.btn-warning:hover{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled.focus,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled].focus,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.dropdown-toggle.btn-danger.focus,.open>.dropdown-toggle.btn-danger:focus,.open>.dropdown-toggle.btn-danger:hover{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled.focus,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled].focus,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{font-weight:400;color:#337ab7;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease;-webkit-transition-duration:.35s;-o-transition-duration:.35s;transition-duration:.35s;-webkit-transition-property:height,visibility;-o-transition-property:height,visibility;transition-property:height,visibility}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown,.dropup{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;text-align:left;list-style:none;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175)}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;background-color:#337ab7;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{right:0;left:auto}.dropdown-menu-left{right:auto;left:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{content:"";border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{right:auto;left:0}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-top-right-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-right:0;padding-left:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group .form-control:focus{z-index:3}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group .form-control,.input-group-addon,.input-group-btn{display:table-cell}.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{padding-right:15px;padding-left:15px;overflow-x:visible;-webkit-overflow-scrolling:touch;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1)}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-right:0;padding-left:0}}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;height:50px;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-top:8px;margin-right:15px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{padding:10px 15px;margin-top:8px;margin-right:-15px;margin-bottom:8px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1)}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-left-radius:0;border-top-right-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}}@media (min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#555;background-color:#e7e7e7}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#080808}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.42857143;color:#337ab7;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:4px;border-bottom-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:2;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:3;color:#fff;cursor:default;background-color:#337ab7;border-color:#337ab7}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:6px;border-bottom-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:3px;border-bottom-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:focus,.label-success[href]:hover{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:middle;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#eee}.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{padding-right:15px;padding-left:15px;border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-right:60px;padding-left:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail a>img,.thumbnail>img{margin-right:auto;margin-left:auto}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#337ab7}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:700}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{overflow:hidden;zoom:1}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{color:#555;text-decoration:none;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#777;cursor:not-allowed;background-color:#eee}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-left-radius:3px;border-top-right-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-left-radius:3px;border-top-right-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px;padding-left:15px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;filter:alpha(opacity=20);opacity:.2}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;filter:alpha(opacity=50);opacity:.5}button.close{-webkit-appearance:none;padding:0;cursor:pointer;background:0 0;border:0}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;overflow:hidden;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transition:-webkit-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out;-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%)}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;outline:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5)}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{filter:alpha(opacity=0);opacity:0}.modal-backdrop.in{filter:alpha(opacity=50);opacity:.5}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:12px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;filter:alpha(opacity=0);opacity:0;line-break:auto}.tooltip.in{filter:alpha(opacity=90);opacity:.9}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px;bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2);line-break:auto}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{padding:8px 14px;margin:0;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{content:"";border-width:10px}.popover.top>.arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,.25);border-bottom-width:0}.popover.top>.arrow:after{bottom:1px;margin-left:-10px;content:" ";border-top-color:#fff;border-bottom-width:0}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,.25);border-left-width:0}.popover.right>.arrow:after{bottom:-10px;left:1px;content:" ";border-right-color:#fff;border-left-width:0}.popover.bottom>.arrow{top:-11px;left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25)}.popover.bottom>.arrow:after{top:1px;margin-left:-10px;content:" ";border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{right:1px;bottom:-10px;content:" ";border-right-width:0;border-left-color:#fff}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>a>img,.carousel-inner>.item>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.active.right,.carousel-inner>.item.next{left:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{left:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{left:0;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);background-color:rgba(0,0,0,0);filter:alpha(opacity=50);opacity:.5}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001)));background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);background-repeat:repeat-x}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5)));background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);background-repeat:repeat-x}.carousel-control:focus,.carousel-control:hover{color:#fff;text-decoration:none;filter:alpha(opacity=90);outline:0;opacity:.9}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;z-index:5;display:inline-block;margin-top:-10px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;font-family:serif;line-height:1}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000\9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-10px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-10px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.modal-header:after,.modal-header:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{display:table;content:" "}.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.modal-header:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-lg,.visible-md,.visible-sm,.visible-xs{display:none!important}.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}}@media (max-width:767px){.visible-xs-block{display:block!important}}@media (max-width:767px){.visible-xs-inline{display:inline!important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}}@media (min-width:1200px){.visible-lg-block{display:block!important}}@media (min-width:1200px){.visible-lg-inline{display:inline!important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media (min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/src/blog/static/assets/css/docs.min.css b/src/blog/static/assets/css/docs.min.css new file mode 100644 index 0000000..3945197 --- /dev/null +++ b/src/blog/static/assets/css/docs.min.css @@ -0,0 +1,11 @@ +/*! + * IE10 viewport hack for Surface/desktop Windows 8 bug + * Copyright 2014-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */@-ms-viewport{width:device-width}@-o-viewport{width:device-width}@viewport{width:device-width}.hll{background-color:#ffc}.c{color:#999}.err{color:#A00;background-color:#FAA}.k{color:#069}.o{color:#555}.cm{color:#999}.cp{color:#099}.c1{color:#999}.cs{color:#999}.gd{background-color:#FCC;border:1px solid #C00}.ge{font-style:italic}.gr{color:red}.gh{color:#030}.gi{background-color:#CFC;border:1px solid #0C0}.go{color:#AAA}.gp{color:#009}.gu{color:#030}.gt{color:#9C6}.kc{color:#069}.kd{color:#069}.kn{color:#069}.kp{color:#069}.kr{color:#069}.kt{color:#078}.m{color:#F60}.s{color:#d44950}.na{color:#4f9fcf}.nb{color:#366}.nc{color:#0A8}.no{color:#360}.nd{color:#99F}.ni{color:#999}.ne{color:#C00}.nf{color:#C0F}.nl{color:#99F}.nn{color:#0CF}.nt{color:#2f6f9f}.nv{color:#033}.ow{color:#000}.w{color:#bbb}.mf{color:#F60}.mh{color:#F60}.mi{color:#F60}.mo{color:#F60}.sb{color:#C30}.sc{color:#C30}.sd{color:#C30;font-style:italic}.s2{color:#C30}.se{color:#C30}.sh{color:#C30}.si{color:#A00}.sx{color:#C30}.sr{color:#3AA}.s1{color:#C30}.ss{color:#FC3}.bp{color:#366}.vc{color:#033}.vg{color:#033}.vi{color:#033}.il{color:#F60}.css .nt+.nt,.css .o,.css .o+.nt{color:#999}.select2-container{position:relative;display:inline-block;zoom:1;*display:inline;vertical-align:top;padding:0;border:0}.select2-container:hover{border:0;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none}.select2-container,.select2-drop,.select2-search,.select2-search input{-moz-box-sizing:border-box;-ms-box-sizing:border-box;-webkit-box-sizing:border-box;-khtml-box-sizing:border-box;box-sizing:border-box}.select2-container .select2-choice{display:block;overflow:hidden;text-decoration:none;padding:4px 12px;margin:0;color:#333;text-shadow:0 1px 0 #fff;white-space:nowrap;font-family:Arial,Helvetica,sans-serif;font-weight:700;font-size:13px;cursor:default;height:18px;background-color:#f3f3f3;background-image:-moz-linear-gradient(top,#f5f5f5,#f1f1f1);background-image:-webkit-gradient(linear,0 0,0 100%,from(#f5f5f5),to(#f1f1f1));background-image:-webkit-linear-gradient(top,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(top,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#fff1f1f1', GradientType=0);-webkit-background-clip:padding;-moz-background-clip:padding;background-clip:padding;border:1px solid #dcdcdc;-webkit-border-radius:2px;-moz-border-radius:2px;border-radius:2px;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;-moz-box-sizing:content-box;-ms-box-sizing:content-box;-webkit-box-sizing:content-box;-khtml-box-sizing:content-box;box-sizing:content-box}.select2-container .select2-choice:hover{color:#333;text-shadow:none;border-color:#c6c6c6;background-color:#f5f5f5;background-image:-moz-linear-gradient(top,#f8f8f8,#f1f1f1);background-image:-webkit-gradient(linear,0 0,0 100%,from(#f8f8f8),to(#f1f1f1));background-image:-webkit-linear-gradient(top,#f8f8f8,#f1f1f1);background-image:-o-linear-gradient(top,#f8f8f8,#f1f1f1);background-image:linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff8f8f8', endColorstr='#fff1f1f1', GradientType=0);-webkit-box-shadow:0 1px 1px rgba(0,0,0,.1);-moz-box-shadow:0 1px 1px rgba(0,0,0,.1);box-shadow:0 1px 1px rgba(0,0,0,.1);background-position:0 0;-webkit-transition:none;-moz-transition:none;-o-transition:none;transition:none;z-index:2}.select2-container-active .select2-choice:hover{border:1px solid #4D90FE}.select2-container.select2-drop-above .select2-choice{background-image:-webkit-gradient(linear,left bottom,left top,color-stop(0,#eee),color-stop(.9,#fff));background-image:-webkit-linear-gradient(center bottom,#eee 0,#fff 90%);background-image:-moz-linear-gradient(center bottom,#eee 0,#fff 90%);background-image:-o-linear-gradient(bottom,#eee 0,#fff 90%);background-image:-ms-linear-gradient(top,#eee 0,#fff 90%);filter:progid:DXImageTransform.Microsoft.gradient( startColorstr='#eeeeee', endColorstr='#ffffff', GradientType=0 );background-image:linear-gradient(top,#eee 0,#fff 90%)}.select2-container .select2-choice span{margin-right:26px;display:block;overflow:hidden;white-space:nowrap;-o-text-overflow:ellipsis;-ms-text-overflow:ellipsis;text-overflow:ellipsis}.select2-container .select2-choice abbr{display:block;position:absolute;right:26px;top:8px;width:12px;height:12px;font-size:17px;line-height:16px;color:#595959;font-weight:700;cursor:pointer;text-decoration:none;border:0;outline:0}.select2-container .select2-choice abbr:hover{color:#222;cursor:pointer}.select2-drop-mask{position:absolute;left:0;top:0;z-index:9998;opacity:0}.select2-drop{background:#fff;color:#000;border:1px solid #aaa;position:absolute;top:100%;-webkit-box-shadow:0 2px 4px rgba(0,0,0,.2);-moz-box-shadow:0 2px 4px rgba(0,0,0,.2);-o-box-shadow:0 2px 4px rgba(0,0,0,.2);box-shadow:0 2px 4px rgba(0,0,0,.2);z-index:9999;width:100%;margin-top:1px}.select2-drop.select2-drop-above{margin-top:-1px;-webkit-box-shadow:0 -2px 4px rgba(0,0,0,.2);-moz-box-shadow:0 -2px 4px rgba(0,0,0,.2);-o-box-shadow:0 -2px 4px rgba(0,0,0,.2);box-shadow:0 -2px 4px rgba(0,0,0,.2)}.select2-container .select2-choice div{-webkit-border-radius:0 2px 2px 0;-moz-border-radius:0 2px 2px 0;border-radius:0 2px 2px 0;-moz-background-clip:padding;-webkit-background-clip:padding-box;background-clip:padding-box;position:absolute;right:0;top:0;display:block;height:100%;width:18px}.select2-container .select2-choice div b{background:url(/assets/img/select2.png) no-repeat -30px 2px;display:block;width:100%;height:100%}.select2-search{display:inline-block;white-space:nowrap;z-index:10000;min-height:26px;width:100%;margin:0;padding:4px 4px 0 4px}.select2-search-hidden{display:block;position:absolute;left:-10000px}.select2-search input{background:#fff url(/assets/img/select2.png) no-repeat 100% -22px;background:url(/assets/img/select2.png) no-repeat 100% -22px,-webkit-gradient(linear,left bottom,left top,color-stop(.85,#fff),color-stop(.99,#eee));background:url(/assets/img/select2.png) no-repeat 100% -22px,-webkit-linear-gradient(center bottom,#fff 85%,#eee 99%);background:url(/assets/img/select2.png) no-repeat 100% -22px,-moz-linear-gradient(center bottom,#fff 85%,#eee 99%);background:url(/assets/img/select2.png) no-repeat 100% -22px,-o-linear-gradient(bottom,#fff 85%,#eee 99%);background:url(/assets/img/select2.png) no-repeat 100% -22px,-ms-linear-gradient(top,#fff 85%,#eee 99%);background:url(/assets/img/select2.png) no-repeat 100% -22px,linear-gradient(top,#fff 85%,#eee 99%);padding:4px 20px 4px 5px;outline:0;border:1px solid #aaa;font-family:sans-serif;font-size:1em;width:100%;margin:0;height:auto!important;min-height:26px;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;border-radius:0;-moz-border-radius:0;-webkit-border-radius:0}.select2-drop.select2-drop-above .select2-search input{margin-top:4px}.select2-search input.select2-active{background:#fff url(../img/spinner.gif) no-repeat 100%;background:url(../img/spinner.gif) no-repeat 100%,-webkit-gradient(linear,left bottom,left top,color-stop(.85,#fff),color-stop(.99,#eee));background:url(../img/spinner.gif) no-repeat 100%,-webkit-linear-gradient(center bottom,#fff 85%,#eee 99%);background:url(../img/spinner.gif) no-repeat 100%,-moz-linear-gradient(center bottom,#fff 85%,#eee 99%);background:url(../img/spinner.gif) no-repeat 100%,-o-linear-gradient(bottom,#fff 85%,#eee 99%);background:url(../img/spinner.gif) no-repeat 100%,-ms-linear-gradient(top,#fff 85%,#eee 99%);background:url(../img/spinner.gif) no-repeat 100%,linear-gradient(top,#fff 85%,#eee 99%)}.select2-container-active .select2-choice,.select2-container-active .select2-choices{border:1px solid #4D90FE;outline:0}.select2-dropdown-open .select2-choice,.select2-dropdown-open .select2-choice:hover{background-color:#f4f4f4;background-image:-moz-linear-gradient(top,#f6f6f6,#f1f1f1);background-image:-webkit-gradient(linear,0 0,0 100%,from(#f6f6f6),to(#f1f1f1));background-image:-webkit-linear-gradient(top,#f6f6f6,#f1f1f1);background-image:-o-linear-gradient(top,#f6f6f6,#f1f1f1);background-image:linear-gradient(to bottom,#f6f6f6,#f1f1f1);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff6f6f6', endColorstr='#fff1f1f1', GradientType=0);-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);-moz-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.select2-dropdown-open .select2-choice div{background:0 0;border-left:none}.select2-results{margin:4px 1px 4px 0;padding:0;position:relative;overflow-x:hidden;overflow-y:auto;max-height:200px}.select2-results ul.select2-result-sub{margin:0}.select2-results ul.select2-result-sub>li .select2-result-label{padding-left:20px}.select2-results ul.select2-result-sub ul.select2-result-sub>li .select2-result-label{padding-left:40px}.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub>li .select2-result-label{padding-left:60px}.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub>li .select2-result-label{padding-left:80px}.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub>li .select2-result-label{padding-left:100px}.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub>li .select2-result-label{padding-left:110px}.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub>li .select2-result-label{padding-left:120px}.select2-results li{list-style:none;display:list-item}.select2-results li.select2-result-with-children>.select2-result-label{font-weight:700}.select2-results .select2-result-label{padding:3px 7px 4px;margin:0;cursor:pointer}.select2-results .select2-highlighted{background:#eee}.select2-results li em{background:#feffde;font-style:normal}.select2-results .select2-highlighted em{background:0 0}.select2-results .select2-no-results,.select2-results .select2-searching,.select2-results .select2-selection-limit{background:#f4f4f4;display:list-item;padding-left:4px}.select2-results .select2-disabled{display:none}.select2-more-results.select2-active{background:#f4f4f4 url(../img/spinner.gif) no-repeat 100%}.select2-more-results{background:#f4f4f4;display:list-item}.select2-container.select2-container-disabled .select2-choice{color:#b3b3b3;border-color:#d9d9d9;background-color:#e6e6e6;background-image:none;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;text-shadow:none;cursor:default}.select2-container.select2-container-disabled .select2-choice div{opacity:.5;filter:alpha(opacity=50)}.select2-container-multi .select2-choices{background-color:#fff;border:1px solid #d9d9d9;border-top:1px solid silver;-webkit-border-radius:1px;-moz-border-radius:1px;border-radius:1px;margin:0;padding:0;cursor:text;overflow:hidden;height:auto!important;height:1%;position:relative}.select2-container-multi .select2-choices:hover{border:1px solid #b9b9b9;border-top:1px solid #a0a0a0;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);-moz-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.select2-container-multi .select2-choices{min-height:26px}.select2-container-multi.select2-container-active .select2-choices{border:1px solid #4D90FE;outline:0}.select2-container-multi .select2-choices li{float:left;list-style:none}.select2-container-multi .select2-choices .select2-search-field{white-space:nowrap;margin:0;padding:0}.select2-container-multi .select2-choices .select2-search-field input{color:#666;background:0 0!important;font-family:sans-serif;font-size:100%;height:23px;padding:5px;margin:1px 0;outline:0;border:0;-webkit-box-shadow:none;-moz-box-shadow:none;-o-box-shadow:none;box-shadow:none}.select2-container-multi .select2-choices .select2-search-field input.select2-active{background:#fff url(../img/spinner.gif) no-repeat 100%!important}.select2-default{color:#999!important}.select2-container-multi .select2-choices .select2-search-choice{-webkit-border-radius:2px;-moz-border-radius:2px;border-radius:2px;-moz-background-clip:padding;-webkit-background-clip:padding-box;background-clip:padding-box;background-color:#DAE4F6;color:#222;font-family:Arial;border:1px solid #DAE4F6;line-height:23px;padding:0 19px 0 5px;margin:1px;position:relative;cursor:default}.select2-container-multi .select2-choices .select2-search-choice span{cursor:default}.select2-container-multi .select2-choices .select2-search-choice-focus{background:#A6D7F5}.select2-search-choice-close{display:block;position:absolute;right:3px;top:4px;width:12px;height:13px;font-size:17px;line-height:16px;color:#444;font-weight:700;outline:0}.select2-search-choice-close:hover{text-decoration:none;color:#222;cursor:pointer}.select2-container-multi.select2-container-disabled .select2-choices{background-color:#f4f4f4;background-image:none;border:1px solid #ddd;cursor:default}.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice{background-image:none;background-color:#f4f4f4;border:1px solid #ddd;padding:3px 5px 3px 5px}.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice .select2-search-choice-close{display:none}.select2-result-selectable .select2-match,.select2-result-unselectable .select2-result-selectable .select2-match{font-weight:700}.select2-result-unselectable .select2-match{text-decoration:none}.select2-offscreen{position:absolute;left:-10000px}.select2-results::-webkit-scrollbar{height:16px;width:10px}.select2-results::-webkit-scrollbar-button:end:increment,.select2-results::-webkit-scrollbar-button:start:decrement{background-color:transparent;display:block;height:0}.select2-results::-webkit-scrollbar-track{background-clip:padding-box;border:solid transparent;border-width:0 0 0 4px}.select2-results::-webkit-scrollbar-track-piece{background-color:transparent;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0}.select2-results::-webkit-scrollbar:hover{background-color:#f3f3f3;border:1px solid #dbdbdb}.select2-results::-webkit-scrollbar-thumb:horizontal,.select2-results::-webkit-scrollbar-thumb:vertical{background-color:#c6c6c6;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0}.select2-results::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,.2);border:solid transparent;border-width:0;-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,.1),inset 0 -1px 0 rgba(0,0,0,.07);-moz-box-shadow:inset 1px 1px 0 rgba(0,0,0,.1),inset 0 -1px 0 rgba(0,0,0,.07);box-shadow:inset 1px 1px 0 rgba(0,0,0,.1),inset 0 -1px 0 rgba(0,0,0,.07);background-clip:padding-box}.select2-results::-webkit-scrollbar-thumb:hover{background-color:#949494}.select2-results::-webkit-scrollbar-thumb:active{background-color:rgba(0,0,0,.5);-webkit-box-shadow:inset 1px 1px 3px rgba(0,0,0,.35);-moz-box-shadow:inset 1px 1px 3px rgba(0,0,0,.35);box-shadow:inset 1px 1px 3px rgba(0,0,0,.35)}@media only screen and (-webkit-min-device-pixel-ratio:1.5){.select2-container .select2-choice div b,.select2-search input{background-image:url(/assets/img/select2x2.png)!important;background-repeat:no-repeat!important;background-size:60px 40px!important}.select2-search input{background-position:100% -21px!important}}/*! + * Bootstrap Docs (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under the Creative Commons Attribution 3.0 Unported License. For + * details, see https://creativecommons.org/licenses/by/3.0/. + */body{position:relative;padding-top:94px}.table code{font-size:13px;font-weight:400}h2 code,h3 code,h4 code{background-color:inherit}.btn-outline{color:#4d90fe;background-color:transparent;border-color:#4d90fe}.btn-outline:active,.btn-outline:focus,.btn-outline:hover{color:#fff;background-color:#4d90fe;border-color:#4d90fe}.btn-outline-inverse{color:#fff;background-color:transparent;border-color:#fff}.btn-outline-inverse:active,.btn-outline-inverse:focus,.btn-outline-inverse:hover{color:#2d87e2;text-shadow:none;background-color:#fff;border-color:#fff}#skippy{display:block;padding:1em;color:#777;background-color:#f1f1f1;outline:0}#skippy .skiplink-text{padding:.5em;outline:1px dotted}#content:focus{outline:0}.bs-docs-footer{padding-top:40px;padding-bottom:30px;margin-top:100px;color:#777;text-align:center;border-top:1px solid #e5e5e5}.bs-docs-footer-links{padding-left:0;margin-bottom:20px}.bs-docs-footer-links li{display:inline-block}.bs-docs-footer-links li+li{margin-left:15px}@media (min-width:768px){.bs-docs-footer{text-align:left}.bs-docs-footer p{margin-bottom:0}}.bs-docs-header,.bs-docs-masthead{position:relative;padding:30px 0;color:#b3d4f4;text-align:center;text-shadow:0 1px 0 rgba(0,0,0,.1);background-color:#2d87e2;background-image:-webkit-linear-gradient(top,#1b6ec1 0,#2d87e2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#1b6ec1),to(#2d87e2));background-image:-o-linear-gradient(top,#1b6ec1 0,#2d87e2 100%);background-image:linear-gradient(to bottom,#1b6ec1 0,#2d87e2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#1b6ec1', endColorstr='#2d87e2', GradientType=0);background-repeat:repeat-x}.bs-docs-masthead .bs-docs-booticon{margin:0 auto 30px}.bs-docs-masthead h1{font-weight:300;line-height:1;color:#fff}.bs-docs-masthead .lead{margin:0 auto 30px;font-size:20px;color:#fff}.bs-docs-masthead .version{margin-top:-15px;color:#b3d4f4}.bs-docs-masthead .btn{width:100%;padding:15px 30px;font-size:20px}@media (min-width:480px){.bs-docs-masthead .btn{width:auto}}@media (min-width:768px){.bs-docs-masthead{padding:80px 0}.bs-docs-masthead h1{font-size:60px}.bs-docs-masthead .lead{font-size:24px}}@media (min-width:992px){.bs-docs-masthead .lead{width:80%;font-size:30px}}.bs-docs-header{margin-bottom:40px;font-size:20px}.bs-docs-header h1{margin-top:0;color:#fff}.bs-docs-header p{margin-bottom:0;font-weight:300;line-height:1.4}.bs-docs-header .container{position:relative}@media (min-width:768px){.bs-docs-header{padding-top:60px;padding-bottom:60px;font-size:24px;text-align:left}.bs-docs-header h1{font-size:60px;line-height:1}}@media (min-width:992px){.bs-docs-header h1,.bs-docs-header p{margin-right:380px}}.bs-docs-featurette{padding-top:40px;padding-bottom:40px;font-size:16px;line-height:1.5;color:#555;text-align:center;background-color:#fff;border-bottom:1px solid #e5e5e5}.bs-docs-featurette+.bs-docs-footer{margin-top:0;border-top:0}.bs-docs-featurette-title{margin-bottom:5px;font-size:30px;font-weight:400;color:#333}.half-rule{width:100px;margin:40px auto}.bs-docs-featurette h3{margin-bottom:5px;font-weight:400;color:#333}.bs-docs-featurette-img{display:block;margin-bottom:20px;color:#333}.bs-docs-featurette-img:hover{color:#337ab7;text-decoration:none}.bs-docs-featurette-img img{display:block;margin-bottom:15px}@media (min-width:480px){.bs-docs-featurette .img-responsive{margin-top:30px}}@media (min-width:768px){.bs-docs-featurette{padding-top:100px;padding-bottom:100px}.bs-docs-featurette-title{font-size:40px}.bs-docs-featurette .lead{max-width:80%;margin-right:auto;margin-left:auto}.bs-docs-featurette .img-responsive{margin-top:0}}.bs-docs-featured-sites{margin-right:-1px;margin-left:-1px}.bs-docs-featured-sites .col-xs-6{padding:1px}.bs-docs-featured-sites .img-responsive{margin-top:0}@media (min-width:768px){.bs-docs-featured-sites .col-sm-3:first-child img{border-top-left-radius:4px;border-bottom-left-radius:4px}.bs-docs-featured-sites .col-sm-3:last-child img{border-top-right-radius:4px;border-bottom-right-radius:4px}}.bs-examples .thumbnail{margin-bottom:10px}.bs-examples h4{margin-bottom:5px}.bs-examples p{margin-bottom:20px}@media (max-width:480px){.bs-examples{margin-right:-10px;margin-left:-10px}.bs-examples>[class^=col-]{padding-right:10px;padding-left:10px}}.bs-docs-sidebar.affix{position:static}@media (min-width:768px){.bs-docs-sidebar{padding-left:20px}}.bs-docs-sidenav{margin-top:50px;margin-bottom:20px}.bs-docs-sidebar .nav>li>a{display:block;padding:5px 20px;font-size:13px;font-weight:500;color:#222}.bs-docs-sidebar .nav>li>a:focus,.bs-docs-sidebar .nav>li>a:hover{text-decoration:none;background-color:#eee}.bs-docs-sidebar .nav>.active:focus>a,.bs-docs-sidebar .nav>.active:hover>a,.bs-docs-sidebar .nav>.active>a{color:#dd4b39;background-color:transparent}.bs-docs-sidebar .nav .nav{display:none;margin-bottom:8px}.bs-docs-sidebar .nav .nav>li>a{padding-top:1px;padding-bottom:1px;padding-left:30px;font-size:12px}.back-to-top,.bs-docs-theme-toggle{display:none;padding:4px 10px;margin-top:10px;margin-left:10px;font-size:12px;font-weight:500;color:#999}.back-to-top:hover,.bs-docs-theme-toggle:hover{color:#563d7c;text-decoration:none}.bs-docs-theme-toggle{margin-top:0}@media (min-width:768px){.back-to-top,.bs-docs-theme-toggle{display:block}}@media (min-width:992px){.bs-docs-sidebar .nav>.active>ul{display:block}.bs-docs-sidebar.affix,.bs-docs-sidebar.affix-bottom{width:213px}.bs-docs-sidebar.affix{position:fixed;top:80px}.bs-docs-sidebar.affix-bottom{position:absolute}.bs-docs-sidebar.affix .bs-docs-sidenav,.bs-docs-sidebar.affix-bottom .bs-docs-sidenav{margin-top:0;margin-bottom:0}}@media (min-width:1200px){.bs-docs-sidebar.affix,.bs-docs-sidebar.affix-bottom{width:263px}}.bs-docs-section{margin-bottom:60px}.bs-docs-section:last-child{margin-bottom:0}h1[id]{padding-top:20px;margin-top:0}.bs-callout{padding:20px;margin:20px 0;border:1px solid #eee;border-left-width:5px;border-radius:3px}.bs-callout h4{margin-top:0;margin-bottom:5px}.bs-callout p:last-child{margin-bottom:0}.bs-callout code{border-radius:3px}.bs-callout+.bs-callout{margin-top:-5px}.bs-callout-danger{border-left-color:#dd4b39}.bs-callout-danger h4{color:#c23321}.bs-callout-warning{border-left-color:#f1e7bc}.bs-callout-warning h4{color:#ba9e27}.bs-callout-info{border-left-color:#d0e3f0}.bs-callout-info h4{color:#3b86b9}.color-swatches{margin:0 -5px;overflow:hidden}.color-swatch{float:left;width:60px;height:60px;margin:0 5px;border-radius:3px}@media (min-width:768px){.color-swatch{width:100px;height:100px}}.color-swatches .gray-darker{background-color:#222}.color-swatches .gray-dark{background-color:#333}.color-swatches .gray{background-color:#555}.color-swatches .gray-light{background-color:#999}.color-swatches .gray-lighter{background-color:#eee}.color-swatches .brand-primary{background-color:#4d90fe}.color-swatches .brand-success{background-color:#35aa47}.color-swatches .brand-warning{background-color:#faa937}.color-swatches .brand-danger{background-color:#d84a38}.color-swatches .brand-info{background-color:#5bc0de}.color-swatches .bs-purple{background-color:#1b6ec1}.color-swatches .bs-purple-light{background-color:#c7bfd3}.color-swatches .bs-purple-lighter{background-color:#e5e1ea}.color-swatches .bs-gray{background-color:#f9f9f9}.bs-team .team-member{line-height:32px;color:#555}.bs-team .team-member:hover{color:#333;text-decoration:none}.bs-team .github-btn{float:right;width:180px;height:20px;margin-top:6px;border:none}.bs-team img{float:left;width:32px;margin-right:10px;border-radius:4px}.bs-docs-browser-bugs td p{margin-bottom:0}.bs-docs-browser-bugs th:first-child{width:18%}.show-grid{margin-bottom:15px}.show-grid [class^=col-]{padding-top:10px;padding-bottom:10px;background-color:#f9f9f9;border:1px solid #ddd}.bs-example{position:relative;padding:45px 15px 15px;margin:0 -15px 15px;border-color:#e5e5e5 #eee #eee;border-style:solid;border-width:1px 0;-webkit-box-shadow:inset 0 3px 6px rgba(0,0,0,.05);box-shadow:inset 0 3px 6px rgba(0,0,0,.05)}.bs-example:after{position:absolute;top:15px;left:15px;font-size:12px;font-weight:700;color:#959595;text-transform:uppercase;letter-spacing:1px;content:"Example"}.bs-example-padded-bottom{padding-bottom:24px}.bs-example+.highlight,.bs-example+.zero-clipboard+.highlight{margin:-15px -15px 15px;border-width:0 0 1px;border-radius:0}@media (min-width:768px){.bs-example{margin-right:0;margin-left:0;background-color:#fff;border-color:#ddd;border-width:1px;border-radius:4px 4px 0 0;-webkit-box-shadow:none;box-shadow:none}.bs-example+.highlight,.bs-example+.zero-clipboard+.highlight{margin-top:-16px;margin-right:0;margin-left:0;border-width:1px;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.bs-example-standalone{border-radius:4px}}.bs-example .container{width:auto}.bs-example>.alert:last-child,.bs-example>.form-control:last-child,.bs-example>.jumbotron:last-child,.bs-example>.list-group:last-child,.bs-example>.navbar:last-child,.bs-example>.panel:last-child,.bs-example>.progress:last-child,.bs-example>.table-responsive:last-child>.table,.bs-example>.table:last-child,.bs-example>.well:last-child,.bs-example>blockquote:last-child,.bs-example>ol:last-child,.bs-example>p:last-child,.bs-example>ul:last-child{margin-bottom:0}.bs-example>p>.close{float:none}.bs-example-type .table .type-info{color:#999;vertical-align:middle}.bs-example-type .table td{padding:15px 0;border-color:#eee}.bs-example-type .table tr:first-child td{border-top:0}.bs-example-type h1,.bs-example-type h2,.bs-example-type h3,.bs-example-type h4,.bs-example-type h5,.bs-example-type h6{margin:0}.bs-example-bg-classes p{padding:15px}.bs-example>.img-circle,.bs-example>.img-rounded,.bs-example>.img-thumbnail{margin:5px}.bs-example>.table-responsive>.table{background-color:#fff}.bs-example>.btn,.bs-example>.btn-group{margin-top:5px;margin-bottom:5px}.bs-example>.btn-toolbar+.btn-toolbar{margin-top:10px}.bs-example .select2-container.form-control,.bs-example-control-sizing input[type=text]+input[type=text],.bs-example-control-sizing select{margin-top:10px}.bs-example-form .input-group{margin-bottom:10px}.bs-example>textarea.form-control{resize:vertical}.bs-example>.list-group{max-width:400px}.bs-example .navbar:last-child{margin-bottom:0}.bs-navbar-bottom-example,.bs-navbar-top-example{z-index:1;padding:0;overflow:hidden}.bs-navbar-bottom-example .navbar-header,.bs-navbar-top-example .navbar-header{margin-left:0}.bs-navbar-bottom-example .navbar-fixed-bottom,.bs-navbar-top-example .navbar-fixed-top{position:relative;margin-right:0;margin-left:0}.bs-navbar-top-example{padding-bottom:90px}.bs-navbar-top-example:after{top:auto;bottom:15px}.bs-navbar-top-example .navbar-fixed-top{top:-1px}.bs-navbar-bottom-example{padding-top:90px}.bs-navbar-bottom-example .navbar-fixed-bottom{bottom:-1px}.bs-navbar-bottom-example .navbar{margin-bottom:0}@media (min-width:768px){.bs-navbar-bottom-example .navbar-fixed-bottom,.bs-navbar-top-example .navbar-fixed-top{position:absolute}}.bs-example .pagination{margin-top:10px;margin-bottom:10px}.bs-example>.pager{margin-top:0}.bs-example>.scrollable{height:200px;overflow-y:auto}.bs-example-modal{background-color:#f5f5f5}.bs-example-modal .modal{position:relative;top:auto;right:auto;bottom:auto;left:auto;z-index:1;display:block}.bs-example-modal .modal-dialog{left:auto;margin-right:auto;margin-left:auto}.bs-example .dropup>.dropdown-toggle,.bs-example>.dropdown>.dropdown-toggle{float:left}.bs-example-submenu .dropdown>.dropdown-menu,.bs-example-submenu .dropup>.dropdown-menu,.bs-example>.dropdown>.dropdown-menu{position:static;display:block;margin-bottom:5px;clear:left}.bs-example-submenu .dropdown-menu{margin-right:20px}.bs-example-tabs .nav-tabs{margin-bottom:15px}.bs-example-tooltips{text-align:center}.bs-example-tooltips>.btn{margin-top:5px;margin-bottom:5px}.bs-example-tooltip .tooltip{position:relative;display:inline-block;margin:10px 20px;opacity:1}.bs-example-popover{padding-bottom:24px;background-color:#f9f9f9}.bs-example-popover .popover{position:relative;display:block;float:left;width:260px;margin:20px}.scrollspy-example{position:relative;height:200px;margin-top:10px;overflow:auto}.bs-example>.nav-pills-stacked-example{max-width:300px}#collapseExample .well{margin-bottom:0}.bs-events-table>tbody>tr>td:first-child,.bs-events-table>thead>tr>th:first-child{white-space:nowrap}.bs-events-table>thead>tr>th:first-child{width:150px}.js-options-table>thead>tr>th:nth-child(1),.js-options-table>thead>tr>th:nth-child(2){width:100px}.js-options-table>thead>tr>th:nth-child(3){width:50px}.highlight{padding:9px 14px;margin-bottom:14px;background-color:#f7f7f9;border:1px solid #e1e1e8;border-radius:4px}.highlight pre{padding:0;margin-top:0;margin-bottom:0;word-break:normal;white-space:nowrap;background-color:transparent;border:0}.highlight pre code{font-size:inherit;color:#333}.highlight pre code:first-child{display:inline-block;padding-right:45px}.table-responsive .highlight pre{white-space:normal}.bs-table th small,.responsive-utilities th small{display:block;font-weight:400;color:#999}.responsive-utilities tbody th{font-weight:400}.responsive-utilities td{text-align:center}.responsive-utilities td.is-visible{color:#468847;background-color:#dff0d8!important}.responsive-utilities td.is-hidden{color:#ccc;background-color:#f9f9f9!important}.responsive-utilities-test{margin-top:5px}.responsive-utilities-test .col-xs-6{margin-bottom:10px}.responsive-utilities-test span{display:block;padding:15px 10px;font-size:14px;font-weight:700;line-height:1.1;text-align:center;border-radius:4px}.hidden-on .col-xs-6 .hidden-lg,.hidden-on .col-xs-6 .hidden-md,.hidden-on .col-xs-6 .hidden-sm,.hidden-on .col-xs-6 .hidden-xs,.visible-on .col-xs-6 .hidden-lg,.visible-on .col-xs-6 .hidden-md,.visible-on .col-xs-6 .hidden-sm,.visible-on .col-xs-6 .hidden-xs{color:#999;border:1px solid #ddd}.hidden-on .col-xs-6 .visible-lg-block,.hidden-on .col-xs-6 .visible-md-block,.hidden-on .col-xs-6 .visible-sm-block,.hidden-on .col-xs-6 .visible-xs-block,.visible-on .col-xs-6 .visible-lg-block,.visible-on .col-xs-6 .visible-md-block,.visible-on .col-xs-6 .visible-sm-block,.visible-on .col-xs-6 .visible-xs-block{color:#468847;background-color:#dff0d8;border:1px solid #d6e9c6}.bs-glyphicons{margin:0 -10px 20px;overflow:hidden}.bs-glyphicons-list{padding-left:0;list-style:none}.bs-glyphicons li{float:left;width:25%;height:115px;padding:10px;margin:0 -1px -1px 0;font-size:10px;line-height:1.4;text-align:center;border:1px solid #ddd}.bs-glyphicons .glyphicon{margin-top:5px;margin-bottom:10px;font-size:24px}.bs-glyphicons .glyphicon-class{display:block;text-align:center;word-wrap:break-word}.bs-glyphicons li:hover{background-color:#eee}@media (min-width:768px){.bs-glyphicons{margin-right:0;margin-left:0}.bs-glyphicons li{width:12.5%;font-size:12px}}.bs-customizer .toggle{float:right;margin-top:25px}.bs-customizer label{margin-top:10px;font-weight:500;color:#555}.bs-customizer h2{padding-top:30px;margin-top:0;margin-bottom:5px}.bs-customizer h3{margin-bottom:0}.bs-customizer h4{margin-top:15px;margin-bottom:0}.bs-customizer .bs-callout h4{margin-top:0;margin-bottom:5px}.bs-customizer input[type=text]{font-family:Menlo,Monaco,Consolas,"Courier New",monospace;background-color:#fafafa}.bs-customizer .help-block{margin-bottom:5px;font-size:12px}#less-section label{font-weight:400}.bs-customize-download .btn-outline{padding:20px}.bs-customizer-alert{position:fixed;top:0;right:0;left:0;z-index:1030;padding:15px 0;color:#fff;background-color:#d9534f;border-bottom:1px solid #b94441;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25);box-shadow:inset 0 1px 0 rgba(255,255,255,.25)}.bs-customizer-alert .close{margin-top:-4px;font-size:24px}.bs-customizer-alert p{margin-bottom:0}.bs-customizer-alert .glyphicon{margin-right:5px}.bs-customizer-alert pre{margin:10px 0 0;color:#fff;background-color:#a83c3a;border-color:#973634;-webkit-box-shadow:inset 0 2px 4px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 2px 4px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)}.bs-dropzone{position:relative;padding:20px;margin-bottom:20px;color:#777;text-align:center;border:2px dashed #eee;border-radius:4px}.bs-dropzone .import-header{margin-bottom:5px}.bs-dropzone .glyphicon-download-alt{font-size:40px}.bs-dropzone hr{width:100px}.bs-dropzone .lead{margin-bottom:10px;font-weight:400;color:#333}#import-manual-trigger{cursor:pointer}.bs-dropzone p:last-child{margin-bottom:0}.bs-brand-logos{display:table;width:100%;margin-bottom:15px;overflow:hidden;color:#1b6ec1;background-color:#f9f9f9;border-radius:4px}.bs-brand-item{padding:60px 0;text-align:center}.bs-brand-item+.bs-brand-item{border-top:1px solid #fff}.bs-brand-logos .inverse{color:#fff;background-color:#1b6ec1}.bs-brand-item h1,.bs-brand-item h3{margin-top:0;margin-bottom:0}.bs-brand-item .bs-docs-booticon{margin-right:auto;margin-left:auto}.bs-brand-item .glyphicon{width:30px;height:30px;margin:10px auto -10px;line-height:30px;color:#fff;border-radius:50%}.bs-brand-item .glyphicon-ok{background-color:#5cb85c}.bs-brand-item .glyphicon-remove{background-color:#d9534f}@media (min-width:768px){.bs-brand-item{display:table-cell;width:1%}.bs-brand-item+.bs-brand-item{border-top:0;border-left:1px solid #fff}.bs-brand-item h1{font-size:60px}}.zero-clipboard{position:relative;display:none}.btn-clipboard{position:absolute;top:0;right:0;z-index:10;display:block;padding:5px 8px;font-size:12px;color:#777;cursor:pointer;background-color:#fff;border:1px solid #e1e1e8;border-radius:0 4px 0 4px}.btn-clipboard-hover{color:#fff;background-color:#563d7c;border-color:#563d7c}@media (min-width:768px){.zero-clipboard{display:block}.bs-example+.zero-clipboard .btn-clipboard{top:-16px;border-top-right-radius:0}}.anchorjs-link{color:inherit}@media (max-width:480px){.anchorjs-link{display:none}}:hover>.anchorjs-link{opacity:.75;-webkit-transition:color .16s linear;-o-transition:color .16s linear;transition:color .16s linear}.anchorjs-link:focus,:hover>.anchorjs-link:hover{text-decoration:none;opacity:1}#focusedInput{border:1px solid #4d90fe!important;outline:0;outline:thin dotted\9;-webkit-box-shadow:none;box-shadow:none}.v4-tease{position:fixed;top:0;right:0;left:0;z-index:1030;display:block;padding:15px 20px;font-weight:700;color:#fff;text-align:center;background-color:#1b6ec1}.v4-tease:hover{color:#fff;text-decoration:none;background-color:#2d87e2}@media print{a[href]:after{content:""!important}}.bs-docs-navbar-masthead{top:48px}.bs-docs-dl-options h4{margin-top:15px;margin-bottom:5px} +/*# sourceMappingURL=docs.min.css.map */ \ No newline at end of file diff --git a/src/blog/static/assets/css/ie10-viewport-bug-workaround.css b/src/blog/static/assets/css/ie10-viewport-bug-workaround.css new file mode 100644 index 0000000..4b9518e --- /dev/null +++ b/src/blog/static/assets/css/ie10-viewport-bug-workaround.css @@ -0,0 +1,13 @@ +/*! + * IE10 viewport hack for Surface/desktop Windows 8 bug + * Copyright 2014-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ + +/* + * See the Getting Started docs for more information: + * http://getbootstrap.com/getting-started/#support-ie10-width + */ +@-ms-viewport { width: device-width; } +@-o-viewport { width: device-width; } +@viewport { width: device-width; } diff --git a/src/blog/static/assets/css/signin.css b/src/blog/static/assets/css/signin.css new file mode 100644 index 0000000..121fb0d --- /dev/null +++ b/src/blog/static/assets/css/signin.css @@ -0,0 +1,58 @@ +body { + padding-top: 40px; + padding-bottom: 40px; + background-color: #fff; +} + +.form-signin { + max-width: 330px; + padding: 15px; + margin: 0 auto; +} +.form-signin-heading { + margin: 0 0 15px; + font-size: 18px; + font-weight: 400; + color: #555; +} +.form-signin .checkbox { + margin-bottom: 10px; + font-weight: normal; +} +.form-signin .form-control { + position: relative; + height: auto; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + padding: 10px; + font-size: 16px; +} +.form-signin .form-control:focus { + z-index: 2; +} +.form-signin input[type="email"] { + margin-bottom: 10px; +} +.form-signin input[type="password"] { + margin-bottom: 10px; +} +.card { + width: 304px; + padding: 20px 25px 30px; + margin: 0 auto 25px; + background-color: #f7f7f7; + border-radius: 2px; + -webkit-box-shadow: 0 2px 2px rgba(0, 0, 0, .3); + box-shadow: 0 2px 2px rgba(0, 0, 0, .3); +} +.card-signin { + width: 354px; + padding: 40px; +} +.card-signin .profile-img { + display: block; + width: 96px; + height: 96px; + margin: 0 auto 10px; +} diff --git a/src/blog/static/assets/css/todc-bootstrap.min.css b/src/blog/static/assets/css/todc-bootstrap.min.css new file mode 100644 index 0000000..66c9cb2 --- /dev/null +++ b/src/blog/static/assets/css/todc-bootstrap.min.css @@ -0,0 +1,6 @@ +/*! + * TODC Bootstrap v3.3.7-3.3.7 (http://todc.github.com/todc-bootstrap/) + * Copyright 2011-2016 Tim O'Donnell + * Licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) license + */.panel-group .panel-heading a.collapsed:before,.panel-group .panel-heading a:before{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.caret-left,.caret-right,.collapse-caret.collapsed:before,.collapse-caret:before,.dropdown-submenu>a:after{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}body{font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:1.4;color:#333;background-color:#fff}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#15c}a:focus,a:hover{color:#15c}.img-rounded{border-radius:1px}.img-thumbnail{display:inline-block;max-width:100%;height:auto;padding:0;line-height:1.4;background-color:#fff;border:3px solid #fff;border-radius:0;-webkit-box-shadow:0 0 0 1px #aaa;box-shadow:0 0 0 1px #aaa;-webkit-transition:none;-o-transition:none;transition:none}.caret-left,.caret-right,.collapse-caret.collapsed:before,.dropdown-submenu>a:after{vertical-align:baseline;border-top:4px solid transparent;border-right:0 dotted;border-bottom:4px solid transparent;border-left:4px solid}.caret-left{margin-right:2px;margin-left:0;border-right:4px solid;border-left:0 dotted}.scrollable-shadow{background:-webkit-gradient(linear,left top,left bottom,color-stop(30%,#fff),to(rgba(255,255,255,0))),-webkit-gradient(linear,left top,left bottom,from(rgba(255,255,255,0)),color-stop(70%,#fff)) 0 100%,radial-gradient(50% 0,farthest-side,rgba(0,0,0,.2),rgba(0,0,0,0)),radial-gradient(50% 100%,farthest-side,rgba(0,0,0,.2),rgba(0,0,0,0)) 0 100%;background:-webkit-linear-gradient(white 30%,rgba(255,255,255,0)),-webkit-linear-gradient(rgba(255,255,255,0),#fff 70%) 0 100%,-webkit-radial-gradient(50% 0,farthest-side,rgba(0,0,0,.2),rgba(0,0,0,0)),-webkit-radial-gradient(50% 100%,farthest-side,rgba(0,0,0,.2),rgba(0,0,0,0)) 0 100%;background:-o-linear-gradient(white 30%,rgba(255,255,255,0)),-o-linear-gradient(rgba(255,255,255,0),#fff 70%) 0 100%,-o-radial-gradient(50% 0,farthest-side,rgba(0,0,0,.2),rgba(0,0,0,0)),-o-radial-gradient(50% 100%,farthest-side,rgba(0,0,0,.2),rgba(0,0,0,0)) 0 100%;background:linear-gradient(white 30%,rgba(255,255,255,0)),linear-gradient(rgba(255,255,255,0),#fff 70%) 0 100%,radial-gradient(50% 0,farthest-side,rgba(0,0,0,.2),rgba(0,0,0,0)),radial-gradient(50% 100%,farthest-side,rgba(0,0,0,.2),rgba(0,0,0,0)) 0 100%;background:-webkit-gradient(linear,left top,left bottom,color-stop(30%,#fff),to(rgba(255,255,255,0))),-webkit-gradient(linear,left top,left bottom,from(rgba(255,255,255,0)),color-stop(70%,#fff)) 0 100%,radial-gradient(farthest-side at 50% 0,rgba(0,0,0,.2),rgba(0,0,0,0)),radial-gradient(farthest-side at 50% 100%,rgba(0,0,0,.2),rgba(0,0,0,0)) 0 100%;background:linear-gradient(white 30%,rgba(255,255,255,0)),linear-gradient(rgba(255,255,255,0),#fff 70%) 0 100%,radial-gradient(farthest-side at 50% 0,rgba(0,0,0,.2),rgba(0,0,0,0)),radial-gradient(farthest-side at 50% 100%,rgba(0,0,0,.2),rgba(0,0,0,0)) 0 100%;background-repeat:no-repeat;background-attachment:local,local,scroll,scroll;-webkit-background-size:100% 40px,100% 40px,100% 6px,100% 6px;background-size:100% 40px,100% 40px,100% 6px,100% 6px}.mark,mark{background-color:#f9edbe}.text-primary{color:#4d90fe}a.text-primary:focus,a.text-primary:hover{color:#1a70fe}.text-warning{color:#333}a.text-warning:focus,a.text-warning:hover{color:#1a1a1a}.bg-primary{color:#fff;background-color:#4d90fe}a.bg-primary:focus,a.bg-primary:hover{background-color:#1a70fe}.bg-warning{background-color:#f9edbe}a.bg-warning:focus,a.bg-warning:hover{background-color:#f5e08f}code{padding:2px 4px;border-radius:0}kbd{border-radius:1px}pre{padding:9px;margin:0 0 9px;font-size:12px;line-height:1.4;border-radius:0}table{background-color:transparent}caption{color:#999}.table{margin-bottom:18px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{line-height:1.4;border-top:1px solid #ddd}.table>thead>tr>th{border-bottom:2px solid #ddd}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-bordered{border:1px solid #ddd}.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#ffc}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#f9edbe}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#f7e7a7}@media screen and (max-width:767px){.table-responsive{margin-bottom:13.5px;border:1px solid #ddd}}legend{margin-bottom:18px;font-size:19.5px}input[type=radio],input[type=checkbox]{margin:2px 0 0}output{padding-top:6px;font-size:13px;line-height:1.4;color:#555}.form-control{height:30px;-webkit-appearance:none;padding:5px 8px;font-size:13px;line-height:1.4;background-color:#fff;border:1px solid #d9d9d9;border-top-color:silver;border-radius:2px;-webkit-box-shadow:none;box-shadow:none;-webkit-transition:none;-o-transition:none;transition:none}.form-control:hover{border:1px solid #b9b9b9;border-top-color:#a0a0a0;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.form-control:focus{border-color:#4d90fe;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(77,144,254,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(77,144,254,.6)}.form-control:focus{-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.3);box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}.form-control::-ms-expand{background-color:transparent}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#f1f1f1;border:1px solid #e5e5e5}.form-control[disabled]:active,.form-control[disabled]:focus,.form-control[disabled]:hover,.form-control[readonly]:active,.form-control[readonly]:focus,.form-control[readonly]:hover,fieldset[disabled] .form-control:active,fieldset[disabled] .form-control:focus,fieldset[disabled] .form-control:hover{border:1px solid #e5e5e5;-webkit-box-shadow:none;box-shadow:none}.form-control[readonly] .form-control{border:1px solid #d9d9d9}.form-control[readonly] .form-control:active,.form-control[readonly] .form-control:focus,.form-control[readonly] .form-control:hover{border:1px solid #d9d9d9}textarea.form-control{padding-right:4px}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=time].form-control,input[type=datetime-local].form-control,input[type=month].form-control{line-height:30px}.input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{line-height:26px}.input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{line-height:38px}}.checkbox label,.radio label{min-height:18px}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio],input[type=radio],input[type=checkbox]{position:relative;width:13px;width:16px\9;height:13px;height:16px\9;-webkit-appearance:none;background:#fff;border:1px solid #dcdcdc;border:1px solid transparent\9;border-radius:1px}.checkbox input[type=checkbox]:focus,.checkbox-inline input[type=checkbox]:focus,.radio input[type=radio]:focus,.radio-inline input[type=radio]:focus,input[type=radio]:focus,input[type=checkbox]:focus{border-color:#4d90fe;outline:0}.checkbox input[type=checkbox]:active,.checkbox-inline input[type=checkbox]:active,.radio input[type=radio]:active,.radio-inline input[type=radio]:active,input[type=radio]:active,input[type=checkbox]:active{background-color:#ebebeb;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffffffff', GradientType=0);border-color:#c6c6c6}.checkbox input[type=checkbox]:checked,.checkbox-inline input[type=checkbox]:checked,.radio input[type=radio]:checked,.radio-inline input[type=radio]:checked,input[type=radio]:checked,input[type=checkbox]:checked{background:#fff}.radio input[type=radio],.radio-inline input[type=radio],input[type=radio]{width:15px;width:18px\9;height:15px;height:18px\9;border-radius:1em}.radio input[type=radio]:checked::after,.radio-inline input[type=radio]:checked::after,input[type=radio]:checked::after{position:relative;top:3px;left:3px;display:block;width:7px;height:7px;content:'';background:#666;border-radius:1em}.checkbox input[type=checkbox]:hover,.checkbox-inline input[type=checkbox]:hover,input[type=checkbox]:hover{border-color:#c6c6c6;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.1);-webkit-box-shadow:none\9;box-shadow:inset 0 1px 1px rgba(0,0,0,.1);box-shadow:none\9}.checkbox input[type=checkbox]:checked::after,.checkbox-inline input[type=checkbox]:checked::after,input[type=checkbox]:checked::after{position:absolute;top:-6px;left:-5px;display:block;content:url(../img/checkmark.png)}.form-control-static{min-height:31px;padding-top:6px;padding-bottom:6px}.input-sm{height:26px;padding:3px 8px;font-size:12px;line-height:1.5;border-radius:1px}select.input-sm{height:26px;line-height:26px}select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:26px;padding:3px 8px;font-size:12px;line-height:1.5;border-radius:1px}.form-group-sm select.form-control{height:26px;line-height:26px}.form-group-sm .form-control-static{height:26px;min-height:30px;padding:4px 8px;font-size:12px;line-height:1.5}.input-lg{height:38px;padding:9px 14px;font-size:14px;line-height:1.3;border-radius:1px}select.input-lg{height:38px;line-height:38px}select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:38px;padding:9px 14px;font-size:14px;line-height:1.3;border-radius:1px}.form-group-lg select.form-control{height:38px;line-height:38px}.form-group-lg .form-control-static{height:38px;min-height:32px;padding:10px 14px;font-size:14px;line-height:1.3}.has-feedback .form-control{padding-right:37.5px}.form-control-feedback{top:23px;width:30px;height:30px;line-height:30px}.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{width:38px;height:38px;line-height:38px}.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{width:26px;height:26px;line-height:26px}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.has-success .form-control-feedback{color:#3c763d}.has-success .form-control{-webkit-box-shadow:none;box-shadow:none}.has-success .form-control:hover{border-color:#3c763d;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.1) inset;box-shadow:0 1px 2px rgba(0,0,0,.1) inset}.has-success .form-control:focus{border-color:#3c763d;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.3) inset;box-shadow:0 1px 2px rgba(0,0,0,.3) inset}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#e09b17}.has-warning .form-control{border-color:#e09b17;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#b27b12;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #f0c36d;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #f0c36d}.has-warning .input-group-addon{color:#e09b17;background-color:#f9edbe;border-color:#e09b17}.has-warning .form-control-feedback{color:#e09b17}.has-warning .form-control{-webkit-box-shadow:none;box-shadow:none}.has-warning .form-control:hover{border-color:#e09b17;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.1) inset;box-shadow:0 1px 2px rgba(0,0,0,.1) inset}.has-warning .form-control:focus{border-color:#e09b17;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.3) inset;box-shadow:0 1px 2px rgba(0,0,0,.3) inset}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#dd4b39}.has-error .form-control{border-color:#dd4b39;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#c23321;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ec9a90;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ec9a90}.has-error .input-group-addon{color:#dd4b39;background-color:#f2dede;border-color:#dd4b39}.has-error .form-control-feedback{color:#dd4b39}.has-error .form-control{-webkit-box-shadow:none;box-shadow:none}.has-error .form-control:hover{border-color:#dd4b39;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.1) inset;box-shadow:0 1px 2px rgba(0,0,0,.1) inset}.has-error .form-control:focus{border-color:#dd4b39;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.3) inset;box-shadow:0 1px 2px rgba(0,0,0,.3) inset}.has-feedback label~.form-control-feedback{top:23px}.help-block{color:#777}.form-horizontal .checkbox-inline,.form-horizontal .control-label,.form-horizontal .radio-inline{padding-top:5px}@media (min-width:768px){.form-inline .form-group,.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control,.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static,.navbar-form .form-control-static{display:inline-block}.form-inline .input-group,.navbar-form .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn,.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control,.navbar-form .input-group>.form-control{width:100%}.form-inline .control-label,.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio,.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label,.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio],.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-bottom:-2px;margin-left:0}.form-inline .has-feedback .form-control-feedback,.navbar-form .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:6px}.form-horizontal .checkbox,.form-horizontal .radio{min-height:24px}@media (min-width:768px){.form-horizontal .control-label{padding-top:6px}.form-horizontal .has-feedback .form-control-feedback{top:0}}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:10px;font-size:14px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:4px;font-size:12px}}.btn{padding:5px 12px;font-size:13px;font-weight:700;line-height:18px;cursor:default;-webkit-background-clip:border-box;background-clip:border-box;border-radius:2px;-webkit-box-shadow:none;box-shadow:none}.btn:hover{-webkit-box-shadow:0 1px 1px rgba(0,0,0,.1);box-shadow:0 1px 1px rgba(0,0,0,.1)}.btn.active,.btn:active{-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.btn-default{color:#333;text-shadow:0 1px rgba(0,0,0,.1);text-shadow:0 1px 0 #fff;background-color:#f3f3f3;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#f1f1f1 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#f1f1f1 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#f1f1f1));background-image:linear-gradient(to bottom,#f5f5f5 0,#f1f1f1 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#fff1f1f1', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #dcdcdc}.btn-default:hover{text-shadow:0 1px rgba(0,0,0,.3);-webkit-box-shadow:0 1px 1px rgba(0,0,0,.2);box-shadow:0 1px 1px rgba(0,0,0,.2)}.btn-default.active,.btn-default.focus,.btn-default:active,.btn-default:focus,.btn-default:hover,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e4e4e4;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e4e4e4 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e4e4e4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e4e4e4));background-image:linear-gradient(to bottom,#f5f5f5 0,#e4e4e4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe4e4e4', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #cfcfcf}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{text-shadow:0 1px rgba(0,0,0,.3);background-image:-webkit-linear-gradient(top,#f5f5f5 0,#d8d8d8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#d8d8d8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#d8d8d8));background-image:linear-gradient(to bottom,#f5f5f5 0,#d8d8d8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffd8d8d8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #c3c3c3;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.3);box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}.btn-default.focus,.btn-default:focus{border:1px solid #dcdcdc;-webkit-box-shadow:inset 0 0 0 1px #fff;box-shadow:inset 0 0 0 1px #fff}.btn-default.disabled.focus,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled].focus,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#f5f5f5;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#f1f1f1 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#f1f1f1 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#f1f1f1));background-image:linear-gradient(to bottom,#f5f5f5 0,#f1f1f1 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#fff1f1f1', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #dcdcdc;-webkit-box-shadow:none;box-shadow:none}.btn-default .badge{color:#dcdcdc;background-color:#333}.btn-default:hover{text-shadow:none;background-image:-webkit-linear-gradient(top,#f8f8f8 0,#f1f1f1 100%);background-image:-o-linear-gradient(top,#f8f8f8 0,#f1f1f1 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f8f8f8),to(#f1f1f1));background-image:linear-gradient(to bottom,#f8f8f8 0,#f1f1f1 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff8f8f8', endColorstr='#fff1f1f1', GradientType=0);background-repeat:repeat-x;background-position:0 0;border-color:#c6c6c6;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.1);box-shadow:0 1px 1px rgba(0,0,0,.1);-webkit-transition:none;-o-transition:none;transition:none}.btn-default.active,.btn-default:active,.open .dropdown-toggle.btn-default{text-shadow:0 1px 0 #fff;background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f6f6f6 0,#f1f1f1 100%);background-image:-o-linear-gradient(top,#f6f6f6 0,#f1f1f1 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f6f6f6),to(#f1f1f1));background-image:linear-gradient(to bottom,#f6f6f6 0,#f1f1f1 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff6f6f6', endColorstr='#fff1f1f1', GradientType=0);background-repeat:repeat-x;border:1px solid #dcdcdc;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.btn-default.focus,.btn-default:focus{background-color:#f3f3f3;border-color:#4d90fe;outline-style:none}.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{text-shadow:none;background-color:#f3f3f3}.btn-default .badge{color:#f3f3f3;text-shadow:none}.btn-primary{color:#fff;text-shadow:0 1px rgba(0,0,0,.1);background-image:-webkit-linear-gradient(top,#4d90fe 0,#4787ed 100%);background-image:-o-linear-gradient(top,#4d90fe 0,#4787ed 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#4d90fe),to(#4787ed));background-image:linear-gradient(to bottom,#4d90fe 0,#4787ed 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff4d90fe', endColorstr='#ff4787ed', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #3079ed}.btn-primary:hover{text-shadow:0 1px rgba(0,0,0,.3);-webkit-box-shadow:0 1px 1px rgba(0,0,0,.2);box-shadow:0 1px 1px rgba(0,0,0,.2)}.btn-primary.active,.btn-primary.focus,.btn-primary:active,.btn-primary:focus,.btn-primary:hover,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#3078eb;background-image:-webkit-linear-gradient(top,#4d90fe 0,#3078eb 100%);background-image:-o-linear-gradient(top,#4d90fe 0,#3078eb 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#4d90fe),to(#3078eb));background-image:linear-gradient(to bottom,#4d90fe 0,#3078eb 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff4d90fe', endColorstr='#ff3078eb', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #196aeb}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{text-shadow:0 1px rgba(0,0,0,.3);background-image:-webkit-linear-gradient(top,#4d90fe 0,#1969e8 100%);background-image:-o-linear-gradient(top,#4d90fe 0,#1969e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#4d90fe),to(#1969e8));background-image:linear-gradient(to bottom,#4d90fe 0,#1969e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff4d90fe', endColorstr='#ff1969e8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #135fd7;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.3);box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}.btn-primary.focus,.btn-primary:focus{border:1px solid #3079ed;-webkit-box-shadow:inset 0 0 0 1px #fff;box-shadow:inset 0 0 0 1px #fff}.btn-primary.disabled.focus,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled].focus,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#4d90fe;background-image:-webkit-linear-gradient(top,#4d90fe 0,#4787ed 100%);background-image:-o-linear-gradient(top,#4d90fe 0,#4787ed 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#4d90fe),to(#4787ed));background-image:linear-gradient(to bottom,#4d90fe 0,#4787ed 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff4d90fe', endColorstr='#ff4787ed', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #3079ed;-webkit-box-shadow:none;box-shadow:none}.btn-primary .badge{color:#3079ed;background-color:#fff}.btn-success{color:#fff;text-shadow:0 1px rgba(0,0,0,.1);background-image:-webkit-linear-gradient(top,#35aa47 0,#35aa47 100%);background-image:-o-linear-gradient(top,#35aa47 0,#35aa47 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#35aa47),to(#35aa47));background-image:linear-gradient(to bottom,#35aa47 0,#35aa47 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff35aa47', endColorstr='#ff35aa47', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #359947}.btn-success:hover{text-shadow:0 1px rgba(0,0,0,.3);-webkit-box-shadow:0 1px 1px rgba(0,0,0,.2);box-shadow:0 1px 1px rgba(0,0,0,.2)}.btn-success.active,.btn-success.focus,.btn-success:active,.btn-success:focus,.btn-success:hover,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#2f973f;background-image:-webkit-linear-gradient(top,#35aa47 0,#2f973f 100%);background-image:-o-linear-gradient(top,#35aa47 0,#2f973f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#35aa47),to(#2f973f));background-image:linear-gradient(to bottom,#35aa47 0,#2f973f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff35aa47', endColorstr='#ff2f973f', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #2e863e}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{text-shadow:0 1px rgba(0,0,0,.3);background-image:-webkit-linear-gradient(top,#35aa47 0,#298337 100%);background-image:-o-linear-gradient(top,#35aa47 0,#298337 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#35aa47),to(#298337));background-image:linear-gradient(to bottom,#35aa47 0,#298337 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff35aa47', endColorstr='#ff298337', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #287335;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.3);box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}.btn-success.focus,.btn-success:focus{border:1px solid #359947;-webkit-box-shadow:inset 0 0 0 1px #fff;box-shadow:inset 0 0 0 1px #fff}.btn-success.disabled.focus,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled].focus,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#35aa47;background-image:-webkit-linear-gradient(top,#35aa47 0,#35aa47 100%);background-image:-o-linear-gradient(top,#35aa47 0,#35aa47 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#35aa47),to(#35aa47));background-image:linear-gradient(to bottom,#35aa47 0,#35aa47 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff35aa47', endColorstr='#ff35aa47', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #359947;-webkit-box-shadow:none;box-shadow:none}.btn-success .badge{color:#359947;background-color:#fff}.btn-info{color:#fff;text-shadow:0 1px rgba(0,0,0,.1);background-image:-webkit-linear-gradient(top,#5bc0de 0,#5bc0de 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#5bc0de 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#5bc0de));background-image:linear-gradient(to bottom,#5bc0de 0,#5bc0de 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff5bc0de', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #46b8da}.btn-info:hover{text-shadow:0 1px rgba(0,0,0,.3);-webkit-box-shadow:0 1px 1px rgba(0,0,0,.2);box-shadow:0 1px 1px rgba(0,0,0,.2)}.btn-info.active,.btn-info.focus,.btn-info:active,.btn-info:focus,.btn-info:hover,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#46b8da;background-image:-webkit-linear-gradient(top,#5bc0de 0,#46b8da 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#46b8da 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#46b8da));background-image:linear-gradient(to bottom,#5bc0de 0,#46b8da 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff46b8da', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #31b0d5}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{text-shadow:0 1px rgba(0,0,0,.3);background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#31b0d5));background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #28a1c5;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.3);box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}.btn-info.focus,.btn-info:focus{border:1px solid #46b8da;-webkit-box-shadow:inset 0 0 0 1px #fff;box-shadow:inset 0 0 0 1px #fff}.btn-info.disabled.focus,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled].focus,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;background-image:-webkit-linear-gradient(top,#5bc0de 0,#5bc0de 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#5bc0de 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#5bc0de));background-image:linear-gradient(to bottom,#5bc0de 0,#5bc0de 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff5bc0de', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #46b8da;-webkit-box-shadow:none;box-shadow:none}.btn-info .badge{color:#46b8da;background-color:#fff}.btn-warning{color:#fff;text-shadow:0 1px rgba(0,0,0,.1);background-image:-webkit-linear-gradient(top,#fbb450 0,#faa937 100%);background-image:-o-linear-gradient(top,#fbb450 0,#faa937 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fbb450),to(#faa937));background-image:linear-gradient(to bottom,#fbb450 0,#faa937 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffbb450', endColorstr='#fffaa937', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #faa328}.btn-warning:hover{text-shadow:0 1px rgba(0,0,0,.3);-webkit-box-shadow:0 1px 1px rgba(0,0,0,.2);box-shadow:0 1px 1px rgba(0,0,0,.2)}.btn-warning.active,.btn-warning.focus,.btn-warning:active,.btn-warning:focus,.btn-warning:hover,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#f99e1e;background-image:-webkit-linear-gradient(top,#fbb450 0,#f99e1e 100%);background-image:-o-linear-gradient(top,#fbb450 0,#f99e1e 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fbb450),to(#f99e1e));background-image:linear-gradient(to bottom,#fbb450 0,#f99e1e 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffbb450', endColorstr='#fff99e1e', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #f9980f}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{text-shadow:0 1px rgba(0,0,0,.3);background-image:-webkit-linear-gradient(top,#fbb450 0,#f89306 100%);background-image:-o-linear-gradient(top,#fbb450 0,#f89306 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fbb450),to(#f89306));background-image:linear-gradient(to bottom,#fbb450 0,#f89306 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffbb450', endColorstr='#fff89306', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #e98b06;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.3);box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}.btn-warning.focus,.btn-warning:focus{border:1px solid #faa328;-webkit-box-shadow:inset 0 0 0 1px #fff;box-shadow:inset 0 0 0 1px #fff}.btn-warning.disabled.focus,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled].focus,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#fbb450;background-image:-webkit-linear-gradient(top,#fbb450 0,#faa937 100%);background-image:-o-linear-gradient(top,#fbb450 0,#faa937 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fbb450),to(#faa937));background-image:linear-gradient(to bottom,#fbb450 0,#faa937 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffbb450', endColorstr='#fffaa937', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #faa328;-webkit-box-shadow:none;box-shadow:none}.btn-warning .badge{color:#faa328;background-color:#fff}.btn-danger{color:#fff;text-shadow:0 1px rgba(0,0,0,.1);background-image:-webkit-linear-gradient(top,#dd4b39 0,#d14836 100%);background-image:-o-linear-gradient(top,#dd4b39 0,#d14836 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dd4b39),to(#d14836));background-image:linear-gradient(to bottom,#dd4b39 0,#d14836 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdd4b39', endColorstr='#ffd14836', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #c6322a}.btn-danger:hover{text-shadow:0 1px rgba(0,0,0,.3);-webkit-box-shadow:0 1px 1px rgba(0,0,0,.2);box-shadow:0 1px 1px rgba(0,0,0,.2)}.btn-danger.active,.btn-danger.focus,.btn-danger:active,.btn-danger:focus,.btn-danger:hover,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c13e2c;background-image:-webkit-linear-gradient(top,#dd4b39 0,#c13e2c 100%);background-image:-o-linear-gradient(top,#dd4b39 0,#c13e2c 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dd4b39),to(#c13e2c));background-image:linear-gradient(to bottom,#dd4b39 0,#c13e2c 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdd4b39', endColorstr='#ffc13e2c', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #b12d26}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{text-shadow:0 1px rgba(0,0,0,.3);background-image:-webkit-linear-gradient(top,#dd4b39 0,#ad3727 100%);background-image:-o-linear-gradient(top,#dd4b39 0,#ad3727 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dd4b39),to(#ad3727));background-image:linear-gradient(to bottom,#dd4b39 0,#ad3727 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdd4b39', endColorstr='#ffad3727', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #9c2721;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.3);box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}.btn-danger.focus,.btn-danger:focus{border:1px solid #c6322a;-webkit-box-shadow:inset 0 0 0 1px #fff;box-shadow:inset 0 0 0 1px #fff}.btn-danger.disabled.focus,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled].focus,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#dd4b39;background-image:-webkit-linear-gradient(top,#dd4b39 0,#d14836 100%);background-image:-o-linear-gradient(top,#dd4b39 0,#d14836 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dd4b39),to(#d14836));background-image:linear-gradient(to bottom,#dd4b39 0,#d14836 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdd4b39', endColorstr='#ffd14836', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border:1px solid #c6322a;-webkit-box-shadow:none;box-shadow:none}.btn-danger .badge{color:#c6322a;background-color:#fff}.btn-link{color:#15c}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link.focus,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link.focus,.btn-link:focus,.btn-link:hover{color:#15c;background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link[disabled]:focus .btn-link[disabled].focus,.btn-link[disabled]:focus fieldset[disabled] .btn-link.focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus .btn-link[disabled].focus,fieldset[disabled] .btn-link:focus fieldset[disabled] .btn-link.focus,fieldset[disabled] .btn-link:hover{color:#333}.btn-group-lg>.btn,.btn-lg{padding:9px 14px;font-size:14px;line-height:1.3;border-radius:2px}.btn-group-sm>.btn,.btn-sm{padding:3px 8px;font-size:12px;line-height:1.5;border-radius:2px}.btn-group-xs>.btn,.btn-xs{padding:2px 6px;font-size:11px;line-height:1.25;border-radius:1px}.dropdown-menu{padding:6px 0;margin:1px 0 0;font-size:13px;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:0;-webkit-box-shadow:0 2px 4px rgba(0,0,0,.2);box-shadow:0 2px 4px rgba(0,0,0,.2)}.dropdown-menu .divider{height:1px;margin:8px 0;overflow:hidden;background-color:#ebebeb}.dropdown-menu>li>a{position:relative;padding:3px 30px}.dropdown-menu>li>a .glyphicon{position:absolute;top:4px;left:7px}.dropdown-menu li>a:focus,.dropdown-menu li>a:hover,.dropdown-submenu:focus>a,.dropdown-submenu:hover>a{color:#333;background-color:#eee;background-image:-webkit-linear-gradient(top,#eee 0,#eee 100%);background-image:-o-linear-gradient(top,#eee 0,#eee 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#eee),to(#eee));background-image:linear-gradient(to bottom,#eee 0,#eee 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffeeeeee', endColorstr='#ffeeeeee', GradientType=0);background-repeat:repeat-x}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#333;background-color:#eee;background-image:-webkit-linear-gradient(top,#eee 0,#eee 100%);background-image:-o-linear-gradient(top,#eee 0,#eee 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#eee),to(#eee));background-image:linear-gradient(to bottom,#eee 0,#eee 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffeeeeee', endColorstr='#ffeeeeee', GradientType=0);background-repeat:repeat-x}.dropdown-header{color:#999}.dropdown-submenu{position:relative}.dropdown-submenu>.dropdown-menu{top:0;left:100%;margin-left:-1px;border-radius:0}.dropdown-submenu:hover>.dropdown-menu{display:block}.dropup .dropdown-submenu>.dropdown-menu{top:auto;bottom:0;margin-top:0;margin-bottom:-2px;border-radius:0}.dropdown-submenu>a:after{position:absolute;right:10px;margin-top:5px;content:""}.dropdown-submenu.dropdown-menu-left,.dropdown-submenu.pull-left{float:none!important}.dropdown-submenu.dropdown-menu-left>.dropdown-menu,.dropdown-submenu.pull-left>.dropdown-menu{left:-100%;margin-left:18px;border-radius:0}.btn-group-vertical>.btn:focus,.btn-group>.btn:focus{z-index:3}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:16px}.btn-group>.btn+.dropdown-toggle{-webkit-box-shadow:none;box-shadow:none}.btn-group>.dropdown-toggle:hover{-webkit-box-shadow:0 1px 1px rgba(0,0,0,.1);box-shadow:0 1px 1px rgba(0,0,0,.1)}.btn-group>.btn-danger.dropdown-toggle:hover,.btn-group>.btn-info.dropdown-toggle:hover,.btn-group>.btn-primary.dropdown-toggle:hover,.btn-group>.btn-success.dropdown-toggle:hover,.btn-group>.btn-warning.dropdown-toggle:hover{-webkit-box-shadow:0 1px 1px rgba(0,0,0,.2);box-shadow:0 1px 1px rgba(0,0,0,.2)}.btn-group>.btn.dropdown-toggle.active,.btn-group>.btn.dropdown-toggle:active{-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.btn-group>.btn-danger.dropdown-toggle.active,.btn-group>.btn-danger.dropdown-toggle:active,.btn-group>.btn-info.dropdown-toggle.active,.btn-group>.btn-info.dropdown-toggle:active,.btn-group>.btn-primary.dropdown-toggle.active,.btn-group>.btn-primary.dropdown-toggle:active,.btn-group>.btn-success.dropdown-toggle.active,.btn-group>.btn-success.dropdown-toggle:active,.btn-group>.btn-warning.dropdown-toggle.active,.btn-group>.btn-warning.dropdown-toggle:active{-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.3);box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}.btn-group>.btn-sm.dropdown-toggle{padding:5px 7px}.btn-group>.btn-lg.dropdown-toggle{padding:9px 9px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 1px 6px rgba(0,0,0,.15);box-shadow:inset 0 1px 6px rgba(0,0,0,.15)}.btn-group.open .btn.dropdown-toggle{background-color:#f3f3f3;background-image:none;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.btn-group.open .btn-primary.dropdown-toggle{background-color:#4d90fe;background-image:none;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.3);box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}.btn-group.open .btn-warning.dropdown-toggle{background-color:#faa937;background-image:none;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.3);box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}.btn-group.open .btn-danger.dropdown-toggle{background-color:#d84a38;background-image:none;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.3);box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}.btn-group.open .btn-success.dropdown-toggle{background-color:#35aa47;background-image:none;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.3);box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}.btn-group.open .btn-info.dropdown-toggle{background-color:#5bc0de;background-image:none;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.3);box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}.btn-lg .caret{border-width:5px 5px 0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-left-radius:2px;border-top-right-radius:2px}.btn-group-vertical>.btn:last-child:not(:first-child){border-bottom-right-radius:2px;border-bottom-left-radius:2px}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:38px;padding:9px 14px;font-size:14px;line-height:1.3;border-radius:1px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:38px;line-height:38px}select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:26px;padding:3px 8px;font-size:12px;line-height:1.5;border-radius:1px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:26px;line-height:26px}select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group .form-control,.input-group-addon,.input-group-btn{margin:0;border-radius:0}.input-group-addon{padding:5px 8px;font-size:13px;color:#555;border:1px solid #d9d9d9;border-top-color:silver;border-radius:2px}.input-group-addon.input-sm{padding:3px 8px;font-size:12px;border-radius:1px}.input-group-addon.input-lg{padding:9px 14px;font-size:14px;border-radius:1px}.input-group-addon input[type=radio],.input-group-addon input[type=checkbox]{margin-bottom:-3px}.nav>li.disabled>a{color:#999}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#999}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{color:#fff;background-color:#999;border-color:#999}.nav-tabs>li>a{color:#666;border-radius:2px 2px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{font-weight:700;color:#333}.nav-tabs-google>li{margin:0 -1px 0 0}.nav-tabs-google>li>a{padding:12px 8px;margin:0 8px;line-height:1.4;color:#777;border:3px solid transparent;border-width:3px 0;border-radius:0}.nav-tabs-google>li>a:first-of-type{margin-left:0}.nav-tabs-google>li>a:focus,.nav-tabs-google>li>a:hover{background-color:transparent;border-top-color:transparent}.nav-tabs-google>li>a:hover{color:#000;border-bottom-color:transparent}.nav-tabs-google>li>a:active{color:#dd4b39}.nav-tabs-google>li>a:focus{color:#000;outline:0}.nav-tabs-google>li.active>a,.nav-tabs-google>li.active>a:focus,.nav-tabs-google>li.active>a:hover{color:#dd4b39;border:3px solid transparent;border-width:3px 0;border-bottom-color:#dd4b39}.nav-pills>li>a{border-radius:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#4d90fe}.navbar{min-height:28px;margin-bottom:18px}@media (min-width:768px){.navbar{border-radius:2px}}.navbar-brand{height:28px;padding:5px 15px;font-size:14px;line-height:18px}.navbar-brand>.glyphicon{margin-top:0}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{padding:5px 10px;margin-top:1px;margin-right:15px;margin-bottom:1px;border-radius:2px}.navbar-nav{margin:2px -15px}.navbar-nav>li>a{padding-top:5px;padding-bottom:5px;line-height:18px}@media (max-width:767px){.navbar-nav .open .dropdown-menu>li>a{line-height:18px}}@media (min-width:768px){.navbar-nav{margin:0}.navbar-nav>li>a{padding-top:5px;padding-bottom:5px}}.navbar-form{padding:10px 15px;margin-top:0;margin-right:-15px;margin-bottom:0;margin-left:-15px;-webkit-box-shadow:none;box-shadow:none}.navbar-form>.input-group .form-control{margin-top:1px;margin-bottom:1px}@media (min-width:768px){.navbar-form{padding-top:0;padding-bottom:0;margin-right:0;margin-left:0}}.navbar-form .form-control{height:26px;padding:3px 8px}.navbar .btn,.navbar-btn{padding:3px 8px;margin-top:1px;margin-bottom:1px}.navbar .btn.btn-sm,.navbar-btn.btn-sm{margin-top:1px;margin-bottom:1px}.navbar .btn.btn-xs,.navbar-btn.btn-xs{padding:2px 6px;margin-top:4px;margin-bottom:4px}.navbar-text{margin-top:5px;margin-bottom:5px}.navbar-default{background-color:#2d2d2d;border-color:#000}.navbar-default .navbar-brand{color:#999}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-default .navbar-brand>.caret{border-top-color:#999;border-bottom-color:#999}.navbar-default .navbar-text{color:#999}.navbar-default .navbar-nav>li>a{color:#999}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#fff;background-color:#141414}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#555;background-color:transparent}.navbar-default .navbar-toggle{border-color:#222}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#333}.navbar-default .navbar-toggle .icon-bar{background-color:#fff}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#000}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#fff;background-color:#141414}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#999}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#141414}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#555;background-color:transparent}}.navbar-default .navbar-link{color:#999}.navbar-default .navbar-link:hover{color:#fff}.navbar-default .btn-link{color:#999}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#fff}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#555}.navbar-inverse{background-color:#fafafa;border-color:#dbdbdb}.navbar-inverse .navbar-brand{color:#999}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:grey;background-color:transparent}.navbar-inverse .navbar-brand>.caret{border-top-color:#999;border-bottom-color:#999}.navbar-inverse .navbar-text{color:#999}.navbar-inverse .navbar-nav>li>a{color:#999}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#333;background-color:#e1e1e1}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#ddd}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#ddd}.navbar-inverse .navbar-toggle .icon-bar{background-color:#888}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#e8e8e8}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#333;background-color:#e1e1e1}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#dbdbdb}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#dbdbdb}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#999}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#333;background-color:#e1e1e1}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-inverse .navbar-link{color:#999}.navbar-inverse .navbar-link:hover{color:#333}.navbar-inverse .btn-link{color:#999}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#333}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#ccc}.navbar-masthead{min-height:44px;margin-bottom:18px}@media (min-width:768px){.navbar-masthead{border-radius:2px}}.navbar-masthead .navbar-static-top{z-index:1005}.navbar-masthead .navbar-fixed-bottom,.navbar-masthead .navbar-fixed-top{z-index:1029}.navbar-masthead .navbar-brand{height:44px;padding:13px 15px;font-size:20px}.navbar-masthead .navbar-brand>.glyphicon{margin-top:-3px}@media (min-width:768px){.navbar>.container .navbar-masthead .navbar-brand,.navbar>.container-fluid .navbar-masthead .navbar-brand{margin-left:-15px}}.navbar-masthead .navbar-toggle{margin-top:7px;margin-right:15px;margin-bottom:7px}.navbar-masthead .navbar-nav{margin:6px -15px}@media (min-width:768px){.navbar-masthead .navbar-nav{margin:6px 0}.navbar-masthead .navbar-nav>li>a{padding-top:8px;padding-bottom:6px}}.navbar-masthead .navbar-form{padding:10px 15px;margin-top:0;margin-right:-15px;margin-bottom:0;margin-left:-15px}.navbar-masthead .navbar-form>.input-group .form-control{margin-top:7px;margin-bottom:7px}@media (max-width:767px){.navbar-masthead .navbar-form .form-group{margin-bottom:5px}}@media (min-width:768px){.navbar-masthead .navbar-form{padding-top:0;padding-bottom:0;margin-right:0;margin-left:0}}.navbar-masthead .navbar-form .form-control{height:30px;padding:5px 8px}.navbar-masthead.navbar .btn,.navbar-masthead.navbar-btn{padding:5px 8px;margin-top:7px;margin-bottom:7px}.navbar-masthead.navbar .btn.btn-sm,.navbar-masthead.navbar-btn.btn-sm{padding:3px 8px;margin-top:9px;margin-bottom:9px}.navbar-masthead.navbar .btn.btn-xs,.navbar-masthead.navbar-btn.btn-xs{padding:2px 6px;margin-top:12px;margin-bottom:12px}.navbar-masthead .navbar-text{margin-top:13px;margin-bottom:13px}.navbar-masthead.navbar-default{background-color:#f1f1f1;border-color:#e5e5e5}.navbar-masthead.navbar-default .navbar-brand{color:#777}.navbar-masthead.navbar-default .navbar-brand:focus,.navbar-masthead.navbar-default .navbar-brand:hover{color:#777;background-color:transparent}.navbar-masthead.navbar-default .navbar-brand>.caret{border-top-color:#777;border-bottom-color:#777}.navbar-masthead.navbar-default .navbar-text{color:#777}.navbar-masthead.navbar-default .navbar-nav>li>a{color:#777}.navbar-masthead.navbar-default .navbar-nav>li>a:focus,.navbar-masthead.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-masthead.navbar-default .navbar-nav>.active>a,.navbar-masthead.navbar-default .navbar-nav>.active>a:focus,.navbar-masthead.navbar-default .navbar-nav>.active>a:hover{color:#333;background-color:#f1f1f1}.navbar-masthead.navbar-default .navbar-nav>.disabled>a,.navbar-masthead.navbar-default .navbar-nav>.disabled>a:focus,.navbar-masthead.navbar-default .navbar-nav>.disabled>a:hover{color:#bbb;background-color:transparent}.navbar-masthead.navbar-default .navbar-toggle{border-color:#dcdcdc}.navbar-masthead.navbar-default .navbar-toggle:focus,.navbar-masthead.navbar-default .navbar-toggle:hover{background-color:#e4e4e4}.navbar-masthead.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-masthead.navbar-default .navbar-collapse,.navbar-masthead.navbar-default .navbar-form{border-color:#dfdfdf}.navbar-masthead.navbar-default .navbar-nav>.open>a,.navbar-masthead.navbar-default .navbar-nav>.open>a:focus,.navbar-masthead.navbar-default .navbar-nav>.open>a:hover{color:#333;background-color:#f1f1f1}@media (max-width:767px){.navbar-masthead.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-masthead.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-masthead.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-masthead.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-masthead.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-masthead.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#333;background-color:#f1f1f1}.navbar-masthead.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-masthead.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-masthead.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#bbb;background-color:transparent}}.navbar-masthead.navbar-default .navbar-link{color:#777}.navbar-masthead.navbar-default .navbar-link:hover{color:#333}.navbar-masthead.navbar-default .btn-link{color:#777}.navbar-masthead.navbar-default .btn-link:focus,.navbar-masthead.navbar-default .btn-link:hover{color:#333}.navbar-masthead.navbar-default .btn-link[disabled]:focus,.navbar-masthead.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-masthead.navbar-default .btn-link:focus,fieldset[disabled] .navbar-masthead.navbar-default .btn-link:hover{color:#bbb}.navbar-masthead.navbar-inverse{background-color:#444;border-color:#333}.navbar-masthead.navbar-inverse .navbar-brand{color:#fff}.navbar-masthead.navbar-inverse .navbar-brand:focus,.navbar-masthead.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-masthead.navbar-inverse .navbar-brand>.caret{border-top-color:#fff;border-bottom-color:#fff}.navbar-masthead.navbar-inverse .navbar-text{color:#999}.navbar-masthead.navbar-inverse .navbar-nav>li>a{color:#fff}.navbar-masthead.navbar-inverse .navbar-nav>li>a:focus,.navbar-masthead.navbar-inverse .navbar-nav>li>a:hover{color:#bbb;background-color:transparent}.navbar-masthead.navbar-inverse .navbar-nav>.active>a,.navbar-masthead.navbar-inverse .navbar-nav>.active>a:focus,.navbar-masthead.navbar-inverse .navbar-nav>.active>a:hover{color:#bbb;background-color:#444}.navbar-masthead.navbar-inverse .navbar-nav>.disabled>a,.navbar-masthead.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-masthead.navbar-inverse .navbar-nav>.disabled>a:hover{color:#777;background-color:transparent}.navbar-masthead.navbar-inverse .navbar-toggle{border-color:#222}.navbar-masthead.navbar-inverse .navbar-toggle:focus,.navbar-masthead.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-masthead.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-masthead.navbar-inverse .navbar-collapse,.navbar-masthead.navbar-inverse .navbar-form{border-color:#323232}.navbar-masthead.navbar-inverse .navbar-nav>.open>a,.navbar-masthead.navbar-inverse .navbar-nav>.open>a:focus,.navbar-masthead.navbar-inverse .navbar-nav>.open>a:hover{color:#bbb;background-color:#444}@media (max-width:767px){.navbar-masthead.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#333}.navbar-masthead.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#333}.navbar-masthead.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#fff}.navbar-masthead.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-masthead.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#bbb;background-color:transparent}.navbar-masthead.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-masthead.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-masthead.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#bbb;background-color:#444}.navbar-masthead.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-masthead.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-masthead.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#777;background-color:transparent}}.navbar-masthead.navbar-inverse .navbar-link{color:#fff}.navbar-masthead.navbar-inverse .navbar-link:hover{color:#bbb}.navbar-masthead.navbar-inverse .btn-link{color:#fff}.navbar-masthead.navbar-inverse .btn-link:focus,.navbar-masthead.navbar-inverse .btn-link:hover{color:#bbb}.navbar-masthead.navbar-inverse .btn-link[disabled]:focus,.navbar-masthead.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-masthead.navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-masthead.navbar-inverse .btn-link:hover{color:#777}.navbar-toolbar{min-height:36px;margin-bottom:18px}@media (min-width:768px){.navbar-toolbar{border-radius:2px}}.navbar-toolbar .navbar-static-top{z-index:1008}.navbar-toolbar .navbar-fixed-bottom,.navbar-toolbar .navbar-fixed-top{z-index:1028}.navbar-toolbar .navbar-brand{height:36px;padding:9px 15px;font-size:16px;font-weight:700}@media (min-width:768px){.navbar>.container .navbar-toolbar .navbar-brand,.navbar>.container-fluid .navbar-toolbar .navbar-brand{margin-left:-15px}}.navbar-toolbar .navbar-toggle{margin-top:3px;margin-right:15px;margin-bottom:3px}.navbar-toolbar .navbar-nav{margin:4px -15px}.navbar-toolbar .navbar-nav>li{position:relative}.navbar-toolbar .navbar-nav>li>a{padding:9px 15px}.navbar-toolbar .navbar-nav>li>a:focus,.navbar-toolbar .navbar-nav>li>a:hover{text-decoration:underline}.navbar-toolbar .navbar-nav>li>.dropdown-menu{margin-top:1px}.navbar-toolbar .navbar-nav>.active>a{font-weight:700}.navbar-toolbar .navbar-nav>.active>a:before{position:absolute;bottom:-1px;left:50%;display:inline-block;margin-left:-8px;content:'';border-right:8px solid transparent;border-bottom:8px solid transparent;border-left:8px solid transparent}.navbar-toolbar .navbar-nav>.active>a:after{position:absolute;bottom:-1px;left:50%;display:inline-block;margin-left:-7px;content:'';border-right:7px solid transparent;border-bottom:7px solid transparent;border-left:7px solid transparent}@media (min-width:768px){.navbar-toolbar .navbar-nav{margin:0}.navbar-toolbar .navbar-nav>li>a{padding-top:9px;padding-bottom:9px}}.navbar-toolbar .navbar-form{padding:10px 15px;margin-top:0;margin-right:-15px;margin-bottom:0;margin-left:-15px}.navbar-toolbar .navbar-form>.input-group .form-control{margin-top:3px;margin-bottom:3px}@media (max-width:767px){.navbar-toolbar .navbar-form .form-group{margin-bottom:5px}}@media (min-width:768px){.navbar-toolbar .navbar-form{padding-top:0;padding-bottom:0;margin-right:0;margin-left:0}}.navbar-toolbar .navbar-form .form-control{height:30px;padding:5px 8px}.navbar-toolbar .dropdown-menu{border-top:1px none}.navbar-toolbar.navbar .btn,.navbar-toolbar.navbar-btn{padding:5px 8px;margin-top:3px;margin-bottom:3px}.navbar-toolbar.navbar .btn.btn-sm,.navbar-toolbar.navbar-btn.btn-sm{padding:3px 8px;margin-top:5px;margin-bottom:5px}.navbar-toolbar.navbar .btn.btn-xs,.navbar-toolbar.navbar-btn.btn-xs{padding:2px 6px;margin-top:8px;margin-bottom:8px}.navbar-toolbar .navbar-text{margin-top:9px;margin-bottom:9px}.navbar-toolbar.navbar-default{background-color:#fff;border-color:#ebebeb}.navbar-toolbar.navbar-default .navbar-brand{color:#dd4b39}.navbar-toolbar.navbar-default .navbar-brand:focus,.navbar-toolbar.navbar-default .navbar-brand:hover{color:#dd4b39;background-color:transparent}.navbar-toolbar.navbar-default .navbar-brand>.caret{border-top-color:#dd4b39;border-bottom-color:#dd4b39}.navbar-toolbar.navbar-default .navbar-text{color:#777}.navbar-toolbar.navbar-default .navbar-nav>li>a{color:#777}.navbar-toolbar.navbar-default .navbar-nav>li>a:focus,.navbar-toolbar.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-toolbar.navbar-default .navbar-nav>.active>a,.navbar-toolbar.navbar-default .navbar-nav>.active>a:focus,.navbar-toolbar.navbar-default .navbar-nav>.active>a:hover{color:#333;background-color:#f2f2f2}.navbar-toolbar.navbar-default .navbar-nav>.active>a:before{border-bottom:8px solid #ebebeb}.navbar-toolbar.navbar-default .navbar-nav>.active>a:after{border-bottom:7px solid #fff}.navbar-toolbar.navbar-default .navbar-nav>.disabled>a,.navbar-toolbar.navbar-default .navbar-nav>.disabled>a:focus,.navbar-toolbar.navbar-default .navbar-nav>.disabled>a:hover{color:#bbb;background-color:transparent}.navbar-toolbar.navbar-default .navbar-toggle{border-color:#dcdcdc}.navbar-toolbar.navbar-default .navbar-toggle:focus,.navbar-toolbar.navbar-default .navbar-toggle:hover{background-color:#e4e4e4}.navbar-toolbar.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-toolbar.navbar-default .navbar-collapse,.navbar-toolbar.navbar-default .navbar-form{border-color:#ededed}.navbar-toolbar.navbar-default .navbar-nav>.open>a,.navbar-toolbar.navbar-default .navbar-nav>.open>a:focus,.navbar-toolbar.navbar-default .navbar-nav>.open>a:hover{color:#333;background-color:#f2f2f2}@media (max-width:767px){.navbar-toolbar.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-toolbar.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-toolbar.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-toolbar.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-toolbar.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-toolbar.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#333;background-color:#f2f2f2}.navbar-toolbar.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-toolbar.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-toolbar.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#bbb;background-color:transparent}}.navbar-toolbar.navbar-default .navbar-link{color:#777}.navbar-toolbar.navbar-default .navbar-link:hover{color:#333}.navbar-toolbar.navbar-default .btn-link{color:#777}.navbar-toolbar.navbar-default .btn-link:focus,.navbar-toolbar.navbar-default .btn-link:hover{color:#333}.navbar-toolbar.navbar-default .btn-link[disabled]:focus,.navbar-toolbar.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-toolbar.navbar-default .btn-link:focus,fieldset[disabled] .navbar-toolbar.navbar-default .btn-link:hover{color:#bbb}.navbar-toolbar.navbar-inverse{background-color:#444;border-color:#333}.navbar-toolbar.navbar-inverse .navbar-brand{color:#fff}.navbar-toolbar.navbar-inverse .navbar-brand:focus,.navbar-toolbar.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-toolbar.navbar-inverse .navbar-brand>.caret{border-top-color:#fff;border-bottom-color:#fff}.navbar-toolbar.navbar-inverse .navbar-text{color:#999}.navbar-toolbar.navbar-inverse .navbar-nav>li>a{color:#fff}.navbar-toolbar.navbar-inverse .navbar-nav>li>a:focus,.navbar-toolbar.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-toolbar.navbar-inverse .navbar-nav>.active>a,.navbar-toolbar.navbar-inverse .navbar-nav>.active>a:focus,.navbar-toolbar.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#444}.navbar-toolbar.navbar-inverse .navbar-nav>.active>a:before{border-bottom:8px solid #333}.navbar-toolbar.navbar-inverse .navbar-nav>.active>a:after{border-bottom:7px solid #fff}.navbar-toolbar.navbar-inverse .navbar-nav>.disabled>a,.navbar-toolbar.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-toolbar.navbar-inverse .navbar-nav>.disabled>a:hover{color:#777;background-color:transparent}.navbar-toolbar.navbar-inverse .navbar-toggle{border-color:#222}.navbar-toolbar.navbar-inverse .navbar-toggle:focus,.navbar-toolbar.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-toolbar.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-toolbar.navbar-inverse .navbar-collapse,.navbar-toolbar.navbar-inverse .navbar-form{border-color:#323232}.navbar-toolbar.navbar-inverse .navbar-nav>.open>a,.navbar-toolbar.navbar-inverse .navbar-nav>.open>a:focus,.navbar-toolbar.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#444}@media (max-width:767px){.navbar-toolbar.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#333}.navbar-toolbar.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#333}.navbar-toolbar.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#fff}.navbar-toolbar.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-toolbar.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-toolbar.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-toolbar.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-toolbar.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#444}.navbar-toolbar.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-toolbar.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-toolbar.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#777;background-color:transparent}}.navbar-toolbar.navbar-inverse .navbar-link{color:#fff}.navbar-toolbar.navbar-inverse .navbar-link:hover{color:#fff}.navbar-toolbar.navbar-inverse .btn-link{color:#fff}.navbar-toolbar.navbar-inverse .btn-link:focus,.navbar-toolbar.navbar-inverse .btn-link:hover{color:#fff}.navbar-toolbar.navbar-inverse .btn-link[disabled]:focus,.navbar-toolbar.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-toolbar.navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-toolbar.navbar-inverse .btn-link:hover{color:#777}.navbar-static-top{border-radius:0}.navbar-fixed-top,.navbar-static-top{border-width:1px 0}.navbar-fixed-bottom{border-width:1px 0}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;border-radius:0}.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}.navbar-fixed-top{top:0}.navbar-fixed-bottom{bottom:0;margin-bottom:0}.navbar-btn{padding:3px 8px;margin-top:1px}.btn.navbar-masthead-btn{margin-top:7px}.btn.navbar-toolbar-btn{margin-top:3px}.navbar-link{color:#999}.navbar-link:hover{color:#fff}.navbar-inverse .navbar-link{color:#999}.navbar-inverse .navbar-link:hover{color:#333}.navbar-form .checkbox-inline,.navbar-form .radio-inline{color:#999}.breadcrumb{padding:13px 15px;margin-bottom:18px;background-color:#f3f3f3;border-radius:2px}.breadcrumb>li+li{position:relative;display:inline-block;margin-left:20px}.breadcrumb>li+li:before{border-radius:5px}.breadcrumb>li+li:after,.breadcrumb>li+li:before{position:absolute;width:0;height:0;content:""}.breadcrumb>li+li:before{border:7px solid transparent}.breadcrumb>li+li:after{border:5px solid transparent}.breadcrumb>li+li:after,.breadcrumb>li+li:before{top:9px;left:100%}.breadcrumb>li+li:before{margin-top:-7px;border-left:7px solid;border-left-color:#777}.breadcrumb>li+li:after{margin-top:-5px;border-left:5px solid #f3f3f3}.breadcrumb>li+li:after,.breadcrumb>li+li:before{left:-16px}.breadcrumb>li+li:before{color:#999;content:""}.breadcrumb>li>a{color:#999}.breadcrumb>li>a:hover{color:#000}.breadcrumb>.active,.breadcrumb>.active>a{color:#000}.breadcrumb-inverse{background-color:#393832}.breadcrumb-inverse>li+li{position:relative;display:inline-block}.breadcrumb-inverse>li+li:before{border-radius:5px}.breadcrumb-inverse>li+li:after,.breadcrumb-inverse>li+li:before{position:absolute;width:0;height:0;content:""}.breadcrumb-inverse>li+li:before{border:7px solid transparent}.breadcrumb-inverse>li+li:after{border:5px solid transparent}.breadcrumb-inverse>li+li:after,.breadcrumb-inverse>li+li:before{top:9px;left:100%}.breadcrumb-inverse>li+li:before{margin-top:-7px;border-left:7px solid;border-left-color:#666}.breadcrumb-inverse>li+li:after{margin-top:-5px;border-left:5px solid #393832}.breadcrumb-inverse>li+li:after,.breadcrumb-inverse>li+li:before{left:-16px}.breadcrumb-inverse>li>a{color:#999}.breadcrumb-inverse>li>a:hover{color:#fff}.breadcrumb-inverse>.active,.breadcrumb-inverse>.active>a{color:#fff}.breadcrumb-sm{padding:4px 15px;background-color:#fff;border-bottom:1px solid #ebebeb}.breadcrumb-sm.breadcrumb-inverse{background-color:#393832}.pagination{margin:18px 0;border-radius:2px}.pagination>li>a,.pagination>li>span{padding:5px 12px;line-height:1.4;color:#333;background-color:#f3f3f3;border:1px solid #dcdcdc}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:2px;border-bottom-left-radius:2px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:2px;border-bottom-right-radius:2px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{color:#333;background-color:#f5f5f5;border-color:#c6c6c6;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.1);box-shadow:0 1px 1px rgba(0,0,0,.1)}.pagination>li>a:active{background-color:#f4f4f4;background-image:-webkit-linear-gradient(top,#f6f6f6 0,#f1f1f1 100%);background-image:-o-linear-gradient(top,#f6f6f6 0,#f1f1f1 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f6f6f6),to(#f1f1f1));background-image:linear-gradient(to bottom,#f6f6f6 0,#f1f1f1 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff6f6f6', endColorstr='#fff1f1f1', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{color:#4d90fe;background-color:#f5f5f5;border-color:#c6c6c6;-webkit-box-shadow:none;box-shadow:none}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#b3b3b3;text-shadow:none;background-color:#f3f3f3;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#f1f1f1 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#f1f1f1 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#f1f1f1));background-image:linear-gradient(to bottom,#f5f5f5 0,#f1f1f1 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#fff1f1f1', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#d9d9d9;-webkit-box-shadow:none;box-shadow:none}.pagination-lg>li>a,.pagination-lg>li>span{padding:9px 14px;font-size:14px;line-height:1.3}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:1px;border-bottom-left-radius:1px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:1px;border-bottom-right-radius:1px}.pagination-sm>li>a,.pagination-sm>li>span{padding:3px 8px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:1px;border-bottom-left-radius:1px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:1px;border-bottom-right-radius:1px}.pager{margin:18px 0}.pager li>a,.pager li>span{padding:11px 24px;overflow:visible;font-size:14px;color:#777;text-decoration:none;white-space:nowrap;cursor:default;background-color:#fff;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);border:1px solid #5b5b5b;border:1px solid rgba(0,0,0,.1);border-radius:2px;outline:0;-webkit-box-shadow:0 2px 1px rgba(0,0,0,.1),0 0 1px rgba(0,0,0,.1);box-shadow:0 2px 1px rgba(0,0,0,.1),0 0 1px rgba(0,0,0,.1)}.pager li>a:focus,.pager li>a:hover{color:#444;background-color:#fff}.pager li>a:active{color:#444;background-color:#fff}.pager li .icon-prev{position:relative;display:inline-block;padding-right:8px}.pager li .icon-prev:before{border-radius:5px}.pager li .icon-prev:after,.pager li .icon-prev:before{position:absolute;width:0;height:0;content:""}.pager li .icon-prev:before{border:7px solid transparent}.pager li .icon-prev:after{border:4px solid transparent}.pager li .icon-prev:after,.pager li .icon-prev:before{top:-5px;right:100%}.pager li .icon-prev:before{margin-top:-7px;border-right:7px solid;border-right-color:inherit}.pager li .icon-prev:after{margin-top:-4px;border-right:4px solid #fff}.pager li .icon-next{position:relative;display:inline-block;padding-left:8px}.pager li .icon-next:before{border-radius:5px}.pager li .icon-next:after,.pager li .icon-next:before{position:absolute;width:0;height:0;content:""}.pager li .icon-next:before{border:7px solid transparent}.pager li .icon-next:after{border:4px solid transparent}.pager li .icon-next:after,.pager li .icon-next:before{top:-5px;left:100%}.pager li .icon-next:before{margin-top:-7px;border-left:7px solid;border-left-color:inherit}.pager li .icon-next:after{margin-top:-4px;border-left:4px solid #fff}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#b3b3b3;background-color:#fafafa;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);border-color:#d9d9d9;-webkit-box-shadow:none;box-shadow:none}.pager .disabled .icon-prev{position:relative;display:inline-block;padding-right:8px}.pager .disabled .icon-prev:before{border-radius:5px}.pager .disabled .icon-prev:after,.pager .disabled .icon-prev:before{position:absolute;width:0;height:0;content:""}.pager .disabled .icon-prev:before{border:7px solid transparent}.pager .disabled .icon-prev:after{border:4px solid transparent}.pager .disabled .icon-prev:after,.pager .disabled .icon-prev:before{top:-5px;right:100%}.pager .disabled .icon-prev:before{margin-top:-7px;border-right:7px solid;border-right-color:#b3b3b3}.pager .disabled .icon-prev:after{margin-top:-4px;border-right:4px solid #fafafa}.pager .disabled .icon-next{position:relative;display:inline-block;padding-left:8px}.pager .disabled .icon-next:before{border-radius:5px}.pager .disabled .icon-next:after,.pager .disabled .icon-next:before{position:absolute;width:0;height:0;content:""}.pager .disabled .icon-next:before{border:7px solid transparent}.pager .disabled .icon-next:after{border:4px solid transparent}.pager .disabled .icon-next:after,.pager .disabled .icon-next:before{top:-5px;left:100%}.pager .disabled .icon-next:before{margin-top:-7px;border-left:7px solid;border-left-color:#b3b3b3}.pager .disabled .icon-next:after{margin-top:-4px;border-left:4px solid #fafafa}.label{font-size:80%;border-radius:0}.label-default{background-color:#999}.label-default[href]:focus,.label-default[href]:hover{background-color:grey}.label-primary{background-color:#4d90fe}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#1a70fe}.label-success{background-color:#35aa47}.label-success[href]:focus,.label-success[href]:hover{background-color:#298337}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#faa937}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#f89306}.label-danger{background-color:#d84a38}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#b93524}.badge{font-size:12px}.btn-group-xs>.btn .badge,.btn-xs .badge{font-size:11px}.list-group-item.active>.badge,li.list-group-item.active a>.badge{color:#fff;background-color:#dd4b39}.nav-pills>.active>a>.badge{color:#15c;background-color:#fff}.jumbotron{color:inherit;background-color:#eee}.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{font-size:20px}.container .jumbotron,.container-fluid .jumbotron{border-radius:1px}@media screen and (min-width:768px){.jumbotron .h1,.jumbotron h1{font-size:59px}}.thumbnail{display:block;padding:0;margin-bottom:18px;line-height:1.4;background-color:#fff;border:1px solid #fff;border-radius:0}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#fff;-webkit-box-shadow:0 0 0 1px #dedede;box-shadow:0 0 0 1px #dedede}.thumbnail .caption{padding:9px 4px;color:#000}.alert{padding:8px;margin-bottom:18px;border-radius:2px}.alert .alert-link{font-weight:700}.alert-dismissable,.alert-dismissible{padding-right:28px}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#a3d48e}.alert-success hr{border-top-color:#93cd7c}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#85c5e5}.alert-info hr{border-top-color:#70bbe1}.alert-info .alert-link{color:#245269}.alert-warning{color:#333;background-color:#f9edbe;border-color:#f0c36d}.alert-warning hr{border-top-color:#eeb956}.alert-warning .alert-link{color:#1a1a1a}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#d59595}.alert-danger hr{border-top-color:#ce8383}.alert-danger .alert-link{color:#843534}.alert-danger,.alert-info,.alert-success,.alert-warning{text-shadow:0 1px 0 rgba(255,255,255,.5)}.progress{height:14px;height:18px;padding:1px;margin-bottom:18px;font-size:12px;background-color:transparent;background-image:none;border:1px solid #999;border-radius:0;-webkit-box-shadow:none;box-shadow:none}.progress-bar{line-height:1.25;background-color:#6188f5;background-image:none;-webkit-box-shadow:none;box-shadow:none}.progress-bar-striped,.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress-bar-success{background-color:#2f973f}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#53bddc}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#fbb450}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#c13e2c}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.list-group-item{color:#222;background-color:#fff;border:1px solid #e5e5e5}.list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.list-group-item:last-child{border-bottom-right-radius:0;border-bottom-left-radius:0}.list-group-item .dropdown{display:none}.list-group-item .dropdown-toggle{display:inline-block;padding:5px 6px 5px 5px;color:#222}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{font-weight:700;color:#dd4b39;background-color:transparent;border-color:#e5e5e5;border-left:4px solid #dd4b39;border-left-color:#dd4b39}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{font-weight:400;color:#888}.list-group-item.active:focus,.list-group-item.active:hover{background-color:#eee}a.list-group-item:focus,a.list-group-item:hover,li.list-group-item a:focus,li.list-group-item a:hover{color:#555;text-decoration:none;background-color:#eee}li.list-group-item{padding:0;margin-bottom:0;border:0 none}li.list-group-item>a{display:block;padding:5px 17px;margin:0 0 0 14px;color:#222}li.list-group-item.active,li.list-group-item.active:focus,li.list-group-item.active:hover{background-color:transparent}li.list-group-item.active:focus>a,li.list-group-item.active:hover>a,li.list-group-item.active>a{margin-left:10px;color:#dd4b39}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#333;background-color:#f9edbe}a.list-group-item-warning,button.list-group-item-warning{color:#333}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#333;background-color:#f7e7a7}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#333;border-color:#333}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-wrapper{margin-left:14px}.list-group-item-wrapper:hover>.dropdown{display:block}.list-group-item-wrapper>a{display:block;padding:5px 17px;margin:0;color:#222}.list-group-item-wrapper>.dropdown:hover+a{background-color:#eee}.list-group-item-wrapper>.dropdown.open{display:block}.list-group-item-wrapper>.dropdown.open+a{background-color:#eee}.list-group-item-wrapper>.dropdown>.dropdown-menu{margin-top:0}.list-group-header{display:block;padding:10px 30px 10px 15px;font-size:11px;font-weight:700;line-height:1.4;color:#999;text-shadow:0 1px 0 rgba(255,255,255,.5);text-transform:uppercase}li.list-group-header{padding:3px 15px}.list-group .list-group-header{margin-top:9px}.list-group-item-menu{padding:0;margin:0;border:0 none;border-radius:0;-webkit-box-shadow:none;box-shadow:none}.list-group-item-menu .list-group-item-wrapper>a{padding-left:30px}.list-group-item-menu .list-group-item-menu .list-group-item-wrapper>a{padding-left:44px}.list-group-item-menu>.list-group-item .collapse-caret{margin-left:28px}.collapse-caret{position:absolute;z-index:1;display:inline-block;width:17px;height:28px;margin-left:14px}.collapse-caret:before{position:absolute;top:12px;left:5px;margin-left:0;content:'';border-bottom:0 dotted}.collapse-caret:hover{background-color:#eee}.collapse-caret.collapsed:before{top:10px;left:6px}.list-group .divider{height:1px;margin:8px 0;margin-right:15px;margin-left:15px;overflow:hidden;background-color:#e5e5e5}.panel{word-wrap:break-word;background-color:#fff;border:1px solid transparent;border-bottom-width:2px;border-radius:3px;-webkit-box-shadow:none;box-shadow:none}.panel-body{padding:15px 20px}.panel-heading{padding:15px 20px;border-top-left-radius:3px;border-top-right-radius:3px}.panel-title{font-size:16px}.panel-footer{padding:15px 20px;background-color:#f8f8f8;border-top:1px solid #e5e5e5;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group{padding:15px 20px;padding-top:0}.panel>.list-group:first-child .list-group-item:first-child{border-top-left-radius:2px;border-top-right-radius:2px}.panel>.list-group:last-child .list-group-item:last-child{border-bottom-right-radius:2px;border-bottom-left-radius:2px}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px 20px;padding-left:15px 20px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{border-top-left-radius:2px;border-top-right-radius:2px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:2px;border-top-right-radius:2px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:2px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:2px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{border-bottom-right-radius:2px;border-bottom-left-radius:2px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:2px;border-bottom-left-radius:2px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:2px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:2px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel-default{border-color:#d8d8d8}.panel-default>.panel-heading{color:#333;background-color:#fff;border-color:#fff}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d8d8d8}.panel-default>.panel-heading .badge{color:#fff;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d8d8d8}.panel-primary{border-color:#4d90fe}.panel-primary>.panel-heading{color:#fff;background-color:#4d90fe;border-color:#4d90fe}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#4d90fe}.panel-primary>.panel-heading .badge{color:#4d90fe;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#4d90fe}.panel-success{border-color:#a3d48e}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#a3d48e}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#a3d48e}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#a3d48e}.panel-info{border-color:#85c5e5}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#85c5e5}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#85c5e5}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#85c5e5}.panel-warning{border-color:#f0c36d}.panel-warning>.panel-heading{color:#333;background-color:#f9edbe;border-color:#f0c36d}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#f0c36d}.panel-warning>.panel-heading .badge{color:#f9edbe;background-color:#333}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#f0c36d}.panel-danger{border-color:#d59595}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#d59595}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d59595}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d59595}.panel-group{margin-bottom:18px}.panel-group .panel{border-color:transparent;border-radius:0}.panel-group .panel+.panel{margin-top:-3px}.panel-group .panel-heading{padding:0 15px;background-color:#fafafa;border-top:1px dashed #ccc;border-bottom:1px dashed #ccc}.panel-group .panel-heading a{display:block;padding:10px 0 9px;color:#444;text-decoration:none}.panel-group .panel-heading a:before{margin-right:7px;content:"\e082"}.panel-group .panel-heading a:hover{background-color:#f5f5f5}.panel-group .panel-heading a:focus{outline:0}.panel-group .panel-heading a.collapsed:before{margin-right:7px;content:"\e081"}.panel-group .panel-heading .panel-title{font-size:13px}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:0 none}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:0 none}.well{background-color:#f1f1f1;border:1px solid #e5e5e5;border-radius:0;-webkit-box-shadow:none;box-shadow:none}.well-lg{border-radius:0}.well-sm{border-radius:0}.scrollable::-webkit-scrollbar{width:10px;height:16px}.scrollable::-webkit-scrollbar:hover{background-color:#f3f3f3;border:1px solid #dbdbdb}.scrollable::-webkit-scrollbar-button:end:increment,.scrollable::-webkit-scrollbar-button:start:decrement{display:block;height:0;background-color:transparent}.scrollable::-webkit-scrollbar-track{-webkit-background-clip:padding-box;background-clip:padding-box;border:solid transparent;border-width:0 0 0 4px}.scrollable::-webkit-scrollbar-track-piece{background-color:transparent;border-radius:0}.scrollable::-webkit-scrollbar-thumb{background-color:#515151;background-color:rgba(0,0,0,.2);-webkit-background-clip:padding-box;background-clip:padding-box;border:solid transparent;border-width:0;-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,.1),inset 0 -1px 0 rgba(0,0,0,.07);box-shadow:inset 1px 1px 0 rgba(0,0,0,.1),inset 0 -1px 0 rgba(0,0,0,.07)}.scrollable::-webkit-scrollbar-thumb:hover{background-color:#949494}.scrollable::-webkit-scrollbar-thumb:active{background-color:#3b3b3b;background-color:rgba(0,0,0,.5);-webkit-box-shadow:inset 1px 1px 3px rgba(0,0,0,.35);box-shadow:inset 1px 1px 3px rgba(0,0,0,.35)}.scrollable::-webkit-scrollbar-thumb:horizontal,.scrollable::-webkit-scrollbar-thumb:vertical{background-color:#c6c6c6;border-radius:0}.modal-content{color:#222;border:1px solid #aaa;border:1px solid rgba(0,0,0,.333);border-radius:0;-webkit-box-shadow:0 4px 16px rgba(0,0,0,.2);box-shadow:0 4px 16px rgba(0,0,0,.2)}.modal-backdrop{background-color:#fff}.modal-header .close{font-weight:400;filter:alpha(opacity=40);opacity:.4}.modal-body{padding:15px}.tooltip{font-family:Arial,Helvetica,sans-serif;font-size:11px;font-style:normal;font-weight:400;font-weight:700;line-height:1.4;line-height:1.25;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-break:break-word;word-spacing:normal;word-wrap:normal;white-space:normal;line-break:auto}.tooltip.in{filter:alpha(opacity=100);opacity:1}.tooltip-inner{padding:7px 9px;background-color:#2a2a2a;border:1px solid #fff;border-radius:0}.tooltip-arrow:before{position:absolute;z-index:-1;content:" ";border:7px solid transparent}.tooltip.top .tooltip-arrow,.tooltip.top-left .tooltip-arrow,.tooltip.top-right .tooltip-arrow{bottom:1px;border-top-color:#2a2a2a}.tooltip.top .tooltip-arrow:before,.tooltip.top-left .tooltip-arrow:before,.tooltip.top-right .tooltip-arrow:before{top:-5px;left:-7px;border-top-color:#fff;border-bottom:0 dotted}.tooltip.right .tooltip-arrow{left:1px;border-right-color:#2a2a2a}.tooltip.right .tooltip-arrow:before{top:-7px;right:-5px;border-right-color:#fff;border-left:0 dotted}.tooltip.left .tooltip-arrow{right:1px;border-left-color:#2a2a2a}.tooltip.left .tooltip-arrow:before{top:-7px;left:-5px;border-right:0 dotted;border-left-color:#fff}.tooltip.bottom .tooltip-arrow,.tooltip.bottom-left .tooltip-arrow,.tooltip.bottom-right .tooltip-arrow{top:1px;border-bottom-color:#2a2a2a}.tooltip.bottom .tooltip-arrow:before,.tooltip.bottom-left .tooltip-arrow:before,.tooltip.bottom-right .tooltip-arrow:before{bottom:-5px;left:-7px;border-top:0 dotted;border-bottom-color:#fff}.popover{padding:0;font-family:Arial,Helvetica,sans-serif;font-size:13px;font-style:normal;font-weight:400;line-height:1.4;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;background-color:#fff;border-radius:2px;-webkit-box-shadow:0 2px 10px rgba(0,0,0,.2);box-shadow:0 2px 10px rgba(0,0,0,.2);line-break:auto}.popover-footer,.popover-title{padding:10px;font-size:13px;background-color:#f5f5f5;border-bottom:1px solid #ccc;border-bottom:1px solid rgba(0,0,0,.2);border-radius:0}.popover-footer{border-top:1px solid #ccc;border-top:1px solid rgba(0,0,0,.2);border-bottom:none}.popover-content{padding:10px}.carousel{width:100%;padding:50px;overflow:hidden;background-color:#f5f5f5;background-image:-webkit-linear-gradient(top,#eee 0,#f5f5f5 100%),-webkit-linear-gradient(bottom,#eee 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#eee 0,#f5f5f5 100%),-o-linear-gradient(bottom,#eee 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#eee),to(#f5f5f5)),-webkit-gradient(linear,left bottom,left top,from(#eee),to(#f5f5f5));background-image:linear-gradient(to bottom,#eee 0,#f5f5f5 100%),linear-gradient(to top,#eee 0,#f5f5f5 100%);background-repeat:no-repeat;background-position:0 0,0 100%;-webkit-background-size:100% 10px;background-size:100% 10px}.carousel-control{width:100px;color:#777;text-shadow:none;filter:alpha(opacity=33);opacity:.33}.carousel-control.left{background-image:none}.carousel-control.right{background-image:none}.carousel-control:focus,.carousel-control:hover{color:#777}.carousel-control .icon-next:before,.carousel-control .icon-prev:before{content:''}.carousel-control .icon-prev{position:relative;position:absolute;right:0;display:inline-block}.carousel-control .icon-prev:before{border-radius:20px}.carousel-control .icon-prev:after,.carousel-control .icon-prev:before{position:absolute;width:0;height:0;content:""}.carousel-control .icon-prev:before{border:22px solid transparent}.carousel-control .icon-prev:after{border:19px solid transparent}.carousel-control .icon-prev:after,.carousel-control .icon-prev:before{top:8px;right:100%}.carousel-control .icon-prev:before{margin-top:-22px;border-right:22px solid;border-right-color:#777}.carousel-control .icon-prev:after{margin-top:-19px;border-right:19px solid #f5f5f5}.carousel-control .icon-next{position:relative;position:absolute;right:0;left:50%;display:inline-block}.carousel-control .icon-next:before{border-radius:20px}.carousel-control .icon-next:after,.carousel-control .icon-next:before{position:absolute;width:0;height:0;content:""}.carousel-control .icon-next:before{border:22px solid transparent}.carousel-control .icon-next:after{border:19px solid transparent}.carousel-control .icon-next:after,.carousel-control .icon-next:before{top:8px;left:100%}.carousel-control .icon-next:before{margin-top:-22px;border-left:22px solid;border-left-color:#777}.carousel-control .icon-next:after{margin-top:-19px;border-left:19px solid #f5f5f5}.carousel-control .icon-next:after,.carousel-control .icon-next:before{left:50%}.carousel-indicators{bottom:5px;left:0;width:100%;margin-left:0}.carousel-indicators li{background-color:#c2c2c2;border:1px solid #c2c2c2}.carousel-indicators .active{width:10px;height:10px;margin:1px;background-color:#444;border:1px solid #444}.carousel-caption{right:0;bottom:0;left:0;padding:10px;color:#fff;text-shadow:none;background-color:#262626;background-color:rgba(0,0,0,.55)} +/*# sourceMappingURL=todc-bootstrap.min.css.map */ \ No newline at end of file diff --git a/src/blog/static/assets/img/checkmark.png b/src/blog/static/assets/img/checkmark.png new file mode 100644 index 0000000..4bd0eb3 Binary files /dev/null and b/src/blog/static/assets/img/checkmark.png differ diff --git a/src/blog/static/assets/js/ie-emulation-modes-warning.js b/src/blog/static/assets/js/ie-emulation-modes-warning.js new file mode 100644 index 0000000..3f97ba5 --- /dev/null +++ b/src/blog/static/assets/js/ie-emulation-modes-warning.js @@ -0,0 +1,51 @@ +// NOTICE!! DO NOT USE ANY OF THIS JAVASCRIPT +// IT'S JUST JUNK FOR OUR DOCS! +// ++++++++++++++++++++++++++++++++++++++++++ +/*! + * Copyright 2014-2015 Twitter, Inc. + * + * Licensed under the Creative Commons Attribution 3.0 Unported License. For + * details, see https://creativecommons.org/licenses/by/3.0/. + */ +// Intended to prevent false-positive bug reports about Bootstrap not working properly in old versions of IE due to folks testing using IE's unreliable emulation modes. +(function () { + 'use strict'; + + function emulatedIEMajorVersion() { + var groups = /MSIE ([0-9.]+)/.exec(window.navigator.userAgent) + if (groups === null) { + return null + } + var ieVersionNum = parseInt(groups[1], 10) + var ieMajorVersion = Math.floor(ieVersionNum) + return ieMajorVersion + } + + function actualNonEmulatedIEMajorVersion() { + // Detects the actual version of IE in use, even if it's in an older-IE emulation mode. + // IE JavaScript conditional compilation docs: https://msdn.microsoft.com/library/121hztk3%28v=vs.94%29.aspx + // @cc_on docs: https://msdn.microsoft.com/library/8ka90k2e%28v=vs.94%29.aspx + var jscriptVersion = new Function('/*@cc_on return @_jscript_version; @*/')() // jshint ignore:line + if (jscriptVersion === undefined) { + return 11 // IE11+ not in emulation mode + } + if (jscriptVersion < 9) { + return 8 // IE8 (or lower; haven't tested on IE<8) + } + return jscriptVersion // IE9 or IE10 in any mode, or IE11 in non-IE11 mode + } + + var ua = window.navigator.userAgent + if (ua.indexOf('Opera') > -1 || ua.indexOf('Presto') > -1) { + return // Opera, which might pretend to be IE + } + var emulated = emulatedIEMajorVersion() + if (emulated === null) { + return // Not IE + } + var nonEmulated = actualNonEmulatedIEMajorVersion() + + if (emulated !== nonEmulated) { + window.alert('WARNING: You appear to be using IE' + nonEmulated + ' in IE' + emulated + ' emulation mode.\nIE emulation modes can behave significantly differently from ACTUAL older versions of IE.\nPLEASE DON\'T FILE BOOTSTRAP BUGS based on testing in IE emulation modes!') + } +})(); diff --git a/src/blog/static/assets/js/ie10-viewport-bug-workaround.js b/src/blog/static/assets/js/ie10-viewport-bug-workaround.js new file mode 100644 index 0000000..479a6eb --- /dev/null +++ b/src/blog/static/assets/js/ie10-viewport-bug-workaround.js @@ -0,0 +1,23 @@ +/*! + * IE10 viewport hack for Surface/desktop Windows 8 bug + * Copyright 2014-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ + +// See the Getting Started docs for more information: +// http://getbootstrap.com/getting-started/#support-ie10-width + +(function () { + 'use strict'; + + if (navigator.userAgent.match(/IEMobile\/10\.0/)) { + var msViewportStyle = document.createElement('style') + msViewportStyle.appendChild( + document.createTextNode( + '@-ms-viewport{width:auto!important}' + ) + ) + document.querySelector('head').appendChild(msViewportStyle) + } + +})(); diff --git a/src/blog/static/blog/css/dark-mode-fixes.css b/src/blog/static/blog/css/dark-mode-fixes.css new file mode 100644 index 0000000..f573713 --- /dev/null +++ b/src/blog/static/blog/css/dark-mode-fixes.css @@ -0,0 +1,457 @@ +/* 深色模式修复 - 覆盖 style.css 中的硬编码白色背景 */ + +/* 覆盖所有白色背景为使用CSS变量 */ +[data-theme="dark"] .site-header, +[data-theme="dark"] #masthead { + background-color: var(--nav-bg) !important; + color: var(--nav-text) !important; +} + +[data-theme="dark"] .site-content, +[data-theme="dark"] #content { + background-color: var(--bg-primary) !important; +} + +[data-theme="dark"] .widget-area, +[data-theme="dark"] #secondary { + background-color: var(--sidebar-bg) !important; +} + +[data-theme="dark"] .entry-content, +[data-theme="dark"] .entry-summary, +[data-theme="dark"] .page-content, +[data-theme="dark"] article { + background-color: var(--article-bg) !important; + color: var(--text-primary) !important; +} + +[data-theme="dark"] .site { + background-color: var(--bg-primary) !important; +} + +[data-theme="dark"] body { + background-color: var(--bg-primary) !important; + color: var(--text-primary) !important; +} + +/* 修复所有白色背景的元素 */ +[data-theme="dark"] *[style*="background: #fff"], +[data-theme="dark"] *[style*="background-color: #fff"], +[data-theme="dark"] *[style*="background: white"], +[data-theme="dark"] *[style*="background-color: white"] { + background-color: var(--bg-primary) !important; +} + +/* 修复所有白色文字的元素(排除按钮和链接) */ +[data-theme="dark"] *[style*="color: #fff"]:not(.btn):not(a), +[data-theme="dark"] *[style*="color: white"]:not(.btn):not(a) { + color: var(--text-primary) !important; +} + +/* 评论区修复 */ +[data-theme="dark"] #comments, +[data-theme="dark"] .comment-list, +[data-theme="dark"] .comment, +[data-theme="dark"] .comment-body, +[data-theme="dark"] .comment-content { + background-color: var(--comment-bg) !important; + color: var(--text-primary) !important; + border-color: var(--comment-border) !important; +} + +/* 导航菜单修复 */ +[data-theme="dark"] .nav-menu, +[data-theme="dark"] .main-navigation, +[data-theme="dark"] #site-navigation { + background-color: var(--nav-bg) !important; +} + +[data-theme="dark"] .nav-menu li, +[data-theme="dark"] .main-navigation li { + background-color: transparent !important; +} + +[data-theme="dark"] .nav-menu a, +[data-theme="dark"] .main-navigation a { + color: var(--nav-text) !important; +} + +[data-theme="dark"] .nav-menu a:hover, +[data-theme="dark"] .main-navigation a:hover { + background-color: var(--nav-hover-bg) !important; + color: var(--link-hover) !important; +} + +/* Widget 修复 */ +[data-theme="dark"] .widget { + background-color: var(--sidebar-bg) !important; + color: var(--text-primary) !important; + border-color: var(--sidebar-border) !important; +} + +[data-theme="dark"] .widget-title { + color: var(--text-primary) !important; + border-color: var(--border-primary) !important; +} + +[data-theme="dark"] .widget ul, +[data-theme="dark"] .widget ol { + background-color: transparent !important; +} + +[data-theme="dark"] .widget a { + color: var(--link-color) !important; +} + +/* 文章列表修复 */ +[data-theme="dark"] .hentry, +[data-theme="dark"] .post, +[data-theme="dark"] .page { + background-color: var(--card-bg) !important; + color: var(--text-primary) !important; + border-color: var(--card-border) !important; +} + +[data-theme="dark"] .entry-header { + background-color: transparent !important; +} + +[data-theme="dark"] .entry-title a { + color: var(--text-primary) !important; +} + +[data-theme="dark"] .entry-title a:hover { + color: var(--link-hover) !important; +} + +[data-theme="dark"] .entry-meta, +[data-theme="dark"] .entry-footer { + color: var(--text-secondary) !important; + background-color: transparent !important; +} + +/* 搜索框修复 */ +[data-theme="dark"] #searchform, +[data-theme="dark"] .search-form { + background-color: var(--input-bg) !important; +} + +[data-theme="dark"] #s, +[data-theme="dark"] .search-field { + background-color: var(--input-bg) !important; + color: var(--input-text) !important; + border-color: var(--input-border) !important; +} + +/* 分页修复 */ +[data-theme="dark"] .pagination, +[data-theme="dark"] .page-links, +[data-theme="dark"] .nav-links { + background-color: transparent !important; +} + +[data-theme="dark"] .pagination a, +[data-theme="dark"] .page-links a, +[data-theme="dark"] .nav-links a { + background-color: var(--card-bg) !important; + color: var(--link-color) !important; + border-color: var(--border-primary) !important; +} + +[data-theme="dark"] .pagination a:hover, +[data-theme="dark"] .page-links a:hover, +[data-theme="dark"] .nav-links a:hover { + background-color: var(--bg-hover) !important; +} + +[data-theme="dark"] .pagination .current, +[data-theme="dark"] .page-links > .current { + background-color: var(--accent-primary) !important; + color: var(--text-inverse) !important; +} + +/* 面包屑导航修复 */ +[data-theme="dark"] .breadcrumbs, +[data-theme="dark"] .breadcrumb { + background-color: var(--bg-secondary) !important; + color: var(--text-secondary) !important; +} + +/* 侧边栏小工具特定修复 */ +[data-theme="dark"] #calendar_wrap { + background-color: var(--card-bg) !important; +} + +[data-theme="dark"] #calendar_wrap table, +[data-theme="dark"] #calendar_wrap th, +[data-theme="dark"] #calendar_wrap td { + background-color: transparent !important; + color: var(--text-primary) !important; + border-color: var(--border-primary) !important; +} + +/* 标签云修复 */ +[data-theme="dark"] .tagcloud a, +[data-theme="dark"] .wp_widget_tag_cloud a { + background-color: var(--bg-tertiary) !important; + color: var(--text-primary) !important; + border-color: var(--border-primary) !important; +} + +[data-theme="dark"] .tagcloud a:hover, +[data-theme="dark"] .wp_widget_tag_cloud a:hover { + background-color: var(--bg-hover) !important; + color: var(--link-hover) !important; +} + +/* 最近评论修复 */ +[data-theme="dark"] .recentcomments { + background-color: transparent !important; + color: var(--text-primary) !important; +} + +/* RSS 链接修复 */ +[data-theme="dark"] .rss-date, +[data-theme="dark"] .rssSummary { + color: var(--text-secondary) !important; +} + +/* 存档页面修复 */ +[data-theme="dark"] .archive-meta, +[data-theme="dark"] .page-header { + background-color: var(--bg-secondary) !important; + color: var(--text-primary) !important; + border-color: var(--border-primary) !important; +} + +/* 404 页面修复 */ +[data-theme="dark"] .error404 .widget { + background-color: var(--card-bg) !important; +} + +/* 图片说明修复 */ +[data-theme="dark"] .wp-caption, +[data-theme="dark"] .gallery-caption { + background-color: var(--bg-secondary) !important; + color: var(--text-secondary) !important; +} + +[data-theme="dark"] .wp-caption-text { + color: var(--text-secondary) !important; +} + +/* 嵌入内容修复 */ +[data-theme="dark"] embed, +[data-theme="dark"] iframe, +[data-theme="dark"] object { + border-color: var(--border-primary) !important; +} + +/* 按钮修复 - 确保按钮上的白色文字不被改变 */ +[data-theme="dark"] .btn, +[data-theme="dark"] button, +[data-theme="dark"] input[type="submit"], +[data-theme="dark"] input[type="button"], +[data-theme="dark"] .comment-reply-link { + color: inherit; +} + +[data-theme="dark"] .btn-primary, +[data-theme="dark"] .btn-success, +[data-theme="dark"] .btn-info, +[data-theme="dark"] .btn-warning, +[data-theme="dark"] .btn-danger { + color: var(--text-inverse) !important; +} + +/* Sticky post 修复 */ +[data-theme="dark"] .sticky { + background-color: var(--bg-secondary) !important; + border-color: var(--accent-primary) !important; +} + +/* 引用文字修复 */ +[data-theme="dark"] cite { + color: var(--text-secondary) !important; +} + +/* 列表修复 */ +[data-theme="dark"] ul, +[data-theme="dark"] ol, +[data-theme="dark"] dl { + color: var(--text-primary) !important; +} + +/* 定义列表修复 */ +[data-theme="dark"] dt { + color: var(--text-primary) !important; +} + +[data-theme="dark"] dd { + color: var(--text-secondary) !important; +} + +/* 强调文本修复 */ +[data-theme="dark"] strong, +[data-theme="dark"] b { + color: var(--text-primary) !important; +} + +[data-theme="dark"] em, +[data-theme="dark"] i { + color: var(--text-primary) !important; +} + +/* 删除线修复 */ +[data-theme="dark"] del, +[data-theme="dark"] s { + color: var(--text-tertiary) !important; +} + +/* 下划线修复 */ +[data-theme="dark"] ins, +[data-theme="dark"] u { + color: var(--text-primary) !important; + background-color: var(--bg-tertiary) !important; +} + +/* 小号文字修复 */ +[data-theme="dark"] small { + color: var(--text-secondary) !important; +} + +/* 标记文字修复 */ +[data-theme="dark"] mark { + background-color: var(--bg-tertiary) !important; + color: var(--text-primary) !important; +} + +/* Pygments 代码高亮修复 */ +[data-theme="dark"] .highlight, +[data-theme="dark"] .codehilite { + background-color: var(--code-block-bg) !important; +} + +[data-theme="dark"] .highlight pre, +[data-theme="dark"] .codehilite pre { + background-color: transparent !important; +} + +/* 站点标题和描述修复 */ +[data-theme="dark"] .site-title, +[data-theme="dark"] .site-description { + color: var(--nav-text) !important; +} + +[data-theme="dark"] .site-title a { + color: var(--nav-text) !important; +} + +[data-theme="dark"] .site-title a:hover { + color: var(--link-hover) !important; +} + +/* 页面容器修复 */ +[data-theme="dark"] #page, +[data-theme="dark"] .site, +[data-theme="dark"] #main, +[data-theme="dark"] .wrapper { + background-color: var(--bg-primary) !important; +} + +/* 修复特定的警告框背景 */ +[data-theme="dark"] *[style*="background: #fff9c0"], +[data-theme="dark"] *[style*="background-color: #fff9c0"] { + background-color: rgba(255, 249, 192, 0.2) !important; + color: var(--text-primary) !important; +} + +/* 修复特定的警告框背景 */ +[data-theme="dark"] *[style*="background: #fff3cd"], +[data-theme="dark"] *[style*="background-color: #fff3cd"] { + background-color: rgba(255, 243, 205, 0.2) !important; + color: var(--text-primary) !important; +} + +/* 补充:文章卡片内部元素修复 */ +[data-theme="dark"] .post-thumbnail, +[data-theme="dark"] .entry-thumbnail { + background-color: transparent !important; +} + +/* 补充:作者信息框修复 */ +[data-theme="dark"] .author-info, +[data-theme="dark"] .author-bio { + background-color: var(--bg-secondary) !important; + color: var(--text-primary) !important; + border-color: var(--border-primary) !important; +} + +/* 补充:相关文章修复 */ +[data-theme="dark"] .related-posts, +[data-theme="dark"] .related-articles { + background-color: var(--bg-secondary) !important; +} + +/* 补充:分类和标签显示修复 */ +[data-theme="dark"] .cat-links, +[data-theme="dark"] .tags-links { + color: var(--text-secondary) !important; +} + +[data-theme="dark"] .cat-links a, +[data-theme="dark"] .tags-links a { + color: var(--link-color) !important; + background-color: var(--bg-tertiary) !important; +} + +/* 补充:阅读更多链接修复 */ +[data-theme="dark"] .more-link { + color: var(--link-color) !important; +} + +[data-theme="dark"] .more-link:hover { + color: var(--link-hover) !important; +} + +/* 补充:表单元素标签修复 */ +[data-theme="dark"] label { + color: var(--text-primary) !important; +} + +/* 补充:占位符修复 */ +[data-theme="dark"] ::placeholder { + color: var(--input-placeholder) !important; + opacity: 1; +} + +[data-theme="dark"] :-ms-input-placeholder { + color: var(--input-placeholder) !important; +} + +[data-theme="dark"] ::-ms-input-placeholder { + color: var(--input-placeholder) !important; +} + +/* 补充:选中文本修复 */ +[data-theme="dark"] ::selection { + background-color: var(--accent-primary) !important; + color: var(--text-inverse) !important; +} + +[data-theme="dark"] ::-moz-selection { + background-color: var(--accent-primary) !important; + color: var(--text-inverse) !important; +} + +/* 修复 hfeed 类容器 */ +[data-theme="dark"] .hfeed { + background-color: var(--bg-primary) !important; +} + +/* 修复所有可能的白色背景覆盖 */ +[data-theme="dark"] .site-header, +[data-theme="dark"] .site-content, +[data-theme="dark"] .site-footer { + background-color: transparent !important; +} diff --git a/src/blog/static/blog/css/ie.css b/src/blog/static/blog/css/ie.css new file mode 100644 index 0000000..706f510 --- /dev/null +++ b/src/blog/static/blog/css/ie.css @@ -0,0 +1,273 @@ +/* +Styles for older IE versions (previous to IE9). +*/ + +body { + background-color: #e6e6e6; +} +body.custom-background-empty { + background-color: #fff; +} +body.custom-background-empty .site, +body.custom-background-white .site { + box-shadow: none; + margin-bottom: 0; + margin-top: 0; + padding: 0; +} +.assistive-text, +.site .screen-reader-text { + clip: rect(1px 1px 1px 1px); +} +.full-width .site-content { + float: none; + width: 100%; +} +img.size-full, +img.size-large, +img.header-image, +img.wp-post-image, +img[class*="align"], +img[class*="wp-image-"], +img[class*="attachment-"] { + width: auto; /* Prevent stretching of full-size and large-size images with height and width attributes in IE8 */ +} +.author-avatar { + float: left; + margin-top: 8px; + margin-top: 0.571428571rem; +} +.author-description { + float: right; + width: 80%; +} +.site { + box-shadow: 0 2px 6px rgba(100, 100, 100, 0.3); + margin: 48px auto; + max-width: 960px; + overflow: hidden; + padding: 0 40px; +} +.site-content { + float: left; + width: 65.104166667%; +} +body.template-front-page .site-content, +body.attachment .site-content, +body.full-width .site-content { + width: 100%; +} +.widget-area { + float: right; + width: 26.041666667%; +} +.site-header h1, +.site-header h2 { + text-align: left; +} +.site-header h1 { + font-size: 26px; + line-height: 1.846153846; +} +.main-navigation ul.nav-menu, +.main-navigation div.nav-menu > ul { + border-bottom: 1px solid #ededed; + border-top: 1px solid #ededed; + display: inline-block !important; + text-align: left; + width: 100%; +} +.main-navigation ul { + margin: 0; + text-indent: 0; +} +.main-navigation li a, +.main-navigation li { + display: inline-block; + text-decoration: none; +} +.ie7 .main-navigation li a, +.ie7 .main-navigation li { + display: inline; +} +.main-navigation li a { + border-bottom: 0; + color: #6a6a6a; + line-height: 3.692307692; + text-transform: uppercase; +} +.main-navigation li a:hover { + color: #000; +} +.main-navigation li { + margin: 0 40px 0 0; + position: relative; +} +.main-navigation li ul { + margin: 0; + padding: 0; + position: absolute; + top: 100%; + z-index: 1; + height: 1px; + width: 1px; + overflow: hidden; + clip: rect(1px, 1px, 1px, 1px); +} +.ie7 .main-navigation li ul { + clip: inherit; + display: none; + left: 0; + overflow: visible; +} +.main-navigation li ul ul, +.ie7 .main-navigation li ul ul { + top: 0; + left: 100%; +} +.main-navigation ul li:hover > ul, +.main-navigation ul li:focus > ul, +.main-navigation .focus > ul { + border-left: 0; + clip: inherit; + overflow: inherit; + height: inherit; + width: inherit; +} +.ie7 .main-navigation ul li:hover > ul, +.ie7 .main-navigation ul li:focus > ul { + display: block; +} +.main-navigation li ul li a { + background: #efefef; + border-bottom: 1px solid #ededed; + display: block; + font-size: 11px; + line-height: 2.181818182; + padding: 8px 10px; + width: 180px; +} +.main-navigation li ul li a:hover { + background: #e3e3e3; + color: #444; +} +.main-navigation .current-menu-item > a, +.main-navigation .current-menu-ancestor > a, +.main-navigation .current_page_item > a, +.main-navigation .current_page_ancestor > a { + color: #636363; + font-weight: bold; +} +.main-navigation .menu-toggle { + display: none; +} +.entry-header .entry-title { + font-size: 22px; +} +#respond form input[type="text"] { + width: 46.333333333%; +} +#respond form textarea.blog-textarea { + width: 79.666666667%; +} +.template-front-page .site-content, +.template-front-page article { + overflow: hidden; +} +.template-front-page.has-post-thumbnail article { + float: left; + width: 47.916666667%; +} +.entry-page-image { + float: right; + margin-bottom: 0; + width: 47.916666667%; +} +/* IE Front Page Template Widget fix */ +.template-front-page .widget-area { + clear: both; +} +.template-front-page .widget { + width: 100% !important; + border: none; +} +.template-front-page .widget-area .widget, +.template-front-page .first.front-widgets, +.template-front-page.two-sidebars .widget-area .front-widgets { + float: left; + margin-bottom: 24px; + width: 51.875%; +} +.template-front-page .second.front-widgets, +.template-front-page .widget-area .widget:nth-child(odd) { + clear: right; +} +.template-front-page .first.front-widgets, +.template-front-page .second.front-widgets, +.template-front-page.two-sidebars .widget-area .front-widgets + .front-widgets { + float: right; + margin: 0 0 24px; + width: 39.0625%; +} +.template-front-page.two-sidebars .widget, +.template-front-page.two-sidebars .widget:nth-child(even) { + float: none; + width: auto; +} +/* add input font for ul { + text-align: right; +} +.rtl .main-navigation ul li ul li, +.rtl .main-navigation ul li ul li ul li { + margin-left: 40px; + margin-right: auto; +} +.rtl .main-navigation li ul ul { + position: absolute; + bottom: 0; + right: 100%; + z-index: 1; +} +.ie7 .rtl .main-navigation li ul ul { + position: absolute; + bottom: 0; + right: 100%; + z-index: 1; +} +.ie7 .rtl .main-navigation ul li { + z-index: 99; +} +.ie7 .rtl .main-navigation li ul { + position: absolute; + bottom: 100%; + right: 0; + z-index: 1; +} +.ie7 .rtl .main-navigation li { + margin-right: auto; + margin-left: 40px; +} +.ie7 .rtl .main-navigation li ul ul ul { + position: relative; + z-index: 1; +} \ No newline at end of file diff --git a/src/blog/static/blog/css/nprogress.css b/src/blog/static/blog/css/nprogress.css new file mode 100644 index 0000000..90c7b6c --- /dev/null +++ b/src/blog/static/blog/css/nprogress.css @@ -0,0 +1,74 @@ +/* Make clicks pass-through */ +#nprogress { + pointer-events: none; +} + +#nprogress .bar { + background: red; + + position: fixed; + z-index: 1031; + top: 0; + left: 0; + + width: 100%; + height: 2px; +} + +/* Fancy blur effect */ +#nprogress .peg { + display: block; + position: absolute; + right: 0px; + width: 100px; + height: 100%; + box-shadow: 0 0 10px #29d, 0 0 5px #29d; + opacity: 1.0; + + -webkit-transform: rotate(3deg) translate(0px, -4px); + -ms-transform: rotate(3deg) translate(0px, -4px); + transform: rotate(3deg) translate(0px, -4px); +} + +/* Remove these to get rid of the spinner */ +#nprogress .spinner { + display: block; + position: fixed; + z-index: 1031; + top: 15px; + right: 15px; +} + +#nprogress .spinner-icon { + width: 18px; + height: 18px; + box-sizing: border-box; + + border: solid 2px transparent; + border-top-color: red; + border-left-color: red; + border-radius: 50%; + + -webkit-animation: nprogress-spinner 400ms linear infinite; + animation: nprogress-spinner 400ms linear infinite; +} + +.nprogress-custom-parent { + overflow: hidden; + position: relative; +} + +.nprogress-custom-parent #nprogress .spinner, +.nprogress-custom-parent #nprogress .bar { + position: absolute; +} + +@-webkit-keyframes nprogress-spinner { + 0% { -webkit-transform: rotate(0deg); } + 100% { -webkit-transform: rotate(360deg); } +} +@keyframes nprogress-spinner { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + diff --git a/src/blog/static/blog/css/oauth_style.css b/src/blog/static/blog/css/oauth_style.css new file mode 100644 index 0000000..8af78af --- /dev/null +++ b/src/blog/static/blog/css/oauth_style.css @@ -0,0 +1,305 @@ + +.icon-sn-google { + background-position: 0 -28px; +} + +.icon-sn-bg-google { + background-color: #4285f4; + background-position: 0 0; +} + +.fa-sn-google { + color: #4285f4; +} + +.icon-sn-github { + background-position: -28px -28px; +} + +.icon-sn-bg-github { + background-color: #333; + background-position: -28px 0; +} + +.fa-sn-github { + color: #333; +} + +.icon-sn-weibo { + background-position: -56px -28px; +} + +.icon-sn-bg-weibo { + background-color: #e90d24; + background-position: -56px 0; +} + +.fa-sn-weibo { + color: #e90d24; +} + +.icon-sn-qq { + background-position: -84px -28px; +} + +.icon-sn-bg-qq { + background-color: #0098e6; + background-position: -84px 0; +} + +.fa-sn-qq { + color: #0098e6; +} + +.icon-sn-twitter { + background-position: -112px -28px; +} + +.icon-sn-bg-twitter { + background-color: #50abf1; + background-position: -112px 0; +} + +.fa-sn-twitter { + color: #50abf1; +} + +.icon-sn-facebook { + background-position: -140px -28px; +} + +.icon-sn-bg-facebook { + background-color: #4862a3; + background-position: -140px 0; +} + +.fa-sn-facebook { + color: #4862a3; +} + +.icon-sn-renren { + background-position: -168px -28px; +} + +.icon-sn-bg-renren { + background-color: #197bc8; + background-position: -168px 0; +} + +.fa-sn-renren { + color: #197bc8; +} + +.icon-sn-tqq { + background-position: -196px -28px; +} + +.icon-sn-bg-tqq { + background-color: #1f9ed2; + background-position: -196px 0; +} + +.fa-sn-tqq { + color: #1f9ed2; +} + +.icon-sn-douban { + background-position: -224px -28px; +} + +.icon-sn-bg-douban { + background-color: #279738; + background-position: -224px 0; +} + +.fa-sn-douban { + color: #279738; +} + +.icon-sn-weixin { + background-position: -252px -28px; +} + +.icon-sn-bg-weixin { + background-color: #00b500; + background-position: -252px 0; +} + +.fa-sn-weixin { + color: #00b500; +} + +.icon-sn-dotted { + background-position: -280px -28px; +} + +.icon-sn-bg-dotted { + background-color: #eee; + background-position: -280px 0; +} + +.fa-sn-dotted { + color: #eee; +} + +.icon-sn-site { + background-position: -308px -28px; +} + +.icon-sn-bg-site { + background-color: #00b500; + background-position: -308px 0; +} + +.fa-sn-site { + color: #00b500; +} + +.icon-sn-linkedin { + background-position: -336px -28px; +} + +.icon-sn-bg-linkedin { + background-color: #0077b9; + background-position: -336px 0; +} + +.fa-sn-linkedin { + color: #0077b9; +} + +[class*=icon-sn-] { + display: inline-block; + background-image: url('../img/icon-sn.svg'); + background-repeat: no-repeat; + width: 28px; + height: 28px; + vertical-align: middle; + background-size: auto 56px; +} + +[class*=icon-sn-]:hover { + opacity: .8; + filter: alpha(opacity=80); +} + +.btn-sn-google { + background: #4285f4; +} + +.btn-sn-google:active, .btn-sn-google:focus, .btn-sn-google:hover { + background: #2a75f3; +} + +.btn-sn-github { + background: #333; +} + +.btn-sn-github:active, .btn-sn-github:focus, .btn-sn-github:hover { + background: #262626; +} + +.btn-sn-weibo { + background: #e90d24; +} + +.btn-sn-weibo:active, .btn-sn-weibo:focus, .btn-sn-weibo:hover { + background: #d10c20; +} + +.btn-sn-qq { + background: #0098e6; +} + +.btn-sn-qq:active, .btn-sn-qq:focus, .btn-sn-qq:hover { + background: #0087cd; +} + +.btn-sn-twitter { + background: #50abf1; +} + +.btn-sn-twitter:active, .btn-sn-twitter:focus, .btn-sn-twitter:hover { + background: #38a0ef; +} + +.btn-sn-facebook { + background: #4862a3; +} + +.btn-sn-facebook:active, .btn-sn-facebook:focus, .btn-sn-facebook:hover { + background: #405791; +} + +.btn-sn-renren { + background: #197bc8; +} + +.btn-sn-renren:active, .btn-sn-renren:focus, .btn-sn-renren:hover { + background: #166db1; +} + +.btn-sn-tqq { + background: #1f9ed2; +} + +.btn-sn-tqq:active, .btn-sn-tqq:focus, .btn-sn-tqq:hover { + background: #1c8dbc; +} + +.btn-sn-douban { + background: #279738; +} + +.btn-sn-douban:active, .btn-sn-douban:focus, .btn-sn-douban:hover { + background: #228330; +} + +.btn-sn-weixin { + background: #00b500; +} + +.btn-sn-weixin:active, .btn-sn-weixin:focus, .btn-sn-weixin:hover { + background: #009c00; +} + +.btn-sn-dotted { + background: #eee; +} + +.btn-sn-dotted:active, .btn-sn-dotted:focus, .btn-sn-dotted:hover { + background: #e1e1e1; +} + +.btn-sn-site { + background: #00b500; +} + +.btn-sn-site:active, .btn-sn-site:focus, .btn-sn-site:hover { + background: #009c00; +} + +.btn-sn-linkedin { + background: #0077b9; +} + +.btn-sn-linkedin:active, .btn-sn-linkedin:focus, .btn-sn-linkedin:hover { + background: #0067a0; +} + +[class*=btn-sn-], [class*=btn-sn-]:active, [class*=btn-sn-]:focus, [class*=btn-sn-]:hover { + border: none; + color: #fff; +} + +.btn-sn-more { + padding: 0; +} + +.btn-sn-more, .btn-sn-more:active, .btn-sn-more:hover { + box-shadow: none; +} + +[class*=btn-sn-] [class*=icon-sn-] { + background-color: transparent; +} \ No newline at end of file diff --git a/src/blog/static/blog/css/style.css b/src/blog/static/blog/css/style.css new file mode 100644 index 0000000..cdbd790 --- /dev/null +++ b/src/blog/static/blog/css/style.css @@ -0,0 +1,2898 @@ +html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + vertical-align: baseline; +} + +body { + line-height: 1; +} + +ol, +ul { + list-style: none; +} + +blockquote, +q { + quotes: none; +} + +blockquote:before, +blockquote:after, +q:before, +q:after { + content: ''; + content: none; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +caption, +th, +td { + font-weight: normal; + text-align: left; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + clear: both; +} + +html { + overflow-y: scroll; + font-size: 100%; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; +} + +a:focus { + outline: thin dotted; +} + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +nav, +section { + display: block; +} + +audio, +canvas, +video { + display: inline-block; +} + +audio:not([controls]) { + display: none; +} + +del { + color: #333; +} + +ins { + background: #fff9c0; + text-decoration: none; +} + +hr { + background-color: #ccc; + border: 0; + height: 1px; + margin: 24px; + margin-bottom: 1.714285714rem; +} + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sup { + top: -0.5em; +} + +sub { + bottom: -0.25em; +} + +small { + font-size: smaller; +} + +img { + border: 0; + -ms-interpolation-mode: bicubic; +} + +/* Clearing floats */ +.clear:after, +.wrapper:after, +.format-status .entry-header:after { + clear: both; +} + +.clear:before, +.clear:after, +.wrapper:before, +.wrapper:after, +.format-status .entry-header:before, +.format-status .entry-header:after { + display: table; + content: ""; +} + + +/* =Repeatable patterns +-------------------------------------------------------------- */ + +/* Small headers */ +.archive-title, +.page-title, +.widget-title, +.entry-content th, +.comment-content th { + font-size: 11px; + font-size: 0.785714286rem; + line-height: 2.181818182; + font-weight: bold; + text-transform: uppercase; + color: #636363; +} + +/* Shared Post Format styling */ +article.format-quote footer.entry-meta, +article.format-link footer.entry-meta, +article.format-status footer.entry-meta { + font-size: 11px; + font-size: 0.785714286rem; + line-height: 2.181818182; +} + +/* Form fields, general styles first */ +button, +input, +select, +textarea { + border: 1px solid #ccc; + border-radius: 3px; + font-family: inherit; + padding: 6px; + padding: 0.428571429rem; +} + +button, +input { + line-height: normal; +} + +textarea { + font-size: 100%; + overflow: auto; + vertical-align: top; +} + +/* Reset non-text input types */ +input[type="checkbox"], +input[type="radio"], +input[type="file"], +input[type="hidden"], +input[type="image"], +input[type="color"] { + border: 0; + border-radius: 0; + padding: 0; +} + +/* Buttons */ +.menu-toggle, +input[type="submit"], +input[type="button"], +input[type="reset"], +article.post-password-required input[type=submit], +.bypostauthor cite span { + padding: 6px 10px; + padding: 0.428571429rem 0.714285714rem; + font-size: 11px; + font-size: 0.785714286rem; + line-height: 1.428571429; + font-weight: normal; + color: #7c7c7c; + background-color: #e6e6e6; + background-repeat: repeat-x; + background-image: -moz-linear-gradient(top, #f4f4f4, #e6e6e6); + background-image: -ms-linear-gradient(top, #f4f4f4, #e6e6e6); + background-image: -webkit-linear-gradient(top, #f4f4f4, #e6e6e6); + background-image: -o-linear-gradient(top, #f4f4f4, #e6e6e6); + background-image: linear-gradient(to bottom, #f4f4f4, #e6e6e6); + border: 1px solid #d2d2d2; + border-radius: 3px; + box-shadow: 0 1px 2px rgba(64, 64, 64, 0.1); +} + +.menu-toggle, +button, +input[type="submit"], +input[type="button"], +input[type="reset"] { + cursor: pointer; +} + +button[disabled], +input[disabled] { + cursor: default; +} + +.menu-toggle:hover, +.menu-toggle:focus, +button:hover, +input[type="submit"]:hover, +input[type="button"]:hover, +input[type="reset"]:hover, +article.post-password-required input[type=submit]:hover { + color: #5e5e5e; + background-color: #ebebeb; + background-repeat: repeat-x; + background-image: -moz-linear-gradient(top, #f9f9f9, #ebebeb); + background-image: -ms-linear-gradient(top, #f9f9f9, #ebebeb); + background-image: -webkit-linear-gradient(top, #f9f9f9, #ebebeb); + background-image: -o-linear-gradient(top, #f9f9f9, #ebebeb); + background-image: linear-gradient(to bottom, #f9f9f9, #ebebeb); +} + +.menu-toggle:active, +.menu-toggle.toggled-on, +button:active, +input[type="submit"]:active, +input[type="button"]:active, +input[type="reset"]:active { + color: #757575; + background-color: #e1e1e1; + background-repeat: repeat-x; + background-image: -moz-linear-gradient(top, #ebebeb, #e1e1e1); + background-image: -ms-linear-gradient(top, #ebebeb, #e1e1e1); + background-image: -webkit-linear-gradient(top, #ebebeb, #e1e1e1); + background-image: -o-linear-gradient(top, #ebebeb, #e1e1e1); + background-image: linear-gradient(to bottom, #ebebeb, #e1e1e1); + box-shadow: inset 0 0 8px 2px #c6c6c6, 0 1px 0 0 #f4f4f4; + border-color: transparent; +} + +.bypostauthor cite span { + color: #fff; + background-color: #21759b; + background-image: none; + border: 1px solid #1f6f93; + border-radius: 2px; + box-shadow: none; + padding: 0; +} + +/* Responsive images */ +.entry-content img, +.comment-content img, +.widget img { + max-width: 100%; /* Fluid images for posts, comments, and widgets */ +} + +img[class*="align"], +img[class*="wp-image-"], +img[class*="attachment-"] { + height: auto; /* Make sure images with WordPress-added height and width attributes are scaled correctly */ +} + +img.size-full, +img.size-large, +img.header-image, +img.wp-post-image { + max-width: 100%; + height: auto; /* Make sure images with WordPress-added height and width attributes are scaled correctly */ +} + +/* Make sure videos and embeds fit their containers */ +embed, +iframe, +object, +video { + max-width: 100%; +} + +.entry-content .twitter-tweet-rendered { + max-width: 100% !important; /* Override the Twitter embed fixed width */ +} + +/* Images */ +.alignleft { + float: left; +} + +.alignright { + float: right; +} + +.aligncenter { + display: block; + margin-left: auto; + margin-right: auto; +} + +.entry-content img, +.comment-content img, +.widget img, +img.header-image, +.author-avatar img, +img.wp-post-image { + /* Add fancy borders to all WordPress-added images but not things like badges and icons and the like */ + border-radius: 3px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); +} + +.wp-caption { + max-width: 100%; /* Keep wide captions from overflowing their container. */ + padding: 4px; +} + +.wp-caption .wp-caption-text, +.gallery-caption, +.entry-caption { + font-style: italic; + font-size: 12px; + font-size: 0.857142857rem; + line-height: 2; + color: #757575; +} + +img.wp-smiley, +.rsswidget img { + border: 0; + border-radius: 0; + box-shadow: none; + margin-bottom: 0; + margin-top: 0; + padding: 0; +} + +.entry-content dl.gallery-item { + margin: 0; +} + +.gallery-item a, +.gallery-caption { + width: 90%; +} + +.gallery-item a { + display: block; +} + +.gallery-caption a { + display: inline; +} + +.gallery-columns-1 .gallery-item a { + max-width: 100%; + width: auto; +} + +.gallery .gallery-icon img { + height: auto; + max-width: 90%; + padding: 5%; +} + +.gallery-columns-1 .gallery-icon img { + padding: 3%; +} + +/* Navigation */ +.site-content nav { + clear: both; + line-height: 2; + overflow: hidden; +} + +#nav-above { + padding: 24px 0; + padding: 1.714285714rem 0; +} + +#nav-above { + display: none; +} + +.paged #nav-above { + display: block; +} + +.nav-previous, +.previous-image { + float: left; + width: 50%; +} + +.nav-next, +.next-image { + float: right; + text-align: right; + width: 50%; +} + +.nav-single + .comments-area, +#comment-nav-above { + margin: 48px 0; + margin: 3.428571429rem 0; +} + +/* Author profiles */ +.author .archive-header { + margin-bottom: 24px; + margin-bottom: 1.714285714rem; +} + +.author-info { + border-top: 1px solid #ededed; + margin: 24px 0; + margin: 1.714285714rem 0; + padding-top: 24px; + padding-top: 1.714285714rem; + overflow: hidden; +} + +.author-description p { + color: #757575; + font-size: 13px; + font-size: 0.928571429rem; + line-height: 1.846153846; +} + +.author.archive .author-info { + border-top: 0; + margin: 0 0 48px; + margin: 0 0 3.428571429rem; +} + +.author.archive .author-avatar { + margin-top: 0; +} + + +/* =Basic structure +-------------------------------------------------------------- */ + +/* Body, links, basics */ +html { + font-size: 87.5%; +} + +body { + font-size: 14px; + font-size: 1rem; + font-family: Helvetica, Arial, sans-serif; + text-rendering: optimizeLegibility; + color: #444; +} + +body.custom-font-enabled { + font-family: "Open Sans", Helvetica, Arial, sans-serif; +} + +a { + outline: none; + color: #21759b; +} + +a:hover { + color: #0f3647; +} + +/* Assistive text */ +.assistive-text, +.site .screen-reader-text { + position: absolute !important; + clip: rect(1px, 1px, 1px, 1px); + overflow: hidden; + height: 1px; + width: 1px; +} + +.main-navigation .assistive-text:focus, +.site .screen-reader-text:hover, +.site .screen-reader-text:active, +.site .screen-reader-text:focus { + background: #fff; + border: 2px solid #333; + border-radius: 3px; + clip: auto !important; + color: #000; + display: block; + font-size: 12px; + height: auto; + padding: 12px; + position: absolute; + top: 5px; + left: 5px; + width: auto; + z-index: 100000; /* Above WP toolbar */ +} + +/* Page structure */ +.site { + padding: 0 24px; + padding: 0 1.714285714rem; + background-color: #fff; +} + +.site-content { + margin: 24px 0 0; + margin: 1.714285714rem 0 0; +} + +.widget-area { + margin: 24px 0 0; + margin: 1.714285714rem 0 0; +} + +/* Header */ +.site-header { + padding: 24px 0; + padding: 1.714285714rem 0; +} + +.site-header h1, +.site-header h2 { + text-align: center; +} + +.site-header h1 a, +.site-header h2 a { + color: #515151; + display: inline-block; + text-decoration: none; +} + +.site-header h1 a:hover, +.site-header h2 a:hover { + color: #21759b; +} + +.site-header h1 { + font-size: 24px; + font-size: 1.714285714rem; + line-height: 1.285714286; + margin-bottom: 14px; + margin-bottom: 1rem; +} + +.site-header h2 { + font-weight: normal; + font-size: 13px; + font-size: 0.928571429rem; + line-height: 1.846153846; + color: #757575; +} + +.header-image { + margin-top: 24px; + margin-top: 1.714285714rem; +} + +/* Navigation Menu */ +.main-navigation { + margin-top: 24px; + margin-top: 1.714285714rem; + text-align: center; +} + +.main-navigation li { + margin-top: 24px; + margin-top: 1.714285714rem; + font-size: 12px; + font-size: 0.857142857rem; + line-height: 1.42857143; +} + +.main-navigation a { + color: #5e5e5e; +} + +.main-navigation a:hover, +.main-navigation a:focus { + color: #21759b; +} + +.main-navigation ul.nav-menu, +.main-navigation div.nav-menu > ul { + display: none; +} + +.main-navigation ul.nav-menu.toggled-on, +.menu-toggle { + display: inline-block; +} + +/* Banner */ +section[role="banner"] { + margin-bottom: 48px; + margin-bottom: 3.428571429rem; +} + +/* Sidebar */ +.widget-area .widget { + -webkit-hyphens: auto; + -moz-hyphens: auto; + hyphens: auto; + margin-bottom: 48px; + margin-bottom: 3.428571429rem; + word-wrap: break-word; +} + +.widget-area .widget h3 { + margin-bottom: 24px; + margin-bottom: 1.714285714rem; +} + +.widget-area .widget p, +.widget-area .widget li, +.widget-area .widget .textwidget { + font-size: 13px; + font-size: 0.928571429rem; + line-height: 1.846153846; +} + +.widget-area .widget p { + margin-bottom: 24px; + margin-bottom: 1.714285714rem; +} + +.widget-area .textwidget ul, +.widget-area .textwidget ol { + list-style: disc outside; + margin: 0 0 24px; + margin: 0 0 1.714285714rem; +} + +.widget-area .textwidget li > ul, +.widget-area .textwidget li > ol { + margin-bottom: 0; +} + +.widget-area .textwidget ol { + list-style: decimal; +} + +.widget-area .textwidget li { + margin-left: 36px; + margin-left: 2.571428571rem; +} + +.widget-area .widget a { + color: #757575; +} + +.widget-area .widget a:hover { + color: #21759b; +} + +.widget-area .widget a:visited { + color: #9f9f9f; +} + +.widget-area #s { + width: 53.66666666666%; /* define a width to avoid dropping a wider submit button */ +} + +/* Footer */ +footer[role="contentinfo"] { + border-top: 1px solid #ededed; + clear: both; + font-size: 12px; + font-size: 0.857142857rem; + line-height: 2; + max-width: 960px; + max-width: 68.571428571rem; + margin-top: 24px; + margin-top: 1.714285714rem; + margin-left: auto; + margin-right: auto; + padding: 24px 0; + padding: 1.714285714rem 0; +} + +footer[role="contentinfo"] a { + color: #686868; +} + +footer[role="contentinfo"] a:hover { + color: #21759b; +} + +.site-info span[role=separator] { + padding: 0 0.3em 0 0.6em; +} + +.site-info span[role=separator]::before { + content: '\002f'; +} + + +/* =Main content and comment content +-------------------------------------------------------------- */ + +.entry-meta { + clear: both; +} + +.entry-header { + margin-bottom: 24px; + margin-bottom: 1.714285714rem; +} + +.entry-header img.wp-post-image { + margin-bottom: 24px; + margin-bottom: 1.714285714rem; +} + +.entry-header .entry-title { + font-size: 20px; + font-size: 1.428571429rem; + line-height: 1.2; + font-weight: normal; +} + +.entry-header .entry-title a { + text-decoration: none; +} + +.entry-header .entry-format { + margin-top: 24px; + margin-top: 1.714285714rem; + font-weight: normal; +} + +.entry-header .comments-link { + margin-top: 24px; + margin-top: 1.714285714rem; + font-size: 13px; + font-size: 0.928571429rem; + line-height: 1.846153846; + color: #757575; +} + +.comments-link a, +.entry-meta a { + color: #757575; +} + +.comments-link a:hover, +.entry-meta a:hover { + color: #21759b; +} + +article.sticky .featured-post { + border-top: 4px double #ededed; + border-bottom: 4px double #ededed; + color: #757575; + font-size: 13px; + font-size: 0.928571429rem; + line-height: 3.692307692; + margin-bottom: 24px; + margin-bottom: 1.714285714rem; + text-align: center; +} + +.entry-content, +.entry-summary, +.mu_register { + line-height: 1.714285714; +} + +.entry-content h1, +.comment-content h1, +.entry-content h2, +.comment-content h2, +.entry-content h3, +.comment-content h3, +.entry-content h4, +.comment-content h4, +.entry-content h5, +.comment-content h5, +.entry-content h6, +.comment-content h6 { + margin: 24px 0; + margin: 1.714285714rem 0; + line-height: 1.714285714; +} + +.entry-content h1, +.comment-content h1 { + font-size: 21px; + font-size: 1.5rem; + line-height: 1.5; +} + +.entry-content h2, +.comment-content h2, +.mu_register h2 { + font-size: 18px; + font-size: 1.285714286rem; + line-height: 1.6; +} + +.entry-content h3, +.comment-content h3 { + font-size: 16px; + font-size: 1.142857143rem; + line-height: 1.846153846; +} + +.entry-content h4, +.comment-content h4 { + font-size: 14px; + font-size: 1rem; + line-height: 1.846153846; +} + +.entry-content h5, +.comment-content h5 { + font-size: 13px; + font-size: 0.928571429rem; + line-height: 1.846153846; +} + +.entry-content h6, +.comment-content h6 { + font-size: 12px; + font-size: 0.857142857rem; + line-height: 1.846153846; +} + +.entry-content p, +.entry-summary p, +.comment-content p, +.mu_register p { + margin: 0 0 24px; + margin: 0 0 1.714285714rem; + line-height: 1.714285714; +} + +.entry-content a:visited, +.comment-content a:visited { + color: #9f9f9f; +} + +.entry-content .more-link { + white-space: nowrap; +} + +.entry-content ol, +.comment-content ol, +.entry-content ul, +.comment-content ul, +.mu_register ul { + margin: 0 0 24px; + margin: 0 0 1.714285714rem; + line-height: 1.714285714; +} + +.entry-content ul ul, +.comment-content ul ul, +.entry-content ol ol, +.comment-content ol ol, +.entry-content ul ol, +.comment-content ul ol, +.entry-content ol ul, +.comment-content ol ul { + margin-bottom: 0; +} + +.entry-content ul, +.comment-content ul, +.mu_register ul { + list-style: disc outside; +} + +.entry-content ol, +.comment-content ol { + list-style: decimal outside; +} + +.entry-content li, +.comment-content li, +.mu_register li { + margin: 0 0 0 36px; + margin: 0 0 0 2.571428571rem; +} + +.entry-content blockquote, +.comment-content blockquote { + margin-bottom: 24px; + margin-bottom: 1.714285714rem; + padding: 24px; + padding: 1.714285714rem; + font-style: italic; +} + +.entry-content blockquote p:last-child, +.comment-content blockquote p:last-child { + margin-bottom: 0; +} + +.entry-content code, +.comment-content code { + font-family: Consolas, Monaco, Lucida Console, monospace; + font-size: 12px; + font-size: 0.857142857rem; + line-height: 2; +} + +.entry-content pre, +.comment-content pre { + border: 1px solid #ededed; + color: #666; + font-family: Consolas, Monaco, Lucida Console, monospace; + font-size: 12px; + font-size: 0.857142857rem; + line-height: 1.714285714; + margin: 24px 0; + margin: 1.714285714rem 0; + overflow: auto; + padding: 24px; + padding: 1.714285714rem; +} + +.entry-content pre code, +.comment-content pre code { + display: block; +} + +.entry-content abbr, +.comment-content abbr, +.entry-content dfn, +.comment-content dfn, +.entry-content acronym, +.comment-content acronym { + border-bottom: 1px dotted #666; + cursor: help; +} + +.entry-content address, +.comment-content address { + display: block; + line-height: 1.714285714; + margin: 0 0 24px; + margin: 0 0 1.714285714rem; +} + +img.alignleft, +.wp-caption.alignleft { + margin: 12px 24px 12px 0; + margin: 0.857142857rem 1.714285714rem 0.857142857rem 0; +} + +img.alignright, +.wp-caption.alignright { + margin: 12px 0 12px 24px; + margin: 0.857142857rem 0 0.857142857rem 1.714285714rem; +} + +img.aligncenter, +.wp-caption.aligncenter { + clear: both; + margin-top: 12px; + margin-top: 0.857142857rem; + margin-bottom: 12px; + margin-bottom: 0.857142857rem; +} + +.entry-content embed, +.entry-content iframe, +.entry-content object, +.entry-content video { + margin-bottom: 24px; + margin-bottom: 1.714285714rem; +} + +.entry-content dl, +.comment-content dl { + margin: 0 24px; + margin: 0 1.714285714rem; +} + +.entry-content dt, +.comment-content dt { + font-weight: bold; + line-height: 1.714285714; +} + +.entry-content dd, +.comment-content dd { + line-height: 1.714285714; + margin-bottom: 24px; + margin-bottom: 1.714285714rem; +} + +.entry-content table, +.comment-content table { + border-bottom: 1px solid #ededed; + color: #757575; + font-size: 12px; + font-size: 0.857142857rem; + line-height: 2; + margin: 0 0 24px; + margin: 0 0 1.714285714rem; + width: 100%; +} + +.entry-content table caption, +.comment-content table caption { + font-size: 16px; + font-size: 1.142857143rem; + margin: 24px 0; + margin: 1.714285714rem 0; +} + +.entry-content td, +.comment-content td { + border-top: 1px solid #ededed; + padding: 6px 10px 6px 0; +} + +.site-content article { + border-bottom: 4px double #ededed; + margin-bottom: 72px; + margin-bottom: 5.142857143rem; + padding-bottom: 24px; + padding-bottom: 1.714285714rem; + word-wrap: break-word; + -webkit-hyphens: auto; + -moz-hyphens: auto; + hyphens: auto; +} + +.page-links { + clear: both; + line-height: 1.714285714; +} + +footer.entry-meta { + margin-top: 24px; + margin-top: 1.714285714rem; + font-size: 13px; + font-size: 0.928571429rem; + line-height: 1.846153846; + color: #757575; +} + +.single-author .entry-meta .by-author { + display: none; +} + +.mu_register h2 { + color: #757575; + font-weight: normal; +} + + +/* =Archives +-------------------------------------------------------------- */ + +.archive-header, +.page-header { + margin-bottom: 48px; + margin-bottom: 3.428571429rem; + padding-bottom: 22px; + padding-bottom: 1.571428571rem; + border-bottom: 1px solid #ededed; +} + +.archive-meta { + color: #757575; + font-size: 12px; + font-size: 0.857142857rem; + line-height: 2; + margin-top: 22px; + margin-top: 1.571428571rem; +} + +/* =Single audio/video attachment view +-------------------------------------------------------------- */ + +.attachment .entry-content .mejs-audio { + max-width: 400px; +} + +.attachment .entry-content .mejs-container { + margin-bottom: 24px; +} + + +/* =Single image attachment view +-------------------------------------------------------------- */ + +.article.attachment { + overflow: hidden; +} + +.image-attachment div.attachment { + text-align: center; +} + +.image-attachment div.attachment p { + text-align: center; +} + +.image-attachment div.attachment img { + display: block; + height: auto; + margin: 0 auto; + max-width: 100%; +} + +.image-attachment .entry-caption { + margin-top: 8px; + margin-top: 0.571428571rem; +} + + +/* =Aside post format +-------------------------------------------------------------- */ + +article.format-aside h1 { + margin-bottom: 24px; + margin-bottom: 1.714285714rem; +} + +article.format-aside h1 a { + text-decoration: none; + color: #4d525a; +} + +article.format-aside h1 a:hover { + color: #2e3542; +} + +article.format-aside .aside { + padding: 24px 24px 0; + padding: 1.714285714rem; + background: #d2e0f9; + border-left: 22px solid #a8bfe8; +} + +article.format-aside p { + font-size: 13px; + font-size: 0.928571429rem; + line-height: 1.846153846; + color: #4a5466; +} + +article.format-aside blockquote:last-child, +article.format-aside p:last-child { + margin-bottom: 0; +} + + +/* =Post formats +-------------------------------------------------------------- */ + +/* Image posts */ +article.format-image footer h1 { + font-size: 13px; + font-size: 0.928571429rem; + line-height: 1.846153846; + font-weight: normal; +} + +article.format-image footer h2 { + font-size: 11px; + font-size: 0.785714286rem; + line-height: 2.181818182; +} + +article.format-image footer a h2 { + font-weight: normal; +} + +/* Link posts */ +article.format-link header { + padding: 0 10px; + padding: 0 0.714285714rem; + float: right; + font-size: 11px; + font-size: 0.785714286rem; + line-height: 2.181818182; + font-weight: bold; + font-style: italic; + text-transform: uppercase; + color: #848484; + background-color: #ebebeb; + border-radius: 3px; +} + +article.format-link .entry-content { + max-width: 80%; + float: left; +} + +article.format-link .entry-content a { + font-size: 22px; + font-size: 1.571428571rem; + line-height: 1.090909091; + text-decoration: none; +} + +/* Quote posts */ +article.format-quote .entry-content p { + margin: 0; + padding-bottom: 24px; + padding-bottom: 1.714285714rem; +} + +article.format-quote .entry-content blockquote { + display: block; + padding: 24px 24px 0; + padding: 1.714285714rem 1.714285714rem 0; + font-size: 15px; + font-size: 1.071428571rem; + line-height: 1.6; + font-style: normal; + color: #6a6a6a; + background: #efefef; +} + +/* Status posts */ +.format-status .entry-header { + margin-bottom: 24px; + margin-bottom: 1.714285714rem; +} + +.format-status .entry-header header { + display: inline-block; +} + +.format-status .entry-header h1 { + font-size: 15px; + font-size: 1.071428571rem; + font-weight: normal; + line-height: 1.6; + margin: 0; +} + +.format-status .entry-header h2 { + font-size: 12px; + font-size: 0.857142857rem; + font-weight: normal; + line-height: 2; + margin: 0; +} + +.format-status .entry-header header a { + color: #757575; +} + +.format-status .entry-header header a:hover { + color: #21759b; +} + +.format-status .entry-header img { + float: left; + margin-right: 21px; + margin-right: 1.5rem; +} + + +/* =Comments +-------------------------------------------------------------- */ + +.comments-title { + margin-bottom: 48px; + margin-bottom: 3.428571429rem; + font-size: 16px; + font-size: 1.142857143rem; + line-height: 1.5; + font-weight: normal; +} + +.comments-area article { + margin: 24px 0; + margin: 1.714285714rem 0; +} + +.comments-area article header { + margin: 0 0 48px; + margin: 0 0 3.428571429rem; + overflow: hidden; + position: relative; +} + +.comments-area article header img { + float: left; + padding: 0; + line-height: 0; +} + +.comments-area article header cite, +.comments-area article header time { + display: block; + margin-left: 85px; + margin-left: 6.071428571rem; +} + +.comments-area article header cite { + font-style: normal; + font-size: 15px; + font-size: 1.071428571rem; + line-height: 1.42857143; +} + +.comments-area cite b { + font-weight: normal; +} + +.comments-area article header time { + line-height: 1.714285714; + text-decoration: none; + font-size: 12px; + font-size: 0.857142857rem; + color: #5e5e5e; +} + +.comments-area article header a { + text-decoration: none; + color: #5e5e5e; +} + +.comments-area article header a:hover { + color: #21759b; +} + +.comments-area article header cite a { + color: #444; +} + +.comments-area article header cite a:hover { + text-decoration: underline; +} + +.comments-area article header h4 { + position: absolute; + top: 0; + right: 0; + padding: 6px 12px; + padding: 0.428571429rem 0.857142857rem; + font-size: 12px; + font-size: 0.857142857rem; + font-weight: normal; + color: #fff; + background-color: #0088d0; + background-repeat: repeat-x; + background-image: -moz-linear-gradient(top, #009cee, #0088d0); + background-image: -ms-linear-gradient(top, #009cee, #0088d0); + background-image: -webkit-linear-gradient(top, #009cee, #0088d0); + background-image: -o-linear-gradient(top, #009cee, #0088d0); + background-image: linear-gradient(to bottom, #009cee, #0088d0); + border-radius: 3px; + border: 1px solid #007cbd; +} + +.comments-area .bypostauthor cite span { + position: absolute; + margin-left: 5px; + margin-left: 0.357142857rem; + padding: 2px 5px; + padding: 0.142857143rem 0.357142857rem; + font-size: 10px; + font-size: 0.714285714rem; +} + +.comments-area .bypostauthor cite b { + font-weight: bold; +} + +a.comment-reply-link, +a.comment-edit-link { + color: #686868; + font-size: 13px; + font-size: 0.928571429rem; + line-height: 1.846153846; +} + +a.comment-reply-link:hover, +a.comment-edit-link:hover { + color: #21759b; +} + +.commentlist .pingback { + line-height: 1.714285714; + margin-bottom: 24px; + margin-bottom: 1.714285714rem; +} + +/* Comment form */ +#respond { + margin-top: 48px; + margin-top: 3.428571429rem; +} + +#respond h3#reply-title { + font-size: 16px; + font-size: 1.142857143rem; + line-height: 1.5; +} + +#respond h3#reply-title #cancel-comment-reply-link { + margin-left: 10px; + margin-left: 0.714285714rem; + font-weight: normal; + font-size: 12px; + font-size: 0.857142857rem; +} + +#respond form { + margin: 24px 0; + margin: 1.714285714rem 0; +} + +#respond form p { + margin: 11px 0; + margin: 0.785714286rem 0; +} + +#respond form p.logged-in-as { + margin-bottom: 24px; + margin-bottom: 1.714285714rem; +} + +#respond form label { + display: block; + line-height: 1.714285714; +} + +#respond form input[type="text"], +#respond form textarea { + -moz-box-sizing: border-box; + box-sizing: border-box; + font-size: 12px; + font-size: 0.857142857rem; + line-height: 1.714285714; + padding: 10px; + padding: 0.714285714rem; + width: 100%; +} + +#respond form p.form-allowed-tags { + margin: 0; + font-size: 12px; + font-size: 0.857142857rem; + line-height: 2; + color: #5e5e5e; +} + +#respond #wp-comment-cookies-consent { + margin: 0 10px 0 0; +} + +#respond .comment-form-cookies-consent label { + display: inline; +} + +.required { + color: red; +} + + +/* =Front page template +-------------------------------------------------------------- */ + +.entry-page-image { + margin-bottom: 14px; + margin-bottom: 1rem; +} + +.template-front-page .site-content article { + border: 0; + margin-bottom: 0; +} + +.template-front-page .widget-area { + clear: both; + float: none; + width: auto; + padding-top: 24px; + padding-top: 1.714285714rem; + border-top: 1px solid #ededed; +} + +.template-front-page .widget-area .widget li { + margin: 8px 0 0; + margin: 0.571428571rem 0 0; + font-size: 13px; + font-size: 0.928571429rem; + line-height: 1.714285714; + list-style-type: square; + list-style-position: inside; +} + +.template-front-page .widget-area .widget li a { + color: #757575; +} + +.template-front-page .widget-area .widget li a:hover { + color: #21759b; +} + +.template-front-page .widget-area .widget_text img { + float: left; + margin: 8px 24px 8px 0; + margin: 0.571428571rem 1.714285714rem 0.571428571rem 0; +} + + +/* =Widgets +-------------------------------------------------------------- */ + +.widget select { + max-width: 100%; +} + +.widget-area .widget ul ul { + margin-left: 12px; + margin-left: 0.857142857rem; +} + +.widget_rss li { + margin: 12px 0; + margin: 0.857142857rem 0; +} + +.widget_recent_entries .post-date, +.widget_rss .rss-date { + color: #aaa; + font-size: 11px; + font-size: 0.785714286rem; + margin-left: 12px; + margin-left: 0.857142857rem; +} + +.wp-calendar-nav, +#wp-calendar { + margin: 0; + width: 100%; + font-size: 13px; + font-size: 0.928571429rem; + line-height: 1.846153846; + color: #686868; +} + +#wp-calendar th, +#wp-calendar td, +#wp-calendar caption { + text-align: left; +} + +.wp-calendar-nav { + display: table; +} + +.wp-calendar-nav span { + display: table-cell; +} + +.wp-calendar-nav-next, +#wp-calendar #next { + padding-right: 24px; + padding-right: 1.714285714rem; + text-align: right; +} + +.widget_search label { + display: block; + font-size: 13px; + font-size: 0.928571429rem; + line-height: 1.846153846; +} + +.widget_twitter li { + list-style-type: none; +} + +.widget_twitter .timesince { + display: block; + text-align: right; +} + +.tagcloud ul { + list-style-type: none; +} + +.tagcloud ul li { + display: inline-block; +} + +.widget-area .widget.widget_tag_cloud li { + line-height: 1; +} + +.template-front-page .widget-area .widget.widget_tag_cloud li { + margin: 0; +} + +.widget-area .gallery-columns-2.gallery-size-full .gallery-icon img, +.widget-area .gallery-columns-3.gallery-size-full .gallery-icon img, +.widget-area .gallery-columns-4.gallery-size-full .gallery-icon img, +.widget-area .gallery-columns-5.gallery-size-full .gallery-icon img, +.widget-area .gallery-columns-6 .gallery-icon img, +.widget-area .gallery-columns-7 .gallery-icon img, +.widget-area .gallery-columns-8 .gallery-icon img, +.widget-area .gallery-columns-9 .gallery-icon img { + height: auto; + max-width: 80%; +} + +/* =Plugins +----------------------------------------------- */ + +img#wpstats { + display: block; + margin: 0 auto 24px; + margin: 0 auto 1.714285714rem; +} + + +/* =Media queries +-------------------------------------------------------------- */ + +/* Does the same thing as , + * but in the future W3C standard way. -ms- prefix is required for IE10+ to + * render responsive styling in Windows 8 "snapped" views; IE10+ does not honor + * the meta tag. See https://core.trac.wordpress.org/ticket/25888. + */ +@-ms-viewport { + width: device-width; +} + +@viewport { + width: device-width; +} + +/* Minimum width of 600 pixels. */ +@media screen and (min-width: 600px) { + .author-avatar { + float: left; + margin-top: 8px; + margin-top: 0.571428571rem; + } + + .author-description { + float: right; + width: 80%; + } + + .site { + margin: 0 auto; + max-width: 960px; + max-width: 68.571428571rem; + overflow: hidden; + } + + .site-content { + float: left; + width: 65.104166667%; + } + + body.template-front-page .site-content, + body.attachment .site-content, + body.full-width .site-content { + width: 100%; + } + + .widget-area { + float: right; + width: 26.041666667%; + } + + .site-header h1, + .site-header h2 { + text-align: left; + } + + .site-header h1 { + font-size: 26px; + font-size: 1.857142857rem; + line-height: 1.846153846; + margin-bottom: 0; + } + + .main-navigation ul.nav-menu, + .main-navigation div.nav-menu > ul { + border-bottom: 1px solid #ededed; + border-top: 1px solid #ededed; + display: inline-block !important; + text-align: left; + width: 100%; + } + + .main-navigation ul { + margin: 0; + text-indent: 0; + } + + .main-navigation li a, + .main-navigation li { + display: inline-block; + text-decoration: none; + } + + .main-navigation li a { + border-bottom: 0; + color: #6a6a6a; + line-height: 3.692307692; + text-transform: uppercase; + white-space: nowrap; + } + + .main-navigation li a:hover, + .main-navigation li a:focus { + color: #000; + } + + .main-navigation li { + margin: 0 40px 0 0; + margin: 0 2.857142857rem 0 0; + position: relative; + } + + .main-navigation li ul { + margin: 0; + padding: 0; + position: absolute; + top: 100%; + z-index: 1; + height: 1px; + width: 1px; + overflow: hidden; + clip: rect(1px, 1px, 1px, 1px); + } + + .main-navigation li ul ul { + top: 0; + left: 100%; + } + + .main-navigation ul li:hover > ul, + .main-navigation ul li:focus > ul, + .main-navigation .focus > ul { + border-left: 0; + clip: inherit; + overflow: inherit; + height: inherit; + width: inherit; + } + + .main-navigation li ul li a { + background: #efefef; + border-bottom: 1px solid #ededed; + display: block; + font-size: 11px; + font-size: 0.785714286rem; + line-height: 2.181818182; + padding: 8px 10px; + padding: 0.571428571rem 0.714285714rem; + width: 180px; + width: 12.85714286rem; + white-space: normal; + } + + .main-navigation li ul li a:hover, + .main-navigation li ul li a:focus { + background: #e3e3e3; + color: #444; + } + + .main-navigation .current-menu-item > a, + .main-navigation .current-menu-ancestor > a, + .main-navigation .current_page_item > a, + .main-navigation .current_page_ancestor > a { + color: #636363; + font-weight: bold; + } + + .menu-toggle { + display: none; + } + + .entry-header .entry-title { + font-size: 22px; + font-size: 1.571428571rem; + } + + #respond form input[type="text"] { + width: 46.333333333%; + } + + #respond form textarea.blog-textarea { + width: 79.666666667%; + } + + .template-front-page .site-content, + .template-front-page article { + overflow: hidden; + } + + .template-front-page.has-post-thumbnail article { + float: left; + width: 47.916666667%; + } + + .entry-page-image { + float: right; + margin-bottom: 0; + width: 47.916666667%; + } + + .template-front-page .widget-area .widget, + .template-front-page.two-sidebars .widget-area .front-widgets { + float: left; + width: 51.875%; + margin-bottom: 24px; + margin-bottom: 1.714285714rem; + } + + .template-front-page .widget-area .widget:nth-child(odd) { + clear: right; + } + + .template-front-page .widget-area .widget:nth-child(even), + .template-front-page.two-sidebars .widget-area .front-widgets + .front-widgets { + float: right; + width: 39.0625%; + margin: 0 0 24px; + margin: 0 0 1.714285714rem; + } + + .template-front-page.two-sidebars .widget, + .template-front-page.two-sidebars .widget:nth-child(even) { + float: none; + width: auto; + } + + .commentlist .children { + margin-left: 48px; + margin-left: 3.428571429rem; + } +} + +/* Minimum width of 960 pixels. */ +@media screen and (min-width: 960px) { + body { + background-color: #e6e6e6; + } + + body .site { + padding: 0 40px; + padding: 0 2.857142857rem; + margin-top: 48px; + margin-top: 3.428571429rem; + margin-bottom: 48px; + margin-bottom: 3.428571429rem; + box-shadow: 0 2px 6px rgba(100, 100, 100, 0.3); + } + + body.custom-background-empty { + background-color: #fff; + } + + body.custom-background-empty .site, + body.custom-background-white .site { + padding: 0; + margin-top: 0; + margin-bottom: 0; + box-shadow: none; + } +} + + +/* =Print +----------------------------------------------- */ + +@media print { + body { + background: none !important; + color: #000; + font-size: 10pt; + } + + footer a[rel=bookmark]:link:after, + footer a[rel=bookmark]:visited:after { + content: " [" attr(href) "] "; /* Show URLs */ + } + + a { + text-decoration: none; + } + + .entry-content img, + .comment-content img, + .author-avatar img, + img.wp-post-image { + border-radius: 0; + box-shadow: none; + } + + .site { + clear: both !important; + display: block !important; + float: none !important; + max-width: 100%; + position: relative !important; + } + + .site-header { + margin-bottom: 72px; + margin-bottom: 5.142857143rem; + text-align: left; + } + + .site-header h1 { + font-size: 21pt; + line-height: 1; + text-align: left; + } + + .site-header h2 { + color: #000; + font-size: 10pt; + text-align: left; + } + + .site-header h1 a, + .site-header h2 a { + color: #000; + } + + .author-avatar, + #colophon, + #respond, + .commentlist .comment-edit-link, + .commentlist .reply, + .entry-header .comments-link, + .entry-meta .edit-link a, + .page-link, + .site-content nav, + .widget-area, + img.header-image, + .main-navigation { + display: none; + } + + .wrapper { + border-top: none; + box-shadow: none; + } + + .site-content { + margin: 0; + width: auto; + } + + .entry-header .entry-title, + .entry-title { + font-size: 21pt; + } + + footer.entry-meta, + footer.entry-meta a { + color: #444; + font-size: 10pt; + } + + .author-description { + float: none; + width: auto; + } + + /* Comments */ + .commentlist > li.comment { + background: none; + position: relative; + width: auto; + } + + + + .comments-area article header cite, + .comments-area article header time { + margin-left: 50px; + margin-left: 3.57142857rem; + } +} + +.breadcrumb +div { + display: inline; + font-size: 13px; + margin-left: -3px; +} + +#wp-auto-top { + position: fixed; + top: 45%; + right: 50%; + display: block; + margin-right: -540px; + z-index: 9999; +} + +#wp-auto-top-top, #wp-auto-top-comment, #wp-auto-top-bottom { + background: url(https://www.lylinux.org/wp-content/plugins/wp-auto-top/img/1.png) no-repeat; + position: relative; + cursor: pointer; + height: 25px; + width: 29px; + margin: 10px 0 0; +} + +#wp-auto-top-comment { + background-position: left -30px; + height: 32px; +} + +#wp-auto-top-bottom { + background-position: left -68px; +} + +#wp-auto-top-comment:hover { + background-position: right -30px; +} + +#wp-auto-top-top:hover { + background-position: right 0; +} + +#wp-auto-top-bottom:hover { + background-position: right -68px; +} + +.widget-login { + margin-top: 15px !important; +} + +/* ------------------------------------------------------------------------- * + * Comments +/* ------------------------------------------------------------------------- */ +#comments { + margin-top: 20px; +} + +#pinglist-container { + display: none; +} + +.comment-tabs { + margin-bottom: 20px; + font-size: 15px; + border-bottom: 2px solid #e5e5e5; +} + +.comment-tabs li { + float: left; + margin-bottom: -2px; +} + +.comment-tabs li a { + display: block; + padding: 0 10px 10px; + font-weight: 600; + color: #aaa; + border-bottom: 2px solid #e5e5e5; +} + +.comment-tabs li a:hover { + color: #444; + border-color: #ccc; +} + +.comment-tabs li span { + margin-left: 8px; + padding: 0 6px; + border-radius: 4px; + background-color: #e5e5e5; +} + +.comment-tabs li i { + margin-right: 6px; +} + +.comment-tabs li.active a { + color: #e8554e; + border-bottom-color: #e8554e; +} + +.commentlist, .pinglist { + margin-bottom: 20px; +} + +.commentlist li, .pinglist li { + padding-left: 60px; + font-size: 14px; + line-height: 22px; + font-weight: 400; +} + +.commentlist .comment-body, .pinglist li { + position: relative; + padding-bottom: 20px; + clear: both; + word-break: break-all; +} + +/* 评论整体布局 - 使用相对定位实现头像左侧布局 */ +.commentlist .comment-body { + position: relative; + padding-left: 60px; /* 为48px头像 + 12px间距留出空间 */ + min-height: 48px; /* 确保有足够高度容纳头像 */ +} + +/* 评论作者信息 - 用户名和时间在同一行 */ +.commentlist .comment-author { + display: inline-block; + margin: 0 10px 5px 0; + font-size: 13px; + position: relative; +} + +.commentlist .comment-meta { + display: inline-block; + margin: 0 0 8px 0; + font-size: 12px; + color: #666; +} + +.commentlist .comment-awaiting-moderation { + display: block; + font-size: 13px; + line-height: 22px; +} + +/* 头像样式 - 绝对定位到左侧 */ +.commentlist .comment-author .avatar { + position: absolute !important; + left: -60px; /* 定位到容器左侧 */ + top: 0; + width: 48px !important; + height: 48px !important; + border-radius: 50%; + display: block; + object-fit: cover; + background-color: #f5f5f5; + border: 1px solid #ddd; +} + +/* 评论作者名称样式 */ +.commentlist .comment-author .fn { + display: inline; + margin: 0; + font-weight: 600; + color: #2e7bb8; + font-size: 13px; +} + +.commentlist .comment-author .fn a { + color: #2e7bb8; + text-decoration: none; +} + +.commentlist .comment-author .fn a:hover { + text-decoration: underline; +} + +/* 评论内容样式 */ +.commentlist .comment-body p { + margin: 5px 0 10px 0; + line-height: 1.5; +} + +.commentlist .fn, .pinglist .ping-link { + color: #444; + font-size: 13px; + font-style: normal; + font-weight: 600; +} + +.commentlist .says { + display: none; +} + +/* 通用头像样式 */ +.commentlist .avatar { + width: 48px !important; + height: 48px !important; + border-radius: 50%; + display: block; + object-fit: cover; + background-color: #f5f5f5; + border: 1px solid #ddd; +} + +.commentlist .comment-meta:before, .pinglist .ping-meta:before { + + vertical-align: 4%; + margin-right: 3px; + font-size: 10px; + font-family: FontAwesome; + color: #ccc; +} + +.commentlist .comment-meta a, .pinglist .ping-meta { + color: #aaa; +} + +.commentlist .reply { + font-size: 13px; + line-height: 16px; +} + +.commentlist .reply a, +.commentlist .comment-reply-chain { + color: #aaa; +} + +.commentlist .reply a:hover, +.commentlist .comment-reply-chain:hover { + color: #444; +} + +.comment-awaiting-moderation { + color: #e8554e; + font-style: normal; +} + +/* pings */ +.pinglist li { + padding-left: 0; +} + +/* comment text */ +.commentlist .comment-body p { + margin-bottom: 8px; + color: #777; + clear: both; +} + +.commentlist .comment-body strong { + font-weight: 600; +} + +.commentlist .comment-body ol li { + margin-left: 2em; + padding: 0; + list-style: decimal; +} + +.commentlist .comment-body ul li { + margin-left: 2em; + padding: 0; + list-style: square; +} + +/* post author & admin comment */ +.commentlist li.bypostauthor > .comment-body:after, +.commentlist li.comment-author-admin > .comment-body:after { + display: block; + position: absolute; + content: "\f040"; + width: 12px; + line-height: 12px; + font-style: normal; + font-family: FontAwesome; + text-align: center; + color: #fff; + background-color: #e8554e; +} + +.commentlist li.comment-author-admin > .comment-body:after { + content: "\f005"; /* star for admin */ +} + +.commentlist li.bypostauthor > .comment-body:after, +.commentlist li.comment-author-admin > .comment-body:after { + padding: 3px; + top: 32px; + left: -28px; + font-size: 12px; + border-radius: 100%; +} + +.commentlist li li.bypostauthor > .comment-body:after, +.commentlist li li.comment-author-admin > .comment-body:after { + padding: 2px; + top: 22px; + left: -26px; + font-size: 10px; + border-radius: 100%; +} + +/* child comment */ +.commentlist li ul { +} + +.commentlist li li { + margin: 0; + padding-left: 48px; +} + +/* 嵌套评论整体布局 */ +.commentlist li li .comment-body { + padding-left: 60px; /* 为48px头像 + 12px间距留出空间 */ + min-height: 48px; /* 确保有足够高度容纳头像 */ +} + +/* 嵌套评论作者信息 */ +.commentlist li li .comment-author { + display: inline-block; + margin: 0 8px 5px 0; + font-size: 12px; /* 稍小一点 */ +} + +.commentlist li li .comment-meta { + display: inline-block; + margin: 0 0 8px 0; + font-size: 11px; /* 稍小一点 */ + color: #666; +} + +/* 评论容器整体左移 - 使用更高优先级 */ +#comments #commentlist-container.comment-tab { + margin-left: -15px !important; /* 在小屏幕上向左移动15px */ + padding-left: 0 !important; /* 移除左内边距 */ + position: relative !important; /* 确保定位正确 */ +} + +/* 在较大屏幕上进一步左移 */ +@media screen and (min-width: 600px) { + #comments #commentlist-container.comment-tab { + margin-left: -30px !important; /* 在大屏幕上向左移动30px */ + } + + /* 响应式设计下的评论布局 - 保持48px头像 */ + .commentlist .comment-body { + padding-left: 60px !important; /* 为48px头像 + 12px间距留出空间 */ + min-height: 48px !important; + } + + .commentlist .comment-author { + display: inline-block !important; + margin: 0 8px 5px 0 !important; + } + + .commentlist .comment-meta { + display: inline-block !important; + margin: 0 0 8px 0 !important; + } + + /* 响应式设计下头像保持48px */ + .commentlist .comment-author .avatar { + left: -60px !important; + width: 48px !important; + height: 48px !important; + } + + /* 嵌套评论在响应式设计下也保持48px头像 */ + .commentlist li li .comment-body { + padding-left: 60px !important; + min-height: 48px !important; + } + + .commentlist li li .comment-author .avatar { + left: -60px !important; + width: 48px !important; + height: 48px !important; + } +} + +/* 嵌套评论头像 */ +.commentlist li li .comment-author .avatar { + position: absolute !important; + left: -60px; /* 定位到容器左侧 */ + top: 0; + width: 48px !important; + height: 48px !important; + border-radius: 50%; + display: block; + object-fit: cover; + background-color: #f5f5f5; + border: 1px solid #ddd; +} + +/* comments : nav +/* ------------------------------------ */ +.comments-nav { + margin-bottom: 20px; +} + +.comments-nav a { + font-weight: 600; +} + +.comments-nav .nav-previous { + float: left; +} + +.comments-nav .nav-next { + float: right; +} + +/* comments : form +/* ------------------------------------ */ +.logged-in-as, +.comment-notes, +.form-allowed-tags { + display: none; +} + +#respond { + position: relative; +} + +#reply-title { + margin-bottom: 20px; +} + +li #reply-title { + margin: 0 !important; + padding: 0; + height: 0; + font-size: 0; + border-top: 0; +} + +#cancel-comment-reply-link { + float: right; + bottom: 26px; + right: 20px; + font-size: 12px; + color: #999; +} + +#cancel-comment-reply-link:hover { + color: #777; +} + +#commentform { + margin-bottom: 20px; + padding: 10px 20px 20px; + border-radius: 4px; + background-color: #e5e5e5; +} + +#commentform p.comment-form-author { + float: left; + width: 48%; +} + +#commentform p.comment-form-email { + float: right; + width: 48%; +} + +#commentform p.comment-form-url, +#commentform p.comment-form-comment { + clear: both; +} + +#commentform label { + display: block; + padding: 6px 0; + font-weight: 600; +} + +#commentform input[type="text"], +#commentform textarea { + max-width: 100%; + width: 100%; +} + +#commentform textarea { + height: 100px; +} + +#commentform p.form-submit { + margin-top: 10px; +} + +.logged-in #reply-title { + margin-bottom: 20px; +} + +.logged-in #commentform p.comment-form-comment { + margin-top: 10px; +} + +.logged-in #commentform p.comment-form-comment label { + display: none; +} + +.heading, +#reply-title { + margin-bottom: 1em; + font-size: 18px; + font-weight: 600; + text-transform: uppercase; + color: #222; +} + +.heading i { + margin-right: 6px; + font-size: 22px; +} + +.group:before { + content: ""; + display: table; +} + +.group:after { + content: ""; + display: table; + clear: both; +} + +.cancel-comment { + margin: 0; + padding: 0; + border: 0; + font: inherit; + vertical-align: baseline; +} + +#rocket { + position: fixed; + right: 50px; + bottom: 50px; + display: block; + visibility: hidden; + width: 26px; + height: 48px; + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAB8CAYAAAB356CJAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKTWlDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVN3WJP3Fj7f92UPVkLY8LGXbIEAIiOsCMgQWaIQkgBhhBASQMWFiApWFBURnEhVxILVCkidiOKgKLhnQYqIWotVXDjuH9yntX167+3t+9f7vOec5/zOec8PgBESJpHmomoAOVKFPDrYH49PSMTJvYACFUjgBCAQ5svCZwXFAADwA3l4fnSwP/wBr28AAgBw1S4kEsfh/4O6UCZXACCRAOAiEucLAZBSAMguVMgUAMgYALBTs2QKAJQAAGx5fEIiAKoNAOz0ST4FANipk9wXANiiHKkIAI0BAJkoRyQCQLsAYFWBUiwCwMIAoKxAIi4EwK4BgFm2MkcCgL0FAHaOWJAPQGAAgJlCLMwAIDgCAEMeE80DIEwDoDDSv+CpX3CFuEgBAMDLlc2XS9IzFLiV0Bp38vDg4iHiwmyxQmEXKRBmCeQinJebIxNI5wNMzgwAABr50cH+OD+Q5+bk4eZm52zv9MWi/mvwbyI+IfHf/ryMAgQAEE7P79pf5eXWA3DHAbB1v2upWwDaVgBo3/ldM9sJoFoK0Hr5i3k4/EAenqFQyDwdHAoLC+0lYqG9MOOLPv8z4W/gi372/EAe/tt68ABxmkCZrcCjg/1xYW52rlKO58sEQjFu9+cj/seFf/2OKdHiNLFcLBWK8ViJuFAiTcd5uVKRRCHJleIS6X8y8R+W/QmTdw0ArIZPwE62B7XLbMB+7gECiw5Y0nYAQH7zLYwaC5EAEGc0Mnn3AACTv/mPQCsBAM2XpOMAALzoGFyolBdMxggAAESggSqwQQcMwRSswA6cwR28wBcCYQZEQAwkwDwQQgbkgBwKoRiWQRlUwDrYBLWwAxqgEZrhELTBMTgN5+ASXIHrcBcGYBiewhi8hgkEQcgIE2EhOogRYo7YIs4IF5mOBCJhSDSSgKQg6YgUUSLFyHKkAqlCapFdSCPyLXIUOY1cQPqQ28ggMor8irxHMZSBslED1AJ1QLmoHxqKxqBz0XQ0D12AlqJr0Rq0Hj2AtqKn0UvodXQAfYqOY4DRMQ5mjNlhXIyHRWCJWBomxxZj5Vg1Vo81Yx1YN3YVG8CeYe8IJAKLgBPsCF6EEMJsgpCQR1hMWEOoJewjtBK6CFcJg4Qxwicik6hPtCV6EvnEeGI6sZBYRqwm7iEeIZ4lXicOE1+TSCQOyZLkTgohJZAySQtJa0jbSC2kU6Q+0hBpnEwm65Btyd7kCLKArCCXkbeQD5BPkvvJw+S3FDrFiOJMCaIkUqSUEko1ZT/lBKWfMkKZoKpRzame1AiqiDqfWkltoHZQL1OHqRM0dZolzZsWQ8ukLaPV0JppZ2n3aC/pdLoJ3YMeRZfQl9Jr6Afp5+mD9HcMDYYNg8dIYigZaxl7GacYtxkvmUymBdOXmchUMNcyG5lnmA+Yb1VYKvYqfBWRyhKVOpVWlX6V56pUVXNVP9V5qgtUq1UPq15WfaZGVbNQ46kJ1Bar1akdVbupNq7OUndSj1DPUV+jvl/9gvpjDbKGhUaghkijVGO3xhmNIRbGMmXxWELWclYD6yxrmE1iW7L57Ex2Bfsbdi97TFNDc6pmrGaRZp3mcc0BDsax4PA52ZxKziHODc57LQMtPy2x1mqtZq1+rTfaetq+2mLtcu0W7eva73VwnUCdLJ31Om0693UJuja6UbqFutt1z+o+02PreekJ9cr1Dund0Uf1bfSj9Rfq79bv0R83MDQINpAZbDE4Y/DMkGPoa5hpuNHwhOGoEctoupHEaKPRSaMnuCbuh2fjNXgXPmasbxxirDTeZdxrPGFiaTLbpMSkxeS+Kc2Ua5pmutG003TMzMgs3KzYrMnsjjnVnGueYb7ZvNv8jYWlRZzFSos2i8eW2pZ8ywWWTZb3rJhWPlZ5VvVW16xJ1lzrLOtt1ldsUBtXmwybOpvLtqitm63Edptt3xTiFI8p0in1U27aMez87ArsmuwG7Tn2YfYl9m32zx3MHBId1jt0O3xydHXMdmxwvOuk4TTDqcSpw+lXZxtnoXOd8zUXpkuQyxKXdpcXU22niqdun3rLleUa7rrStdP1o5u7m9yt2W3U3cw9xX2r+00umxvJXcM970H08PdY4nHM452nm6fC85DnL152Xlle+70eT7OcJp7WMG3I28Rb4L3Le2A6Pj1l+s7pAz7GPgKfep+Hvqa+It89viN+1n6Zfgf8nvs7+sv9j/i/4XnyFvFOBWABwQHlAb2BGoGzA2sDHwSZBKUHNQWNBbsGLww+FUIMCQ1ZH3KTb8AX8hv5YzPcZyya0RXKCJ0VWhv6MMwmTB7WEY6GzwjfEH5vpvlM6cy2CIjgR2yIuB9pGZkX+X0UKSoyqi7qUbRTdHF09yzWrORZ+2e9jvGPqYy5O9tqtnJ2Z6xqbFJsY+ybuIC4qriBeIf4RfGXEnQTJAntieTE2MQ9ieNzAudsmjOc5JpUlnRjruXcorkX5unOy553PFk1WZB8OIWYEpeyP+WDIEJQLxhP5aduTR0T8oSbhU9FvqKNolGxt7hKPJLmnVaV9jjdO31D+miGT0Z1xjMJT1IreZEZkrkj801WRNberM/ZcdktOZSclJyjUg1plrQr1zC3KLdPZisrkw3keeZtyhuTh8r35CP5c/PbFWyFTNGjtFKuUA4WTC+oK3hbGFt4uEi9SFrUM99m/ur5IwuCFny9kLBQuLCz2Lh4WfHgIr9FuxYji1MXdy4xXVK6ZHhp8NJ9y2jLspb9UOJYUlXyannc8o5Sg9KlpUMrglc0lamUycturvRauWMVYZVkVe9ql9VbVn8qF5VfrHCsqK74sEa45uJXTl/VfPV5bdra3kq3yu3rSOuk626s91m/r0q9akHV0IbwDa0b8Y3lG19tSt50oXpq9Y7NtM3KzQM1YTXtW8y2rNvyoTaj9nqdf13LVv2tq7e+2Sba1r/dd3vzDoMdFTve75TsvLUreFdrvUV99W7S7oLdjxpiG7q/5n7duEd3T8Wej3ulewf2Re/ranRvbNyvv7+yCW1SNo0eSDpw5ZuAb9qb7Zp3tXBaKg7CQeXBJ9+mfHvjUOihzsPcw83fmX+39QjrSHkr0jq/dawto22gPaG97+iMo50dXh1Hvrf/fu8x42N1xzWPV56gnSg98fnkgpPjp2Snnp1OPz3Umdx590z8mWtdUV29Z0PPnj8XdO5Mt1/3yfPe549d8Lxw9CL3Ytslt0utPa49R35w/eFIr1tv62X3y+1XPK509E3rO9Hv03/6asDVc9f41y5dn3m978bsG7duJt0cuCW69fh29u0XdwruTNxdeo94r/y+2v3qB/oP6n+0/rFlwG3g+GDAYM/DWQ/vDgmHnv6U/9OH4dJHzEfVI0YjjY+dHx8bDRq98mTOk+GnsqcTz8p+Vv9563Or59/94vtLz1j82PAL+YvPv655qfNy76uprzrHI8cfvM55PfGm/K3O233vuO+638e9H5ko/ED+UPPR+mPHp9BP9z7nfP78L/eE8/sl0p8zAAAAIGNIUk0AAHolAACAgwAA+f8AAIDpAAB1MAAA6mAAADqYAAAXb5JfxUYAAAbdSURBVHja5NlbbBRVGAfw5VID+LAK8cEoxqTgmw8kPPhwipTGxJTDUAVBQBMNKtZboiDE2ES8pFEjGhNkkCrin3JbZo4YCqloUOoKJCDIRWyRAgW6R3dobU2bJtj6+eCMTqczs2d3Zh6Mm3xpdvc7++vMnHNmzvlSRJQqJgA8B8AC8EQx7YoBxgD4CAC54i0Ao2KDAIwCsNGDOPF6nNBLAYgTiyNDAKYDGCwA/Q7gtpIhAKMBHC+AOPF5FGiBIuLEXaVCR4uEzKIhAHcViRCAP4OuVRi0pgSIACwvFurw/ohhGJTP56m7u5vy+TwZhuEHHVKGANzmh3R3d48IH2wQwPWq0CIv5ByJN/L5vN9RzVKF3vQ29kOcULlOQZAZ8YjWq0JHI1wjAvClKnTJr+sq9joCcEoV6itxDDmRU4UoYvT8f6GeiFCXKpSLCJ1XhU5GhI6oQs0RoT2qUENESFeFlkeEXlCFZkeEqlWhWyNCtxSE7GdsPSL0AYAxgRCACQB2xzAzEAABYMIIyEYOxIQ4sR/AOC+UiRlxYvM/EID5CSFO1DjQoYShFmfFMJgwdC0FYHzCCAEYck5dZ8LQWQdCwpAe19xWKCocqAzA1YSQiwBGuwfs2yHJpwDcEBJHQtqu9s4MU0KSHy+wBF0c1NsATPabVL/ye6IBML4AVAbgik/bvUGz9zyf5HrFTY9VPm0XBkFlAH7xrN5uVYQmAuh3P0Q6M3fQje81V/LWIne+1gY9oPglTwLQai+Wby8SugnAj/Y2W7nqqnyUz2cagDb7P24DoAXshI2Nsl9XZXdXb/etintjMBswVrJxQ0H3rMG4oYEAaOA/e+rqAqC6uKHyAKg8VsjGDnqQg7Hve9tQrQeqTQpKuybOfgDpRCDParAhkZKBC5pmQ9MShWysvtg2RSOZTKYu0WqLYRhjTdMUQghqbGxMrtpimuYuIQQJIWj79u3JVFsMw3jHQYQQfhuC0asthmFUCiGG3JAQgjZv3hxftaW5uXmMEOJnLyKEoK1bt8ZXbTEMY5kfIoSgHTt2xFdtEUK0BkE7d+6Mp9piGMY9QYgQgkzTjKfaYprmJvcPn/vhOHV8+D511j5EuUWzqXPZEmpd9x59/102WrVFCPGrG7myopZkzUyS2ox/Ijf3bjq/8mkvpl5tMQzjDvfRdKx7l+TcmZR7bAH1nThGf167Rn0njlHn0gcoV1NJrWvXlFZtMQzjaTfU+eQSknMqqP+n0+R+9Z05RXJOBXUsW1xatcUwjAY3lLu/iuScCvJ7SW0GXVlUXVq1xTTN/cOghfcGH5E2w++I1Kot3vFzceP6vy++5xrlli6gXM1MOvOxXlq1RQiR946by6tXkpw7vNfJmko698qL1NzUVFq1RQgx4DdIL2z7lDqfephyD2l05dlH6ELjRj9EvdoSNiMozA7qtQlVSAjx34H6IkJdqlBXROi86oBtjwgdUYUOR4T2qEJmREhXnVTrI0IvqEJLIg7YalWoXAUKqSwXrrZIzsZIzvSfT5woCTr2zdckOftAchZcbZGcTZCc7ZacUfu+vQWhTCYzAjq9vZEkZyQ5E5KzkdUWGzlgJ9GFjetLgtrerXcgkpztl5yN80IZVwJdWvVMQcizqiAAdPHZR90QSc7+rbZIzuZ7vqTcfZXUdvp0KOR9/j78bQvlaiq9EEnOahzokM+X1P7FnlBoy5Ytw69P4yd+CEnOWlKSs9GSs0G/hI41bxQ1WNtffj4IupaSnI0P+JJyD1bT8aNHlbr24ZYWys2rCoKGnFPXGYS1N+1S6nFnPtaDEJKcnXUgBCVdfrHWF9q2bdswqGPZ4jBId6DZIUnUnm0J7Qgnd5lhCEnOKhyoTHJ2NSjx0qurQifTCytqw5CLkrPR7gH7dkhy6HaZ5OzbkLarvTPDlJDkRQWg+UG9TXI22W9S/conWUrOrisAjbVPkbft3qDZe55P8qsqmx6SsxU+bRcGQWWSs19ciX9Izm5WhG6UnPW52vY4M3fQje81V3JR1RbJ2Vr32Cl0h50kOWuVnHVIzm4vErpJcvaj5MySnKlVWyRnw7bHLF1L9WbTWm823dabTZP9V7N0bUQ7yVnp1RZL16p69k0eshHqzaapZ9/kIUvX4q22WLqW7cpMJzfUlZlOlq5l44YGrQ3VwyBrQzVZujYYNzRg6Rr1tkz8G2qZSJaukaVrA7GfOkvX6LemqdSbTdNvTVMdKPZTV2fpGl3dNIt6s2m6ummWA9XFDZXbP0zdn93pIGTpWnncUMrStYMugOz3qSSgWg9UmxSUtnSt30b67feJQClL1xpsqMH5LClomg1NSxpKWbpW736v0v6vAQCo4CbBrd8RBQAAAABJRU5ErkJggg==") no-repeat 50% 0; + cursor: pointer; + -webkit-transition: all 0s; + transition: all 0s; +} + +#rocket:hover { + background-position: 50% -62px; +} + +#rocket.show { + visibility: visible; + opacity: 1; +} + +#rocket.move { + background-position: 50% -62px; + -webkit-animation: toTop .8s ease-in; + animation: toTop .8s ease-in; + animation-fill-mode: forwards; + -webkit-animation-fill-mode: forwards; +} + +.comment-markdown { + float: right; + font-size: small; +} + +.breadcrumb { + margin-bottom: 20px; + list-style: none; + border-radius: 4px; +} + +.breadcrumb > li { + display: inline-block; +} + +.breadcrumb > li + li:before { + color: #ccc; + content: "/\00a0"; +} + +.breadcrumb > .active { + color: #777; +} + +.break_line { + height: 1px; + border: none; + /*border-top: 1px dashed #f5d6d6;*/ +} + +/* ============================================================================= + 评论内容溢出修复样式 + 解决代码块和长文本撑开页面布局的问题 + ============================================================================= */ + +/* 评论容器基础样式 */ +.comment-body { + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + max-width: 100%; + box-sizing: border-box; +} + +/* 修复评论中的代码块溢出 */ +.comment-content pre, +.comment-body pre { + white-space: pre-wrap !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + max-width: 100% !important; + overflow-x: auto; + padding: 10px; + background-color: #f8f8f8; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 12px; + line-height: 1.4; + margin: 10px 0; +} + +/* 修复评论中的行内代码 */ +.comment-content code, +.comment-body code { + word-wrap: break-word !important; + overflow-wrap: break-word !important; + white-space: pre-wrap; + max-width: 100%; + display: inline-block; + vertical-align: top; +} + +/* 修复评论中的长链接 */ +.comment-content a, +.comment-body a { + word-wrap: break-word !important; + overflow-wrap: break-word !important; + word-break: break-all; + max-width: 100%; +} + +/* 修复评论段落 */ +.comment-content p, +.comment-body p { + word-wrap: break-word !important; + overflow-wrap: break-word !important; + max-width: 100%; + margin: 10px 0; +} + +/* 特殊处理代码高亮块 - 关键修复! */ +.comment-content .codehilite, +.comment-body .codehilite { + max-width: 100% !important; + overflow-x: auto; + margin: 10px 0; + background: #f8f8f8 !important; + border: 1px solid #ddd; + border-radius: 4px; + padding: 10px; + font-size: 12px; + line-height: 1.4; + /* 关键:防止内容撑开容器 */ + width: 100%; + box-sizing: border-box; + display: block; +} + +.comment-content .codehilite pre, +.comment-body .codehilite pre { + white-space: pre-wrap !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + margin: 0 !important; + padding: 0 !important; + background: transparent !important; + border: none !important; + font-size: inherit; + line-height: inherit; + /* 确保pre标签不会超出父容器 */ + max-width: 100%; + width: 100%; + box-sizing: border-box; +} + +/* 修复代码高亮中的span标签 */ +.comment-content .codehilite span, +.comment-body .codehilite span { + word-wrap: break-word !important; + overflow-wrap: break-word !important; + /* 防止行内元素导致的溢出 */ + display: inline; + max-width: 100%; +} + +/* 针对特定的代码高亮类 */ +.comment-content .codehilite .kt, +.comment-content .codehilite .nf, +.comment-content .codehilite .n, +.comment-content .codehilite .p, +.comment-body .codehilite .kt, +.comment-body .codehilite .nf, +.comment-body .codehilite .n, +.comment-body .codehilite .p { + word-wrap: break-word !important; + overflow-wrap: break-word !important; +} + +/* 搜索结果高亮样式 */ +.search-result { + margin-bottom: 30px; + padding: 20px; + border: 1px solid #e1e1e1; + border-radius: 5px; + background: #fff; +} + +.search-result .entry-title { + margin: 0 0 10px 0; + font-size: 1.5em; +} + +.search-result .entry-title a { + color: #2c3e50; + text-decoration: none; +} + +.search-result .entry-title a:hover { + color: #3498db; +} + +.search-result .entry-meta { + color: #7f8c8d; + font-size: 0.9em; + margin-bottom: 15px; +} + +.search-result .entry-meta span { + margin-right: 15px; +} + +.search-excerpt { + line-height: 1.6; + color: #555; +} + +.search-excerpt p { + margin: 10px 0; +} + +/* 搜索关键词高亮 */ +.search-excerpt em, +.search-result .entry-title em { + background-color: #fff3cd; + color: #856404; + font-style: normal; + font-weight: bold; + padding: 2px 4px; + border-radius: 3px; +} + +.more-link { + color: #3498db; + text-decoration: none; + font-weight: bold; +} + +.more-link:hover { + text-decoration: underline; +} +.comment-content .codehilite .w, +.comment-content .codehilite .o, +.comment-body .codehilite .kt, +.comment-body .codehilite .nf, +.comment-body .codehilite .n, +.comment-body .codehilite .p, +.comment-body .codehilite .w, +.comment-body .codehilite .o { + word-break: break-all; + overflow-wrap: break-word; +} + +/* 修复评论列表项 */ +.commentlist li { + max-width: 100%; + overflow: hidden; + box-sizing: border-box; +} + +/* 确保评论内容不超出容器 */ +.commentlist .comment-body { + max-width: calc(100% - 20px); /* 留出一些边距 */ + margin-left: 10px; + margin-right: 10px; + overflow: hidden; /* 防止内容溢出 */ + word-wrap: break-word; +} + +/* 重要:限制评论列表项的最大宽度 */ +.commentlist li[style*="margin-left"] { + max-width: calc(100% - 2rem) !important; + overflow: hidden; + box-sizing: border-box; +} + +/* 特别处理深层嵌套的评论 */ +.commentlist li[style*="margin-left: 3rem"], +.commentlist li[style*="margin-left: 6rem"], +.commentlist li[style*="margin-left: 9rem"] { + max-width: calc(100% - 1rem) !important; +} + +/* 移动端优化 */ +@media (max-width: 768px) { + .comment-content pre, + .comment-body pre { + font-size: 11px; + padding: 8px; + margin: 8px 0; + } + + .commentlist .comment-body { + max-width: calc(100% - 10px); + margin-left: 5px; + margin-right: 5px; + } + + /* 移动端评论缩进调整 */ + .commentlist li[style*="margin-left"] { + margin-left: 1rem !important; + max-margin-left: 2rem !important; + } +} + +/* 防止表格溢出 */ +.comment-content table, +.comment-body table { + max-width: 100%; + overflow-x: auto; + display: block; + white-space: nowrap; +} + +/* 修复图片溢出 */ +.comment-content img, +.comment-body img { + max-width: 100% !important; + height: auto !important; +} + +/* 修复引用块 */ +.comment-content blockquote, +.comment-body blockquote { + max-width: 100%; + overflow-wrap: break-word; + word-wrap: break-word; + padding: 10px 15px; + margin: 10px 0; + border-left: 4px solid #ddd; + background-color: #f9f9f9; +} \ No newline at end of file diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2 new file mode 100644 index 0000000..0fb066c Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2 new file mode 100644 index 0000000..bc2aea0 Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2 new file mode 100644 index 0000000..fcce594 Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2 new file mode 100644 index 0000000..ffc8e9c Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2 new file mode 100644 index 0000000..6375e9c Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2 new file mode 100644 index 0000000..2e849f6 Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2 new file mode 100644 index 0000000..5de3fea Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2 new file mode 100644 index 0000000..e5c936b Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2 new file mode 100644 index 0000000..5cf8aff Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2 new file mode 100644 index 0000000..bdc12e8 Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2 new file mode 100644 index 0000000..b5d54e7 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2 new file mode 100644 index 0000000..bed5b67 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2 new file mode 100644 index 0000000..9164ccb Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2 new file mode 100644 index 0000000..08bed85 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2 new file mode 100644 index 0000000..307b214 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2 new file mode 100644 index 0000000..0b0b3a4 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2 new file mode 100644 index 0000000..4bce1d0 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2 new file mode 100644 index 0000000..5bd7b8f Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2 new file mode 100644 index 0000000..b969602 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2 new file mode 100644 index 0000000..a804b10 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/open-sans.css b/src/blog/static/blog/fonts/open-sans.css new file mode 100644 index 0000000..e6dd4a9 --- /dev/null +++ b/src/blog/static/blog/fonts/open-sans.css @@ -0,0 +1,600 @@ +/* cyrillic-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* hebrew */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2'); + unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; +} +/* math */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* hebrew */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2'); + unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; +} +/* math */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* hebrew */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2'); + unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; +} +/* math */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* hebrew */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2'); + unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; +} +/* math */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* hebrew */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2'); + unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; +} +/* math */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* hebrew */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2'); + unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; +} +/* math */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/src/blog/static/blog/img/avatar.png b/src/blog/static/blog/img/avatar.png new file mode 100644 index 0000000..320756f Binary files /dev/null and b/src/blog/static/blog/img/avatar.png differ diff --git a/src/blog/static/blog/img/icon-sn.svg b/src/blog/static/blog/img/icon-sn.svg new file mode 100644 index 0000000..2c2da0a --- /dev/null +++ b/src/blog/static/blog/img/icon-sn.svg @@ -0,0 +1 @@ +icon-sn \ No newline at end of file diff --git a/src/blog/static/blog/js/blog.js b/src/blog/static/blog/js/blog.js new file mode 100644 index 0000000..c50dd7d --- /dev/null +++ b/src/blog/static/blog/js/blog.js @@ -0,0 +1,91 @@ +/** + * Created by liangliang on 2016/11/20. + */ + + +function do_reply(parentid) { + console.log(parentid); + $("#id_parent_comment_id").val(parentid) + $("#commentform").appendTo($("#div-comment-" + parentid)); + $("#reply-title").hide(); + $("#cancel_comment").show(); +} + +function cancel_reply() { + $("#reply-title").show(); + $("#cancel_comment").hide(); + $("#id_parent_comment_id").val('') + $("#commentform").appendTo($("#respond")); +} + +NProgress.start(); +NProgress.set(0.4); +//Increment +var interval = setInterval(function () { + NProgress.inc(); +}, 1000); +$(document).ready(function () { + NProgress.done(); + clearInterval(interval); +}); + + +/** 侧边栏回到顶部 */ +var rocket = $('#rocket'); + +$(window).on('scroll', debounce(slideTopSet, 300)); + +function debounce(func, wait) { + var timeout; + return function () { + clearTimeout(timeout); + timeout = setTimeout(func, wait); + }; +} + +function slideTopSet() { + var top = $(document).scrollTop(); + + if (top > 200) { + rocket.addClass('show'); + } else { + rocket.removeClass('show'); + } +} + +$(document).on('click', '#rocket', function (event) { + rocket.addClass('move'); + $('body, html').animate({ + scrollTop: 0 + }, 800); +}); +$(document).on('animationEnd', function () { + setTimeout(function () { + rocket.removeClass('move'); + }, 400); + +}); +$(document).on('webkitAnimationEnd', function () { + setTimeout(function () { + rocket.removeClass('move'); + }, 400); +}); + + +window.onload = function () { + var replyLinks = document.querySelectorAll(".comment-reply-link"); + for (var i = 0; i < replyLinks.length; i++) { + replyLinks[i].onclick = function () { + var pk = this.getAttribute("data-pk"); + do_reply(pk); + }; + } +}; + +// $(document).ready(function () { +// var form = $('#i18n-form'); +// var selector = $('.i18n-select'); +// selector.on('change', function () { +// form.submit(); +// }); +// }); \ No newline at end of file diff --git a/src/blog/static/blog/js/html5.js b/src/blog/static/blog/js/html5.js new file mode 100644 index 0000000..6168aac --- /dev/null +++ b/src/blog/static/blog/js/html5.js @@ -0,0 +1,8 @@ +/* + HTML5 Shiv v3.7.0 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed +*/ +(function(l,f){function m(){var a=e.elements;return"string"==typeof a?a.split(" "):a}function i(a){var b=n[a[o]];b||(b={},h++,a[o]=h,n[h]=b);return b}function p(a,b,c){b||(b=f);if(g)return b.createElement(a);c||(c=i(b));b=c.cache[a]?c.cache[a].cloneNode():r.test(a)?(c.cache[a]=c.createElem(a)).cloneNode():c.createElem(a);return b.canHaveChildren&&!s.test(a)?c.frag.appendChild(b):b}function t(a,b){if(!b.cache)b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag(); +a.createElement=function(c){return!e.shivMethods?b.createElem(c):p(c,a,b)};a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+m().join().replace(/[\w\-]+/g,function(a){b.createElem(a);b.frag.createElement(a);return'c("'+a+'")'})+");return n}")(e,b.frag)}function q(a){a||(a=f);var b=i(a);if(e.shivCSS&&!j&&!b.hasCSS){var c,d=a;c=d.createElement("p");d=d.getElementsByTagName("head")[0]||d.documentElement;c.innerHTML="x"; +c=d.insertBefore(c.lastChild,d.firstChild);b.hasCSS=!!c}g||t(a,b);return a}var k=l.html5||{},s=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,r=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,j,o="_html5shiv",h=0,n={},g;(function(){try{var a=f.createElement("a");a.innerHTML="";j="hidden"in a;var b;if(!(b=1==a.childNodes.length)){f.createElement("a");var c=f.createDocumentFragment();b="undefined"==typeof c.cloneNode|| +"undefined"==typeof c.createDocumentFragment||"undefined"==typeof c.createElement}g=b}catch(d){g=j=!0}})();var e={elements:k.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output progress section summary template time video",version:"3.7.0",shivCSS:!1!==k.shivCSS,supportsUnknownElements:g,shivMethods:!1!==k.shivMethods,type:"default",shivDocument:q,createElement:p,createDocumentFragment:function(a,b){a||(a=f); +if(g)return a.createDocumentFragment();for(var b=b||i(a),c=b.frag.cloneNode(),d=0,e=m(),h=e.length;d+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var _t,zt=[],Ut=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=zt.pop()||S.expando+"_"+wt.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Ut.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Ut.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Ut,"$1"+r):!1!==e.jsonp&&(e.url+=(Tt.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,zt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((_t=E.implementation.createHTMLDocument("").body).innerHTML="
",2===_t.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=Fe(y.pixelPosition,function(e,t){if(t)return t=We(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0 { + console.log('MathJax渲染完成'); + // 触发自定义事件,通知其他脚本MathJax已就绪 + document.dispatchEvent(new CustomEvent('mathjaxReady')); + }).catch(error => { + console.error('MathJax渲染失败:', error); + }); + } + }, + // 输出配置 + chtml: { + scale: 1, + minScale: 0.5, + matchFontHeight: false, + displayAlign: 'center', + displayIndent: '0' + } + }; + } + + /** + * 加载MathJax库 + */ + function loadMathJax() { + console.log('检测到数学公式,开始加载MathJax...'); + + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js'; + script.async = true; + script.defer = true; + + script.onload = function() { + console.log('MathJax库加载成功'); + }; + + script.onerror = function() { + console.error('MathJax库加载失败,尝试备用CDN...'); + // 备用CDN + const fallbackScript = document.createElement('script'); + fallbackScript.src = 'https://polyfill.io/v3/polyfill.min.js?features=es6'; + fallbackScript.onload = function() { + const mathJaxScript = document.createElement('script'); + mathJaxScript.src = 'https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-MML-AM_CHTML'; + mathJaxScript.async = true; + document.head.appendChild(mathJaxScript); + }; + document.head.appendChild(fallbackScript); + }; + + document.head.appendChild(script); + } + + /** + * 初始化函数 + */ + function init() { + // 等待DOM完全加载 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + return; + } + + // 检测是否需要加载MathJax + if (hasMathFormulas()) { + // 先配置,再加载 + configureMathJax(); + loadMathJax(); + } else { + console.log('未检测到数学公式,跳过MathJax加载'); + } + } + + // 提供重新渲染的全局方法,供动态内容使用 + window.rerenderMathJax = function(element) { + if (window.MathJax && window.MathJax.typesetPromise) { + const target = element || document.body; + return window.MathJax.typesetPromise([target]); + } + return Promise.resolve(); + }; + + // 启动初始化 + init(); +})(); diff --git a/src/blog/static/blog/js/navigation.js b/src/blog/static/blog/js/navigation.js new file mode 100644 index 0000000..f7141bf --- /dev/null +++ b/src/blog/static/blog/js/navigation.js @@ -0,0 +1,55 @@ +/** + * Handles toggling the navigation menu for small screens and + * accessibility for submenu items. + */ +( function() { + var nav = document.getElementById( 'site-navigation' ), button, menu; + if ( ! nav ) { + return; + } + + button = nav.getElementsByTagName( 'button' )[0]; + menu = nav.getElementsByTagName( 'ul' )[0]; + if ( ! button ) { + return; + } + + // Hide button if menu is missing or empty. + if ( ! menu || ! menu.childNodes.length ) { + button.style.display = 'none'; + return; + } + + button.onclick = function() { + if ( -1 === menu.className.indexOf( 'nav-menu' ) ) { + menu.className = 'nav-menu'; + } + + if ( -1 !== button.className.indexOf( 'toggled-on' ) ) { + button.className = button.className.replace( ' toggled-on', '' ); + menu.className = menu.className.replace( ' toggled-on', '' ); + } else { + button.className += ' toggled-on'; + menu.className += ' toggled-on'; + } + }; +} )(); + +// Better focus for hidden submenu items for accessibility. +( function( $ ) { + $( '.main-navigation' ).find( 'a' ).on( 'focus.twentytwelve blur.twentytwelve', function() { + $( this ).parents( '.menu-item, .page_item' ).toggleClass( 'focus' ); + } ); + + if ( 'ontouchstart' in window ) { + $('body').on( 'touchstart.twentytwelve', '.menu-item-has-children > a, .page_item_has_children > a', function( e ) { + var el = $( this ).parent( 'li' ); + + if ( ! el.hasClass( 'focus' ) ) { + e.preventDefault(); + el.toggleClass( 'focus' ); + el.siblings( '.focus').removeClass( 'focus' ); + } + } ); + } +} )( jQuery ); diff --git a/src/blog/static/blog/js/nprogress.js b/src/blog/static/blog/js/nprogress.js new file mode 100644 index 0000000..d29c2aa --- /dev/null +++ b/src/blog/static/blog/js/nprogress.js @@ -0,0 +1,480 @@ +/* NProgress, (c) 2013, 2014 Rico Sta. Cruz - http://ricostacruz.com/nprogress + * @license MIT */ + +;(function(root, factory) { + + if (typeof define === 'function' && define.amd) { + define(factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } else { + root.NProgress = factory(); + } + +})(this, function() { + var NProgress = {}; + + NProgress.version = '0.2.0'; + + var Settings = NProgress.settings = { + minimum: 0.08, + easing: 'linear', + positionUsing: '', + speed: 200, + trickle: true, + trickleSpeed: 200, + showSpinner: true, + barSelector: '[role="bar"]', + spinnerSelector: '[role="spinner"]', + parent: 'body', + template: '
' + }; + + /** + * Updates configuration. + * + * NProgress.configure({ + * minimum: 0.1 + * }); + */ + NProgress.configure = function(options) { + var key, value; + for (key in options) { + value = options[key]; + if (value !== undefined && options.hasOwnProperty(key)) Settings[key] = value; + } + + return this; + }; + + /** + * Last number. + */ + + NProgress.status = null; + + /** + * Sets the progress bar status, where `n` is a number from `0.0` to `1.0`. + * + * NProgress.set(0.4); + * NProgress.set(1.0); + */ + + NProgress.set = function(n) { + var started = NProgress.isStarted(); + + n = clamp(n, Settings.minimum, 1); + NProgress.status = (n === 1 ? null : n); + + var progress = NProgress.render(!started), + bar = progress.querySelector(Settings.barSelector), + speed = Settings.speed, + ease = Settings.easing; + + progress.offsetWidth; /* Repaint */ + + queue(function(next) { + // Set positionUsing if it hasn't already been set + if (Settings.positionUsing === '') Settings.positionUsing = NProgress.getPositioningCSS(); + + // Add transition + css(bar, barPositionCSS(n, speed, ease)); + + if (n === 1) { + // Fade out + css(progress, { + transition: 'none', + opacity: 1 + }); + progress.offsetWidth; /* Repaint */ + + setTimeout(function() { + css(progress, { + transition: 'all ' + speed + 'ms linear', + opacity: 0 + }); + setTimeout(function() { + NProgress.remove(); + next(); + }, speed); + }, speed); + } else { + setTimeout(next, speed); + } + }); + + return this; + }; + + NProgress.isStarted = function() { + return typeof NProgress.status === 'number'; + }; + + /** + * Shows the progress bar. + * This is the same as setting the status to 0%, except that it doesn't go backwards. + * + * NProgress.start(); + * + */ + NProgress.start = function() { + if (!NProgress.status) NProgress.set(0); + + var work = function() { + setTimeout(function() { + if (!NProgress.status) return; + NProgress.trickle(); + work(); + }, Settings.trickleSpeed); + }; + + if (Settings.trickle) work(); + + return this; + }; + + /** + * Hides the progress bar. + * This is the *sort of* the same as setting the status to 100%, with the + * difference being `done()` makes some placebo effect of some realistic motion. + * + * NProgress.done(); + * + * If `true` is passed, it will show the progress bar even if its hidden. + * + * NProgress.done(true); + */ + + NProgress.done = function(force) { + if (!force && !NProgress.status) return this; + + return NProgress.inc(0.3 + 0.5 * Math.random()).set(1); + }; + + /** + * Increments by a random amount. + */ + + NProgress.inc = function(amount) { + var n = NProgress.status; + + if (!n) { + return NProgress.start(); + } else if(n > 1) { + + } else { + if (typeof amount !== 'number') { + if (n >= 0 && n < 0.2) { amount = 0.1; } + else if (n >= 0.2 && n < 0.5) { amount = 0.04; } + else if (n >= 0.5 && n < 0.8) { amount = 0.02; } + else if (n >= 0.8 && n < 0.99) { amount = 0.005; } + else { amount = 0; } + } + + n = clamp(n + amount, 0, 0.994); + return NProgress.set(n); + } + }; + + NProgress.trickle = function() { + return NProgress.inc(); + }; + + /** + * Waits for all supplied jQuery promises and + * increases the progress as the promises resolve. + * + * @param $promise jQUery Promise + */ + (function() { + var initial = 0, current = 0; + + NProgress.promise = function($promise) { + if (!$promise || $promise.state() === "resolved") { + return this; + } + + if (current === 0) { + NProgress.start(); + } + + initial++; + current++; + + $promise.always(function() { + current--; + if (current === 0) { + initial = 0; + NProgress.done(); + } else { + NProgress.set((initial - current) / initial); + } + }); + + return this; + }; + + })(); + + /** + * (Internal) renders the progress bar markup based on the `template` + * setting. + */ + + NProgress.render = function(fromStart) { + if (NProgress.isRendered()) return document.getElementById('nprogress'); + + addClass(document.documentElement, 'nprogress-busy'); + + var progress = document.createElement('div'); + progress.id = 'nprogress'; + progress.innerHTML = Settings.template; + + var bar = progress.querySelector(Settings.barSelector), + perc = fromStart ? '-100' : toBarPerc(NProgress.status || 0), + parent = document.querySelector(Settings.parent), + spinner; + + css(bar, { + transition: 'all 0 linear', + transform: 'translate3d(' + perc + '%,0,0)' + }); + + if (!Settings.showSpinner) { + spinner = progress.querySelector(Settings.spinnerSelector); + spinner && removeElement(spinner); + } + + if (parent != document.body) { + addClass(parent, 'nprogress-custom-parent'); + } + + parent.appendChild(progress); + return progress; + }; + + /** + * Removes the element. Opposite of render(). + */ + + NProgress.remove = function() { + removeClass(document.documentElement, 'nprogress-busy'); + removeClass(document.querySelector(Settings.parent), 'nprogress-custom-parent'); + var progress = document.getElementById('nprogress'); + progress && removeElement(progress); + }; + + /** + * Checks if the progress bar is rendered. + */ + + NProgress.isRendered = function() { + return !!document.getElementById('nprogress'); + }; + + /** + * Determine which positioning CSS rule to use. + */ + + NProgress.getPositioningCSS = function() { + // Sniff on document.body.style + var bodyStyle = document.body.style; + + // Sniff prefixes + var vendorPrefix = ('WebkitTransform' in bodyStyle) ? 'Webkit' : + ('MozTransform' in bodyStyle) ? 'Moz' : + ('msTransform' in bodyStyle) ? 'ms' : + ('OTransform' in bodyStyle) ? 'O' : ''; + + if (vendorPrefix + 'Perspective' in bodyStyle) { + // Modern browsers with 3D support, e.g. Webkit, IE10 + return 'translate3d'; + } else if (vendorPrefix + 'Transform' in bodyStyle) { + // Browsers without 3D support, e.g. IE9 + return 'translate'; + } else { + // Browsers without translate() support, e.g. IE7-8 + return 'margin'; + } + }; + + /** + * Helpers + */ + + function clamp(n, min, max) { + if (n < min) return min; + if (n > max) return max; + return n; + } + + /** + * (Internal) converts a percentage (`0..1`) to a bar translateX + * percentage (`-100%..0%`). + */ + + function toBarPerc(n) { + return (-1 + n) * 100; + } + + + /** + * (Internal) returns the correct CSS for changing the bar's + * position given an n percentage, and speed and ease from Settings + */ + + function barPositionCSS(n, speed, ease) { + var barCSS; + + if (Settings.positionUsing === 'translate3d') { + barCSS = { transform: 'translate3d('+toBarPerc(n)+'%,0,0)' }; + } else if (Settings.positionUsing === 'translate') { + barCSS = { transform: 'translate('+toBarPerc(n)+'%,0)' }; + } else { + barCSS = { 'margin-left': toBarPerc(n)+'%' }; + } + + barCSS.transition = 'all '+speed+'ms '+ease; + + return barCSS; + } + + /** + * (Internal) Queues a function to be executed. + */ + + var queue = (function() { + var pending = []; + + function next() { + var fn = pending.shift(); + if (fn) { + fn(next); + } + } + + return function(fn) { + pending.push(fn); + if (pending.length == 1) next(); + }; + })(); + + /** + * (Internal) Applies css properties to an element, similar to the jQuery + * css method. + * + * While this helper does assist with vendor prefixed property names, it + * does not perform any manipulation of values prior to setting styles. + */ + + var css = (function() { + var cssPrefixes = [ 'Webkit', 'O', 'Moz', 'ms' ], + cssProps = {}; + + function camelCase(string) { + return string.replace(/^-ms-/, 'ms-').replace(/-([\da-z])/gi, function(match, letter) { + return letter.toUpperCase(); + }); + } + + function getVendorProp(name) { + var style = document.body.style; + if (name in style) return name; + + var i = cssPrefixes.length, + capName = name.charAt(0).toUpperCase() + name.slice(1), + vendorName; + while (i--) { + vendorName = cssPrefixes[i] + capName; + if (vendorName in style) return vendorName; + } + + return name; + } + + function getStyleProp(name) { + name = camelCase(name); + return cssProps[name] || (cssProps[name] = getVendorProp(name)); + } + + function applyCss(element, prop, value) { + prop = getStyleProp(prop); + element.style[prop] = value; + } + + return function(element, properties) { + var args = arguments, + prop, + value; + + if (args.length == 2) { + for (prop in properties) { + value = properties[prop]; + if (value !== undefined && properties.hasOwnProperty(prop)) applyCss(element, prop, value); + } + } else { + applyCss(element, args[1], args[2]); + } + } + })(); + + /** + * (Internal) Determines if an element or space separated list of class names contains a class name. + */ + + function hasClass(element, name) { + var list = typeof element == 'string' ? element : classList(element); + return list.indexOf(' ' + name + ' ') >= 0; + } + + /** + * (Internal) Adds a class to an element. + */ + + function addClass(element, name) { + var oldList = classList(element), + newList = oldList + name; + + if (hasClass(oldList, name)) return; + + // Trim the opening space. + element.className = newList.substring(1); + } + + /** + * (Internal) Removes a class from an element. + */ + + function removeClass(element, name) { + var oldList = classList(element), + newList; + + if (!hasClass(element, name)) return; + + // Replace the class name. + newList = oldList.replace(' ' + name + ' ', ' '); + + // Trim the opening and closing spaces. + element.className = newList.substring(1, newList.length - 1); + } + + /** + * (Internal) Gets a space separated list of the class names on the element. + * The list is wrapped with a single space on each end to facilitate finding + * matches within the list. + */ + + function classList(element) { + return (' ' + (element && element.className || '') + ' ').replace(/\s+/gi, ' '); + } + + /** + * (Internal) Removes an element from the DOM. + */ + + function removeElement(element) { + element && element.parentNode && element.parentNode.removeChild(element); + } + + return NProgress; +}); diff --git a/src/blog/static/pygments/default.css b/src/blog/static/pygments/default.css new file mode 100644 index 0000000..73e6e49 --- /dev/null +++ b/src/blog/static/pygments/default.css @@ -0,0 +1,293 @@ +.codehilite .hll { + background-color: #ffffcc +} + +.codehilite { + background: #ffffff; +} + +.codehilite .c { + color: #177500 +} + +/* Comment */ +.codehilite .err { + color: #000000 +} + +/* Error */ +.codehilite .k { + color: #A90D91 +} + +/* Keyword */ +.codehilite .l { + color: #1C01CE +} + +/* Literal */ +.codehilite .n { + color: #000000 +} + +/* Name */ +.codehilite .o { + color: #000000 +} + +/* Operator */ +.codehilite .ch { + color: #177500 +} + +/* Comment.Hashbang */ +.codehilite .cm { + color: #177500 +} + +/* Comment.Multiline */ +.codehilite .cp { + color: #633820 +} + +/* Comment.Preproc */ +.codehilite .cpf { + color: #177500 +} + +/* Comment.PreprocFile */ +.codehilite .c1 { + color: #177500 +} + +/* Comment.Single */ +.codehilite .cs { + color: #177500 +} + +/* Comment.Special */ +.codehilite .kc { + color: #A90D91 +} + +/* Keyword.Constant */ +.codehilite .kd { + color: #A90D91 +} + +/* Keyword.Declaration */ +.codehilite .kn { + color: #A90D91 +} + +/* Keyword.Namespace */ +.codehilite .kp { + color: #A90D91 +} + +/* Keyword.Pseudo */ +.codehilite .kr { + color: #A90D91 +} + +/* Keyword.Reserved */ +.codehilite .kt { + color: #A90D91 +} + +/* Keyword.Type */ +.codehilite .ld { + color: #1C01CE +} + +/* Literal.Date */ +.codehilite .m { + color: #1C01CE +} + +/* Literal.Number */ +.codehilite .s { + color: #C41A16 +} + +/* Literal.String */ +.codehilite .na { + color: #836C28 +} + +/* Name.Attribute */ +.codehilite .nb { + color: #A90D91 +} + +/* Name.Builtin */ +.codehilite .nc { + color: #3F6E75 +} + +/* Name.Class */ +.codehilite .no { + color: #000000 +} + +/* Name.Constant */ +.codehilite .nd { + color: #000000 +} + +/* Name.Decorator */ +.codehilite .ni { + color: #000000 +} + +/* Name.Entity */ +.codehilite .ne { + color: #000000 +} + +/* Name.Exception */ +.codehilite .nf { + color: #000000 +} + +/* Name.Function */ +.codehilite .nl { + color: #000000 +} + +/* Name.Label */ +.codehilite .nn { + color: #000000 +} + +/* Name.Namespace */ +.codehilite .nx { + color: #000000 +} + +/* Name.Other */ +.codehilite .py { + color: #000000 +} + +/* Name.Property */ +.codehilite .nt { + color: #000000 +} + +/* Name.Tag */ +.codehilite .nv { + color: #000000 +} + +/* Name.Variable */ +.codehilite .ow { + color: #000000 +} + +/* Operator.Word */ +.codehilite .mb { + color: #1C01CE +} + +/* Literal.Number.Bin */ +.codehilite .mf { + color: #1C01CE +} + +/* Literal.Number.Float */ +.codehilite .mh { + color: #1C01CE +} + +/* Literal.Number.Hex */ +.codehilite .mi { + color: #1C01CE +} + +/* Literal.Number.Integer */ +.codehilite .mo { + color: #1C01CE +} + +/* Literal.Number.Oct */ +.codehilite .sb { + color: #C41A16 +} + +/* Literal.String.Backtick */ +.codehilite .sc { + color: #2300CE +} + +/* Literal.String.Char */ +.codehilite .sd { + color: #C41A16 +} + +/* Literal.String.Doc */ +.codehilite .s2 { + color: #C41A16 +} + +/* Literal.String.Double */ +.codehilite .se { + color: #C41A16 +} + +/* Literal.String.Escape */ +.codehilite .sh { + color: #C41A16 +} + +/* Literal.String.Heredoc */ +.codehilite .si { + color: #C41A16 +} + +/* Literal.String.Interpol */ +.codehilite .sx { + color: #C41A16 +} + +/* Literal.String.Other */ +.codehilite .sr { + color: #C41A16 +} + +/* Literal.String.Regex */ +.codehilite .s1 { + color: #C41A16 +} + +/* Literal.String.Single */ +.codehilite .ss { + color: #C41A16 +} + +/* Literal.String.Symbol */ +.codehilite .bp { + color: #5B269A +} + +/* Name.Builtin.Pseudo */ +.codehilite .vc { + color: #000000 +} + +/* Name.Variable.Class */ +.codehilite .vg { + color: #000000 +} + +/* Name.Variable.Global */ +.codehilite .vi { + color: #000000 +} + +/* Name.Variable.Instance */ +.codehilite .il { + color: #1C01CE +} + +/* Literal.Number.Integer.Long */ \ No newline at end of file diff --git a/src/blog/templatetags/blog_tags.py b/src/blog/templatetags/blog_tags.py index d6cd5d5..aa8f354 100644 --- a/src/blog/templatetags/blog_tags.py +++ b/src/blog/templatetags/blog_tags.py @@ -51,7 +51,75 @@ def datetimeformat(data): @register.filter() @stringfilter def custom_markdown(content): - return mark_safe(CommonMarkdown.get_markdown(content)) + """ + 通用markdown过滤器,应用文章内容插件 + 主要用于文章内容处理 + """ + html_content = CommonMarkdown.get_markdown(content) + + # 然后应用插件过滤器优化HTML + from djangoblog.plugin_manage import hooks + from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME + optimized_html = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, html_content) + + return mark_safe(optimized_html) + + +@register.filter() +@stringfilter +def sidebar_markdown(content): + html_content = CommonMarkdown.get_markdown(content) + return mark_safe(html_content) + + +@register.simple_tag(takes_context=True) +def render_article_content(context, article, is_summary=False): + """ + 渲染文章内容,包含完整的上下文信息供插件使用 + + Args: + context: 模板上下文 + article: 文章对象 + is_summary: 是否为摘要模式(首页使用) + """ + if not article or not hasattr(article, 'body'): + return '' + + # 先转换Markdown为HTML + html_content = CommonMarkdown.get_markdown(article.body) + + # 如果是摘要模式,先截断内容再应用插件 + if is_summary: + # 截断HTML内容到合适的长度(约300字符) + from django.utils.html import strip_tags + from django.template.defaultfilters import truncatechars + + # 先去除HTML标签,截断纯文本,然后重新转换为HTML + plain_text = strip_tags(html_content) + truncated_text = truncatechars(plain_text, 300) + + # 重新转换截断后的文本为HTML(简化版,避免复杂的插件处理) + html_content = CommonMarkdown.get_markdown(truncated_text) + + # 然后应用插件过滤器,传递完整的上下文 + from djangoblog.plugin_manage import hooks + from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME + + # 获取request对象 + request = context.get('request') + + # 应用所有文章内容相关的插件 + # 注意:摘要模式下某些插件(如版权声明)可能不适用 + optimized_html = hooks.apply_filters( + ARTICLE_CONTENT_HOOK_NAME, + html_content, + article=article, + request=request, + context=context, + is_summary=is_summary # 传递摘要标志,插件可以据此调整行为 + ) + + return mark_safe(optimized_html) @register.simple_tag @@ -198,9 +266,19 @@ def load_article_metas(article, user): :param article: :return: """ + # 获取关注状态 + is_following = False + if user.is_authenticated: + try: + from blog.models_social import UserFollow + is_following = UserFollow.is_following(user, article.author) + except Exception: + pass + return { 'article': article, - 'user': user + 'user': user, + 'is_following': is_following } @@ -284,46 +362,78 @@ def load_article_detail(article, isindex, user): from djangoblog.utils import get_blog_setting blogsetting = get_blog_setting() + # 获取点赞和收藏数据 + like_count = 0 + favorite_count = 0 + is_liked = False + is_favorited = False + + try: + from blog.models_social import ArticleLike, ArticleFavorite + like_count = ArticleLike.get_article_like_count(article) + favorite_count = ArticleFavorite.get_article_favorite_count(article) + + if user.is_authenticated: + is_liked = ArticleLike.is_liked(user, article) + is_favorited = ArticleFavorite.is_favorited(user, article) + except Exception: + pass + return { 'article': article, 'isindex': isindex, 'user': user, 'open_site_comment': blogsetting.open_site_comment, + 'like_count': like_count, + 'favorite_count': favorite_count, + 'is_liked': is_liked, + 'is_favorited': is_favorited, } -# return only the URL of the gravatar -# TEMPLATE USE: {{ email|gravatar_url:150 }} +# 返回用户头像URL +# 模板使用方法: {{ email|gravatar_url:150 }} @register.filter def gravatar_url(email, size=40): - """获得gravatar头像""" - cachekey = 'gravatat/' + email + """获得用户头像 - 优先使用OAuth头像,否则使用默认头像""" + cachekey = 'avatar/' + email url = cache.get(cachekey) if url: return url - else: - usermodels = OAuthUser.objects.filter(email=email) - if usermodels: - o = list(filter(lambda x: x.picture is not None, usermodels)) - if o: - return o[0].picture - email = email.encode('utf-8') - - default = static('blog/img/avatar.png') - - url = "https://www.gravatar.com/avatar/%s?%s" % (hashlib.md5( - email.lower()).hexdigest(), urllib.parse.urlencode({'d': default, 's': str(size)})) - cache.set(cachekey, url, 60 * 60 * 10) - logger.info('set gravatar cache.key:{key}'.format(key=cachekey)) - return url + + # 检查OAuth用户是否有自定义头像 + usermodels = OAuthUser.objects.filter(email=email) + if usermodels: + # 过滤出有头像的用户 + users_with_picture = list(filter(lambda x: x.picture is not None, usermodels)) + if users_with_picture: + # 获取默认头像路径用于比较 + default_avatar_path = static('blog/img/avatar.png') + + # 优先选择非默认头像的用户,否则选择第一个 + non_default_users = [u for u in users_with_picture if u.picture != default_avatar_path and not u.picture.endswith('/avatar.png')] + selected_user = non_default_users[0] if non_default_users else users_with_picture[0] + + url = selected_user.picture + cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时 + + avatar_type = 'non-default' if non_default_users else 'default' + logger.info('Using {} OAuth avatar for {} from {}'.format(avatar_type, email, selected_user.type)) + return url + + # 使用默认头像 + url = static('blog/img/avatar.png') + cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时 + logger.info('Using default avatar for {}'.format(email)) + return url @register.filter def gravatar(email, size=40): - """获得gravatar头像""" + """获得用户头像HTML标签""" url = gravatar_url(email, size) return mark_safe( - '' % + '用户头像' % (url, size, size)) @@ -342,3 +452,134 @@ def query(qs, **kwargs): def addstr(arg1, arg2): """concatenate arg1 & arg2""" return str(arg1) + str(arg2) + + +# === 插件系统模板标签 === + +@register.simple_tag(takes_context=True) +def render_plugin_widgets(context, position, **kwargs): + """ + 渲染指定位置的所有插件组件 + + Args: + context: 模板上下文 + position: 位置标识 + **kwargs: 传递给插件的额外参数 + + Returns: + 按优先级排序的所有插件HTML内容 + """ + from djangoblog.plugin_manage.loader import get_loaded_plugins + + widgets = [] + + for plugin in get_loaded_plugins(): + try: + widget_data = plugin.render_position_widget( + position=position, + context=context, + **kwargs + ) + if widget_data: + widgets.append(widget_data) + except Exception as e: + logger.error(f"Error rendering widget from plugin {plugin.PLUGIN_NAME}: {e}") + + # 按优先级排序(数字越小优先级越高) + widgets.sort(key=lambda x: x['priority']) + + # 合并HTML内容 + html_parts = [widget['html'] for widget in widgets] + return mark_safe(''.join(html_parts)) + + +@register.simple_tag(takes_context=True) +def plugin_head_resources(context): + """渲染所有插件的head资源(仅自定义HTML,CSS已集成到压缩系统)""" + from djangoblog.plugin_manage.loader import get_loaded_plugins + + resources = [] + + for plugin in get_loaded_plugins(): + try: + # 只处理自定义head HTML(CSS文件已通过压缩系统处理) + head_html = plugin.get_head_html(context) + if head_html: + resources.append(head_html) + + except Exception as e: + logger.error(f"Error loading head resources from plugin {plugin.PLUGIN_NAME}: {e}") + + return mark_safe('\n'.join(resources)) + + +@register.simple_tag(takes_context=True) +def plugin_body_resources(context): + """渲染所有插件的body资源(仅自定义HTML,JS已集成到压缩系统)""" + from djangoblog.plugin_manage.loader import get_loaded_plugins + + resources = [] + + for plugin in get_loaded_plugins(): + try: + # 只处理自定义body HTML(JS文件已通过压缩系统处理) + body_html = plugin.get_body_html(context) + if body_html: + resources.append(body_html) + + except Exception as e: + logger.error(f"Error loading body resources from plugin {plugin.PLUGIN_NAME}: {e}") + + return mark_safe('\n'.join(resources)) + + +@register.inclusion_tag('plugins/css_includes.html') +def plugin_compressed_css(): + """插件CSS压缩包含模板""" + from djangoblog.plugin_manage.loader import get_loaded_plugins + + css_files = [] + for plugin in get_loaded_plugins(): + for css_file in plugin.get_css_files(): + css_url = plugin.get_static_url(css_file) + css_files.append(css_url) + + return {'css_files': css_files} + + +@register.inclusion_tag('plugins/js_includes.html') +def plugin_compressed_js(): + """插件JS压缩包含模板""" + from djangoblog.plugin_manage.loader import get_loaded_plugins + + js_files = [] + for plugin in get_loaded_plugins(): + for js_file in plugin.get_js_files(): + js_url = plugin.get_static_url(js_file) + js_files.append(js_url) + + return {'js_files': js_files} + + + + +@register.simple_tag(takes_context=True) +def plugin_widget(context, plugin_name, widget_type='default', **kwargs): + """ + 渲染指定插件的组件 + + 使用方式: + {% plugin_widget 'article_recommendation' 'bottom' article=article count=5 %} + """ + from djangoblog.plugin_manage.loader import get_plugin_by_slug + + plugin = get_plugin_by_slug(plugin_name) + if plugin and hasattr(plugin, 'render_template'): + try: + widget_context = {**context.flatten(), **kwargs} + template_name = f"{widget_type}.html" + return mark_safe(plugin.render_template(template_name, widget_context)) + except Exception as e: + logger.error(f"Error rendering plugin widget {plugin_name}.{widget_type}: {e}") + + return "" \ No newline at end of file diff --git a/src/blog/urls.py b/src/blog/urls.py index adf2703..6158ac6 100644 --- a/src/blog/urls.py +++ b/src/blog/urls.py @@ -2,61 +2,183 @@ from django.urls import path from django.views.decorators.cache import cache_page from . import views +from . import views_draft # 导入草稿视图 +from . import views_social # 导入社交功能视图 +from . import views_media # 导入多媒体管理视图 -app_name = "blog" +app_name = "blog" #zhq: 应用命名空间,用于URL反向解析 urlpatterns = [ +#zhq: 首页路由 - 显示文章列表 path( r'', views.IndexView.as_view(), name='index'), +#zhq: 首页分页路由 - 支持分页浏览 path( r'page//', views.IndexView.as_view(), name='index_page'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( r'article////.html', views.ArticleDetailView.as_view(), name='detailbyid'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( r'category/.html', views.CategoryDetailView.as_view(), name='category_detail'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( r'category//.html', views.CategoryDetailView.as_view(), name='category_detail_page'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( r'author/.html', views.AuthorDetailView.as_view(), name='author_detail'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( r'author//.html', views.AuthorDetailView.as_view(), name='author_detail_page'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( r'tag/.html', views.TagDetailView.as_view(), name='tag_detail'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( r'tag//.html', views.TagDetailView.as_view(), name='tag_detail_page'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( 'archives.html', cache_page( 60 * 60)( views.ArchivesView.as_view()), name='archives'), +#zhq: 友情链接页路由 - 显示所有启用的友情链接 path( 'links.html', views.LinkListView.as_view(), name='links'), +#zhq: 友情链接页路由 - 显示所有启用的友情链接 path( r'upload', views.fileupload, name='upload'), +# zhq: 缓存清理路由 - 手动清理系统缓存 path( r'clean', views.clean_cache_view, name='clean'), + + # 草稿 API 路由 + path( + 'api/draft/save/', + views_draft.save_draft_api, + name='save_draft'), + path( + 'api/draft/get/', + views_draft.get_draft_api, + name='get_draft'), + path( + 'api/draft/list/', + views_draft.list_drafts_api, + name='list_drafts'), + path( + 'api/draft/delete/', + views_draft.delete_draft_api, + name='delete_draft'), + path( + 'api/draft/apply/', + views_draft.apply_draft_api, + name='apply_draft'), + + # 关注 API 路由 + path( + 'api/follow/', + views_social.follow_user_api, + name='follow_user'), + path( + 'api/unfollow/', + views_social.unfollow_user_api, + name='unfollow_user'), + path( + 'api/check-following/', + views_social.check_following_api, + name='check_following'), + path( + 'api/following-list/', + views_social.following_list_api, + name='following_list'), + path( + 'api/followers-list/', + views_social.followers_list_api, + name='followers_list'), + + # 收藏 API 路由 + path( + 'api/favorite/', + views_social.favorite_article_api, + name='favorite_article'), + path( + 'api/unfavorite/', + views_social.unfavorite_article_api, + name='unfavorite_article'), + path( + 'api/check-favorite/', + views_social.check_favorite_api, + name='check_favorite'), + path( + 'api/favorites-list/', + views_social.favorites_list_api, + name='favorites_list'), + + # 点赞 API 路由 + path( + 'api/like/', + views_social.like_article_api, + name='like_article'), + path( + 'api/unlike/', + views_social.unlike_article_api, + name='unlike_article'), + path( + 'api/check-like/', + views_social.check_like_api, + name='check_like'), + path( + 'api/likes-list/', + views_social.likes_list_api, + name='likes_list'), + + # 多媒体管理 API 路由 + path( + 'api/media/upload/', + views_media.upload_media_api, + name='upload_media'), + path( + 'api/media/list/', + views_media.list_media_api, + name='list_media'), + path( + 'api/media/delete/', + views_media.delete_media_api, + name='delete_media'), + path( + 'api/media/update/', + views_media.update_media_api, + name='update_media'), + path( + 'api/media/folder/create/', + views_media.create_folder_api, + name='create_media_folder'), + path( + 'api/media/folder/list/', + views_media.list_folders_api, + name='list_media_folders'), ] diff --git a/src/blog/views.py b/src/blog/views.py index d5dc7ec..9cf3b33 100644 --- a/src/blog/views.py +++ b/src/blog/views.py @@ -23,7 +23,7 @@ from djangoblog.utils import cache, get_blog_setting, get_sha256 logger = logging.getLogger(__name__) - +#zhq: 文章列表基类视图 - 提供通用的列表功能和缓存机制 class ArticleListView(ListView): # template_name属性用于指定使用哪个模板进行渲染 template_name = 'blog/article_index.html' @@ -33,7 +33,7 @@ class ArticleListView(ListView): # 页面类型,分类目录或标签列表等 page_type = '' - paginate_by = settings.PAGINATE_BY + paginate_by = settings.PAGINATE_BY #zhq: 从配置获取分页大小 page_kwarg = 'page' link_type = LinkShowType.L @@ -42,6 +42,7 @@ class ArticleListView(ListView): @property def page_number(self): + # zhq: 获取当前页码,支持URL参数和GET参数 page_kwarg = self.page_kwarg page = self.kwargs.get( page_kwarg) or self.request.GET.get(page_kwarg) or 1 @@ -88,7 +89,7 @@ class ArticleListView(ListView): kwargs['linktype'] = self.link_type return super(ArticleListView, self).get_context_data(**kwargs) - + #zhq: 获取当前页码,支持URL参数和GET参数 class IndexView(ArticleListView): ''' 首页 @@ -97,14 +98,19 @@ class IndexView(ArticleListView): link_type = LinkShowType.I def get_queryset_data(self): - article_list = Article.objects.filter(type='a', status='p') + # zhq: 获取已发布的普通文章 + # 性能优化:使用 select_related 预加载外键关系,避免 N+1 查询 + # 使用 prefetch_related 预加载多对多关系(标签) + article_list = Article.objects.filter(type='a', status='p') \ + .select_related('author', 'category') \ + .prefetch_related('tags') return article_list def get_queryset_cache_key(self): cache_key = 'index_{page}'.format(page=self.page_number) return cache_key - +#zhq: 文章详情页视图 - 显示单篇文章内容和评论 class ArticleDetailView(DetailView): ''' 文章详情页面 @@ -117,6 +123,7 @@ class ArticleDetailView(DetailView): def get_context_data(self, **kwargs): comment_form = CommentForm() + # zhq: 获取文章评论并进行分页处理 article_comments = self.object.comment_list() parent_comments = article_comments.filter(parent_comment=None) blog_setting = get_blog_setting() @@ -135,6 +142,7 @@ class ArticleDetailView(DetailView): 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 + # zhq: 获取文章评论并进行分页处理 if next_page: kwargs[ 'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container' @@ -152,15 +160,16 @@ class ArticleDetailView(DetailView): context = super(ArticleDetailView, self).get_context_data(**kwargs) article = self.object + + # 触发文章详情加载钩子,让插件可以添加额外的上下文数据 + 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) - # # Filter Hook, 允许插件修改文章正文 - article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article, - request=self.request) - return context - +#zhq: 分类详情页视图 - 显示指定分类下的文章 class CategoryDetailView(ArticleListView): ''' 分类目录列表 @@ -173,10 +182,14 @@ class CategoryDetailView(ArticleListView): categoryname = category.name self.categoryname = categoryname + # zhq: 获取分类及其所有子分类 categorynames = list( map(lambda c: c.name, category.get_sub_categorys())) + # 性能优化:预加载关联对象 article_list = Article.objects.filter( - category__name__in=categorynames, status='p') + category__name__in=categorynames, status='p') \ + .select_related('author', 'category') \ + .prefetch_related('tags') return article_list def get_queryset_cache_key(self): @@ -199,7 +212,7 @@ class CategoryDetailView(ArticleListView): kwargs['tag_name'] = categoryname return super(CategoryDetailView, self).get_context_data(**kwargs) - +#zhq: 作者详情页视图 - 显示指定作者的文章 class AuthorDetailView(ArticleListView): ''' 作者详情页 @@ -215,8 +228,11 @@ class AuthorDetailView(ArticleListView): def get_queryset_data(self): author_name = self.kwargs['author_name'] + # 性能优化:预加载关联对象 article_list = Article.objects.filter( - author__username=author_name, type='a', status='p') + author__username=author_name, type='a', status='p') \ + .select_related('author', 'category') \ + .prefetch_related('tags') return article_list def get_context_data(self, **kwargs): @@ -225,7 +241,7 @@ class AuthorDetailView(ArticleListView): kwargs['tag_name'] = author_name return super(AuthorDetailView, self).get_context_data(**kwargs) - +#zhq: 标签详情页视图 - 显示指定标签的文章 class TagDetailView(ArticleListView): ''' 标签列表页面 @@ -237,8 +253,11 @@ class TagDetailView(ArticleListView): tag = get_object_or_404(Tag, slug=slug) tag_name = tag.name self.name = tag_name + # 性能优化:预加载关联对象,tags 使用 prefetch_related article_list = Article.objects.filter( - tags__name=tag_name, type='a', status='p') + tags__name=tag_name, type='a', status='p') \ + .select_related('author', 'category') \ + .prefetch_related('tags') return article_list def get_queryset_cache_key(self): @@ -257,7 +276,7 @@ class TagDetailView(ArticleListView): kwargs['tag_name'] = tag_name return super(TagDetailView, self).get_context_data(**kwargs) - +#zhq: 文章归档页视图 - 显示所有文章的按时间归档 class ArchivesView(ArticleListView): ''' 文章归档页面 @@ -268,13 +287,16 @@ class ArchivesView(ArticleListView): template_name = 'blog/article_archives.html' def get_queryset_data(self): - return Article.objects.filter(status='p').all() + # 性能优化:预加载关联对象 + return Article.objects.filter(status='p') \ + .select_related('author', 'category') \ + .prefetch_related('tags').all() def get_queryset_cache_key(self): cache_key = 'archives' return cache_key - +#zhq: 友情链接页视图 class LinkListView(ListView): model = Links template_name = 'blog/links_list.html' @@ -282,7 +304,7 @@ class LinkListView(ListView): def get_queryset(self): return Links.objects.filter(is_enable=True) - +#zhq: Elasticsearch搜索视图 class EsSearchView(SearchView): def get_context(self): paginator, page = self.build_page() @@ -299,7 +321,7 @@ class EsSearchView(SearchView): return context - +#zhq: 文件上传视图 - 支持图片和其他文件上传 @csrf_exempt def fileupload(request): """ @@ -308,6 +330,7 @@ def fileupload(request): :return: """ if request.method == 'POST': + # zhq: 验证上传签名,确保安全性 sign = request.GET.get('sign', None) if not sign: return HttpResponseForbidden() @@ -322,6 +345,7 @@ def fileupload(request): 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) + # zhq: 使用UUID生成唯一文件名,避免冲突 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") @@ -329,6 +353,7 @@ def fileupload(request): for chunk in request.FILES[filename].chunks(): wfile.write(chunk) if isimage: + # zhq: 对图片进行压缩优化 from PIL import Image image = Image.open(savepath) image.save(savepath, quality=20, optimize=True) @@ -339,7 +364,7 @@ def fileupload(request): else: return HttpResponse("only for post") - +#zhq: 404错误页面视图 def page_not_found_view( request, exception, @@ -353,7 +378,7 @@ def page_not_found_view( 'statuscode': '404'}, status=404) - +#zhq: 500服务器错误页面视图 def server_error_view(request, template_name='blog/error_page.html'): return render(request, template_name, @@ -361,7 +386,7 @@ def server_error_view(request, template_name='blog/error_page.html'): 'statuscode': '500'}, status=500) - +#zhq: 403权限拒绝页面视图 def permission_denied_view( request, exception, @@ -373,7 +398,7 @@ def permission_denied_view( 'message': _('Sorry, you do not have permission to access this page?'), 'statuscode': '403'}, status=403) - +#zhq: 清理缓存视图 def clean_cache_view(request): cache.clear() return HttpResponse('ok') diff --git a/src/blog/views_draft.py b/src/blog/views_draft.py new file mode 100644 index 0000000..18bd6c4 --- /dev/null +++ b/src/blog/views_draft.py @@ -0,0 +1,292 @@ +# 文章草稿 API 视图 +# 提供草稿的自动保存、获取和恢复功能 + +import json +import logging +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from blog.models_draft import ArticleDraft +from blog.models import Article + +logger = logging.getLogger(__name__) + + +@login_required +@require_http_methods(["POST"]) +def save_draft_api(request): + """ + 自动保存草稿 API + + POST 参数: + - title: 文章标题 + - body: 文章正文 + - article_id: 文章ID(编辑现有文章时) + - category_id: 分类ID + - tags: 标签ID列表(JSON) + - status: 状态 + - comment_status: 评论状态 + - type: 类型 + - session_id: 会话ID + + 返回: + JSON: {success: true, draft_id: xxx, message: xxx} + """ + try: + data = json.loads(request.body) + + title = data.get('title', '') + body = data.get('body', '') + article_id = data.get('article_id') + category_id = data.get('category_id') + tags_data = data.get('tags', []) + status = data.get('status', 'd') + comment_status = data.get('comment_status', 'o') + type_value = data.get('type', 'a') + session_id = data.get('session_id', '') + + # 保存草稿 + draft = ArticleDraft.save_draft( + user=request.user, + title=title, + body=body, + article_id=article_id, + category_id=category_id, + tags_data=tags_data, + status=status, + comment_status=comment_status, + type=type_value, + session_id=session_id + ) + + return JsonResponse({ + 'success': True, + 'draft_id': draft.id, + 'message': '草稿已自动保存', + 'last_update': draft.last_update_time.strftime('%Y-%m-%d %H:%M:%S') + }) + + except Exception as e: + logger.error(f"保存草稿失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'保存草稿失败: {str(e)}' + }, status=500) + + +@login_required +@require_http_methods(["GET"]) +def get_draft_api(request): + """ + 获取草稿 API + + GET 参数: + - article_id: 文章ID(获取该文章的草稿) + - session_id: 会话ID(获取该会话的草稿) + - draft_id: 草稿ID(直接获取指定草稿) + + 返回: + JSON: {success: true, draft: {...}} + """ + try: + article_id = request.GET.get('article_id') + session_id = request.GET.get('session_id') + draft_id = request.GET.get('draft_id') + + draft = None + + if draft_id: + draft = ArticleDraft.objects.filter( + id=draft_id, + author=request.user, + is_published=False + ).first() + elif session_id: + draft = ArticleDraft.objects.filter( + author=request.user, + session_id=session_id, + is_published=False + ).first() + elif article_id: + draft = ArticleDraft.objects.filter( + author=request.user, + article_id=article_id, + is_published=False + ).first() + else: + # 获取最新的未发布草稿 + draft = ArticleDraft.objects.filter( + author=request.user, + is_published=False + ).first() + + if draft: + return JsonResponse({ + 'success': True, + 'draft': { + 'id': draft.id, + 'title': draft.title, + 'body': draft.body, + 'article_id': draft.article_id, + 'category_id': draft.category_id, + 'tags': draft.tags_data, + 'status': draft.status, + 'comment_status': draft.comment_status, + 'type': draft.type, + 'last_update': draft.last_update_time.strftime('%Y-%m-%d %H:%M:%S'), + 'session_id': draft.session_id + } + }) + else: + return JsonResponse({ + 'success': False, + 'message': '未找到草稿' + }, status=404) + + except Exception as e: + logger.error(f"获取草稿失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'获取草稿失败: {str(e)}' + }, status=500) + + +@login_required +@require_http_methods(["GET"]) +def list_drafts_api(request): + """ + 获取用户的所有草稿列表 API + + 返回: + JSON: {success: true, drafts: [...]} + """ + try: + drafts = ArticleDraft.objects.filter( + author=request.user, + is_published=False + ).order_by('-last_update_time')[:20] + + drafts_data = [] + for draft in drafts: + drafts_data.append({ + 'id': draft.id, + 'title': draft.title or '(无标题)', + 'preview': draft.get_preview_text(50), + 'article_id': draft.article_id, + 'last_update': draft.last_update_time.strftime('%Y-%m-%d %H:%M:%S'), + 'creation_time': draft.creation_time.strftime('%Y-%m-%d %H:%M:%S') + }) + + return JsonResponse({ + 'success': True, + 'drafts': drafts_data, + 'count': len(drafts_data) + }) + + except Exception as e: + logger.error(f"获取草稿列表失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'获取草稿列表失败: {str(e)}' + }, status=500) + + +@login_required +@require_http_methods(["POST"]) +def delete_draft_api(request): + """ + 删除草稿 API + + POST 参数: + - draft_id: 草稿ID + + 返回: + JSON: {success: true, message: xxx} + """ + try: + data = json.loads(request.body) + draft_id = data.get('draft_id') + + if not draft_id: + return JsonResponse({ + 'success': False, + 'message': '缺少 draft_id 参数' + }, status=400) + + draft = ArticleDraft.objects.filter( + id=draft_id, + author=request.user + ).first() + + if draft: + draft.delete() + return JsonResponse({ + 'success': True, + 'message': '草稿已删除' + }) + else: + return JsonResponse({ + 'success': False, + 'message': '未找到草稿' + }, status=404) + + except Exception as e: + logger.error(f"删除草稿失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'删除草稿失败: {str(e)}' + }, status=500) + + +@login_required +@require_http_methods(["POST"]) +def apply_draft_api(request): + """ + 应用草稿到文章 API + + POST 参数: + - draft_id: 草稿ID + + 返回: + JSON: {success: true, article_id: xxx, message: xxx} + """ + try: + data = json.loads(request.body) + draft_id = data.get('draft_id') + + if not draft_id: + return JsonResponse({ + 'success': False, + 'message': '缺少 draft_id 参数' + }, status=400) + + draft = ArticleDraft.objects.filter( + id=draft_id, + author=request.user, + is_published=False + ).first() + + if not draft: + return JsonResponse({ + 'success': False, + 'message': '未找到草稿' + }, status=404) + + # 应用草稿到文章 + article = draft.apply_to_article() + + return JsonResponse({ + 'success': True, + 'article_id': article.id, + 'message': '草稿已应用到文章', + 'article_url': article.get_absolute_url() + }) + + except Exception as e: + logger.error(f"应用草稿失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'应用草稿失败: {str(e)}' + }, status=500) diff --git a/src/blog/views_media.py b/src/blog/views_media.py new file mode 100644 index 0000000..0474b45 --- /dev/null +++ b/src/blog/views_media.py @@ -0,0 +1,463 @@ +# 多媒体管理 API 视图 +# 提供图片上传、管理、删除等功能 + +import os +import uuid +import mimetypes +import logging +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse, HttpResponseForbidden +from django.views.decorators.http import require_http_methods +from django.core.paginator import Paginator +from PIL import Image + +from blog.models_media import MediaFile, MediaFolder, MediaFileFolder +from djangoblog.utils import get_sha256 + +logger = logging.getLogger(__name__) + + +# ==================== 文件上传 API ==================== + +@login_required +@require_http_methods(["POST"]) +def upload_media_api(request): + """ + 上传媒体文件 API + + POST 参数: + - file: 上传的文件 + - folder_id: 文件夹ID(可选) + - description: 文件描述(可选) + - is_public: 是否公开(可选,默认True) + + 返回: + JSON: {success: true, file: {...}} + """ + try: + # 检查是否有文件 + if 'file' not in request.FILES: + return JsonResponse({ + 'success': False, + 'message': '没有上传文件' + }, status=400) + + uploaded_file = request.FILES['file'] + folder_id = request.POST.get('folder_id') + description = request.POST.get('description', '') + is_public = request.POST.get('is_public', 'true').lower() == 'true' + + # 验证文件大小(默认最大10MB) + max_size = getattr(settings, 'MAX_UPLOAD_SIZE', 10 * 1024 * 1024) + if uploaded_file.size > max_size: + return JsonResponse({ + 'success': False, + 'message': f'文件大小超过限制(最大{max_size // (1024*1024)}MB)' + }, status=400) + + # 计算文件哈希 + file_hash = MediaFile.get_file_hash(uploaded_file) + + # 检查是否已存在相同文件 + existing_file = MediaFile.check_duplicate(file_hash) + if existing_file: + # 如果已存在,直接返回已有文件 + return JsonResponse({ + 'success': True, + 'message': '文件已存在,使用已有文件', + 'file': _serialize_media_file(existing_file), + 'is_duplicate': True + }) + + # 判断文件类型 + original_filename = uploaded_file.name + file_ext = os.path.splitext(original_filename)[1].lower() + mime_type = uploaded_file.content_type or mimetypes.guess_type(original_filename)[0] or 'application/octet-stream' + + # 判断是否为图片 + image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'] + is_image = file_ext in image_extensions + + # 生成存储文件名 + from django.utils import timezone + timestr = timezone.now().strftime('%Y/%m/%d') + stored_filename = f"{uuid.uuid4().hex}{file_ext}" + + # 确定存储路径 + file_type = 'image' if is_image else 'files' + base_dir = os.path.join(settings.STATICFILES, file_type, timestr) + if not os.path.exists(base_dir): + os.makedirs(base_dir) + + # 保存文件 + save_path = os.path.join(base_dir, stored_filename) + with open(save_path, 'wb+') as destination: + for chunk in uploaded_file.chunks(): + destination.write(chunk) + + # 相对路径 + relative_path = os.path.join(file_type, timestr, stored_filename).replace('\\', '/') + + # 获取图片尺寸 + width, height = None, None + if is_image: + try: + with Image.open(save_path) as img: + width, height = img.size + + # 如果图片太大,进行压缩 + max_dimension = 2000 + if width > max_dimension or height > max_dimension: + img.thumbnail((max_dimension, max_dimension), Image.Resampling.LANCZOS) + img.save(save_path, quality=85, optimize=True) + width, height = img.size + else: + # 优化图片 + img.save(save_path, quality=85, optimize=True) + + except Exception as e: + logger.error(f"处理图片失败: {e}", exc_info=True) + + # 创建数据库记录 + media_file = MediaFile.objects.create( + original_filename=original_filename, + stored_filename=stored_filename, + file_type='image' if is_image else 'file', + file_size=uploaded_file.size, + file_hash=file_hash, + mime_type=mime_type, + file_path=relative_path, + uploader=request.user, + width=width, + height=height, + description=description, + is_public=is_public + ) + + # 生成缩略图 + if is_image: + media_file.generate_thumbnail() + + # 添加到文件夹 + if folder_id: + try: + folder = MediaFolder.objects.get(id=folder_id, owner=request.user) + MediaFileFolder.objects.create(file=media_file, folder=folder) + except MediaFolder.DoesNotExist: + pass + + return JsonResponse({ + 'success': True, + 'message': '上传成功', + 'file': _serialize_media_file(media_file) + }) + + except Exception as e: + logger.error(f"上传文件失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'上传失败: {str(e)}' + }, status=500) + + +@login_required +@require_http_methods(["GET"]) +def list_media_api(request): + """ + 获取媒体文件列表 API + + GET 参数: + - page: 页码(可选,默认1) + - page_size: 每页数量(可选,默认20) + - file_type: 文件类型筛选(可选:image/file) + - folder_id: 文件夹ID(可选) + - search: 搜索关键词(可选) + + 返回: + JSON: {success: true, files: [...], total: xxx, page: xxx} + """ + try: + page = int(request.GET.get('page', 1)) + page_size = int(request.GET.get('page_size', 20)) + file_type = request.GET.get('file_type') + folder_id = request.GET.get('folder_id') + search = request.GET.get('search', '').strip() + + # 基础查询(只显示用户自己的文件) + queryset = MediaFile.objects.filter(uploader=request.user) + + # 文件类型筛选 + if file_type in ['image', 'file']: + queryset = queryset.filter(file_type=file_type) + + # 文件夹筛选 + if folder_id: + try: + folder = MediaFolder.objects.get(id=folder_id, owner=request.user) + file_ids = MediaFileFolder.objects.filter(folder=folder).values_list('file_id', flat=True) + queryset = queryset.filter(id__in=file_ids) + except MediaFolder.DoesNotExist: + pass + + # 搜索 + if search: + queryset = queryset.filter(original_filename__icontains=search) + + # 排序 + queryset = queryset.order_by('-upload_time') + + # 分页 + paginator = Paginator(queryset, page_size) + page_obj = paginator.get_page(page) + + files_data = [_serialize_media_file(f) for f in page_obj] + + return JsonResponse({ + 'success': True, + 'files': files_data, + 'total': paginator.count, + 'page': page, + 'page_size': page_size, + 'total_pages': paginator.num_pages + }) + + except Exception as e: + logger.error(f"获取文件列表失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'获取失败: {str(e)}' + }, status=500) + + +@login_required +@require_http_methods(["DELETE"]) +def delete_media_api(request): + """ + 删除媒体文件 API + + DELETE 参数(JSON): + - file_id: 文件ID + + 返回: + JSON: {success: true, message: xxx} + """ + try: + import json + data = json.loads(request.body) + file_id = data.get('file_id') + + if not file_id: + return JsonResponse({ + 'success': False, + 'message': '缺少 file_id 参数' + }, status=400) + + # 获取文件(只能删除自己的文件) + try: + media_file = MediaFile.objects.get(id=file_id, uploader=request.user) + except MediaFile.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': '文件不存在或无权限删除' + }, status=404) + + # 检查引用计数 + if media_file.reference_count > 0: + return JsonResponse({ + 'success': False, + 'message': f'文件正被{media_file.reference_count}处引用,无法删除' + }, status=400) + + filename = media_file.original_filename + media_file.delete() + + return JsonResponse({ + 'success': True, + 'message': f'已删除文件:{filename}' + }) + + except Exception as e: + logger.error(f"删除文件失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'删除失败: {str(e)}' + }, status=500) + + +@login_required +@require_http_methods(["POST"]) +def update_media_api(request): + """ + 更新媒体文件信息 API + + POST 参数(JSON): + - file_id: 文件ID + - description: 描述(可选) + - is_public: 是否公开(可选) + + 返回: + JSON: {success: true, file: {...}} + """ + try: + import json + data = json.loads(request.body) + file_id = data.get('file_id') + + if not file_id: + return JsonResponse({ + 'success': False, + 'message': '缺少 file_id 参数' + }, status=400) + + # 获取文件 + try: + media_file = MediaFile.objects.get(id=file_id, uploader=request.user) + except MediaFile.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': '文件不存在或无权限修改' + }, status=404) + + # 更新字段 + if 'description' in data: + media_file.description = data['description'] + + if 'is_public' in data: + media_file.is_public = data['is_public'] + + media_file.save() + + return JsonResponse({ + 'success': True, + 'message': '更新成功', + 'file': _serialize_media_file(media_file) + }) + + except Exception as e: + logger.error(f"更新文件失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'更新失败: {str(e)}' + }, status=500) + + +# ==================== 文件夹管理 API ==================== + +@login_required +@require_http_methods(["POST"]) +def create_folder_api(request): + """创建文件夹""" + try: + import json + data = json.loads(request.body) + name = data.get('name', '').strip() + parent_id = data.get('parent_id') + description = data.get('description', '') + + if not name: + return JsonResponse({ + 'success': False, + 'message': '文件夹名称不能为空' + }, status=400) + + # 获取父文件夹 + parent = None + if parent_id: + try: + parent = MediaFolder.objects.get(id=parent_id, owner=request.user) + except MediaFolder.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': '父文件夹不存在' + }, status=404) + + # 创建文件夹 + folder = MediaFolder.objects.create( + name=name, + parent=parent, + owner=request.user, + description=description + ) + + return JsonResponse({ + 'success': True, + 'message': '创建成功', + 'folder': { + 'id': folder.id, + 'name': folder.name, + 'full_path': folder.get_full_path(), + 'parent_id': folder.parent_id, + 'created_time': folder.created_time.strftime('%Y-%m-%d %H:%M:%S'), + 'description': folder.description + } + }) + + except Exception as e: + logger.error(f"创建文件夹失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'创建失败: {str(e)}' + }, status=500) + + +@login_required +@require_http_methods(["GET"]) +def list_folders_api(request): + """获取文件夹列表""" + try: + folders = MediaFolder.objects.filter(owner=request.user).order_by('name') + + folders_data = [] + for folder in folders: + folders_data.append({ + 'id': folder.id, + 'name': folder.name, + 'full_path': folder.get_full_path(), + 'parent_id': folder.parent_id, + 'created_time': folder.created_time.strftime('%Y-%m-%d %H:%M:%S'), + 'files_count': folder.file_relations.count() + }) + + return JsonResponse({ + 'success': True, + 'folders': folders_data + }) + + except Exception as e: + logger.error(f"获取文件夹列表失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'获取失败: {str(e)}' + }, status=500) + + +# ==================== 工具函数 ==================== + +def _serialize_media_file(media_file): + """序列化媒体文件对象""" + return { + 'id': media_file.id, + 'original_filename': media_file.original_filename, + 'file_type': media_file.file_type, + 'file_size': media_file.file_size, + 'file_size_readable': _format_file_size(media_file.file_size), + 'mime_type': media_file.mime_type, + 'url': media_file.get_absolute_url(), + 'thumbnail_url': media_file.get_thumbnail_url(), + 'width': media_file.width, + 'height': media_file.height, + 'upload_time': media_file.upload_time.strftime('%Y-%m-%d %H:%M:%S'), + 'description': media_file.description, + 'is_public': media_file.is_public, + 'reference_count': media_file.reference_count + } + + +def _format_file_size(size_bytes): + """格式化文件大小""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f} TB" diff --git a/src/blog/views_social.py b/src/blog/views_social.py new file mode 100644 index 0000000..8e09175 --- /dev/null +++ b/src/blog/views_social.py @@ -0,0 +1,744 @@ +# 用户关注和收藏 API 视图 +# 提供关注、取消关注、收藏、取消收藏等功能 + +import json +import logging +from functools import wraps +from django.contrib.auth import get_user_model +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods +from django.shortcuts import get_object_or_404 + +from blog.models import Article +from blog.models_social import UserFollow, ArticleFavorite, ArticleLike +from blog.rate_limit import rate_limit, user_rate_limit + +logger = logging.getLogger(__name__) +User = get_user_model() + + +def ajax_login_required(view_func): + """ + 自定义装饰器:AJAX请求时返回JSON响应而不是重定向 + """ + @wraps(view_func) + def wrapper(request, *args, **kwargs): + if not request.user.is_authenticated: + return JsonResponse({ + 'success': False, + 'message': '请先登录', + 'login_required': True + }, status=401) + return view_func(request, *args, **kwargs) + return wrapper + + +# ==================== 关注相关 API ==================== + +@ajax_login_required +@require_http_methods(["POST"]) +@user_rate_limit(limit={'requests': 20, 'window': 60}, scope='follow') # 20次/分钟 +def follow_user_api(request): + """ + 关注用户 API + + POST 参数: + - user_id: 要关注的用户ID + + 返回: + JSON: {success: true, message: xxx} + """ + try: + data = json.loads(request.body) + user_id = data.get('user_id') + + if not user_id: + return JsonResponse({ + 'success': False, + 'message': '缺少 user_id 参数' + }, status=400) + + # 获取要关注的用户 + following_user = get_object_or_404(User, id=user_id) + + # 不能关注自己 + if following_user == request.user: + return JsonResponse({ + 'success': False, + 'message': '不能关注自己' + }, status=400) + + # 执行关注 + follow = UserFollow.follow(request.user, following_user) + + if follow: + return JsonResponse({ + 'success': True, + 'message': f'已关注 {following_user.username}', + 'following_count': UserFollow.get_following_count(request.user), + 'is_following': True + }) + else: + return JsonResponse({ + 'success': False, + 'message': '已经关注过该用户' + }, status=400) + + except User.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': '用户不存在' + }, status=404) + except Exception as e: + logger.error(f"关注用户失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'关注失败: {str(e)}' + }, status=500) + + +@ajax_login_required +@require_http_methods(["POST"]) +@user_rate_limit(limit={'requests': 20, 'window': 60}, scope='unfollow') # 20次/分钟 +def unfollow_user_api(request): + """ + 取消关注用户 API + + POST 参数: + - user_id: 要取消关注的用户ID + + 返回: + JSON: {success: true, message: xxx} + """ + try: + data = json.loads(request.body) + user_id = data.get('user_id') + + if not user_id: + return JsonResponse({ + 'success': False, + 'message': '缺少 user_id 参数' + }, status=400) + + # 获取要取消关注的用户 + following_user = get_object_or_404(User, id=user_id) + + # 执行取消关注 + success = UserFollow.unfollow(request.user, following_user) + + if success: + return JsonResponse({ + 'success': True, + 'message': f'已取消关注 {following_user.username}', + 'following_count': UserFollow.get_following_count(request.user), + 'is_following': False + }) + else: + return JsonResponse({ + 'success': False, + 'message': '未关注该用户' + }, status=400) + + except User.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': '用户不存在' + }, status=404) + except Exception as e: + logger.error(f"取消关注失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'取消关注失败: {str(e)}' + }, status=500) + + +@ajax_login_required +@require_http_methods(["GET"]) +@user_rate_limit(limit={'requests': 100, 'window': 60}, scope='check_following') # 100次/分钟 +def check_following_api(request): + """ + 检查是否已关注某用户 API + + GET 参数: + - user_id: 用户ID + + 返回: + JSON: {success: true, is_following: true/false} + """ + try: + user_id = request.GET.get('user_id') + + if not user_id: + return JsonResponse({ + 'success': False, + 'message': '缺少 user_id 参数' + }, status=400) + + following_user = get_object_or_404(User, id=user_id) + is_following = UserFollow.is_following(request.user, following_user) + + return JsonResponse({ + 'success': True, + 'is_following': is_following + }) + + except User.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': '用户不存在' + }, status=404) + except Exception as e: + logger.error(f"检查关注状态失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'检查失败: {str(e)}' + }, status=500) + + +@ajax_login_required +@require_http_methods(["GET"]) +@user_rate_limit(limit={'requests': 50, 'window': 60}, scope='following_list') # 50次/分钟 +def following_list_api(request): + """ + 获取关注列表 API + + GET 参数: + - user_id: 用户ID(可选,默认为当前用户) + - limit: 返回数量限制(可选) + + 返回: + JSON: {success: true, following: [...], count: xxx} + """ + try: + user_id = request.GET.get('user_id') + limit = request.GET.get('limit', 20) + + try: + limit = int(limit) + except ValueError: + limit = 20 + + # 获取用户 + if user_id: + user = get_object_or_404(User, id=user_id) + else: + user = request.user + + # 获取关注列表 + following_list = UserFollow.get_following_list(user, limit) + + following_data = [] + for u in following_list: + following_data.append({ + 'id': u.id, + 'username': u.username, + 'email': u.email if u == request.user else None, + 'date_joined': u.date_joined.strftime('%Y-%m-%d') + }) + + return JsonResponse({ + 'success': True, + 'following': following_data, + 'count': UserFollow.get_following_count(user), + 'total_count': len(following_data) + }) + + except User.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': '用户不存在' + }, status=404) + except Exception as e: + logger.error(f"获取关注列表失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'获取失败: {str(e)}' + }, status=500) + + +@ajax_login_required +@require_http_methods(["GET"]) +@user_rate_limit(limit={'requests': 50, 'window': 60}, scope='followers_list') # 50次/分钟 +def followers_list_api(request): + """ + 获取粉丝列表 API + + GET 参数: + - user_id: 用户ID(可选,默认为当前用户) + - limit: 返回数量限制(可选) + + 返回: + JSON: {success: true, followers: [...], count: xxx} + """ + try: + user_id = request.GET.get('user_id') + limit = request.GET.get('limit', 20) + + try: + limit = int(limit) + except ValueError: + limit = 20 + + # 获取用户 + if user_id: + user = get_object_or_404(User, id=user_id) + else: + user = request.user + + # 获取粉丝列表 + followers_list = UserFollow.get_followers_list(user, limit) + + followers_data = [] + for u in followers_list: + followers_data.append({ + 'id': u.id, + 'username': u.username, + 'date_joined': u.date_joined.strftime('%Y-%m-%d') + }) + + return JsonResponse({ + 'success': True, + 'followers': followers_data, + 'count': UserFollow.get_followers_count(user), + 'total_count': len(followers_data) + }) + + except User.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': '用户不存在' + }, status=404) + except Exception as e: + logger.error(f"获取粉丝列表失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'获取失败: {str(e)}' + }, status=500) + + +# ==================== 收藏相关 API ==================== + +@ajax_login_required +@require_http_methods(["POST"]) +@user_rate_limit(limit={'requests': 30, 'window': 60}, scope='favorite') # 30次/分钟 +def favorite_article_api(request): + """ + 收藏文章 API + + POST 参数: + - article_id: 文章ID + - note: 收藏备注(可选) + + 返回: + JSON: {success: true, message: xxx} + """ + try: + data = json.loads(request.body) + article_id = data.get('article_id') + note = data.get('note', '') + + if not article_id: + return JsonResponse({ + 'success': False, + 'message': '缺少 article_id 参数' + }, status=400) + + # 获取文章 + article = get_object_or_404(Article, id=article_id) + + # 执行收藏 + favorite = ArticleFavorite.add_favorite(request.user, article, note) + + if favorite: + return JsonResponse({ + 'success': True, + 'message': f'已收藏文章《{article.title}》', + 'favorite_count': ArticleFavorite.get_article_favorite_count(article), # 返回文章被收藏次数 + 'user_favorite_count': ArticleFavorite.get_favorite_count(request.user), # 返回用户收藏总数 + 'is_favorited': True + }) + else: + return JsonResponse({ + 'success': False, + 'message': '已经收藏过该文章' + }, status=400) + + except Article.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': '文章不存在' + }, status=404) + except Exception as e: + logger.error(f"收藏文章失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'收藏失败: {str(e)}' + }, status=500) + + +@ajax_login_required +@require_http_methods(["POST"]) +@user_rate_limit(limit={'requests': 30, 'window': 60}, scope='unfavorite') # 30次/分钟 +def unfavorite_article_api(request): + """ + 取消收藏文章 API + + POST 参数: + - article_id: 文章ID + + 返回: + JSON: {success: true, message: xxx} + """ + try: + data = json.loads(request.body) + article_id = data.get('article_id') + + if not article_id: + return JsonResponse({ + 'success': False, + 'message': '缺少 article_id 参数' + }, status=400) + + # 获取文章 + article = get_object_or_404(Article, id=article_id) + + # 执行取消收藏 + success = ArticleFavorite.remove_favorite(request.user, article) + + if success: + return JsonResponse({ + 'success': True, + 'message': f'已取消收藏《{article.title}》', + 'favorite_count': ArticleFavorite.get_article_favorite_count(article), # 返回文章被收藏次数 + 'user_favorite_count': ArticleFavorite.get_favorite_count(request.user), # 返回用户收藏总数 + 'is_favorited': False + }) + else: + return JsonResponse({ + 'success': False, + 'message': '未收藏该文章' + }, status=400) + + except Article.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': '文章不存在' + }, status=404) + except Exception as e: + logger.error(f"取消收藏失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'取消收藏失败: {str(e)}' + }, status=500) + + +@ajax_login_required +@require_http_methods(["GET"]) +@user_rate_limit(limit={'requests': 100, 'window': 60}, scope='check_favorite') # 100次/分钟 +def check_favorite_api(request): + """ + 检查是否已收藏某文章 API + + GET 参数: + - article_id: 文章ID + + 返回: + JSON: {success: true, is_favorited: true/false} + """ + try: + article_id = request.GET.get('article_id') + + if not article_id: + return JsonResponse({ + 'success': False, + 'message': '缺少 article_id 参数' + }, status=400) + + article = get_object_or_404(Article, id=article_id) + is_favorited = ArticleFavorite.is_favorited(request.user, article) + + return JsonResponse({ + 'success': True, + 'is_favorited': is_favorited, + 'favorite_count': ArticleFavorite.get_article_favorite_count(article), # 文章被收藏次数 + 'user_favorite_count': ArticleFavorite.get_favorite_count(request.user) # 用户收藏总数 + }) + + except Article.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': '文章不存在' + }, status=404) + except Exception as e: + logger.error(f"检查收藏状态失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'检查失败: {str(e)}' + }, status=500) + + +@ajax_login_required +@require_http_methods(["GET"]) +@user_rate_limit(limit={'requests': 50, 'window': 60}, scope='favorites_list') # 50次/分钟 +def favorites_list_api(request): + """ + 获取收藏列表 API + + GET 参数: + - limit: 返回数量限制(可选) + + 返回: + JSON: {success: true, favorites: [...], count: xxx} + """ + try: + limit = request.GET.get('limit', 20) + + try: + limit = int(limit) + except ValueError: + limit = 20 + + # 获取收藏列表 + favorites = ArticleFavorite.get_user_favorites(request.user, limit) + + favorites_data = [] + for fav in favorites: + article = fav.article + favorites_data.append({ + 'id': fav.id, + 'article_id': article.id, + 'article_title': article.title, + 'article_url': article.get_absolute_url(), + 'author': article.author.username, + 'category': article.category.name, + 'pub_time': article.pub_time.strftime('%Y-%m-%d'), + 'favorited_time': fav.creation_time.strftime('%Y-%m-%d %H:%M:%S'), + 'note': fav.note + }) + + return JsonResponse({ + 'success': True, + 'favorites': favorites_data, + 'count': ArticleFavorite.get_favorite_count(request.user), + 'total_count': len(favorites_data) + }) + + except Exception as e: + logger.error(f"获取收藏列表失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'获取失败: {str(e)}' + }, status=500) + + +# ==================== 点赞相关 API ==================== + +@ajax_login_required +@require_http_methods(["POST"]) +@user_rate_limit(limit={'requests': 60, 'window': 60}, scope='like') # 60次/分钟 +def like_article_api(request): + """ + 点赞文章 API + + POST 参数: + - article_id: 文章ID + + 返回: + JSON: {success: true, message: xxx, like_count: xxx} + """ + try: + data = json.loads(request.body) + article_id = data.get('article_id') + + if not article_id: + return JsonResponse({ + 'success': False, + 'message': '缺少 article_id 参数' + }, status=400) + + # 获取文章 + article = get_object_or_404(Article, id=article_id) + + # 执行点赞 + like = ArticleLike.add_like(request.user, article) + + if like: + like_count = ArticleLike.get_article_like_count(article) + return JsonResponse({ + 'success': True, + 'message': f'已点赞《{article.title}》', + 'like_count': like_count, + 'is_liked': True + }) + else: + return JsonResponse({ + 'success': False, + 'message': '已经点赞过该文章' + }, status=400) + + except Article.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': '文章不存在' + }, status=404) + except Exception as e: + logger.error(f"点赞文章失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'点赞失败: {str(e)}' + }, status=500) + + +@ajax_login_required +@require_http_methods(["POST"]) +@user_rate_limit(limit={'requests': 60, 'window': 60}, scope='unlike') # 60次/分钟 +def unlike_article_api(request): + """ + 取消点赞文章 API + + POST 参数: + - article_id: 文章ID + + 返回: + JSON: {success: true, message: xxx, like_count: xxx} + """ + try: + data = json.loads(request.body) + article_id = data.get('article_id') + + if not article_id: + return JsonResponse({ + 'success': False, + 'message': '缺少 article_id 参数' + }, status=400) + + # 获取文章 + article = get_object_or_404(Article, id=article_id) + + # 执行取消点赞 + success = ArticleLike.remove_like(request.user, article) + + if success: + like_count = ArticleLike.get_article_like_count(article) + return JsonResponse({ + 'success': True, + 'message': f'已取消点赞《{article.title}》', + 'like_count': like_count, + 'is_liked': False + }) + else: + return JsonResponse({ + 'success': False, + 'message': '未点赞该文章' + }, status=400) + + except Article.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': '文章不存在' + }, status=404) + except Exception as e: + logger.error(f"取消点赞失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'取消点赞失败: {str(e)}' + }, status=500) + + +@ajax_login_required +@require_http_methods(["GET"]) +@user_rate_limit(limit={'requests': 100, 'window': 60}, scope='check_like') # 100次/分钟 +def check_like_api(request): + """ + 检查是否已点赞某文章 API + + GET 参数: + - article_id: 文章ID + + 返回: + JSON: {success: true, is_liked: true/false, like_count: xxx} + """ + try: + article_id = request.GET.get('article_id') + + if not article_id: + return JsonResponse({ + 'success': False, + 'message': '缺少 article_id 参数' + }, status=400) + + article = get_object_or_404(Article, id=article_id) + is_liked = ArticleLike.is_liked(request.user, article) + like_count = ArticleLike.get_article_like_count(article) + + return JsonResponse({ + 'success': True, + 'is_liked': is_liked, + 'like_count': like_count + }) + + except Article.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': '文章不存在' + }, status=404) + except Exception as e: + logger.error(f"检查点赞状态失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'检查失败: {str(e)}' + }, status=500) + + +@ajax_login_required +@require_http_methods(["GET"]) +@user_rate_limit(limit={'requests': 50, 'window': 60}, scope='likes_list') # 50次/分钟 +def likes_list_api(request): + """ + 获取点赞列表 API + + GET 参数: + - limit: 返回数量限制(可选) + + 返回: + JSON: {success: true, likes: [...], count: xxx} + """ + try: + limit = request.GET.get('limit', 20) + + try: + limit = int(limit) + except ValueError: + limit = 20 + + # 获取点赞列表 + likes = ArticleLike.get_user_likes(request.user, limit) + + likes_data = [] + for like in likes: + article = like.article + likes_data.append({ + 'id': like.id, + 'article_id': article.id, + 'article_title': article.title, + 'article_url': article.get_absolute_url(), + 'author': article.author.username, + 'category': article.category.name, + 'pub_time': article.pub_time.strftime('%Y-%m-%d'), + 'liked_time': like.creation_time.strftime('%Y-%m-%d %H:%M:%S') + }) + + return JsonResponse({ + 'success': True, + 'likes': likes_data, + 'count': ArticleLike.get_user_like_count(request.user), + 'total_count': len(likes_data) + }) + + except Exception as e: + logger.error(f"获取点赞列表失败: {e}", exc_info=True) + return JsonResponse({ + 'success': False, + 'message': f'获取失败: {str(e)}' + }, status=500) diff --git a/src/check_admin.py b/src/check_admin.py new file mode 100644 index 0000000..dd53df9 --- /dev/null +++ b/src/check_admin.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +import django +import os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangoblog.settings') +django.setup() + +from django.contrib import admin + +print("=" * 60) +print("检查 Django Admin 注册情况") +print("=" * 60) + +blog_models = [] +for model in admin.site._registry.keys(): + if model._meta.app_label == 'blog': + blog_models.append(model) + +print(f"\n已注册的 Blog 模型数量: {len(blog_models)}") +print("\n模型列表:") +for model in sorted(blog_models, key=lambda m: m.__name__): + verbose = model._meta.verbose_name + verbose_plural = model._meta.verbose_name_plural + print(f" {model.__name__}") + print(f" 显示名称: {verbose} / {verbose_plural}") + +print("\n" + "=" * 60) diff --git a/src/check_custom_admin.py b/src/check_custom_admin.py new file mode 100644 index 0000000..61c09f9 --- /dev/null +++ b/src/check_custom_admin.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +import django +import os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangoblog.settings') +django.setup() + +from djangoblog.admin_site import admin_site + +print("=" * 60) +print("检查自定义 Admin Site 注册情况") +print("=" * 60) + +blog_models = [] +for model in admin_site._registry.keys(): + if model._meta.app_label == 'blog': + blog_models.append(model) + +print(f"\n已注册的 Blog 模型数量: {len(blog_models)}") +print("\n模型列表:") +for model in sorted(blog_models, key=lambda m: m.__name__): + verbose = model._meta.verbose_name + verbose_plural = model._meta.verbose_name_plural + print(f" {model.__name__}") + print(f" 显示名称: {verbose} / {verbose_plural}") + +print("\n" + "=" * 60) diff --git a/src/codecov.yml b/src/codecov.yml new file mode 100644 index 0000000..2298829 --- /dev/null +++ b/src/codecov.yml @@ -0,0 +1,87 @@ +codecov: + require_ci_to_pass: yes + +coverage: + precision: 2 + round: down + range: "70...100" + + status: + project: + default: + target: auto + threshold: 1% + informational: true + 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" diff --git a/src/comments/admin.py b/src/comments/admin.py index a814f3f..878f17e 100644 --- a/src/comments/admin.py +++ b/src/comments/admin.py @@ -1,23 +1,38 @@ +#hz 评论模块的Django后台管理配置 +#hz 本模块配置了评论模型在Django管理后台的显示、过滤、搜索和批量操作功能 + 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 _ +#hz 批量禁用评论的管理动作函数 +#hz 将选中的评论设置为不可见(is_enable=False) def disable_commentstatus(modeladmin, request, queryset): + #hz 批量更新选中评论的is_enable字段为False queryset.update(is_enable=False) +#hz 批量启用评论的管理动作函数 +#hz 将选中的评论设置为可见(is_enable=True) def enable_commentstatus(modeladmin, request, queryset): + #hz 批量更新选中评论的is_enable字段为True queryset.update(is_enable=True) +#hz 设置批量禁用评论动作的显示名称 disable_commentstatus.short_description = _('Disable comments') +#hz 设置批量启用评论动作的显示名称 enable_commentstatus.short_description = _('Enable comments') +#hz 评论模型的后台管理类 +#hz 配置评论在Django管理后台的显示方式、过滤选项、搜索字段和批量操作 class CommentAdmin(admin.ModelAdmin): + #hz 每页显示的评论数量 list_per_page = 20 + #hz 列表页显示的字段列 list_display = ( 'id', 'body', @@ -25,23 +40,45 @@ class CommentAdmin(admin.ModelAdmin): 'link_to_article', 'is_enable', 'creation_time') + #hz 列表页可点击的字段链接(点击可进入编辑页面) list_display_links = ('id', 'body', 'is_enable') + #hz 列表页右侧的过滤选项,可按is_enable字段进行过滤 list_filter = ('is_enable',) + #hz 编辑页面中排除的字段(不显示在编辑表单中) + #hz creation_time和last_modify_time通常由系统自动管理,不需要手动编辑 exclude = ('creation_time', 'last_modify_time') + #hz 可用的批量操作动作列表 actions = [disable_commentstatus, enable_commentstatus] + #hz 使用原始ID字段的选择器(用于外键字段,避免加载所有相关对象) + #hz 当关联对象数量很大时,使用raw_id_fields可以提升性能 + raw_id_fields = ('author', 'article') + #hz 可搜索的字段列表,支持按评论正文内容进行搜索 + search_fields = ('body',) + #hz 生成指向用户信息管理页面的链接 + #hz 在列表页显示评论作者的可点击链接,点击后跳转到用户编辑页面 def link_to_userinfo(self, obj): + #hz 获取作者对象的应用标签和模型名称 info = (obj.author._meta.app_label, obj.author._meta.model_name) + #hz 生成用户编辑页面的URL link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) + #hz 返回HTML格式的链接,显示用户的昵称或邮箱 return format_html( u'%s' % (link, obj.author.nickname if obj.author.nickname else obj.author.email)) + #hz 生成指向文章管理页面的链接 + #hz 在列表页显示评论关联文章的可点击链接,点击后跳转到文章编辑页面 def link_to_article(self, obj): + #hz 获取文章对象的应用标签和模型名称 info = (obj.article._meta.app_label, obj.article._meta.model_name) + #hz 生成文章编辑页面的URL link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,)) + #hz 返回HTML格式的链接,显示文章的标题 return format_html( u'%s' % (link, obj.article.title)) + #hz 设置link_to_userinfo字段在列表页的列标题 link_to_userinfo.short_description = _('User') + #hz 设置link_to_article字段在列表页的列标题 link_to_article.short_description = _('Article') diff --git a/src/comments/apps.py b/src/comments/apps.py index ff01b77..a0f1b9d 100644 --- a/src/comments/apps.py +++ b/src/comments/apps.py @@ -1,5 +1,11 @@ +#hz 评论模块的Django应用配置 +#hz 本模块定义了评论应用的配置类,用于Django应用的初始化和配置管理 + from django.apps import AppConfig +#hz 评论应用的配置类 +#hz 继承自AppConfig,用于配置评论应用的基本信息和行为 class CommentsConfig(AppConfig): + #hz 应用的完整Python路径,Django使用此路径来识别和定位应用 name = 'comments' diff --git a/src/comments/forms.py b/src/comments/forms.py index e83737d..b6d5cba 100644 --- a/src/comments/forms.py +++ b/src/comments/forms.py @@ -1,13 +1,25 @@ +#hz 评论模块的表单定义 +#hz 本模块定义了评论提交表单,用于收集和处理用户提交的评论数据 + from django import forms from django.forms import ModelForm from .models import Comment +#hz 评论表单类 +#hz 继承自ModelForm,用于创建和验证评论数据 class CommentForm(ModelForm): + #hz 父评论ID字段,用于实现嵌套评论功能 + #hz 使用HiddenInput隐藏输入框,用户不可见,通过JavaScript或后端逻辑设置 + #hz required=False表示该字段不是必填项(顶级评论不需要父评论ID) parent_comment_id = forms.IntegerField( widget=forms.HiddenInput, required=False) + #hz 表单的元数据配置 class Meta: + #hz 关联的模型类 model = Comment + #hz 表单中包含的字段,只包含评论正文body字段 + #hz 其他字段(如作者、文章、创建时间等)由视图逻辑自动设置 fields = ['body'] diff --git a/src/comments/management/__init__.py b/src/comments/management/__init__.py new file mode 100644 index 0000000..8b4cc36 --- /dev/null +++ b/src/comments/management/__init__.py @@ -0,0 +1 @@ +# Management commands module diff --git a/src/comments/management/commands/__init__.py b/src/comments/management/commands/__init__.py new file mode 100644 index 0000000..2c1c7c1 --- /dev/null +++ b/src/comments/management/commands/__init__.py @@ -0,0 +1 @@ +# Management commands diff --git a/src/comments/management/commands/manage_comments.py b/src/comments/management/commands/manage_comments.py new file mode 100644 index 0000000..23761c6 --- /dev/null +++ b/src/comments/management/commands/manage_comments.py @@ -0,0 +1,69 @@ +# 评论管理命令 +# 提供评论反垃圾功能的管理工具 + +from django.core.management.base import BaseCommand +from django.core.cache import cache + + +class Command(BaseCommand): + help = '评论系统管理工具' + + def add_arguments(self, parser): + parser.add_argument( + 'action', + type=str, + choices=['clear_cache', 'unblock_ip', 'show_blocked'], + help='操作类型:clear_cache(清除缓存), unblock_ip(解封IP), show_blocked(显示被封IP)' + ) + parser.add_argument( + '--ip', + type=str, + help='IP地址(用于unblock_ip)' + ) + + def handle(self, *args, **options): + action = options['action'] + + if action == 'clear_cache': + self.clear_comment_cache() + elif action == 'unblock_ip': + ip = options.get('ip') + if not ip: + self.stdout.write(self.style.ERROR('请使用 --ip 参数指定IP地址')) + return + self.unblock_ip(ip) + elif action == 'show_blocked': + self.show_blocked_ips() + + def clear_comment_cache(self): + """清除所有评论相关的缓存""" + self.stdout.write('正在清除评论缓存...') + + # 无法直接遍历所有缓存键,所以只能提示 + self.stdout.write(self.style.WARNING( + '注意:由于缓存系统限制,无法自动清除所有评论缓存。\n' + '如需完全清除,请使用以下命令:\n' + ' python manage.py shell\n' + ' >>> from django.core.cache import cache\n' + ' >>> cache.clear() # 清除所有缓存\n' + )) + + self.stdout.write(self.style.SUCCESS('提示已显示')) + + def unblock_ip(self, ip): + """解封被封禁的IP""" + blacklist_key = f'ip_blacklist_{ip}' + count_key = f'ip_comment_count_{ip}' + + # 删除黑名单和计数 + cache.delete(blacklist_key) + cache.delete(count_key) + + self.stdout.write(self.style.SUCCESS(f'已解封IP: {ip}')) + + def show_blocked_ips(self): + """显示被封禁的IP列表""" + self.stdout.write(self.style.WARNING( + '由于缓存系统限制,无法列出所有被封IP。\n' + '可以尝试查看应用日志了解被封IP信息。\n' + )) diff --git a/src/comments/migrations/0002_add_performance_indexes.py b/src/comments/migrations/0002_add_performance_indexes.py new file mode 100644 index 0000000..6e7a824 --- /dev/null +++ b/src/comments/migrations/0002_add_performance_indexes.py @@ -0,0 +1,23 @@ +# 性能优化:为评论模型添加数据库索引 +# Generated manually for performance optimization + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('comments', '0001_initial'), + ] + + operations = [ + # 为 Comment 模型添加组合索引 + migrations.AddIndex( + model_name='comment', + index=models.Index(fields=['article', 'is_enable'], name='comment_article_enable_idx'), + ), + migrations.AddIndex( + model_name='comment', + index=models.Index(fields=['is_enable', '-id'], name='comment_enable_id_idx'), + ), + ] diff --git a/src/comments/migrations/0004_merge_20251124_0221.py b/src/comments/migrations/0004_merge_20251124_0221.py new file mode 100644 index 0000000..15ae30f --- /dev/null +++ b/src/comments/migrations/0004_merge_20251124_0221.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.7 on 2025-11-24 02:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('comments', '0002_add_performance_indexes'), + ('comments', '0003_alter_comment_options_remove_comment_created_time_and_more'), + ] + + operations = [ + ] diff --git a/src/comments/migrations/0005_remove_comment_comment_article_enable_idx_and_more.py b/src/comments/migrations/0005_remove_comment_comment_article_enable_idx_and_more.py new file mode 100644 index 0000000..313c8ad --- /dev/null +++ b/src/comments/migrations/0005_remove_comment_comment_article_enable_idx_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.7 on 2025-11-25 13:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('comments', '0004_merge_20251124_0221'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='comment', + name='comment_article_enable_idx', + ), + migrations.RemoveIndex( + model_name='comment', + name='comment_enable_id_idx', + ), + ] diff --git a/src/comments/models.py b/src/comments/models.py index 7c3bbc8..ac50c7f 100644 --- a/src/comments/models.py +++ b/src/comments/models.py @@ -1,3 +1,6 @@ +#hz 评论模块的数据模型定义 +#hz 本模块定义了博客评论系统的核心数据模型Comment,支持评论的创建、管理以及嵌套评论功能 + from django.conf import settings from django.db import models from django.utils.timezone import now @@ -8,32 +11,48 @@ from blog.models import Article # Create your models here. +#hz 评论模型类 +#hz 用于存储博客文章的评论信息,支持评论的正文内容、作者、关联文章、父评论(支持嵌套评论)等字段 class Comment(models.Model): + #hz 评论正文内容,最大长度为300字符 body = models.TextField('正文', max_length=300) + #hz 评论创建时间,默认为当前时间 creation_time = models.DateTimeField(_('creation time'), default=now) + #hz 评论最后修改时间,默认为当前时间 last_modify_time = models.DateTimeField(_('last modify time'), default=now) + #hz 评论作者,关联到用户模型,使用级联删除(删除用户时同时删除该用户的所有评论) author = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_('author'), on_delete=models.CASCADE) + #hz 评论关联的文章,关联到Article模型,使用级联删除(删除文章时同时删除该文章的所有评论) article = models.ForeignKey( Article, verbose_name=_('article'), on_delete=models.CASCADE) + #hz 父评论,支持嵌套评论功能,可以为空(表示顶级评论) + #hz 使用'self'实现自关联,允许评论回复其他评论 parent_comment = models.ForeignKey( 'self', verbose_name=_('parent comment'), blank=True, null=True, on_delete=models.CASCADE) + #hz 评论是否启用(显示),默认为False(需要审核后才能显示) is_enable = models.BooleanField(_('enable'), default=False, blank=False, null=False) + #hz 模型的元数据配置 class Meta: + #hz 按ID降序排列,最新评论在前 ordering = ['-id'] + #hz 单数形式的模型名称 verbose_name = _('comment') + #hz 复数形式的模型名称 verbose_name_plural = verbose_name + #hz 获取最新记录时使用的字段 get_latest_by = 'id' + #hz 对象的字符串表示方法,返回评论的正文内容 def __str__(self): return self.body diff --git a/src/comments/spam_checker.py b/src/comments/spam_checker.py new file mode 100644 index 0000000..1f29a35 --- /dev/null +++ b/src/comments/spam_checker.py @@ -0,0 +1,479 @@ +# 评论反垃圾和邮件通知工具 +# 提供评论垃圾检测、频率限制和邮件通知功能 + +import hashlib +import logging +from datetime import timedelta +from django.core.cache import cache +from django.core.mail import send_mail, EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.timezone import now +from django.conf import settings + +logger = logging.getLogger(__name__) + + +# ==================== 反垃圾评论功能 ==================== + +class CommentSpamChecker: + """评论垃圾检测器""" + + # 垃圾关键词列表(可从数据库或配置文件加载) + SPAM_KEYWORDS = [ + '赌博', '博彩', '色情', '黄色', '成人', + '代开发票', '办证', '贷款', '信用卡套现', + '私服', '外挂', '刷钻', '代刷', + '六合彩', '时时彩', '北京赛车', + '免费领取', '点击领取', '加QQ', '加微信', + 'viagra', 'casino', 'poker', 'cialis', + ] + + # 允许重复评论的最小间隔时间(秒) + MIN_COMMENT_INTERVAL = 10 + + # 单个用户每小时最大评论数 + MAX_COMMENTS_PER_HOUR = 20 + + # 单个IP每小时最大评论数 + MAX_COMMENTS_PER_IP_HOUR = 30 + + @classmethod + def check_spam_keywords(cls, content): + """ + 检查评论内容是否包含垃圾关键词 + + Args: + content: 评论内容 + + Returns: + (bool, str): (是否包含垃圾词, 匹配到的关键词) + """ + content_lower = content.lower() + for keyword in cls.SPAM_KEYWORDS: + if keyword.lower() in content_lower: + return True, keyword + return False, None + + @classmethod + def check_duplicate(cls, user, content, article_id): + """ + 检查是否为重复评论 + + Args: + user: 用户对象 + content: 评论内容 + article_id: 文章ID + + Returns: + bool: 是否为重复评论 + """ + # 生成内容哈希 + content_hash = hashlib.md5(content.encode('utf-8')).hexdigest() + cache_key = f'comment_hash_{user.id}_{article_id}_{content_hash}' + + # 检查缓存中是否存在(5分钟内) + if cache.get(cache_key): + return True + + # 设置缓存标记(5分钟过期) + cache.set(cache_key, '1', 300) + return False + + @classmethod + def check_rate_limit_user(cls, user): + """ + 检查用户评论频率限制 + + Args: + user: 用户对象 + + Returns: + (bool, str): (是否超过限制, 错误信息) + """ + from comments.models import Comment + + # 检查最近一条评论的时间 + last_comment_key = f'user_last_comment_{user.id}' + last_comment_time = cache.get(last_comment_key) + + if last_comment_time: + time_diff = (now() - last_comment_time).total_seconds() + if time_diff < cls.MIN_COMMENT_INTERVAL: + wait_time = int(cls.MIN_COMMENT_INTERVAL - time_diff) + return True, f'评论太频繁,请等待 {wait_time} 秒后再试' + + # 检查每小时评论数 + one_hour_ago = now() - timedelta(hours=1) + hour_count = Comment.objects.filter( + author=user, + creation_time__gte=one_hour_ago + ).count() + + if hour_count >= cls.MAX_COMMENTS_PER_HOUR: + return True, f'您在1小时内发表评论过多,请稍后再试' + + # 更新最后评论时间 + cache.set(last_comment_key, now(), 3600) + + return False, None + + @classmethod + def check_rate_limit_ip(cls, ip_address): + """ + 检查IP评论频率限制 + + Args: + ip_address: IP地址 + + Returns: + (bool, str): (是否超过限制, 错误信息) + """ + # 检查IP是否在黑名单 + blacklist_key = f'ip_blacklist_{ip_address}' + if cache.get(blacklist_key): + return True, '您的IP已被限制评论' + + # 检查IP每小时评论数 + ip_count_key = f'ip_comment_count_{ip_address}' + ip_count = cache.get(ip_count_key, 0) + + if ip_count >= cls.MAX_COMMENTS_PER_IP_HOUR: + # 将IP加入黑名单(1小时) + cache.set(blacklist_key, '1', 3600) + return True, '该IP地址评论过于频繁,已被暂时限制' + + # 增加计数 + cache.set(ip_count_key, ip_count + 1, 3600) + + return False, None + + @classmethod + def is_spam(cls, user, content, article_id, ip_address): + """ + 综合检查评论是否为垃圾评论 + + Args: + user: 用户对象 + content: 评论内容 + article_id: 文章ID + ip_address: IP地址 + + Returns: + (bool, str): (是否为垃圾评论, 原因) + """ + # 检查垃圾关键词 + has_spam, keyword = cls.check_spam_keywords(content) + if has_spam: + return True, f'评论包含敏感词: {keyword}' + + # 检查重复评论 + if cls.check_duplicate(user, content, article_id): + return True, '请不要发表重复评论' + + # 检查用户频率限制 + is_limited, msg = cls.check_rate_limit_user(user) + if is_limited: + return True, msg + + # 检查IP频率限制 + is_limited, msg = cls.check_rate_limit_ip(ip_address) + if is_limited: + return True, msg + + return False, None + + +# ==================== 邮件通知功能 ==================== + +class CommentNotifier: + """评论邮件通知器""" + + @classmethod + def get_client_ip(cls, request): + """获取客户端IP地址""" + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + @classmethod + def send_comment_notification(cls, comment, site_url=''): + """ + 发送评论通知邮件 + + Args: + comment: 评论对象 + site_url: 网站URL(可选) + """ + try: + # 通知文章作者 + if comment.article.author.email: + cls._notify_article_author(comment, site_url) + + # 如果是回复评论,通知被回复者 + if comment.parent_comment and comment.parent_comment.author.email: + cls._notify_parent_comment_author(comment, site_url) + + # 如果需要审核,通知管理员 + if not comment.is_enable: + cls._notify_admin_for_review(comment, site_url) + + except Exception as e: + logger.error(f"发送评论通知失败: {e}", exc_info=True) + + @classmethod + def _notify_article_author(cls, comment, site_url): + """通知文章作者有新评论""" + # 不要通知自己 + if comment.author == comment.article.author: + return + + subject = f'您的文章《{comment.article.title}》有新评论' + article_url = site_url + comment.article.get_absolute_url() + + # 构建邮件内容 + context = { + 'comment': comment, + 'article': comment.article, + 'article_url': article_url, + 'site_url': site_url, + } + + # 纯文本版本 + text_content = f""" +您好 {comment.article.author.username}, + +您的文章《{comment.article.title}》收到了新评论: + +评论者:{comment.author.username} +评论内容: +{comment.body} + +查看评论:{article_url}#div-comment-{comment.id} + +--- +此邮件由系统自动发送,请勿回复。 + """.strip() + + # HTML版本(可以更美观) + html_content = f""" + + + + + + + +
+
+

新评论通知

+
+

您好 {comment.article.author.username},

+

您的文章《{comment.article.title}》收到了新评论:

+
+

评论者:{comment.author.username}

+

评论内容:

+

{comment.body}

+
+

点击查看评论

+ +
+ + + """.strip() + + # 发送邮件 + msg = EmailMultiAlternatives( + subject, + text_content, + settings.DEFAULT_FROM_EMAIL, + [comment.article.author.email] + ) + msg.attach_alternative(html_content, "text/html") + msg.send(fail_silently=True) + + logger.info(f"已向文章作者 {comment.article.author.username} 发送评论通知") + + @classmethod + def _notify_parent_comment_author(cls, comment, site_url): + """通知被回复的评论作者""" + # 不要通知自己 + if comment.author == comment.parent_comment.author: + return + + # 如果父评论作者就是文章作者,已经通知过了 + if comment.parent_comment.author == comment.article.author: + return + + subject = f'{comment.author.username} 回复了您的评论' + article_url = site_url + comment.article.get_absolute_url() + + text_content = f""" +您好 {comment.parent_comment.author.username}, + +{comment.author.username} 回复了您在文章《{comment.article.title}》中的评论: + +您的评论: +{comment.parent_comment.body} + +回复内容: +{comment.body} + +查看回复:{article_url}#div-comment-{comment.id} + +--- +此邮件由系统自动发送,请勿回复。 + """.strip() + + html_content = f""" + + + + + + + +
+
+

评论回复通知

+
+

您好 {comment.parent_comment.author.username},

+

{comment.author.username} 回复了您在文章《{comment.article.title}》中的评论:

+
+

您的评论:

+

{comment.parent_comment.body}

+
+
+

回复内容:

+

{comment.body}

+
+

点击查看回复

+ +
+ + + """.strip() + + msg = EmailMultiAlternatives( + subject, + text_content, + settings.DEFAULT_FROM_EMAIL, + [comment.parent_comment.author.email] + ) + msg.attach_alternative(html_content, "text/html") + msg.send(fail_silently=True) + + logger.info(f"已向评论者 {comment.parent_comment.author.username} 发送回复通知") + + @classmethod + def _notify_admin_for_review(cls, comment, site_url): + """通知管理员审核评论""" + # 获取所有管理员邮箱 + from django.contrib.auth import get_user_model + User = get_user_model() + admin_emails = list(User.objects.filter( + is_staff=True, + is_active=True, + email__isnull=False + ).exclude(email='').values_list('email', flat=True)) + + if not admin_emails: + return + + subject = f'新评论待审核 - {comment.article.title}' + article_url = site_url + comment.article.get_absolute_url() + admin_url = site_url + '/admin/comments/comment/' + + text_content = f""" +管理员您好, + +有新评论需要审核: + +文章:《{comment.article.title}》 +评论者:{comment.author.username} +评论时间:{comment.creation_time.strftime('%Y-%m-%d %H:%M:%S')} +评论内容: +{comment.body} + +审核评论:{admin_url}{comment.id}/change/ +查看文章:{article_url} + +--- +此邮件由系统自动发送,请勿回复。 + """.strip() + + html_content = f""" + + + + + + + +
+
+

新评论待审核

+
+

管理员您好,

+

有新评论需要审核:

+
+

文章:《{comment.article.title}》

+

评论者:{comment.author.username}

+

评论时间:{comment.creation_time.strftime('%Y-%m-%d %H:%M:%S')}

+

评论内容:

+

{comment.body}

+
+

+ 审核评论 + 查看文章 +

+ +
+ + + """.strip() + + msg = EmailMultiAlternatives( + subject, + text_content, + settings.DEFAULT_FROM_EMAIL, + admin_emails + ) + msg.attach_alternative(html_content, "text/html") + msg.send(fail_silently=True) + + logger.info(f"已向 {len(admin_emails)} 位管理员发送审核通知") diff --git a/src/comments/templatetags/comments_tags.py b/src/comments/templatetags/comments_tags.py index fde02b4..abf84f6 100644 --- a/src/comments/templatetags/comments_tags.py +++ b/src/comments/templatetags/comments_tags.py @@ -1,30 +1,51 @@ +#hz 评论模块的Django模板标签 +#hz 本模块定义了评论相关的自定义模板标签,用于在模板中处理和显示评论数据 + from django import template +#hz 注册模板标签库 register = template.Library() +#hz 解析评论树结构的模板标签 +#hz 获得当前评论的所有子评论列表(递归获取所有嵌套的子评论) +#hz 用法: {% parse_commenttree article_comments comment as childcomments %} @register.simple_tag def parse_commenttree(commentlist, comment): """获得当前评论子评论的列表 用法: {% parse_commenttree article_comments comment as childcomments %} """ + #hz 存储所有子评论的列表 datas = [] + #hz 递归解析评论树的内部函数 + #hz 从指定的评论开始,递归查找所有子评论(支持多级嵌套) def parse(c): + #hz 查找当前评论的所有直接子评论(父评论为c且已启用的评论) childs = commentlist.filter(parent_comment=c, is_enable=True) + #hz 遍历所有子评论 for child in childs: + #hz 将子评论添加到结果列表 datas.append(child) + #hz 递归解析子评论的子评论(实现多级嵌套评论的解析) parse(child) + #hz 从传入的评论开始解析评论树 parse(comment) + #hz 返回所有子评论的列表 return datas +#hz 显示评论项的包含标签 +#hz 渲染评论项的模板,支持区分父评论和子评论的显示样式 @register.inclusion_tag('comments/tags/comment_item.html') def show_comment_item(comment, ischild): """评论""" + #hz 设置评论的显示深度:如果是子评论(ischild=True),深度为1;否则深度为2 + #hz 深度用于控制评论的缩进或样式,实现评论的层级显示效果 depth = 1 if ischild else 2 + #hz 返回传递给模板的上下文数据 return { - 'comment_item': comment, - 'depth': depth + 'comment_item': comment, #hz 评论对象 + 'depth': depth #hz 评论的显示深度 } diff --git a/src/comments/tests.py b/src/comments/tests.py index 2a7f55f..c9f9c01 100644 --- a/src/comments/tests.py +++ b/src/comments/tests.py @@ -1,3 +1,6 @@ +#hz 评论模块的单元测试 +#hz 本模块包含评论功能的单元测试用例,用于测试评论的创建、验证、嵌套评论和模板标签等功能 + from django.test import Client, RequestFactory, TransactionTestCase from django.urls import reverse @@ -10,71 +13,102 @@ from djangoblog.utils import get_max_articleid_commentid # Create your tests here. +#hz 评论功能测试类 +#hz 继承自TransactionTestCase,用于测试评论相关的功能,包括评论提交、审核、嵌套评论等 class CommentsTest(TransactionTestCase): + #hz 测试用例的初始化方法 + #hz 在每个测试方法执行前运行,用于设置测试环境和测试数据 def setUp(self): + #hz 创建测试客户端,用于模拟HTTP请求 self.client = Client() + #hz 创建请求工厂,用于创建请求对象 self.factory = RequestFactory() + #hz 设置博客配置:启用评论审核功能 from blog.models import BlogSettings value = BlogSettings() value.comment_need_review = True value.save() + #hz 创建测试用的超级用户 self.user = BlogUser.objects.create_superuser( email="liangliangyy1@gmail.com", username="liangliangyy1", password="liangliangyy1") + #hz 更新文章评论状态的辅助方法 + #hz 将指定文章的所有评论设置为启用状态(用于测试中模拟审核通过) def update_article_comment_status(self, article): + #hz 获取文章的所有评论 comments = article.comment_set.all() + #hz 遍历所有评论,将is_enable字段设置为True for comment in comments: comment.is_enable = True comment.save() + #hz 测试评论验证功能 + #hz 测试评论的创建、审核、嵌套评论和模板标签等功能 def test_validate_comment(self): + #hz 登录测试用户 self.client.login(username='liangliangyy1', password='liangliangyy1') + #hz 创建测试用的分类 category = Category() category.name = "categoryccc" category.save() + #hz 创建测试用的文章 article = Article() article.title = "nicetitleccc" article.body = "nicecontentccc" article.author = self.user article.category = category - article.type = 'a' - article.status = 'p' + article.type = 'a' #hz 文章类型 + article.status = 'p' #hz 文章状态:已发布 article.save() + #hz 获取评论提交的URL comment_url = reverse( 'comments:postcomment', kwargs={ 'article_id': article.id}) + #hz 测试提交第一条评论 response = self.client.post(comment_url, { 'body': '123ffffffffff' }) + #hz 验证响应状态码为302(重定向) self.assertEqual(response.status_code, 302) + #hz 重新获取文章对象,检查评论列表 article = Article.objects.get(pk=article.pk) + #hz 验证评论列表长度为0(因为评论需要审核,初始状态为未启用) self.assertEqual(len(article.comment_list()), 0) + #hz 更新评论状态为启用(模拟审核通过) self.update_article_comment_status(article) + #hz 验证评论列表长度为1(审核通过后可见) self.assertEqual(len(article.comment_list()), 1) + #hz 测试提交第二条评论 response = self.client.post(comment_url, { 'body': '123ffffffffff', }) + #hz 验证响应状态码为302(重定向) self.assertEqual(response.status_code, 302) + #hz 重新获取文章对象,更新评论状态,验证评论数量 article = Article.objects.get(pk=article.pk) self.update_article_comment_status(article) + #hz 验证评论列表长度为2 self.assertEqual(len(article.comment_list()), 2) + #hz 获取第一条评论的ID,用于测试嵌套评论 parent_comment_id = article.comment_list()[0].id + #hz 测试提交嵌套评论(回复评论) + #hz 评论内容包含Markdown格式,测试复杂内容的处理 response = self.client.post(comment_url, { 'body': ''' @@ -90,20 +124,32 @@ class CommentsTest(TransactionTestCase): ''', - 'parent_comment_id': parent_comment_id + 'parent_comment_id': parent_comment_id #hz 指定父评论ID,创建嵌套评论 }) + #hz 验证响应状态码为302(重定向) self.assertEqual(response.status_code, 302) + #hz 更新评论状态为启用 self.update_article_comment_status(article) + #hz 重新获取文章对象 article = Article.objects.get(pk=article.pk) + #hz 验证评论列表长度为3(包含2条顶级评论和1条回复评论) self.assertEqual(len(article.comment_list()), 3) + #hz 获取父评论对象 comment = Comment.objects.get(id=parent_comment_id) + #hz 测试评论树解析功能:解析父评论的所有子评论 tree = parse_commenttree(article.comment_list(), comment) + #hz 验证子评论数量为1 self.assertEqual(len(tree), 1) + #hz 测试评论项显示模板标签 data = show_comment_item(comment, True) + #hz 验证返回数据不为空 self.assertIsNotNone(data) + #hz 测试获取最大文章ID和评论ID的工具函数 s = get_max_articleid_commentid() + #hz 验证返回结果不为空 self.assertIsNotNone(s) + #hz 测试发送评论邮件功能 from comments.utils import send_comment_email send_comment_email(comment) diff --git a/src/comments/urls.py b/src/comments/urls.py index 7df3fab..a0d80d3 100644 --- a/src/comments/urls.py +++ b/src/comments/urls.py @@ -1,9 +1,17 @@ +#hz 评论模块的URL路由配置 +#hz 本模块定义了评论相关的URL路由规则,将URL路径映射到对应的视图处理函数 + from django.urls import path from . import views +#hz 应用命名空间,用于区分不同应用中同名的URL模式 app_name = "comments" +#hz URL模式列表,定义了评论模块的所有URL路由规则 urlpatterns = [ + #hz 评论提交路由 + #hz 路径格式:/article/<文章ID>/postcomment + #hz 处理评论的提交请求,使用CommentPostView视图类处理GET和POST请求 path( 'article//postcomment', views.CommentPostView.as_view(), diff --git a/src/comments/utils.py b/src/comments/utils.py index f01dba7..0f6cb81 100644 --- a/src/comments/utils.py +++ b/src/comments/utils.py @@ -1,3 +1,6 @@ +#hz 评论模块的工具函数 +#hz 本模块提供了评论相关的工具函数,主要包括发送评论通知邮件的功能 + import logging from django.utils.translation import gettext_lazy as _ @@ -5,13 +8,21 @@ from django.utils.translation import gettext_lazy as _ from djangoblog.utils import get_current_site from djangoblog.utils import send_email +#hz 获取当前模块的日志记录器,用于记录错误信息 logger = logging.getLogger(__name__) +#hz 发送评论通知邮件函数 +#hz 当用户提交评论后,向评论作者发送感谢邮件;如果是回复评论,则向被回复的用户发送通知邮件 def send_comment_email(comment): + #hz 获取当前网站的域名 site = get_current_site().domain + #hz 邮件主题 subject = _('Thanks for your comment') + #hz 构建文章的完整URL地址 article_url = f"https://{site}{comment.article.get_absolute_url()}" + #hz 构建发送给评论作者的邮件内容(感谢邮件) + #hz 包含文章链接和标题,感谢用户评论 html_content = _("""

Thank you very much for your comments on this site

You can visit %(article_title)s to review your comments, @@ -19,10 +30,16 @@ def send_comment_email(comment):
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} + #hz 获取评论作者的邮箱地址 tomail = comment.author.email + #hz 向评论作者发送感谢邮件 send_email([tomail], subject, html_content) + #hz 处理回复评论的情况:如果当前评论是回复其他评论,则向被回复的用户发送通知邮件 try: + #hz 检查当前评论是否有父评论(是否为回复评论) if comment.parent_comment: + #hz 构建发送给被回复用户的邮件内容(回复通知邮件) + #hz 包含文章链接、标题和被回复的评论内容,通知用户有人回复了他们的评论 html_content = _("""Your comment on %(article_title)s
has received a reply.
%(comment_body)s
@@ -32,7 +49,10 @@ def send_comment_email(comment): %(article_url)s """) % {'article_url': article_url, 'article_title': comment.article.title, 'comment_body': comment.parent_comment.body} + #hz 获取被回复评论的作者邮箱地址 tomail = comment.parent_comment.author.email + #hz 向被回复的用户发送通知邮件 send_email([tomail], subject, html_content) + #hz 捕获发送邮件过程中可能出现的异常,记录错误日志但不影响主流程 except Exception as e: logger.error(e) diff --git a/src/comments/views.py b/src/comments/views.py index ad9b2b9..7d146a2 100644 --- a/src/comments/views.py +++ b/src/comments/views.py @@ -1,3 +1,6 @@ +#hz 评论模块的视图处理 +#hz 本模块定义了评论提交相关的视图类,处理用户评论的提交、验证和保存逻辑 + # Create your views here. from django.core.exceptions import ValidationError from django.http import HttpResponseRedirect @@ -12,52 +15,85 @@ from .forms import CommentForm from .models import Comment +#hz 评论提交视图类 +#hz 继承自FormView,用于处理评论表单的提交和验证 class CommentPostView(FormView): + #hz 使用的表单类 form_class = CommentForm + #hz 渲染模板名称 template_name = 'blog/article_detail.html' + #hz 请求分发方法,添加CSRF保护装饰器,防止跨站请求伪造攻击 @method_decorator(csrf_protect) def dispatch(self, *args, **kwargs): return super(CommentPostView, self).dispatch(*args, **kwargs) + #hz 处理GET请求的方法 + #hz 当用户通过GET方式访问评论提交URL时,重定向到文章详情页的评论区域 def get(self, request, *args, **kwargs): + #hz 从URL参数中获取文章ID article_id = self.kwargs['article_id'] + #hz 获取文章对象,如果不存在则返回404错误 article = get_object_or_404(Article, pk=article_id) + #hz 获取文章的绝对URL url = article.get_absolute_url() + #hz 重定向到文章详情页的评论区域(通过锚点#comments定位) return HttpResponseRedirect(url + "#comments") + #hz 表单验证失败时的处理方法 + #hz 当用户提交的评论表单验证失败时,重新渲染文章详情页并显示表单错误信息 def form_invalid(self, form): + #hz 从URL参数中获取文章ID article_id = self.kwargs['article_id'] + #hz 获取文章对象,如果不存在则返回404错误 article = get_object_or_404(Article, pk=article_id) + #hz 返回包含表单和文章对象的响应,用于重新渲染页面并显示错误信息 return self.render_to_response({ 'form': form, 'article': article }) + #hz 表单验证成功时的处理方法 + #hz 提交的数据验证合法后的逻辑:保存评论、处理嵌套评论、设置审核状态等 def form_valid(self, form): """提交的数据验证合法后的逻辑""" + #hz 获取当前登录用户 user = self.request.user + #hz 获取用户对应的BlogUser对象 author = BlogUser.objects.get(pk=user.pk) + #hz 从URL参数中获取文章ID article_id = self.kwargs['article_id'] + #hz 获取文章对象,如果不存在则返回404错误 article = get_object_or_404(Article, pk=article_id) + #hz 检查文章的评论状态和文章状态,如果评论已关闭或文章已关闭,则抛出验证错误 if article.comment_status == 'c' or article.status == 'c': raise ValidationError("该文章评论已关闭.") + #hz 保存表单数据但不提交到数据库(commit=False),以便进一步处理 comment = form.save(False) + #hz 设置评论关联的文章 comment.article = article + #hz 获取博客设置,用于判断评论是否需要审核 from djangoblog.utils import get_blog_setting settings = get_blog_setting() + #hz 如果博客设置中不需要审核评论,则直接启用评论(设置为可见) if not settings.comment_need_review: comment.is_enable = True + #hz 设置评论的作者 comment.author = author + #hz 处理嵌套评论:如果表单中包含父评论ID,则设置评论的父评论 if form.cleaned_data['parent_comment_id']: + #hz 获取父评论对象 parent_comment = Comment.objects.get( pk=form.cleaned_data['parent_comment_id']) + #hz 设置评论的父评论,实现评论回复功能 comment.parent_comment = parent_comment + #hz 保存评论到数据库(commit=True) comment.save(True) + #hz 重定向到文章详情页并定位到新提交的评论位置(通过锚点定位) return HttpResponseRedirect( "%s#div-comment-%d" % (article.get_absolute_url(), comment.pk)) diff --git a/src/deploy/k8s/deployment.yaml b/src/deploy/k8s/deployment.yaml index 414fdcc..b50c411 100644 --- a/src/deploy/k8s/deployment.yaml +++ b/src/deploy/k8s/deployment.yaml @@ -26,13 +26,13 @@ spec: name: djangoblog-env readinessProbe: httpGet: - path: / + path: /health/ port: 8000 initialDelaySeconds: 10 periodSeconds: 30 livenessProbe: httpGet: - path: / + path: /health/ port: 8000 initialDelaySeconds: 10 periodSeconds: 30 diff --git a/src/djangoblog/admin_site.py b/src/djangoblog/admin_site.py index f120405..8574b36 100644 --- a/src/djangoblog/admin_site.py +++ b/src/djangoblog/admin_site.py @@ -40,6 +40,7 @@ class DjangoBlogAdminSite(AdminSite): admin_site = DjangoBlogAdminSite(name='admin') +# Blog 核心模型 admin_site.register(Article, ArticlelAdmin) admin_site.register(Category, CategoryAdmin) admin_site.register(Tag, TagAdmin) @@ -47,6 +48,25 @@ admin_site.register(Links, LinksAdmin) admin_site.register(SideBar, SideBarAdmin) admin_site.register(BlogSettings, BlogSettingsAdmin) +# Blog 新功能模型 +from blog.models_version import ArticleVersion +from blog.models_draft import ArticleDraft +from blog.models_social import UserFollow, ArticleFavorite, ArticleLike +from blog.models_media import MediaFile, MediaFolder, MediaFileFolder +from blog.admin_version import ArticleVersionAdmin +from blog.admin_draft import ArticleDraftAdmin +from blog.admin_social import UserFollowAdmin, ArticleFavoriteAdmin, ArticleLikeAdmin +from blog.admin_media import MediaFileAdmin, MediaFolderAdmin, MediaFileFolderAdmin + +admin_site.register(ArticleVersion, ArticleVersionAdmin) +admin_site.register(ArticleDraft, ArticleDraftAdmin) +admin_site.register(UserFollow, UserFollowAdmin) +admin_site.register(ArticleFavorite, ArticleFavoriteAdmin) +admin_site.register(ArticleLike, ArticleLikeAdmin) +admin_site.register(MediaFile, MediaFileAdmin) +admin_site.register(MediaFolder, MediaFolderAdmin) +admin_site.register(MediaFileFolder, MediaFileFolderAdmin) + admin_site.register(commands, CommandsAdmin) admin_site.register(EmailSendLog, EmailSendLogAdmin) diff --git a/src/djangoblog/plugin_manage/base_plugin.py b/src/djangoblog/plugin_manage/base_plugin.py index 2b4be5c..df1ce0b 100644 --- a/src/djangoblog/plugin_manage/base_plugin.py +++ b/src/djangoblog/plugin_manage/base_plugin.py @@ -1,4 +1,8 @@ import logging +from pathlib import Path + +from django.template import TemplateDoesNotExist +from django.template.loader import render_to_string logger = logging.getLogger(__name__) @@ -8,13 +12,34 @@ 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): """ 插件初始化逻辑 @@ -29,6 +54,129 @@ class BasePlugin: """ 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_head_html(self, context=None): + """获取需要插入到中的HTML内容""" + return "" + + def get_body_html(self, context=None): + """获取需要插入到底部的HTML内容""" + return "" + def get_plugin_info(self): """ 获取插件信息 @@ -37,5 +185,10 @@ class BasePlugin: return { 'name': self.PLUGIN_NAME, 'description': self.PLUGIN_DESCRIPTION, - 'version': self.PLUGIN_VERSION + '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 } diff --git a/src/djangoblog/plugin_manage/hook_constants.py b/src/djangoblog/plugin_manage/hook_constants.py index 6685b7c..8ed4e89 100644 --- a/src/djangoblog/plugin_manage/hook_constants.py +++ b/src/djangoblog/plugin_manage/hook_constants.py @@ -5,3 +5,18 @@ 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' + diff --git a/src/djangoblog/plugin_manage/loader.py b/src/djangoblog/plugin_manage/loader.py index 12e824b..ee750d0 100644 --- a/src/djangoblog/plugin_manage/loader.py +++ b/src/djangoblog/plugin_manage/loader.py @@ -4,16 +4,61 @@ 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: - __import__(f'plugins.{plugin_name}.plugin') - logger.info(f"Successfully loaded plugin: {plugin_name}") + # 导入插件模块 + 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) \ No newline at end of file + 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) + +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] \ No newline at end of file diff --git a/src/djangoblog/settings.py b/src/djangoblog/settings.py index 788f41f..dec93b1 100644 --- a/src/djangoblog/settings.py +++ b/src/djangoblog/settings.py @@ -105,19 +105,25 @@ 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 '0809', - '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'}, - }} +if os.environ.get('DJANGO_DB', 'mysql') == 'sqlite': + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } + } +else: + 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 '0809', + '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 diff --git a/src/djangoblog/urls.py b/src/djangoblog/urls.py index 4aae58a..6a9e1de 100644 --- a/src/djangoblog/urls.py +++ b/src/djangoblog/urls.py @@ -20,6 +20,8 @@ from django.contrib.sitemaps.views import sitemap from django.urls import path, include from django.urls import re_path from haystack.views import search_view_factory +from django.http import JsonResponse +import time from blog.views import EsSearchView from djangoblog.admin_site import admin_site @@ -40,8 +42,20 @@ handler404 = 'blog.views.page_not_found_view' handler500 = 'blog.views.server_error_view' handle403 = 'blog.views.permission_denied_view' + +def health_check(request): + """ + 健康检查接口 + 简单返回服务健康状态 + """ + return JsonResponse({ + 'status': 'healthy', + 'timestamp': time.time() + }) + urlpatterns = [ path('i18n/', include('django.conf.urls.i18n')), + path('health/', health_check, name='health_check'), ] urlpatterns += i18n_patterns( re_path(r'^admin/', admin_site.urls), diff --git a/src/djangoblog/utils.py b/src/djangoblog/utils.py index 57f63dc..91d2b91 100644 --- a/src/djangoblog/utils.py +++ b/src/djangoblog/utils.py @@ -224,9 +224,49 @@ def get_resource_url(): ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1', - 'h2', 'p'] -ALLOWED_ATTRIBUTES = {'a': ['href', 'title'], 'abbr': ['title'], 'acronym': ['title']} - + 'h2', 'p', 'span', 'div'] + +# 安全的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'], + 'abbr': ['title'], + 'acronym': ['title'], + 'span': class_filter, + 'div': class_filter, + 'pre': class_filter, + 'code': class_filter +} + +# 安全的协议白名单 - 防止javascript:等危险协议 +ALLOWED_PROTOCOLS = ['http', 'https', 'mailto'] def sanitize_html(html): - return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES) + """ + 安全的HTML清理函数 + 使用bleach库进行白名单过滤,防止XSS攻击 + """ + return bleach.clean( + html, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + protocols=ALLOWED_PROTOCOLS, # 限制允许的协议 + strip=True, # 移除不允许的标签而不是转义 + strip_comments=True # 移除HTML注释 + ) diff --git a/src/plugins/article_copyright/plugin.py b/src/plugins/article_copyright/plugin.py index 317fed2..5dba3b3 100644 --- a/src/plugins/article_copyright/plugin.py +++ b/src/plugins/article_copyright/plugin.py @@ -22,6 +22,11 @@ class ArticleCopyrightPlugin(BasePlugin): 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 diff --git a/src/plugins/article_recommendation/__init__.py b/src/plugins/article_recommendation/__init__.py new file mode 100644 index 0000000..951f2ff --- /dev/null +++ b/src/plugins/article_recommendation/__init__.py @@ -0,0 +1 @@ +# 文章推荐插件 diff --git a/src/plugins/article_recommendation/plugin.py b/src/plugins/article_recommendation/plugin.py new file mode 100644 index 0000000..6656a07 --- /dev/null +++ b/src/plugins/article_recommendation/plugin.py @@ -0,0 +1,205 @@ +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() diff --git a/src/plugins/article_recommendation/static/article_recommendation/css/recommendation.css b/src/plugins/article_recommendation/static/article_recommendation/css/recommendation.css new file mode 100644 index 0000000..b223f41 --- /dev/null +++ b/src/plugins/article_recommendation/static/article_recommendation/css/recommendation.css @@ -0,0 +1,166 @@ +/* 文章推荐插件样式 - 与网站风格保持一致 */ + +/* 文章底部推荐样式 */ +.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; +} + +/* 侧边栏推荐样式 */ +.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; + } +} diff --git a/src/plugins/article_recommendation/static/article_recommendation/js/recommendation.js b/src/plugins/article_recommendation/static/article_recommendation/js/recommendation.js new file mode 100644 index 0000000..eb19211 --- /dev/null +++ b/src/plugins/article_recommendation/static/article_recommendation/js/recommendation.js @@ -0,0 +1,93 @@ +/** + * 文章推荐插件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 + }; + +})(); diff --git a/src/plugins/image_lazy_loading/__init__.py b/src/plugins/image_lazy_loading/__init__.py new file mode 100644 index 0000000..2d27de0 --- /dev/null +++ b/src/plugins/image_lazy_loading/__init__.py @@ -0,0 +1 @@ +# Image Lazy Loading Plugin diff --git a/src/plugins/image_lazy_loading/plugin.py b/src/plugins/image_lazy_loading/plugin.py new file mode 100644 index 0000000..b4b9e0a --- /dev/null +++ b/src/plugins/image_lazy_loading/plugin.py @@ -0,0 +1,182 @@ +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() diff --git a/src/plugins/reading_time/plugin.py b/src/plugins/reading_time/plugin.py index 35f9db1..4b929d8 100644 --- a/src/plugins/reading_time/plugin.py +++ b/src/plugins/reading_time/plugin.py @@ -17,7 +17,15 @@ class ReadingTimePlugin(BasePlugin): 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() diff --git a/src/plugins/seo_optimizer/plugin.py b/src/plugins/seo_optimizer/plugin.py index b5b19a3..de12c15 100644 --- a/src/plugins/seo_optimizer/plugin.py +++ b/src/plugins/seo_optimizer/plugin.py @@ -97,6 +97,8 @@ class SeoOptimizerPlugin(BasePlugin): 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", @@ -131,12 +133,15 @@ class SeoOptimizerPlugin(BasePlugin): json_ld_script = f'' - return f""" + seo_html = f""" {seo_data.get("title", "")} {seo_data.get("meta_tags", "")} {json_ld_script} """ + + # 将SEO内容追加到现有的metas内容上 + return metas + seo_html plugin = SeoOptimizerPlugin() diff --git a/src/requirements.txt b/src/requirements.txt index 9dc5c93..5dc7a09 100644 Binary files a/src/requirements.txt and b/src/requirements.txt differ diff --git a/src/static/blog/css/dark-mode-fixes.css b/src/static/blog/css/dark-mode-fixes.css new file mode 100644 index 0000000..f573713 --- /dev/null +++ b/src/static/blog/css/dark-mode-fixes.css @@ -0,0 +1,457 @@ +/* 深色模式修复 - 覆盖 style.css 中的硬编码白色背景 */ + +/* 覆盖所有白色背景为使用CSS变量 */ +[data-theme="dark"] .site-header, +[data-theme="dark"] #masthead { + background-color: var(--nav-bg) !important; + color: var(--nav-text) !important; +} + +[data-theme="dark"] .site-content, +[data-theme="dark"] #content { + background-color: var(--bg-primary) !important; +} + +[data-theme="dark"] .widget-area, +[data-theme="dark"] #secondary { + background-color: var(--sidebar-bg) !important; +} + +[data-theme="dark"] .entry-content, +[data-theme="dark"] .entry-summary, +[data-theme="dark"] .page-content, +[data-theme="dark"] article { + background-color: var(--article-bg) !important; + color: var(--text-primary) !important; +} + +[data-theme="dark"] .site { + background-color: var(--bg-primary) !important; +} + +[data-theme="dark"] body { + background-color: var(--bg-primary) !important; + color: var(--text-primary) !important; +} + +/* 修复所有白色背景的元素 */ +[data-theme="dark"] *[style*="background: #fff"], +[data-theme="dark"] *[style*="background-color: #fff"], +[data-theme="dark"] *[style*="background: white"], +[data-theme="dark"] *[style*="background-color: white"] { + background-color: var(--bg-primary) !important; +} + +/* 修复所有白色文字的元素(排除按钮和链接) */ +[data-theme="dark"] *[style*="color: #fff"]:not(.btn):not(a), +[data-theme="dark"] *[style*="color: white"]:not(.btn):not(a) { + color: var(--text-primary) !important; +} + +/* 评论区修复 */ +[data-theme="dark"] #comments, +[data-theme="dark"] .comment-list, +[data-theme="dark"] .comment, +[data-theme="dark"] .comment-body, +[data-theme="dark"] .comment-content { + background-color: var(--comment-bg) !important; + color: var(--text-primary) !important; + border-color: var(--comment-border) !important; +} + +/* 导航菜单修复 */ +[data-theme="dark"] .nav-menu, +[data-theme="dark"] .main-navigation, +[data-theme="dark"] #site-navigation { + background-color: var(--nav-bg) !important; +} + +[data-theme="dark"] .nav-menu li, +[data-theme="dark"] .main-navigation li { + background-color: transparent !important; +} + +[data-theme="dark"] .nav-menu a, +[data-theme="dark"] .main-navigation a { + color: var(--nav-text) !important; +} + +[data-theme="dark"] .nav-menu a:hover, +[data-theme="dark"] .main-navigation a:hover { + background-color: var(--nav-hover-bg) !important; + color: var(--link-hover) !important; +} + +/* Widget 修复 */ +[data-theme="dark"] .widget { + background-color: var(--sidebar-bg) !important; + color: var(--text-primary) !important; + border-color: var(--sidebar-border) !important; +} + +[data-theme="dark"] .widget-title { + color: var(--text-primary) !important; + border-color: var(--border-primary) !important; +} + +[data-theme="dark"] .widget ul, +[data-theme="dark"] .widget ol { + background-color: transparent !important; +} + +[data-theme="dark"] .widget a { + color: var(--link-color) !important; +} + +/* 文章列表修复 */ +[data-theme="dark"] .hentry, +[data-theme="dark"] .post, +[data-theme="dark"] .page { + background-color: var(--card-bg) !important; + color: var(--text-primary) !important; + border-color: var(--card-border) !important; +} + +[data-theme="dark"] .entry-header { + background-color: transparent !important; +} + +[data-theme="dark"] .entry-title a { + color: var(--text-primary) !important; +} + +[data-theme="dark"] .entry-title a:hover { + color: var(--link-hover) !important; +} + +[data-theme="dark"] .entry-meta, +[data-theme="dark"] .entry-footer { + color: var(--text-secondary) !important; + background-color: transparent !important; +} + +/* 搜索框修复 */ +[data-theme="dark"] #searchform, +[data-theme="dark"] .search-form { + background-color: var(--input-bg) !important; +} + +[data-theme="dark"] #s, +[data-theme="dark"] .search-field { + background-color: var(--input-bg) !important; + color: var(--input-text) !important; + border-color: var(--input-border) !important; +} + +/* 分页修复 */ +[data-theme="dark"] .pagination, +[data-theme="dark"] .page-links, +[data-theme="dark"] .nav-links { + background-color: transparent !important; +} + +[data-theme="dark"] .pagination a, +[data-theme="dark"] .page-links a, +[data-theme="dark"] .nav-links a { + background-color: var(--card-bg) !important; + color: var(--link-color) !important; + border-color: var(--border-primary) !important; +} + +[data-theme="dark"] .pagination a:hover, +[data-theme="dark"] .page-links a:hover, +[data-theme="dark"] .nav-links a:hover { + background-color: var(--bg-hover) !important; +} + +[data-theme="dark"] .pagination .current, +[data-theme="dark"] .page-links > .current { + background-color: var(--accent-primary) !important; + color: var(--text-inverse) !important; +} + +/* 面包屑导航修复 */ +[data-theme="dark"] .breadcrumbs, +[data-theme="dark"] .breadcrumb { + background-color: var(--bg-secondary) !important; + color: var(--text-secondary) !important; +} + +/* 侧边栏小工具特定修复 */ +[data-theme="dark"] #calendar_wrap { + background-color: var(--card-bg) !important; +} + +[data-theme="dark"] #calendar_wrap table, +[data-theme="dark"] #calendar_wrap th, +[data-theme="dark"] #calendar_wrap td { + background-color: transparent !important; + color: var(--text-primary) !important; + border-color: var(--border-primary) !important; +} + +/* 标签云修复 */ +[data-theme="dark"] .tagcloud a, +[data-theme="dark"] .wp_widget_tag_cloud a { + background-color: var(--bg-tertiary) !important; + color: var(--text-primary) !important; + border-color: var(--border-primary) !important; +} + +[data-theme="dark"] .tagcloud a:hover, +[data-theme="dark"] .wp_widget_tag_cloud a:hover { + background-color: var(--bg-hover) !important; + color: var(--link-hover) !important; +} + +/* 最近评论修复 */ +[data-theme="dark"] .recentcomments { + background-color: transparent !important; + color: var(--text-primary) !important; +} + +/* RSS 链接修复 */ +[data-theme="dark"] .rss-date, +[data-theme="dark"] .rssSummary { + color: var(--text-secondary) !important; +} + +/* 存档页面修复 */ +[data-theme="dark"] .archive-meta, +[data-theme="dark"] .page-header { + background-color: var(--bg-secondary) !important; + color: var(--text-primary) !important; + border-color: var(--border-primary) !important; +} + +/* 404 页面修复 */ +[data-theme="dark"] .error404 .widget { + background-color: var(--card-bg) !important; +} + +/* 图片说明修复 */ +[data-theme="dark"] .wp-caption, +[data-theme="dark"] .gallery-caption { + background-color: var(--bg-secondary) !important; + color: var(--text-secondary) !important; +} + +[data-theme="dark"] .wp-caption-text { + color: var(--text-secondary) !important; +} + +/* 嵌入内容修复 */ +[data-theme="dark"] embed, +[data-theme="dark"] iframe, +[data-theme="dark"] object { + border-color: var(--border-primary) !important; +} + +/* 按钮修复 - 确保按钮上的白色文字不被改变 */ +[data-theme="dark"] .btn, +[data-theme="dark"] button, +[data-theme="dark"] input[type="submit"], +[data-theme="dark"] input[type="button"], +[data-theme="dark"] .comment-reply-link { + color: inherit; +} + +[data-theme="dark"] .btn-primary, +[data-theme="dark"] .btn-success, +[data-theme="dark"] .btn-info, +[data-theme="dark"] .btn-warning, +[data-theme="dark"] .btn-danger { + color: var(--text-inverse) !important; +} + +/* Sticky post 修复 */ +[data-theme="dark"] .sticky { + background-color: var(--bg-secondary) !important; + border-color: var(--accent-primary) !important; +} + +/* 引用文字修复 */ +[data-theme="dark"] cite { + color: var(--text-secondary) !important; +} + +/* 列表修复 */ +[data-theme="dark"] ul, +[data-theme="dark"] ol, +[data-theme="dark"] dl { + color: var(--text-primary) !important; +} + +/* 定义列表修复 */ +[data-theme="dark"] dt { + color: var(--text-primary) !important; +} + +[data-theme="dark"] dd { + color: var(--text-secondary) !important; +} + +/* 强调文本修复 */ +[data-theme="dark"] strong, +[data-theme="dark"] b { + color: var(--text-primary) !important; +} + +[data-theme="dark"] em, +[data-theme="dark"] i { + color: var(--text-primary) !important; +} + +/* 删除线修复 */ +[data-theme="dark"] del, +[data-theme="dark"] s { + color: var(--text-tertiary) !important; +} + +/* 下划线修复 */ +[data-theme="dark"] ins, +[data-theme="dark"] u { + color: var(--text-primary) !important; + background-color: var(--bg-tertiary) !important; +} + +/* 小号文字修复 */ +[data-theme="dark"] small { + color: var(--text-secondary) !important; +} + +/* 标记文字修复 */ +[data-theme="dark"] mark { + background-color: var(--bg-tertiary) !important; + color: var(--text-primary) !important; +} + +/* Pygments 代码高亮修复 */ +[data-theme="dark"] .highlight, +[data-theme="dark"] .codehilite { + background-color: var(--code-block-bg) !important; +} + +[data-theme="dark"] .highlight pre, +[data-theme="dark"] .codehilite pre { + background-color: transparent !important; +} + +/* 站点标题和描述修复 */ +[data-theme="dark"] .site-title, +[data-theme="dark"] .site-description { + color: var(--nav-text) !important; +} + +[data-theme="dark"] .site-title a { + color: var(--nav-text) !important; +} + +[data-theme="dark"] .site-title a:hover { + color: var(--link-hover) !important; +} + +/* 页面容器修复 */ +[data-theme="dark"] #page, +[data-theme="dark"] .site, +[data-theme="dark"] #main, +[data-theme="dark"] .wrapper { + background-color: var(--bg-primary) !important; +} + +/* 修复特定的警告框背景 */ +[data-theme="dark"] *[style*="background: #fff9c0"], +[data-theme="dark"] *[style*="background-color: #fff9c0"] { + background-color: rgba(255, 249, 192, 0.2) !important; + color: var(--text-primary) !important; +} + +/* 修复特定的警告框背景 */ +[data-theme="dark"] *[style*="background: #fff3cd"], +[data-theme="dark"] *[style*="background-color: #fff3cd"] { + background-color: rgba(255, 243, 205, 0.2) !important; + color: var(--text-primary) !important; +} + +/* 补充:文章卡片内部元素修复 */ +[data-theme="dark"] .post-thumbnail, +[data-theme="dark"] .entry-thumbnail { + background-color: transparent !important; +} + +/* 补充:作者信息框修复 */ +[data-theme="dark"] .author-info, +[data-theme="dark"] .author-bio { + background-color: var(--bg-secondary) !important; + color: var(--text-primary) !important; + border-color: var(--border-primary) !important; +} + +/* 补充:相关文章修复 */ +[data-theme="dark"] .related-posts, +[data-theme="dark"] .related-articles { + background-color: var(--bg-secondary) !important; +} + +/* 补充:分类和标签显示修复 */ +[data-theme="dark"] .cat-links, +[data-theme="dark"] .tags-links { + color: var(--text-secondary) !important; +} + +[data-theme="dark"] .cat-links a, +[data-theme="dark"] .tags-links a { + color: var(--link-color) !important; + background-color: var(--bg-tertiary) !important; +} + +/* 补充:阅读更多链接修复 */ +[data-theme="dark"] .more-link { + color: var(--link-color) !important; +} + +[data-theme="dark"] .more-link:hover { + color: var(--link-hover) !important; +} + +/* 补充:表单元素标签修复 */ +[data-theme="dark"] label { + color: var(--text-primary) !important; +} + +/* 补充:占位符修复 */ +[data-theme="dark"] ::placeholder { + color: var(--input-placeholder) !important; + opacity: 1; +} + +[data-theme="dark"] :-ms-input-placeholder { + color: var(--input-placeholder) !important; +} + +[data-theme="dark"] ::-ms-input-placeholder { + color: var(--input-placeholder) !important; +} + +/* 补充:选中文本修复 */ +[data-theme="dark"] ::selection { + background-color: var(--accent-primary) !important; + color: var(--text-inverse) !important; +} + +[data-theme="dark"] ::-moz-selection { + background-color: var(--accent-primary) !important; + color: var(--text-inverse) !important; +} + +/* 修复 hfeed 类容器 */ +[data-theme="dark"] .hfeed { + background-color: var(--bg-primary) !important; +} + +/* 修复所有可能的白色背景覆盖 */ +[data-theme="dark"] .site-header, +[data-theme="dark"] .site-content, +[data-theme="dark"] .site-footer { + background-color: transparent !important; +} diff --git a/src/static/blog/css/media-picker.css b/src/static/blog/css/media-picker.css new file mode 100644 index 0000000..14877bc --- /dev/null +++ b/src/static/blog/css/media-picker.css @@ -0,0 +1,296 @@ +/* 多媒体选择器样式 */ + +.media-picker-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 10000; +} + +.media-picker-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); +} + +.media-picker-container { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 90%; + max-width: 1000px; + height: 80%; + max-height: 700px; + background: white; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; +} + +.media-picker-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid #e0e0e0; +} + +.media-picker-header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.media-picker-close { + background: none; + border: none; + font-size: 28px; + cursor: pointer; + color: #666; + padding: 0; + width: 30px; + height: 30px; + line-height: 30px; + text-align: center; +} + +.media-picker-close:hover { + color: #333; +} + +.media-picker-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + border-bottom: 1px solid #e0e0e0; +} + +.media-picker-search input { + padding: 8px 15px; + border: 1px solid #ddd; + border-radius: 4px; + width: 250px; + font-size: 14px; +} + +.btn-upload { + display: inline-block; + padding: 8px 20px; + background: #4CAF50; + color: white; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background 0.3s; +} + +.btn-upload:hover { + background: #45a049; +} + +.media-picker-content { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.media-files-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 15px; +} + +.media-file-item { + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 10px; + cursor: pointer; + transition: all 0.3s; + position: relative; + background: white; +} + +.media-file-item:hover { + border-color: #4CAF50; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.media-file-item.selected { + border-color: #4CAF50; + background: #f1f8f4; +} + +.media-file-preview { + width: 100%; + height: 120px; + display: flex; + align-items: center; + justify-content: center; + background: #f5f5f5; + border-radius: 4px; + overflow: hidden; + margin-bottom: 8px; +} + +.media-file-preview img { + max-width: 100%; + max-height: 100%; + object-fit: cover; +} + +.file-icon { + font-size: 48px; +} + +.media-file-info { + text-align: center; +} + +.media-file-name { + font-size: 13px; + font-weight: 500; + color: #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} + +.media-file-meta { + font-size: 12px; + color: #999; +} + +.media-file-check { + position: absolute; + top: 10px; + right: 10px; + width: 24px; + height: 24px; + background: #4CAF50; + border-radius: 50%; + display: none; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; +} + +.media-file-item.selected .media-file-check { + display: flex; +} + +.media-picker-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + border-top: 1px solid #e0e0e0; +} + +.media-picker-pagination { + display: flex; + align-items: center; + gap: 15px; +} + +.media-picker-pagination button { + padding: 6px 15px; + border: 1px solid #ddd; + background: white; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} + +.media-picker-pagination button:hover:not(:disabled) { + background: #f5f5f5; +} + +.media-picker-pagination button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.media-picker-actions { + display: flex; + gap: 10px; +} + +.btn-cancel, .btn-select { + padding: 8px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background 0.3s; +} + +.btn-cancel { + background: #f0f0f0; + color: #333; +} + +.btn-cancel:hover { + background: #e0e0e0; +} + +.btn-select { + background: #4CAF50; + color: white; +} + +.btn-select:hover:not(:disabled) { + background: #45a049; +} + +.btn-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.loading, .error, .empty { + grid-column: 1 / -1; + text-align: center; + padding: 40px; + color: #999; + font-size: 14px; +} + +.error { + color: #F44336; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .media-picker-container { + width: 95%; + height: 90%; + } + + .media-files-grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 10px; + } + + .media-file-preview { + height: 80px; + } + + .media-picker-toolbar { + flex-direction: column; + gap: 10px; + } + + .media-picker-search input { + width: 100%; + } +} diff --git a/src/static/blog/css/responsive.css b/src/static/blog/css/responsive.css new file mode 100644 index 0000000..9192b45 --- /dev/null +++ b/src/static/blog/css/responsive.css @@ -0,0 +1,750 @@ +/* 响应式布局优化 + * 提供完整的移动端、平板和桌面端适配 + * 基于Bootstrap断点扩展 + */ + +/* ==================== 断点定义 ==================== */ + +/* +xs: <576px (超小屏 - 手机竖屏) +sm: ≥576px (小屏 - 手机横屏) +md: ≥768px (中屏 - 平板竖屏) +lg: ≥992px (大屏 - 平板横屏/小笔记本) +xl: ≥1200px (超大屏 - 桌面) +xxl: ≥1400px (超超大屏 - 大桌面) +*/ + +/* ==================== 基础布局 ==================== */ + +/* 容器最大宽度 */ +.container { + width: 100%; + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} + +@media (min-width: 576px) { + .container { + max-width: 540px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 720px; + } +} + +@media (min-width: 992px) { + .container { + max-width: 960px; + } +} + +@media (min-width: 1200px) { + .container { + max-width: 1140px; + } +} + +@media (min-width: 1400px) { + .container { + max-width: 1320px; + } +} + +/* 流式容器 */ +.container-fluid { + width: 100%; + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} + +/* ==================== 导航栏 ==================== */ + +.navbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + padding: 10px 15px; +} + +.navbar-brand { + font-size: 1.25rem; + white-space: nowrap; +} + +.navbar-toggler { + display: none; + padding: 0.25rem 0.75rem; + font-size: 1.25rem; + border: 1px solid var(--border-primary, #e0e0e0); + border-radius: 0.25rem; + background: transparent; + cursor: pointer; +} + +.navbar-collapse { + flex-grow: 1; + align-items: center; +} + +.navbar-nav { + display: flex; + flex-direction: row; + list-style: none; + padding-left: 0; + margin-bottom: 0; +} + +.nav-item { + margin: 0 10px; +} + +/* 移动端导航 */ +@media (max-width: 991px) { + .navbar-toggler { + display: block; + } + + .navbar-collapse { + display: none; + width: 100%; + } + + .navbar-collapse.show { + display: block; + } + + .navbar-nav { + flex-direction: column; + width: 100%; + } + + .nav-item { + margin: 5px 0; + width: 100%; + } + + .nav-link { + display: block; + padding: 10px 15px; + } +} + +/* ==================== 文章列表 ==================== */ + +.article-list { + display: grid; + gap: 20px; + grid-template-columns: 1fr; +} + +@media (min-width: 768px) { + .article-list.grid-2 { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 992px) { + .article-list.grid-3 { + grid-template-columns: repeat(3, 1fr); + } +} + +/* 文章卡片 */ +.article-card { + display: flex; + flex-direction: column; + border-radius: 8px; + overflow: hidden; +} + +.article-card-img { + width: 100%; + height: 200px; + object-fit: cover; +} + +@media (max-width: 767px) { + .article-card-img { + height: 150px; + } +} + +.article-card-body { + padding: 15px; +} + +.article-card-title { + font-size: 1.25rem; + margin-bottom: 10px; +} + +@media (max-width: 575px) { + .article-card-title { + font-size: 1.1rem; + } +} + +/* ==================== 侧边栏布局 ==================== */ + +.main-content-wrapper { + display: grid; + gap: 30px; + grid-template-columns: 1fr; +} + +@media (min-width: 992px) { + .main-content-wrapper.with-sidebar { + grid-template-columns: 1fr 300px; + } + + .main-content-wrapper.sidebar-left { + grid-template-columns: 300px 1fr; + } +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 20px; +} + +/* 移动端侧边栏 */ +@media (max-width: 991px) { + .sidebar { + order: -1; /* 移到内容上方 */ + } + + .sidebar-widget { + margin-bottom: 20px; + } +} + +/* ==================== 表格响应式 ==================== */ + +.table-responsive { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +@media (max-width: 767px) { + table { + font-size: 0.875rem; + } + + table th, + table td { + padding: 8px 4px; + } + + /* 隐藏不太重要的列 */ + table .hide-mobile { + display: none; + } +} + +/* ==================== 表单 ==================== */ + +.form-group { + margin-bottom: 15px; +} + +.form-control { + display: block; + width: 100%; + padding: 8px 12px; + font-size: 1rem; + line-height: 1.5; + border: 1px solid var(--input-border, #d0d0d0); + border-radius: 4px; +} + +@media (max-width: 575px) { + .form-control { + font-size: 16px; /* 防止iOS自动缩放 */ + } +} + +/* 按钮组响应式 */ +.btn-group { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +@media (max-width: 575px) { + .btn-group { + flex-direction: column; + } + + .btn-group .btn { + width: 100%; + } +} + +/* ==================== 图片响应式 ==================== */ + +img { + max-width: 100%; + height: auto; +} + +.img-responsive { + display: block; + max-width: 100%; + height: auto; +} + +/* 图片网格 */ +.image-grid { + display: grid; + gap: 15px; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); +} + +@media (max-width: 767px) { + .image-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; + } +} + +@media (max-width: 575px) { + .image-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* ==================== 文字排版 ==================== */ + +/* 标题大小 */ +h1 { + font-size: 2.5rem; +} + +h2 { + font-size: 2rem; +} + +h3 { + font-size: 1.75rem; +} + +h4 { + font-size: 1.5rem; +} + +h5 { + font-size: 1.25rem; +} + +h6 { + font-size: 1rem; +} + +@media (max-width: 767px) { + h1 { + font-size: 2rem; + } + + h2 { + font-size: 1.75rem; + } + + h3 { + font-size: 1.5rem; + } + + h4 { + font-size: 1.25rem; + } +} + +@media (max-width: 575px) { + h1 { + font-size: 1.75rem; + } + + h2 { + font-size: 1.5rem; + } + + h3 { + font-size: 1.25rem; + } +} + +/* 段落间距 */ +p { + margin-bottom: 1rem; + line-height: 1.6; +} + +@media (max-width: 575px) { + p { + line-height: 1.7; + } +} + +/* ==================== 间距工具类 ==================== */ + +/* 移动端减少间距 */ +@media (max-width: 767px) { + .mt-md-5 { + margin-top: 3rem !important; + } + + .mb-md-5 { + margin-bottom: 3rem !important; + } + + .p-md-5 { + padding: 2rem !important; + } +} + +@media (max-width: 575px) { + .mt-sm-4 { + margin-top: 1.5rem !important; + } + + .mb-sm-4 { + margin-bottom: 1.5rem !important; + } + + .p-sm-4 { + padding: 1rem !important; + } +} + +/* ==================== 显示/隐藏工具类 ==================== */ + +/* 移动端隐藏 */ +@media (max-width: 575px) { + .hide-xs { + display: none !important; + } +} + +@media (max-width: 767px) { + .hide-sm { + display: none !important; + } +} + +@media (max-width: 991px) { + .hide-md { + display: none !important; + } +} + +/* 移动端显示 */ +.show-xs { + display: none !important; +} + +@media (max-width: 575px) { + .show-xs { + display: block !important; + } +} + +.show-sm { + display: none !important; +} + +@media (max-width: 767px) { + .show-sm { + display: block !important; + } +} + +/* ==================== 评论区 ==================== */ + +.comment-list { + list-style: none; + padding-left: 0; +} + +.comment-item { + padding: 15px; + margin-bottom: 15px; + border-radius: 8px; +} + +.comment-reply { + margin-left: 40px; +} + +@media (max-width: 575px) { + .comment-reply { + margin-left: 20px; + } + + .comment-item { + padding: 10px; + } +} + +/* ==================== 分页 ==================== */ + +.pagination { + display: flex; + justify-content: center; + list-style: none; + padding: 0; + gap: 5px; +} + +.pagination li { + display: inline-block; +} + +.pagination a, +.pagination span { + display: block; + padding: 8px 12px; + border: 1px solid var(--border-primary, #e0e0e0); + border-radius: 4px; +} + +@media (max-width: 575px) { + .pagination a, + .pagination span { + padding: 6px 8px; + font-size: 0.875rem; + } + + .pagination .page-text { + display: none; /* 隐藏"上一页"/"下一页"文字,只显示箭头 */ + } +} + +/* ==================== 模态框 ==================== */ + +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000; +} + +.modal-dialog { + position: relative; + margin: 30px auto; + max-width: 500px; + padding: 0 15px; +} + +@media (min-width: 576px) { + .modal-dialog { + max-width: 500px; + } +} + +@media (min-width: 992px) { + .modal-dialog.modal-lg { + max-width: 800px; + } +} + +@media (max-width: 575px) { + .modal-dialog { + margin: 10px auto; + max-width: 100%; + } + + .modal-content { + border-radius: 0; + } +} + +/* ==================== 搜索框 ==================== */ + +.search-form { + display: flex; + gap: 10px; +} + +@media (max-width: 575px) { + .search-form { + flex-direction: column; + } + + .search-form input { + width: 100%; + } + + .search-form button { + width: 100%; + } +} + +/* ==================== 标签云 ==================== */ + +.tag-cloud { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tag { + display: inline-block; + padding: 4px 10px; + border-radius: 15px; + font-size: 0.875rem; +} + +@media (max-width: 575px) { + .tag { + font-size: 0.75rem; + padding: 3px 8px; + } +} + +/* ==================== 面包屑 ==================== */ + +.breadcrumb { + display: flex; + flex-wrap: wrap; + list-style: none; + padding: 10px 15px; + margin-bottom: 1rem; +} + +.breadcrumb-item + .breadcrumb-item::before { + content: "/"; + padding: 0 8px; +} + +@media (max-width: 575px) { + .breadcrumb { + font-size: 0.875rem; + padding: 8px 10px; + } + + .breadcrumb-item + .breadcrumb-item::before { + padding: 0 4px; + } +} + +/* ==================== 卡片 ==================== */ + +.card { + border-radius: 8px; + overflow: hidden; +} + +.card-header { + padding: 15px; + border-bottom: 1px solid var(--border-primary, #e0e0e0); +} + +.card-body { + padding: 15px; +} + +.card-footer { + padding: 15px; + border-top: 1px solid var(--border-primary, #e0e0e0); +} + +@media (max-width: 575px) { + .card-header, + .card-body, + .card-footer { + padding: 10px; + } +} + +/* ==================== 页脚 ==================== */ + +footer { + padding: 40px 0 20px; +} + +.footer-content { + display: grid; + gap: 30px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +@media (max-width: 767px) { + footer { + padding: 30px 0 15px; + } + + .footer-content { + grid-template-columns: 1fr; + gap: 20px; + text-align: center; + } +} + +/* ==================== 触摸优化 ==================== */ + +/* 增大移动端可点击区域 */ +@media (max-width: 767px) { + a, + button, + .clickable { + min-height: 44px; + min-width: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .nav-link, + .btn { + padding: 12px 20px; + } +} + +/* ==================== 性能优化 ==================== */ + +/* GPU加速 */ +.transform-gpu { + transform: translateZ(0); + backface-visibility: hidden; + perspective: 1000px; +} + +/* 减少重绘 */ +img, +video { + will-change: transform; +} + +/* ==================== 打印样式 ==================== */ + +@media print { + .no-print, + .navbar, + .sidebar, + .comments, + footer { + display: none !important; + } + + .container { + max-width: 100%; + } + + a { + text-decoration: underline; + } + + a[href]:after { + content: " (" attr(href) ")"; + } +} diff --git a/src/static/blog/css/theme.css b/src/static/blog/css/theme.css new file mode 100644 index 0000000..25eb971 --- /dev/null +++ b/src/static/blog/css/theme.css @@ -0,0 +1,876 @@ +/* 深色主题支持 + * 提供浅色/深色主题切换功能 + * 支持系统主题自动检测 + * 优化版本:完整的元素覆盖、流畅的动画、防止FOUC + */ + +/* ==================== CSS变量定义 ==================== */ + +/* 浅色主题(默认) */ +:root { + /* 主要颜色 */ + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-tertiary: #e9ecef; + --bg-hover: #f0f0f0; + --bg-active: #e0e0e0; + + /* 文字颜色 */ + --text-primary: #212529; + --text-secondary: #6c757d; + --text-tertiary: #adb5bd; + --text-inverse: #ffffff; + --text-muted: #868e96; + + /* 边框颜色 */ + --border-primary: #dee2e6; + --border-secondary: #ced4da; + --border-focus: #4CAF50; + + /* 强调色 */ + --accent-primary: #4CAF50; + --accent-secondary: #2196F3; + --accent-warning: #FF9800; + --accent-danger: #F44336; + --accent-info: #00BCD4; + --accent-success: #4CAF50; + + /* 阴影 */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15); + --shadow-xl: 0 12px 36px rgba(0, 0, 0, 0.18); + + /* 卡片 */ + --card-bg: #ffffff; + --card-border: #dee2e6; + + /* 代码块 */ + --code-bg: #f8f9fa; + --code-text: #e83e8c; + --code-block-bg: #2d2d2d; + --code-block-text: #f8f8f2; + + /* 输入框 */ + --input-bg: #ffffff; + --input-border: #ced4da; + --input-text: #495057; + --input-placeholder: #6c757d; + --input-disabled-bg: #e9ecef; + + /* 导航栏 */ + --nav-bg: #ffffff; + --nav-text: #495057; + --nav-hover-bg: #f8f9fa; + --nav-border: #dee2e6; + + /* 侧边栏 */ + --sidebar-bg: #f8f9fa; + --sidebar-border: #dee2e6; + + /* 页脚 */ + --footer-bg: #2c3e50; + --footer-text: #ecf0f1; + + /* 链接 */ + --link-color: #2196F3; + --link-hover: #1976D2; + --link-visited: #9C27B0; + + /* 文章内容 */ + --article-bg: #ffffff; + --article-border: #dee2e6; + + /* 评论区 */ + --comment-bg: #ffffff; + --comment-border: #dee2e6; + --comment-author-bg: #e7f3ff; + + /* 其他 */ + --overlay-bg: rgba(0, 0, 0, 0.5); + --scrollbar-bg: #f1f3f5; + --scrollbar-thumb: #adb5bd; + --scrollbar-thumb-hover: #868e96; + + /* 图片 */ + --img-opacity: 1; + --img-brightness: 1; +} + +/* 深色主题 */ +[data-theme="dark"] { + /* 主要颜色 */ + --bg-primary: #121212; + --bg-secondary: #1e1e1e; + --bg-tertiary: #2d2d2d; + --bg-hover: #252525; + --bg-active: #333333; + + /* 文字颜色 */ + --text-primary: #e4e6eb; + --text-secondary: #b0b3b8; + --text-tertiary: #8a8d91; + --text-inverse: #121212; + --text-muted: #9ca3af; + + /* 边框颜色 */ + --border-primary: #3a3a3a; + --border-secondary: #4a4a4a; + --border-focus: #66BB6A; + + /* 强调色 */ + --accent-primary: #66BB6A; + --accent-secondary: #42A5F5; + --accent-warning: #FFA726; + --accent-danger: #EF5350; + --accent-info: #26C6DA; + --accent-success: #66BB6A; + + /* 阴影 */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6); + --shadow-xl: 0 12px 36px rgba(0, 0, 0, 0.7); + + /* 卡片 */ + --card-bg: #1e1e1e; + --card-border: #3a3a3a; + + /* 代码块 */ + --code-bg: #2d2d2d; + --code-text: #ff79c6; + --code-block-bg: #1a1a1a; + --code-block-text: #e6e6e6; + + /* 输入框 */ + --input-bg: #2d2d2d; + --input-border: #4a4a4a; + --input-text: #e4e6eb; + --input-placeholder: #8a8d91; + --input-disabled-bg: #252525; + + /* 导航栏 */ + --nav-bg: #1e1e1e; + --nav-text: #e4e6eb; + --nav-hover-bg: #252525; + --nav-border: #3a3a3a; + + /* 侧边栏 */ + --sidebar-bg: #1e1e1e; + --sidebar-border: #3a3a3a; + + /* 页脚 */ + --footer-bg: #1a1a1a; + --footer-text: #b0b3b8; + + /* 链接 */ + --link-color: #58a6ff; + --link-hover: #79c0ff; + --link-visited: #bc8cff; + + /* 文章内容 */ + --article-bg: #1e1e1e; + --article-border: #3a3a3a; + + /* 评论区 */ + --comment-bg: #1e1e1e; + --comment-border: #3a3a3a; + --comment-author-bg: #1a3a52; + + /* 其他 */ + --overlay-bg: rgba(0, 0, 0, 0.75); + --scrollbar-bg: #1e1e1e; + --scrollbar-thumb: #4a4a4a; + --scrollbar-thumb-hover: #5a5a5a; + + /* 图片 */ + --img-opacity: 0.85; + --img-brightness: 0.9; +} + +/* ==================== 过渡动画 ==================== */ + +/* 平滑过渡 - 应用于根元素 */ +html.theme-transitioning, +html.theme-transitioning *, +html.theme-transitioning *::before, +html.theme-transitioning *::after { + transition: background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1), + color 0.4s cubic-bezier(0.4, 0, 0.2, 1), + border-color 0.4s cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important; +} + +/* ==================== 基础样式应用 ==================== */ + +html { + color-scheme: light; +} + +[data-theme="dark"] { + color-scheme: dark; +} + +body { + background-color: var(--bg-primary); + color: var(--text-primary); +} + +/* 链接 */ +a { + color: var(--link-color); + transition: color 0.2s ease; +} + +a:hover { + color: var(--link-hover); +} + +a:visited { + color: var(--link-visited); +} + +/* ==================== 布局元素 ==================== */ + +/* 容器 */ +.container, +.container-fluid { + background-color: var(--bg-primary); +} + +/* 行和列 */ +.row { + color: var(--text-primary); +} + +/* ==================== 卡片组件 ==================== */ + +.card, +.article-card, +.comment-card, +.panel, +.panel-default, +.well { + background-color: var(--card-bg); + border-color: var(--card-border); + box-shadow: var(--shadow-sm); + color: var(--text-primary); +} + +.card:hover, +.article-card:hover { + box-shadow: var(--shadow-md); +} + +.card-header, +.card-footer, +.panel-heading, +.panel-footer { + background-color: var(--bg-secondary); + border-color: var(--border-primary); + color: var(--text-primary); +} + +.card-body, +.panel-body { + background-color: var(--card-bg); + color: var(--text-primary); +} + +/* ==================== 代码样式 ==================== */ + +code { + background-color: var(--code-bg); + color: var(--code-text); + padding: 2px 6px; + border-radius: 4px; +} + +pre { + background-color: var(--code-block-bg); + color: var(--code-block-text); + padding: 16px; + border-radius: 6px; + overflow-x: auto; + border: 1px solid var(--border-primary); +} + +pre code { + background-color: transparent; + color: inherit; + padding: 0; +} + +/* ==================== 表单元素 ==================== */ + +input[type="text"], +input[type="email"], +input[type="password"], +input[type="search"], +input[type="number"], +input[type="url"], +input[type="tel"], +input[type="date"], +textarea, +select, +.form-control { + background-color: var(--input-bg); + border-color: var(--input-border); + color: var(--input-text); + transition: all 0.2s ease; +} + +input::placeholder, +textarea::placeholder { + color: var(--input-placeholder); +} + +input:focus, +textarea:focus, +select:focus, +.form-control:focus { + border-color: var(--border-focus); + outline: none; + box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); +} + +input:disabled, +textarea:disabled, +select:disabled, +.form-control:disabled { + background-color: var(--input-disabled-bg); + color: var(--text-tertiary); + cursor: not-allowed; +} + +/* ==================== 按钮 ==================== */ + +.btn { + transition: all 0.2s ease; +} + +.btn-primary { + background-color: var(--accent-primary); + border-color: var(--accent-primary); + color: var(--text-inverse); +} + +.btn-primary:hover { + opacity: 0.9; +} + +.btn-secondary { + background-color: var(--accent-secondary); + border-color: var(--accent-secondary); + color: var(--text-inverse); +} + +.btn-default, +.btn-outline { + background-color: var(--bg-secondary); + border-color: var(--border-primary); + color: var(--text-primary); +} + +.btn-default:hover, +.btn-outline:hover { + background-color: var(--bg-hover); +} + +/* ==================== 导航栏 ==================== */ + +.navbar, +.nav, +nav, +header { + background-color: var(--nav-bg); + color: var(--nav-text); + border-color: var(--nav-border); +} + +.nav-link, +.navbar-nav a, +.menu-item a { + color: var(--nav-text); + transition: background-color 0.2s ease; +} + +.nav-link:hover, +.navbar-nav a:hover, +.menu-item a:hover { + background-color: var(--nav-hover-bg); + color: var(--link-hover); +} + +.navbar-brand { + color: var(--nav-text) !important; +} + +/* ==================== 侧边栏 ==================== */ + +.sidebar, +.widget, +aside { + background-color: var(--sidebar-bg); + border-color: var(--sidebar-border); + color: var(--text-primary); +} + +.sidebar-title, +.widget-title { + color: var(--text-primary); + border-bottom-color: var(--border-primary); +} + +/* ==================== 页脚 ==================== */ + +footer, +.footer, +#footer { + background-color: var(--footer-bg); + color: var(--footer-text); +} + +footer a, +.footer a { + color: var(--footer-text); + opacity: 0.8; +} + +footer a:hover, +.footer a:hover { + opacity: 1; +} + +/* ==================== 文章内容 ==================== */ + +.article, +.entry-content, +.post-content, +article { + background-color: var(--article-bg); + color: var(--text-primary); +} + +.entry-title, +.post-title, +h1, h2, h3, h4, h5, h6 { + color: var(--text-primary); +} + +.entry-meta, +.post-meta { + color: var(--text-secondary); +} + +/* ==================== 评论区 ==================== */ + +.comment, +.comment-body { + background-color: var(--comment-bg); + border-color: var(--comment-border); + color: var(--text-primary); +} + +.comment-author { + color: var(--text-primary); + background-color: var(--comment-author-bg); +} + +.comment-reply-link { + color: var(--link-color); +} + +/* ==================== 边框和分隔线 ==================== */ + +.border, +hr { + border-color: var(--border-primary); +} + +/* ==================== 表格 ==================== */ + +table { + border-color: var(--border-primary); + color: var(--text-primary); +} + +table th { + background-color: var(--bg-secondary); + color: var(--text-primary); + border-color: var(--border-primary); +} + +table td { + border-color: var(--border-primary); + background-color: var(--bg-primary); +} + +table tr:hover td { + background-color: var(--bg-hover); +} + +/* ==================== 引用 ==================== */ + +blockquote { + border-left: 4px solid var(--accent-primary); + background-color: var(--bg-secondary); + color: var(--text-secondary); + padding: 12px 20px; + margin: 16px 0; +} + +/* ==================== 标签和徽章 ==================== */ + +.tag, +.badge, +.label { + background-color: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-primary); +} + +/* ==================== 提示框 ==================== */ + +.alert { + border-color: var(--border-primary); +} + +.alert-success { + background-color: rgba(76, 175, 80, 0.1); + border-color: var(--accent-success); + color: var(--accent-success); +} + +.alert-info { + background-color: rgba(33, 150, 243, 0.1); + border-color: var(--accent-info); + color: var(--accent-info); +} + +.alert-warning { + background-color: rgba(255, 152, 0, 0.1); + border-color: var(--accent-warning); + color: var(--accent-warning); +} + +.alert-danger { + background-color: rgba(244, 67, 54, 0.1); + border-color: var(--accent-danger); + color: var(--accent-danger); +} + +/* ==================== 模态框 ==================== */ + +.modal-content { + background-color: var(--card-bg); + color: var(--text-primary); +} + +.modal-header, +.modal-footer { + background-color: var(--bg-secondary); + border-color: var(--border-primary); +} + +/* ==================== 下拉菜单 ==================== */ + +.dropdown-menu { + background-color: var(--card-bg); + border-color: var(--border-primary); + box-shadow: var(--shadow-md); +} + +.dropdown-item { + color: var(--text-primary); +} + +.dropdown-item:hover { + background-color: var(--bg-hover); +} + +/* ==================== 分页 ==================== */ + +.pagination .page-link { + background-color: var(--card-bg); + border-color: var(--border-primary); + color: var(--link-color); +} + +.pagination .page-link:hover { + background-color: var(--bg-hover); +} + +.pagination .page-item.active .page-link { + background-color: var(--accent-primary); + border-color: var(--accent-primary); +} + +/* ==================== 面包屑 ==================== */ + +.breadcrumb { + background-color: var(--bg-secondary); + color: var(--text-secondary); +} + +.breadcrumb-item.active { + color: var(--text-primary); +} + +/* ==================== 滚动条 ==================== */ + +::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +::-webkit-scrollbar-track { + background: var(--scrollbar-bg); +} + +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 6px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} + +/* Firefox 滚动条 */ +* { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-bg); +} + +/* ==================== 图片优化 ==================== */ + +[data-theme="dark"] img:not([src*=".svg"]) { + opacity: var(--img-opacity); + filter: brightness(var(--img-brightness)); + transition: opacity 0.3s ease, filter 0.3s ease; +} + +[data-theme="dark"] img:hover { + opacity: 1; + filter: brightness(1); +} + +/* SVG 图标在深色模式下的处理 */ +[data-theme="dark"] svg { + filter: invert(0.9) hue-rotate(180deg); +} + +[data-theme="dark"] .logo svg, +[data-theme="dark"] .icon svg { + filter: none; +} + +/* ==================== 主题切换按钮 ==================== */ + +.theme-toggle { + position: fixed; + bottom: 30px; + right: 30px; + width: 56px; + height: 56px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%); + color: var(--text-inverse); + border: none; + box-shadow: var(--shadow-lg); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; +} + +.theme-toggle::before { + content: ''; + position: absolute; + width: 100%; + height: 100%; + background: radial-gradient(circle, rgba(255,255,255,0.3) 0%, transparent 70%); + opacity: 0; + transition: opacity 0.3s ease; +} + +.theme-toggle:hover { + transform: scale(1.1) rotate(15deg); + box-shadow: var(--shadow-xl); +} + +.theme-toggle:hover::before { + opacity: 1; +} + +.theme-toggle:active, +.theme-toggle-clicked { + transform: scale(0.95) rotate(-15deg); +} + +/* 主题切换图标 */ +.theme-toggle .icon-sun, +.theme-toggle .icon-moon { + position: absolute; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.theme-toggle .icon-sun { + opacity: 0; + transform: rotate(-90deg) scale(0.5); +} + +.theme-toggle .icon-moon { + opacity: 1; + transform: rotate(0deg) scale(1); +} + +[data-theme="dark"] .theme-toggle .icon-sun { + opacity: 1; + transform: rotate(0deg) scale(1); +} + +[data-theme="dark"] .theme-toggle .icon-moon { + opacity: 0; + transform: rotate(90deg) scale(0.5); +} + +/* 按钮上的光晕效果 */ +@keyframes pulse { + 0%, 100% { + box-shadow: var(--shadow-lg), 0 0 0 0 rgba(76, 175, 80, 0.7); + } + 50% { + box-shadow: var(--shadow-lg), 0 0 0 10px rgba(76, 175, 80, 0); + } +} + +.theme-toggle-clicked { + animation: pulse 0.6s ease-out; +} + +/* Auto模式特殊标识 */ +.theme-toggle.theme-auto::after { + content: 'A'; + position: absolute; + bottom: 2px; + right: 2px; + width: 16px; + height: 16px; + background: rgba(255, 255, 255, 0.9); + color: var(--accent-primary); + border-radius: 50%; + font-size: 10px; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +/* Auto模式下按钮有渐变边框效果 */ +.theme-toggle.theme-auto { + box-shadow: var(--shadow-lg), 0 0 0 2px rgba(255, 215, 0, 0.6); +} + +/* Auto模式下的动画 */ +@keyframes autoGlow { + 0%, 100% { + box-shadow: var(--shadow-lg), 0 0 0 2px rgba(255, 215, 0, 0.4); + } + 50% { + box-shadow: var(--shadow-lg), 0 0 0 2px rgba(255, 215, 0, 0.8); + } +} + +.theme-toggle.theme-auto { + animation: autoGlow 2s ease-in-out infinite; +} + +/* ==================== 响应式 ==================== */ + +@media (max-width: 768px) { + .theme-toggle { + bottom: 20px; + right: 20px; + width: 50px; + height: 50px; + } +} + +@media (max-width: 480px) { + .theme-toggle { + bottom: 16px; + right: 16px; + width: 46px; + height: 46px; + } +} + +/* ==================== 打印样式 ==================== */ + +@media print { + [data-theme="dark"] { + /* 打印时强制使用浅色主题 */ + color-scheme: light; + --bg-primary: #ffffff; + --text-primary: #000000; + --card-bg: #ffffff; + } + + .theme-toggle { + display: none !important; + } + + [data-theme="dark"] img { + opacity: 1; + filter: none; + } +} + +/* ==================== 辅助类 ==================== */ + +.bg-primary { background-color: var(--bg-primary); } +.bg-secondary { background-color: var(--bg-secondary); } +.bg-tertiary { background-color: var(--bg-tertiary); } + +.text-primary { color: var(--text-primary); } +.text-secondary { color: var(--text-secondary); } +.text-tertiary { color: var(--text-tertiary); } +.text-muted { color: var(--text-muted); } + +.border-primary { border-color: var(--border-primary); } + +/* ==================== 可访问性 ==================== */ + +/* 减少动画(用户偏好) */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + .theme-toggle { + transition: none; + } +} + +/* 高对比度模式 */ +@media (prefers-contrast: high) { + :root { + --border-primary: #000000; + --text-primary: #000000; + } + + [data-theme="dark"] { + --border-primary: #ffffff; + --text-primary: #ffffff; + } +} diff --git a/src/static/blog/js/article-draft-autosave.js b/src/static/blog/js/article-draft-autosave.js new file mode 100644 index 0000000..a60bccb --- /dev/null +++ b/src/static/blog/js/article-draft-autosave.js @@ -0,0 +1,321 @@ +/** + * 文章草稿自动保存功能 + * 定时自动保存编辑中的文章,防止内容丢失 + */ + +(function() { + 'use strict'; + + // 配置 + const CONFIG = { + AUTO_SAVE_INTERVAL: 30000, // 自动保存间隔(30秒) + MIN_CONTENT_LENGTH: 10, // 触发自动保存的最小内容长度 + SESSION_STORAGE_KEY: 'article_draft_session_id', + LAST_CONTENT_KEY: 'article_draft_last_content' + }; + + // 生成唯一的会话ID + function generateSessionId() { + return 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + } + + // 获取或创建会话ID + function getSessionId() { + let sessionId = sessionStorage.getItem(CONFIG.SESSION_STORAGE_KEY); + if (!sessionId) { + sessionId = generateSessionId(); + sessionStorage.setItem(CONFIG.SESSION_STORAGE_KEY, sessionId); + } + return sessionId; + } + + // 获取CSRF Token + function getCookie(name) { + 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; + } + + // 文章草稿自动保存类 + class ArticleDraftAutoSave { + constructor(options) { + this.options = Object.assign({ + titleSelector: '#id_title', + bodySelector: '#id_body', + categorySelector: '#id_category', + tagsSelector: '#id_tags', + statusSelector: '#id_status', + commentStatusSelector: '#id_comment_status', + typeSelector: '#id_type', + articleIdInput: '#article_id', + saveApiUrl: '/api/draft/save/', + getDraftApiUrl: '/api/draft/get/', + statusElement: '#draft-save-status' + }, options); + + this.sessionId = getSessionId(); + this.saveTimer = null; + this.lastContent = ''; + this.isInit = false; + + this.init(); + } + + init() { + if (this.isInit) return; + this.isInit = true; + + // 检查是否在文章编辑页面 + const titleElement = document.querySelector(this.options.titleSelector); + const bodyElement = document.querySelector(this.options.bodySelector); + + if (!titleElement || !bodyElement) { + console.log('未找到文章编辑器元素,自动保存功能未启动'); + return; + } + + console.log('文章草稿自动保存功能已启动'); + + // 检查是否有未保存的草稿 + this.checkExistingDraft(); + + // 启动自动保存 + this.startAutoSave(); + + // 监听页面离开事件 + this.setupBeforeUnloadHandler(); + + // 创建状态显示元素 + this.createStatusElement(); + } + + // 检查是否有未保存的草稿 + checkExistingDraft() { + const articleId = this.getArticleId(); + const url = `${this.options.getDraftApiUrl}?${articleId ? 'article_id=' + articleId : 'session_id=' + this.sessionId}`; + + fetch(url, { + method: 'GET', + credentials: 'same-origin' + }) + .then(response => response.json()) + .then(data => { + if (data.success && data.draft) { + this.showDraftRestorePrompt(data.draft); + } + }) + .catch(error => { + console.error('检查草稿失败:', error); + }); + } + + // 显示草稿恢复提示 + showDraftRestorePrompt(draft) { + const message = `发现未保存的草稿(最后更新: ${draft.last_update}),是否恢复?`; + if (confirm(message)) { + this.restoreDraft(draft); + } + } + + // 恢复草稿 + restoreDraft(draft) { + const titleElement = document.querySelector(this.options.titleSelector); + const bodyElement = document.querySelector(this.options.bodySelector); + const categoryElement = document.querySelector(this.options.categorySelector); + + if (titleElement) titleElement.value = draft.title || ''; + if (bodyElement) bodyElement.value = draft.body || ''; + if (categoryElement && draft.category_id) { + categoryElement.value = draft.category_id; + } + + this.showStatus('草稿已恢复', 'success'); + } + + // 启动自动保存 + startAutoSave() { + this.saveTimer = setInterval(() => { + this.autoSave(); + }, CONFIG.AUTO_SAVE_INTERVAL); + + console.log(`自动保存已启动,间隔: ${CONFIG.AUTO_SAVE_INTERVAL / 1000}秒`); + } + + // 停止自动保存 + stopAutoSave() { + if (this.saveTimer) { + clearInterval(this.saveTimer); + this.saveTimer = null; + console.log('自动保存已停止'); + } + } + + // 执行自动保存 + autoSave() { + const titleElement = document.querySelector(this.options.titleSelector); + const bodyElement = document.querySelector(this.options.bodySelector); + + if (!titleElement || !bodyElement) return; + + const title = titleElement.value || ''; + const body = bodyElement.value || ''; + const currentContent = title + body; + + // 检查内容是否有变化且长度足够 + if (currentContent === this.lastContent || currentContent.length < CONFIG.MIN_CONTENT_LENGTH) { + return; + } + + this.saveDraft(); + } + + // 保存草稿 + saveDraft() { + const titleElement = document.querySelector(this.options.titleSelector); + const bodyElement = document.querySelector(this.options.bodySelector); + const categoryElement = document.querySelector(this.options.categorySelector); + const tagsElement = document.querySelector(this.options.tagsSelector); + + if (!titleElement || !bodyElement) return; + + const title = titleElement.value || ''; + const body = bodyElement.value || ''; + const category_id = categoryElement ? categoryElement.value : null; + const article_id = this.getArticleId(); + + // 获取标签 + let tags = []; + if (tagsElement) { + const selectedOptions = tagsElement.selectedOptions; + for (let i = 0; i < selectedOptions.length; i++) { + tags.push(parseInt(selectedOptions[i].value)); + } + } + + const data = { + title: title, + body: body, + category_id: category_id ? parseInt(category_id) : null, + tags: tags, + article_id: article_id ? parseInt(article_id) : null, + session_id: this.sessionId, + status: 'd', + comment_status: 'o', + type: 'a' + }; + + this.showStatus('正在保存...', 'info'); + + fetch(this.options.saveApiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + credentials: 'same-origin', + body: JSON.stringify(data) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + this.lastContent = title + body; + this.showStatus(`已自动保存 (${data.last_update})`, 'success'); + console.log('草稿已保存:', data); + } else { + this.showStatus('保存失败', 'error'); + console.error('保存草稿失败:', data.message); + } + }) + .catch(error => { + this.showStatus('保存失败', 'error'); + console.error('保存草稿失败:', error); + }); + } + + // 获取文章ID + getArticleId() { + const articleIdInput = document.querySelector(this.options.articleIdInput); + if (articleIdInput) { + return articleIdInput.value; + } + + // 尝试从URL获取 + const match = window.location.pathname.match(/\/article\/\d+\/\d+\/\d+\/(\d+)\.html/); + if (match) { + return match[1]; + } + + return null; + } + + // 创建状态显示元素 + createStatusElement() { + let statusElement = document.querySelector(this.options.statusElement); + if (!statusElement) { + statusElement = document.createElement('div'); + statusElement.id = this.options.statusElement.replace('#', ''); + statusElement.style.cssText = 'position: fixed; bottom: 20px; right: 20px; padding: 10px 20px; ' + + 'background: #f0f0f0; border-radius: 5px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); ' + + 'font-size: 14px; z-index: 9999; display: none;'; + document.body.appendChild(statusElement); + } + } + + // 显示状态 + showStatus(message, type) { + const statusElement = document.querySelector(this.options.statusElement); + if (!statusElement) return; + + const colors = { + 'info': '#2196F3', + 'success': '#4CAF50', + 'error': '#F44336' + }; + + statusElement.textContent = message; + statusElement.style.background = colors[type] || '#f0f0f0'; + statusElement.style.color = type === 'info' ? '#333' : '#fff'; + statusElement.style.display = 'block'; + + setTimeout(() => { + statusElement.style.display = 'none'; + }, 3000); + } + + // 设置页面离开前的警告 + setupBeforeUnloadHandler() { + window.addEventListener('beforeunload', (e) => { + const titleElement = document.querySelector(this.options.titleSelector); + const bodyElement = document.querySelector(this.options.bodySelector); + + if (titleElement && bodyElement) { + const content = titleElement.value + bodyElement.value; + if (content.length > CONFIG.MIN_CONTENT_LENGTH && content !== this.lastContent) { + e.preventDefault(); + e.returnValue = '您有未保存的更改,确定要离开吗?'; + return e.returnValue; + } + } + }); + } + } + + // 自动初始化 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + window.articleDraftAutoSave = new ArticleDraftAutoSave(); + }); + } else { + window.articleDraftAutoSave = new ArticleDraftAutoSave(); + } + +})(); diff --git a/src/static/blog/js/media-picker.js b/src/static/blog/js/media-picker.js new file mode 100644 index 0000000..d08b460 --- /dev/null +++ b/src/static/blog/js/media-picker.js @@ -0,0 +1,375 @@ +/** + * 多媒体管理系统 - 图片选择器 + * 提供图片上传、选择和管理功能 + */ + +(function() { + 'use strict'; + + // 获取CSRF Token + function getCookie(name) { + 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; + } + + // 图片选择器类 + class MediaPicker { + constructor(options = {}) { + this.options = { + multiple: false, // 是否允许多选 + fileType: 'image', // 文件类型:image/file/all + onSelect: null, // 选择回调 + maxSize: 10, // 最大文件大小(MB) + ...options + }; + + this.csrfToken = getCookie('csrftoken'); + this.selectedFiles = []; + this.currentPage = 1; + this.totalPages = 1; + + this.init(); + } + + init() { + this.createModal(); + this.loadFiles(); + } + + createModal() { + // 创建模态框HTML + const modalHTML = ` + + `; + + // 添加到body + document.body.insertAdjacentHTML('beforeend', modalHTML); + + // 绑定事件 + this.bindEvents(); + } + + getAcceptTypes() { + if (this.options.fileType === 'image') { + return 'image/*'; + } else if (this.options.fileType === 'file') { + return '*/*'; + } + return '*/*'; + } + + bindEvents() { + const modal = document.getElementById('media-picker-modal'); + + // 关闭按钮 + modal.querySelector('.media-picker-close').addEventListener('click', () => { + this.close(); + }); + + // 点击遮罩关闭 + modal.querySelector('.media-picker-overlay').addEventListener('click', () => { + this.close(); + }); + + // 取消按钮 + modal.querySelector('#btn-cancel').addEventListener('click', () => { + this.close(); + }); + + // 选择按钮 + modal.querySelector('#btn-select').addEventListener('click', () => { + this.selectFiles(); + }); + + // 文件上传 + modal.querySelector('#media-file-input').addEventListener('change', (e) => { + this.uploadFiles(e.target.files); + }); + + // 搜索 + let searchTimeout; + modal.querySelector('#media-search-input').addEventListener('input', (e) => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + this.currentPage = 1; + this.loadFiles(e.target.value); + }, 500); + }); + + // 分页 + modal.querySelector('#prev-page').addEventListener('click', () => { + if (this.currentPage > 1) { + this.currentPage--; + this.loadFiles(); + } + }); + + modal.querySelector('#next-page').addEventListener('click', () => { + if (this.currentPage < this.totalPages) { + this.currentPage++; + this.loadFiles(); + } + }); + } + + async loadFiles(search = '') { + const grid = document.getElementById('media-files-grid'); + grid.innerHTML = '
加载中...
'; + + try { + const params = new URLSearchParams({ + page: this.currentPage, + page_size: 20 + }); + + if (this.options.fileType !== 'all') { + params.append('file_type', this.options.fileType); + } + + if (search) { + params.append('search', search); + } + + const response = await fetch(`/blog/api/media/list/?${params}`); + const data = await response.json(); + + if (data.success) { + this.displayFiles(data.files); + this.totalPages = data.total_pages; + this.updatePagination(); + } else { + grid.innerHTML = '
加载失败
'; + } + } catch (error) { + console.error('加载文件列表失败:', error); + grid.innerHTML = '
加载失败
'; + } + } + + displayFiles(files) { + const grid = document.getElementById('media-files-grid'); + + if (files.length === 0) { + grid.innerHTML = '
暂无文件
'; + return; + } + + grid.innerHTML = files.map(file => { + const isSelected = this.selectedFiles.some(f => f.id === file.id); + return ` +
+
+ ${file.file_type === 'image' + ? `${file.original_filename}` + : '
📄
'} +
+
+
+ ${file.original_filename} +
+
+ ${file.file_size_readable} +
+
+
+ +
+
+ `; + }).join(''); + + // 绑定点击事件 + grid.querySelectorAll('.media-file-item').forEach(item => { + item.addEventListener('click', () => { + const fileId = parseInt(item.dataset.fileId); + const file = files.find(f => f.id === fileId); + this.toggleFileSelection(file, item); + }); + }); + } + + toggleFileSelection(file, element) { + const index = this.selectedFiles.findIndex(f => f.id === file.id); + + if (index > -1) { + // 取消选择 + this.selectedFiles.splice(index, 1); + element.classList.remove('selected'); + } else { + // 选择 + if (!this.options.multiple) { + // 单选模式,清除之前的选择 + this.selectedFiles = [file]; + document.querySelectorAll('.media-file-item.selected').forEach(el => { + el.classList.remove('selected'); + }); + element.classList.add('selected'); + } else { + // 多选模式 + this.selectedFiles.push(file); + element.classList.add('selected'); + } + } + + // 更新选择按钮状态 + const btnSelect = document.getElementById('btn-select'); + btnSelect.disabled = this.selectedFiles.length === 0; + } + + async uploadFiles(files) { + if (!files || files.length === 0) return; + + const maxSize = this.options.maxSize * 1024 * 1024; + + for (let file of files) { + if (file.size > maxSize) { + alert(`文件 ${file.name} 超过大小限制(${this.options.maxSize}MB)`); + continue; + } + + await this.uploadSingleFile(file); + } + + // 重新加载文件列表 + this.loadFiles(); + + // 清空文件输入 + document.getElementById('media-file-input').value = ''; + } + + async uploadSingleFile(file) { + const formData = new FormData(); + formData.append('file', file); + formData.append('is_public', 'true'); + + try { + const response = await fetch('/blog/api/media/upload/', { + method: 'POST', + headers: { + 'X-CSRFToken': this.csrfToken + }, + credentials: 'same-origin', + body: formData + }); + + const data = await response.json(); + + if (data.success) { + this.showMessage('上传成功', 'success'); + } else { + this.showMessage(data.message || '上传失败', 'error'); + } + } catch (error) { + console.error('上传失败:', error); + this.showMessage('上传失败', 'error'); + } + } + + selectFiles() { + if (this.options.onSelect && typeof this.options.onSelect === 'function') { + this.options.onSelect(this.selectedFiles); + } + this.close(); + } + + updatePagination() { + const prevBtn = document.getElementById('prev-page'); + const nextBtn = document.getElementById('next-page'); + const pageInfo = document.getElementById('page-info'); + + prevBtn.disabled = this.currentPage === 1; + nextBtn.disabled = this.currentPage >= this.totalPages; + pageInfo.textContent = `第 ${this.currentPage} / ${this.totalPages} 页`; + } + + showMessage(message, type) { + // 简单的消息提示 + const messageDiv = document.createElement('div'); + messageDiv.className = `media-message media-message-${type}`; + messageDiv.textContent = message; + messageDiv.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + padding: 15px 20px; + background: ${type === 'success' ? '#4CAF50' : '#F44336'}; + color: white; + border-radius: 5px; + z-index: 10001; + `; + + document.body.appendChild(messageDiv); + + setTimeout(() => { + messageDiv.remove(); + }, 3000); + } + + open() { + const modal = document.getElementById('media-picker-modal'); + modal.style.display = 'block'; + this.selectedFiles = []; + this.currentPage = 1; + this.loadFiles(); + } + + close() { + const modal = document.getElementById('media-picker-modal'); + modal.style.display = 'none'; + this.selectedFiles = []; + } + } + + // 导出到全局 + window.MediaPicker = MediaPicker; + +})(); diff --git a/src/static/blog/js/social-features.js b/src/static/blog/js/social-features.js new file mode 100644 index 0000000..ed94d0b --- /dev/null +++ b/src/static/blog/js/social-features.js @@ -0,0 +1,330 @@ +/** + * 用户关注和文章收藏功能 + * 提供关注/取消关注、收藏/取消收藏的前端交互 + */ + +(function() { + 'use strict'; + + // 获取CSRF Token + function getCookie(name) { + 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; + } + + // 社交功能类 + class SocialFeatures { + constructor() { + this.csrfToken = getCookie('csrftoken'); + this.init(); + } + + init() { + // 绑定关注按钮 + this.bindFollowButtons(); + // 绑定收藏按钮 + this.bindFavoriteButtons(); + // 绑定点赞按钮 + this.bindLikeButtons(); + } + + // ==================== 关注功能 ==================== + + bindFollowButtons() { + const followButtons = document.querySelectorAll('.btn-follow'); + followButtons.forEach(button => { + button.addEventListener('click', (e) => { + e.preventDefault(); + const userId = button.dataset.userId; + const isFollowing = button.dataset.following === 'true'; + + if (isFollowing) { + this.unfollowUser(userId, button); + } else { + this.followUser(userId, button); + } + }); + }); + } + + followUser(userId, button) { + fetch('/blog/api/follow/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': this.csrfToken + }, + credentials: 'same-origin', + body: JSON.stringify({ user_id: userId }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + button.textContent = '已关注'; + button.dataset.following = 'true'; + button.classList.remove('btn-primary'); + button.classList.add('btn-secondary'); + this.showMessage(data.message, 'success'); + this.updateFollowCount(data.following_count); + } else { + this.showMessage(data.message, 'error'); + } + }) + .catch(error => { + console.error('关注失败:', error); + this.showMessage('关注失败,请重试', 'error'); + }); + } + + unfollowUser(userId, button) { + fetch('/blog/api/unfollow/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': this.csrfToken + }, + credentials: 'same-origin', + body: JSON.stringify({ user_id: userId }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + button.textContent = '关注'; + button.dataset.following = 'false'; + button.classList.remove('btn-secondary'); + button.classList.add('btn-primary'); + this.showMessage(data.message, 'success'); + this.updateFollowCount(data.following_count); + } else { + this.showMessage(data.message, 'error'); + } + }) + .catch(error => { + console.error('取消关注失败:', error); + this.showMessage('取消关注失败,请重试', 'error'); + }); + } + + updateFollowCount(count) { + const countElement = document.querySelector('#following-count'); + if (countElement) { + countElement.textContent = count; + } + } + + // ==================== 收藏功能 ==================== + + bindFavoriteButtons() { + const favoriteButtons = document.querySelectorAll('.btn-favorite'); + favoriteButtons.forEach(button => { + button.addEventListener('click', (e) => { + e.preventDefault(); + const articleId = button.dataset.articleId; + const isFavorited = button.dataset.favorited === 'true'; + + if (isFavorited) { + this.unfavoriteArticle(articleId, button); + } else { + this.favoriteArticle(articleId, button); + } + }); + }); + } + + favoriteArticle(articleId, button) { + fetch('/blog/api/favorite/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': this.csrfToken + }, + credentials: 'same-origin', + body: JSON.stringify({ article_id: articleId }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + button.innerHTML = '★ 已收藏'; + button.dataset.favorited = 'true'; + button.classList.add('favorited'); + this.showMessage(data.message, 'success'); + this.updateFavoriteCount(data.favorite_count); + } else { + this.showMessage(data.message, 'error'); + } + }) + .catch(error => { + console.error('收藏失败:', error); + this.showMessage('收藏失败,请重试', 'error'); + }); + } + + unfavoriteArticle(articleId, button) { + fetch('/blog/api/unfavorite/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': this.csrfToken + }, + credentials: 'same-origin', + body: JSON.stringify({ article_id: articleId }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + button.innerHTML = '☆ 收藏'; + button.dataset.favorited = 'false'; + button.classList.remove('favorited'); + this.showMessage(data.message, 'success'); + this.updateFavoriteCount(data.favorite_count); + } else { + this.showMessage(data.message, 'error'); + } + }) + .catch(error => { + console.error('取消收藏失败:', error); + this.showMessage('取消收藏失败,请重试', 'error'); + }); + } + + updateFavoriteCount(count) { + const countElement = document.querySelector('#favorite-count'); + if (countElement) { + countElement.textContent = count; + } + } + + // ==================== 点赞功能 ==================== + + bindLikeButtons() { + const likeButtons = document.querySelectorAll('.btn-like'); + likeButtons.forEach(button => { + button.addEventListener('click', (e) => { + e.preventDefault(); + const articleId = button.dataset.articleId; + const isLiked = button.dataset.liked === 'true'; + + if (isLiked) { + this.unlikeArticle(articleId, button); + } else { + this.likeArticle(articleId, button); + } + }); + }); + } + + likeArticle(articleId, button) { + fetch('/blog/api/like/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': this.csrfToken + }, + credentials: 'same-origin', + body: JSON.stringify({ article_id: articleId }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + button.innerHTML = '👍 ' + data.like_count; + button.dataset.liked = 'true'; + button.classList.add('liked'); + this.showMessage(data.message, 'success'); + this.updateLikeCount(data.like_count, button); + } else { + this.showMessage(data.message, 'error'); + } + }) + .catch(error => { + console.error('点赞失败:', error); + this.showMessage('点赞失败,请重试', 'error'); + }); + } + + unlikeArticle(articleId, button) { + fetch('/blog/api/unlike/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': this.csrfToken + }, + credentials: 'same-origin', + body: JSON.stringify({ article_id: articleId }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + button.innerHTML = '👍 ' + data.like_count; + button.dataset.liked = 'false'; + button.classList.remove('liked'); + this.showMessage(data.message, 'success'); + this.updateLikeCount(data.like_count, button); + } else { + this.showMessage(data.message, 'error'); + } + }) + .catch(error => { + console.error('取消点赞失败:', error); + this.showMessage('取消点赞失败,请重试', 'error'); + }); + } + + updateLikeCount(count, button) { + // 更新按钮显示的点赞数 + const countSpan = button.querySelector('.like-count'); + if (countSpan) { + countSpan.textContent = count; + } + } + + // ==================== 工具方法 ==================== + + showMessage(message, type) { + // 创建或获取消息提示元素 + let messageBox = document.querySelector('#social-message'); + if (!messageBox) { + messageBox = document.createElement('div'); + messageBox.id = 'social-message'; + messageBox.style.cssText = 'position: fixed; top: 20px; right: 20px; padding: 15px 20px; ' + + 'border-radius: 5px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); ' + + 'font-size: 14px; z-index: 9999; display: none;'; + document.body.appendChild(messageBox); + } + + const colors = { + 'success': '#4CAF50', + 'error': '#F44336', + 'info': '#2196F3' + }; + + messageBox.textContent = message; + messageBox.style.background = colors[type] || '#f0f0f0'; + messageBox.style.color = '#fff'; + messageBox.style.display = 'block'; + + setTimeout(() => { + messageBox.style.display = 'none'; + }, 3000); + } + } + + // 自动初始化 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + window.socialFeatures = new SocialFeatures(); + }); + } else { + window.socialFeatures = new SocialFeatures(); + } + +})(); diff --git a/src/static/blog/js/theme-toggle.js b/src/static/blog/js/theme-toggle.js new file mode 100644 index 0000000..d040ed9 --- /dev/null +++ b/src/static/blog/js/theme-toggle.js @@ -0,0 +1,321 @@ +/** + * 深色主题切换功能 + * 支持自动检测系统主题、手动切换、本地存储 + * 优化版本:防止FOUC、更好的动画、完整的适配 + */ + +(function() { + 'use strict'; + + // 主题管理器类 + class ThemeManager { + constructor() { + this.THEME_KEY = 'blog-theme'; + this.THEMES = { + LIGHT: 'light', + DARK: 'dark', + AUTO: 'auto' + }; + + this.currentTheme = null; + this.systemTheme = null; + this.isInitialized = false; + + this.init(); + } + + init() { + if (this.isInitialized) return; + this.isInitialized = true; + + // 检测系统主题偏好 + this.detectSystemTheme(); + + // 加载保存的主题 + this.loadTheme(); + + // 立即应用主题(无动画,防止闪烁) + this.applyTheme(false); + + // 等待DOM加载完成后创建按钮 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + this.createToggleButton(); + this.watchSystemTheme(); + }); + } else { + this.createToggleButton(); + this.watchSystemTheme(); + } + } + + /** + * 检测系统主题偏好 + */ + detectSystemTheme() { + if (window.matchMedia) { + const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + this.systemTheme = isDark ? this.THEMES.DARK : this.THEMES.LIGHT; + } else { + this.systemTheme = this.THEMES.LIGHT; + } + } + + /** + * 监听系统主题变化 + */ + watchSystemTheme() { + if (window.matchMedia) { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleChange = (e) => { + this.systemTheme = e.matches ? this.THEMES.DARK : this.THEMES.LIGHT; + + // 如果当前是自动模式,更新主题 + const savedTheme = localStorage.getItem(this.THEME_KEY); + if (!savedTheme || savedTheme === this.THEMES.AUTO) { + this.applyTheme(true); + } + }; + + // 使用新的addEventListener方法(如果支持) + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', handleChange); + } else if (mediaQuery.addListener) { + // 兼容旧版浏览器 + mediaQuery.addListener(handleChange); + } + } + } + + /** + * 从本地存储加载主题 + */ + loadTheme() { + const savedTheme = localStorage.getItem(this.THEME_KEY); + + if (savedTheme && Object.values(this.THEMES).includes(savedTheme)) { + this.currentTheme = savedTheme; + } else { + // 默认使用系统主题 + this.currentTheme = this.THEMES.AUTO; + } + } + + /** + * 保存主题到本地存储 + */ + saveTheme(theme) { + localStorage.setItem(this.THEME_KEY, theme); + this.currentTheme = theme; + } + + /** + * 获取实际应用的主题 + */ + getEffectiveTheme() { + if (this.currentTheme === this.THEMES.AUTO) { + return this.systemTheme; + } + return this.currentTheme; + } + + /** + * 应用主题 + */ + applyTheme(animate = false) { + const effectiveTheme = this.getEffectiveTheme(); + const root = document.documentElement; + + // 添加过渡类 + if (animate) { + root.classList.add('theme-transitioning'); + } + + // 设置主题 + if (effectiveTheme === this.THEMES.DARK) { + root.setAttribute('data-theme', 'dark'); + root.style.colorScheme = 'dark'; // 提示浏览器使用深色滚动条等 + } else { + root.removeAttribute('data-theme'); + root.style.colorScheme = 'light'; + } + + // 更新切换按钮 + this.updateToggleButton(); + + // 移除过渡类 + if (animate) { + setTimeout(() => { + root.classList.remove('theme-transitioning'); + }, 400); + } + + // 触发自定义事件 + window.dispatchEvent(new CustomEvent('themeChanged', { + detail: { + theme: effectiveTheme, + isDark: effectiveTheme === this.THEMES.DARK + } + })); + } + + /** + * 切换主题(三态循环) + */ + toggle() { + // 三态循环:light -> dark -> auto -> light + if (this.currentTheme === this.THEMES.LIGHT) { + this.saveTheme(this.THEMES.DARK); + } else if (this.currentTheme === this.THEMES.DARK) { + this.saveTheme(this.THEMES.AUTO); + } else { + this.saveTheme(this.THEMES.LIGHT); + } + + this.applyTheme(true); + } + + /** + * 设置特定主题 + */ + setTheme(theme) { + if (!Object.values(this.THEMES).includes(theme)) { + console.error('Invalid theme:', theme); + return; + } + + this.saveTheme(theme); + this.applyTheme(true); + } + + /** + * 创建主题切换按钮 + */ + createToggleButton() { + // 检查是否已存在按钮 + if (document.querySelector('.theme-toggle')) { + return; + } + + const button = document.createElement('button'); + button.className = 'theme-toggle'; + button.setAttribute('aria-label', '切换主题'); + button.setAttribute('title', '切换深色/浅色主题'); + button.type = 'button'; + + // 添加图标(使用SVG图标代替emoji,更清晰) + button.innerHTML = ` + + + + + + + + + + + + + + + `; + + // 绑定点击事件 + button.addEventListener('click', (e) => { + e.preventDefault(); + this.toggle(); + + // 添加点击动画 + button.classList.add('theme-toggle-clicked'); + setTimeout(() => { + button.classList.remove('theme-toggle-clicked'); + }, 400); + }); + + // 添加键盘支持 + button.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.toggle(); + } + }); + + // 添加到页面 + document.body.appendChild(button); + } + + /** + * 更新切换按钮状态 + */ + updateToggleButton() { + const button = document.querySelector('.theme-toggle'); + if (!button) return; + + const effectiveTheme = this.getEffectiveTheme(); + let titleText = ''; + let ariaLabel = ''; + + // 根据当前主题设置提示文本 + if (this.currentTheme === this.THEMES.LIGHT) { + titleText = '当前:浅色主题\n点击切换到:深色主题'; + ariaLabel = '切换到深色主题'; + } else if (this.currentTheme === this.THEMES.DARK) { + titleText = '当前:深色主题\n点击切换到:自动跟随系统'; + ariaLabel = '切换到自动跟随系统'; + } else { + // AUTO 模式 + const systemModeText = this.systemTheme === this.THEMES.DARK ? '深色' : '浅色'; + titleText = `当前:自动跟随系统(${systemModeText})\n点击切换到:浅色主题`; + ariaLabel = '切换到浅色主题'; + } + + button.setAttribute('title', titleText); + button.setAttribute('aria-label', ariaLabel); + + // 添加当前模式类名 + button.classList.remove('theme-light', 'theme-dark', 'theme-auto'); + if (this.currentTheme === this.THEMES.AUTO) { + button.classList.add('theme-auto'); + } else if (this.currentTheme === this.THEMES.DARK) { + button.classList.add('theme-dark'); + } else { + button.classList.add('theme-light'); + } + } + + /** + * 获取当前主题 + */ + getCurrentTheme() { + return this.currentTheme; + } + + /** + * 获取实际主题(考虑auto模式) + */ + getActualTheme() { + return this.getEffectiveTheme(); + } + } + + // 创建全局实例 + const themeManager = new ThemeManager(); + + // 暴露到全局 + window.themeManager = themeManager; + + // 提供便捷方法 + window.toggleTheme = () => themeManager.toggle(); + window.setTheme = (theme) => themeManager.setTheme(theme); + window.getTheme = () => themeManager.getCurrentTheme(); + + // 监听主题变化事件(供其他脚本使用) + // 使用示例: + // window.addEventListener('themeChanged', (e) => { + // console.log('Theme changed to:', e.detail.theme); + // console.log('Is dark:', e.detail.isDark); + // }); + +})(); diff --git a/src/templates/admin/blog/articleversion/compare.html b/src/templates/admin/blog/articleversion/compare.html new file mode 100644 index 0000000..ee28835 --- /dev/null +++ b/src/templates/admin/blog/articleversion/compare.html @@ -0,0 +1,340 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+

版本对比

+ +
+

📊 变更摘要

+
    +
  • + 标题{% if diff.title_changed %} 已修改{% else %} 未变更{% endif %} +
  • +
  • + 正文{% if diff.body_changed %} 已修改{% else %} 未变更{% endif %} +
  • +
  • + 发布状态{% if diff.status_changed %} 已修改{% else %} 未变更{% endif %} +
  • +
  • + 分类{% if diff.category_changed %} 已修改{% else %} 未变更{% endif %} +
  • +
+
+ +
+

基本信息对比

+
+
+

版本 v{{ version.version_number }}

+
+ 标题:
+ {% if diff.title_changed %}{{ version.title }} + {% else %}{{ version.title }}{% endif %} +
+
+ 创建时间:
+ {{ version.creation_time|date:"Y-m-d H:i:s" }} +
+
+ 创建者:
+ {{ version.created_by|default:"系统" }} +
+ {% if version.change_summary %} +
+ 变更说明:
+ {{ version.change_summary }} +
+ {% endif %} +
+ +
+

当前版本

+
+ 标题:
+ {% if diff.title_changed %}{{ current.title }} + {% else %}{{ current.title }}{% endif %} +
+
+ 最后修改:
+ {{ current.last_mod_time|date:"Y-m-d H:i:s" }} +
+
+ 作者:
+ {{ current.author }} +
+
+
+
+ +
+

状态信息对比

+
+
+

版本 v{{ version.version_number }}

+
+ 发布状态:
+ {% if diff.status_changed %} + {% endif %} + {% if version.status == 'p' %}已发布 + {% elif version.status == 'd' %}草稿 + {% else %}{{ version.status }} + {% endif %} + {% if diff.status_changed %}{% endif %} +
+
+ 评论状态:
+ {% if version.comment_status == 'o' %}开放 + {% elif version.comment_status == 'c' %}关闭 + {% else %}{{ version.comment_status }} + {% endif %} +
+
+ +
+

当前版本

+
+ 发布状态:
+ {% if diff.status_changed %} + {% endif %} + {% if current.status == 'p' %}已发布 + {% elif current.status == 'd' %}草稿 + {% else %}{{ current.status }} + {% endif %} + {% if diff.status_changed %}{% endif %} +
+
+ 评论状态:
+ {% if current.comment_status == 'o' %}开放 + {% elif current.comment_status == 'c' %}关闭 + {% else %}{{ current.comment_status }} + {% endif %} +
+
+
+
+ +
+

分类对比

+
+
+

版本 v{{ version.version_number }}

+
+ {% if diff.category_changed %}{{ version.category_name }} + {% else %}{{ version.category_name }}{% endif %} +
+
+ +
+

当前版本

+
+ {% if diff.category_changed %}{{ current.category.name }} + {% else %}{{ current.category.name }}{% endif %} +
+
+
+
+ + {% if diff.body_changed and body_diff_html %} +
+

正文内容详细对比

+

+ 🟨 黄色背景 = 修改的行 | 🟩 绿色背景 = 新增的行 | 🟥 红色背景 = 删除的行 +

+
+ {{ body_diff_html|safe }} +
+
+ {% endif %} + + +
+{% endblock %} diff --git a/src/templates/admin/blog/articleversion/restore_confirmation.html b/src/templates/admin/blog/articleversion/restore_confirmation.html new file mode 100644 index 0000000..ae75696 --- /dev/null +++ b/src/templates/admin/blog/articleversion/restore_confirmation.html @@ -0,0 +1,199 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+

恢复文章版本

+ +
+

⚠️ 重要提示

+
    +
  • 恢复版本会将文章内容替换为所选版本的内容
  • +
  • 当前版本的内容会被自动保存到历史记录中
  • +
  • 此操作可以撤销(通过恢复到其他版本)
  • +
+
+ +
+

版本信息

+ +
+
版本号:
+
v{{ version.version_number }}
+ +
标题:
+
{{ version.title }}
+ +
创建时间:
+
{{ version.creation_time|date:"Y-m-d H:i:s" }}
+ +
创建者:
+
{{ version.created_by|default:"系统" }}
+ +
变更说明:
+
{{ version.change_summary|default:"无" }}
+ +
分类:
+
{{ version.category_name }}
+ +
状态:
+
+ {% if version.status == 'p' %}已发布 + {% elif version.status == 'd' %}草稿 + {% else %}{{ version.status }} + {% endif %} +
+
+ +
+ 内容预览: +
+ {{ version.body|truncatewords:200|safe }} +
+
+
+ +
+

当前版本信息

+ +
+
标题:
+
{{ current.title }}
+ +
最后修改:
+
{{ current.last_mod_time|date:"Y-m-d H:i:s" }}
+ +
分类:
+
{{ current.category.name }}
+
+
+ +
+ {% csrf_token %} +
+ + 📊 查看详细对比 + 取消 +
+
+
+{% endblock %} diff --git a/src/templates/blog/tags/article_info.html b/src/templates/blog/tags/article_info.html index 3deec44..906707c 100644 --- a/src/templates/blog/tags/article_info.html +++ b/src/templates/blog/tags/article_info.html @@ -48,7 +48,7 @@
{% if isindex %} - {{ article.body|custom_markdown|escape|truncatechars_content }} + {% render_article_content article True %}

Read more

{% else %} @@ -62,13 +62,163 @@ {% endif %}
- {{ article.body|custom_markdown|escape }} + {% render_article_content article False %}
{% endif %}
+ + {% if not isindex %} +
+ + + + + +
+ + + {% endif %} + {% load_article_metas article user %} - \ No newline at end of file + + + +{% if not isindex %} + {% render_plugin_widgets 'article_bottom' article=article %} +{% endif %} \ No newline at end of file diff --git a/src/templates/blog/tags/article_meta_info.html b/src/templates/blog/tags/article_meta_info.html index cb6111c..392d15f 100644 --- a/src/templates/blog/tags/article_meta_info.html +++ b/src/templates/blog/tags/article_meta_info.html @@ -5,9 +5,6 @@
{% trans 'posted in' %} {{ article.category.name }} - - - {% if article.type == 'a' %} {% if article.tags.all %} @@ -38,6 +35,13 @@ + {% if user.is_authenticated and user != article.author %} + + {% endif %} @@ -46,14 +50,118 @@ title="{% datetimeformat article.pub_time %}" itemprop="datePublished" content="{% datetimeformat article.pub_time %}" rel="bookmark"> - - - {% if user.is_superuser %} - {% trans 'edit' %} - {% endif %} + + + {% if user.is_superuser %} + {% trans 'edit' %} + {% endif %}
+ + diff --git a/src/templates/blog/tags/sidebar.html b/src/templates/blog/tags/sidebar.html index f70544c..ecb6d20 100644 --- a/src/templates/blog/tags/sidebar.html +++ b/src/templates/blog/tags/sidebar.html @@ -16,7 +16,7 @@ {% endfor %} diff --git a/src/templates/comments/tags/comment_item.html b/src/templates/comments/tags/comment_item.html index ebb0388..0693649 100644 --- a/src/templates/comments/tags/comment_item.html +++ b/src/templates/comments/tags/comment_item.html @@ -2,10 +2,13 @@
  • - + class="avatar avatar-96 photo" + loading="lazy" + decoding="async" + style="max-width:100%;height:auto;">
    diff --git a/src/templates/plugins/article_recommendation/sidebar_widget.html b/src/templates/plugins/article_recommendation/sidebar_widget.html new file mode 100644 index 0000000..5f1afbf --- /dev/null +++ b/src/templates/plugins/article_recommendation/sidebar_widget.html @@ -0,0 +1,17 @@ +{% load i18n %} + diff --git a/src/templates/plugins/css_includes.html b/src/templates/plugins/css_includes.html new file mode 100644 index 0000000..37029ae --- /dev/null +++ b/src/templates/plugins/css_includes.html @@ -0,0 +1,4 @@ +{% comment %}插件CSS文件包含模板 - 用于压缩{% endcomment %} +{% for css_file in css_files %} + +{% endfor %} diff --git a/src/templates/plugins/js_includes.html b/src/templates/plugins/js_includes.html new file mode 100644 index 0000000..2a315e3 --- /dev/null +++ b/src/templates/plugins/js_includes.html @@ -0,0 +1,4 @@ +{% comment %}插件JS文件包含模板 - 用于压缩{% endcomment %} +{% for js_file in js_files %} + +{% endfor %} diff --git a/src/templates/share_layout/base.html b/src/templates/share_layout/base.html index 75d0df5..c55ec1a 100644 --- a/src/templates/share_layout/base.html +++ b/src/templates/share_layout/base.html @@ -15,32 +15,60 @@ + + + - - {% block header %} - {% block title %}{{ SITE_NAME }}{% endblock %} - - - {% endblock %} + + + {% load blog_tags %} {% head_meta %} + {% block header %} + + {% endblock %} + + + + + + - - - + + - {% compress css %} + + + {% compress css %} @@ -50,12 +78,22 @@ + {% block compress_css %} {% endblock %} + + {% plugin_compressed_css %} {% endcompress %} + + + + {% if GLOBAL_HEADER %} {{ GLOBAL_HEADER|safe }} {% endif %} + + + {% plugin_head_resources %} @@ -67,26 +105,6 @@

    {{ SITE_DESCRIPTION }}

    {% load i18n %} - - - {#
    #} - {#
    {% csrf_token %}#} - {# #} - {# #} - {# #} - {#
    #} - {#
    #} - - {% include 'share_layout/nav.html' %} @@ -105,19 +123,26 @@ {% include 'share_layout/footer.html' %}
    - -
    - - {% compress js %} - - - - - - {% block compress_js %} - {% endblock %} - {% endcompress %} - {% block footer %} + +{% compress js %} + + + + + + {% block compress_js %} {% endblock %} -
    + + {% plugin_compressed_js %} +{% endcompress %} + + + + +{% block footer %} +{% endblock %} + + +{% plugin_body_resources %} +