refactor: 重构样式表,代码更易维护,可扩展性更强
This commit is contained in:
1 parent
e839b4e197
commit
3464d2c54f
58 files changed
+3144
-2343
No files matched your search
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
# 更新数据库结构
|
||||
- name: Update Database Schema
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
@@ -10,6 +10,8 @@ jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Build and push Docker images"
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -20,19 +22,19 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Push images to Docker hub
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
steps:
|
||||
# STEP 1: 安装 Rclone
|
||||
- name: 🛠️ Setup Tools
|
||||
uses: AnimMouse/setup-rclone@v1
|
||||
uses: AnimMouse/setup-rclone@02a3642a1ad72d5b765f48873141b9f26e9082f1 # v1
|
||||
with:
|
||||
version: v1.66.0
|
||||
|
||||
|
||||
+182
-73
@@ -25,7 +25,7 @@ jobs:
|
||||
# 1. 检查代码中的敏感信息 (Gitleaks)
|
||||
- name: Secret Detection
|
||||
id: gitleaks
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
uses: gitleaks/gitleaks-action@dcedce43c6f43de0b836d1fe38946645c9c638dc # v2
|
||||
continue-on-error: true
|
||||
|
||||
# 2. 检查依赖包漏洞 (Snyk)
|
||||
@@ -39,7 +39,8 @@ jobs:
|
||||
echo "Installing frontend dependencies..."
|
||||
cd frontend && npm install --package-lock-only && cd ..
|
||||
snyk auth ${{ secrets.SNYK_TOKEN }}
|
||||
snyk test --all-projects > snyk_result.txt || true
|
||||
# 🚀 UX优化:同时输出人类可读的 TXT 日志与供程序解析的 JSON 数据文件
|
||||
snyk test --all-projects --json-file-output=snyk_result.json > snyk_result.txt || true
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
|
||||
@@ -48,8 +49,12 @@ jobs:
|
||||
if: env.ACT != 'true'
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
languages: javascript-typescript, actions
|
||||
queries: security-extended,security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
if: env.ACT != 'true'
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
if: env.ACT != 'true'
|
||||
@@ -58,43 +63,80 @@ jobs:
|
||||
upload: true
|
||||
output: sarif-results
|
||||
|
||||
# 4. 生成 SECURITY.md 报告
|
||||
# 4. 生成 README.md 报告 (🚀 UX 深度优化版)
|
||||
- name: Generate Security Report
|
||||
run: |
|
||||
REPORT_FILE="SECURITY.md"
|
||||
REPORT_FILE="README.md"
|
||||
|
||||
# 1. 动态获取审计文件计数 (多路径探测 + SARIF 补位)
|
||||
CODEQL_SARIFS=$(find sarif-results -name "*.sarif" 2>/dev/null)
|
||||
REAL_CSV=$(find . -name "code-scanning-files-extracted.csv" | head -n 1)
|
||||
SARIF_FILE=$(find . -name "*.sarif" | head -n 1)
|
||||
|
||||
if [ -n "$REAL_CSV" ]; then
|
||||
FE_FILES=$(grep -c "JavaScript,frontend/" "$REAL_CSV" || echo "0")
|
||||
BE_FILES=$(grep -c "TypeScript,backend/" "$REAL_CSV" || echo "0")
|
||||
WF_FILES=$(grep -i -c "GitHub Actions" "$REAL_CSV" || echo "0")
|
||||
elif [ -n "$SARIF_FILE" ]; then
|
||||
# 如果 CSV 缺失,从 SARIF 的 artifacts 中抓取
|
||||
FE_FILES=$(jq '[.runs[].artifacts[]? | select(.location.uri | contains("frontend/"))] | length' "$SARIF_FILE" || echo "0")
|
||||
BE_FILES=$(jq '[.runs[].artifacts[]? | select(.location.uri | contains("backend/"))] | length' "$SARIF_FILE" || echo "0")
|
||||
WF_FILES=$(jq '[.runs[].artifacts[]? | select(.location.uri | contains(".github/workflows/"))] | length' "$SARIF_FILE" || echo "0")
|
||||
# Fallback csv 模式
|
||||
WF_FILES=$(grep ",GitHub Actions," "$REAL_CSV" | grep "\.github/workflows/" | sort -u | wc -l)
|
||||
JS_FILES=$(grep ",JavaScript," "$REAL_CSV" | sort -u | wc -l)
|
||||
TS_FILES=$(grep ",TypeScript," "$REAL_CSV" | sort -u | wc -l)
|
||||
|
||||
gen_csv_table() {
|
||||
echo "| 文件路径 | 状态 |"
|
||||
echo "| :--- | :--- |"
|
||||
if [ "$1" == "GitHub Actions" ]; then
|
||||
grep ",$1," "$REAL_CSV" | grep "\.github/workflows/" | awk -F',' '{print "| `"$3"` | ✅ 已审计 |"}' | sort -u
|
||||
else
|
||||
grep ",$1," "$REAL_CSV" | awk -F',' '{print "| `"$3"` | ✅ 已审计 |"}' | sort -u
|
||||
fi
|
||||
}
|
||||
WF_TABLE=$(gen_csv_table "GitHub Actions")
|
||||
JS_TABLE=$(gen_csv_table "JavaScript")
|
||||
TS_TABLE=$(gen_csv_table "TypeScript")
|
||||
|
||||
elif [ -n "$CODEQL_SARIFS" ]; then
|
||||
WF_FILES=$(jq -s '[.[].runs[].artifacts[]? | select(.location.uri | startswith(".github/workflows/") and test("\\.ya?ml$"))] | unique_by(.location.uri) | length' $CODEQL_SARIFS || echo "0")
|
||||
JS_FILES=$(jq -s '[.[].runs[].artifacts[]? | select(.location.uri | endswith(".js"))] | unique_by(.location.uri) | length' $CODEQL_SARIFS || echo "0")
|
||||
TS_FILES=$(jq -s '[.[].runs[].artifacts[]? | select(.location.uri | endswith(".ts"))] | unique_by(.location.uri) | length' $CODEQL_SARIFS || echo "0")
|
||||
|
||||
gen_sarif_table() {
|
||||
echo "| 文件路径 | 状态 |"
|
||||
echo "| :--- | :--- |"
|
||||
if [ "$1" == "workflow" ]; then
|
||||
jq -s -r '.[].runs[].artifacts[]? | select(.location.uri | startswith(".github/workflows/") and test("\\.ya?ml$")) | .location.uri' $CODEQL_SARIFS | sort -u | awk '{print "| `"$1"` | ✅ 已审计 |"}'
|
||||
elif [ "$1" == "js" ]; then
|
||||
jq -s -r '.[].runs[].artifacts[]? | select(.location.uri | endswith(".js")) | .location.uri' $CODEQL_SARIFS | sort -u | awk '{print "| `"$1"` | ✅ 已审计 |"}'
|
||||
elif [ "$1" == "ts" ]; then
|
||||
jq -s -r '.[].runs[].artifacts[]? | select(.location.uri | endswith(".ts")) | .location.uri' $CODEQL_SARIFS | sort -u | awk '{print "| `"$1"` | ✅ 已审计 |"}'
|
||||
fi
|
||||
}
|
||||
WF_TABLE=$(gen_sarif_table "workflow")
|
||||
JS_TABLE=$(gen_sarif_table "js")
|
||||
TS_TABLE=$(gen_sarif_table "ts")
|
||||
else
|
||||
FE_FILES="0"; BE_FILES="0"; WF_FILES="0"
|
||||
WF_FILES="0"; JS_FILES="0"; TS_FILES="0"
|
||||
WF_TABLE="| No files found | - |"
|
||||
JS_TABLE="| No files found | - |"
|
||||
TS_TABLE="| No files found | - |"
|
||||
fi
|
||||
|
||||
AUDIT_TIME=$(date -u +"%Y-%m-%d %H:%M:%S UTC")
|
||||
RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
|
||||
# 状态徽标
|
||||
BADGE_PASS=""
|
||||
BADGE_WARN=""
|
||||
BADGE_FAIL=""
|
||||
|
||||
# 提取 Gitleaks 结果
|
||||
GITLEAKS_RES="PASS"
|
||||
[ "${{ steps.gitleaks.outcome }}" != "success" ] && GITLEAKS_RES="FAIL"
|
||||
|
||||
# 🚀 UX优化:解析 Snyk JSON 计算实际漏洞数量
|
||||
SNYK_VULNS_COUNT=0
|
||||
if [ -f "snyk_result.json" ]; then
|
||||
SNYK_VULNS_COUNT=$(jq 'if type=="array" then [.[].vulnerabilities[]?] | length else (.vulnerabilities // []) | length end' snyk_result.json || echo "0")
|
||||
fi
|
||||
|
||||
SNYK_RES="PASS"
|
||||
grep -i -q "vulnerabilities found" snyk_result.txt && SNYK_RES="WARN"
|
||||
[ "$SNYK_VULNS_COUNT" -gt 0 ] && SNYK_RES="WARN"
|
||||
[ "$SNYK_VULNS_COUNT" -eq 0 ] && grep -i -q "vulnerabilities found" snyk_result.txt && SNYK_RES="WARN"
|
||||
|
||||
# 核心等级评估
|
||||
OVERALL_GRADE=""
|
||||
if [ "$GITLEAKS_RES" == "FAIL" ]; then
|
||||
OVERALL_GRADE=""
|
||||
@@ -102,24 +144,23 @@ jobs:
|
||||
OVERALL_GRADE=""
|
||||
fi
|
||||
|
||||
# 构建报告
|
||||
{
|
||||
echo "# 🛡️ 安全审计与透明度报告 | Security Audit Report"
|
||||
echo "# 🛡️ 安全审计与透明度报告"
|
||||
echo ""
|
||||
echo " $OVERALL_GRADE"
|
||||
echo ""
|
||||
echo "> [!NOTE]"
|
||||
echo "> 本报告由 **GitHub Actions** 自动生成。为了确保数据主权的绝对透明,我们将所有核心模块的安全扫描结果实时公开。"
|
||||
echo ""
|
||||
echo "| 📅 审计时间 / Audit Time | 📝 运行编号 / Run ID | 🛠️ 环境 / Environment |"
|
||||
echo "| 📅 审计时间 | 📝 运行编号 | 🛠️ 环境 |"
|
||||
echo "| :--- | :--- | :--- |"
|
||||
echo "| \`$AUDIT_TIME\` | [#${{ github.run_id }}]($RUN_URL) | \`GitHub CI/CD\` |"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "## 📉 实时安全看板 | Security Dashboard"
|
||||
echo "## 📉 实时安全看板"
|
||||
echo ""
|
||||
echo "| 项目 / Scope | 状态 / Status | 风险等级 / Risk |"
|
||||
echo "| 项目 | 状态 | 风险等级 |"
|
||||
echo "| :--- | :--- | :--- |"
|
||||
echo "| **敏感泄露检查 (Gitleaks)** | $([ "$GITLEAKS_RES" == "PASS" ] && echo "$BADGE_PASS" || echo "$BADGE_FAIL") | \`NONE\` |"
|
||||
echo "| **依赖项漏洞扫描 (Snyk)** | $([ "$SNYK_RES" == "PASS" ] && echo "$BADGE_PASS" || echo "$BADGE_WARN") | \`LOW/MED\` |"
|
||||
@@ -127,70 +168,117 @@ jobs:
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "## 🔍 扫描覆盖范围 | Scan Coverage"
|
||||
echo "## 🔍 扫描覆盖范围"
|
||||
echo ""
|
||||
echo "| 模块 / Module | 已审计文件数 / Files Audited | 提取状态 / Extraction |"
|
||||
echo "| 模块 | 已审计文件数 | 提取状态 |"
|
||||
echo "| :--- | :---: | :---: |"
|
||||
echo "| **前端代码 (Frontend/JS)** | \`$FE_FILES\` | ✨ **100%** |"
|
||||
echo "| **后端代码 (Backend/TS)** | \`$BE_FILES\` | ✨ **100%** |"
|
||||
echo "| **流水线 (Workflows)** | \`$WF_FILES\` | ✨ **100%** |"
|
||||
echo "| **GitHub Actions** | \`$WF_FILES\` | ✨ **100%** |"
|
||||
echo "| **JavaScript** | \`$JS_FILES\` | ✨ **100%** |"
|
||||
echo "| **TypeScript** | \`$TS_FILES\` | ✨ **100%** |"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "## 🔍 详细发现 | Detailed Findings"
|
||||
echo "## 🔍 详细发现"
|
||||
echo ""
|
||||
echo "### 📦 依赖安全性 (Dependencies)"
|
||||
echo "<details>"
|
||||
echo "<summary><b>点击展开详情 / Click to expand details</b></summary>"
|
||||
|
||||
# 🚀 UX优化:全新的 Snyk 漏洞表格化展示
|
||||
echo "### 📦 依赖安全性"
|
||||
echo "<details open>"
|
||||
echo "<summary><b>点击查看依赖漏洞详情</b></summary>"
|
||||
echo ""
|
||||
echo "\`\`\`text"
|
||||
if [ -f snyk_result.txt ]; then
|
||||
grep -E -i "Tested|found|severity|Vulnerabilit|Upgrade|Path|Issue" snyk_result.txt
|
||||
|
||||
if [ "$SNYK_VULNS_COUNT" -gt 0 ]; then
|
||||
echo "> ⚠️ 共发现 **$SNYK_VULNS_COUNT** 个第三方依赖漏洞。"
|
||||
echo ""
|
||||
echo "| 依赖包 | 严重程度 | 漏洞描述 | 修复方案 |"
|
||||
echo "| :--- | :---: | :--- | :--- |"
|
||||
|
||||
jq -r '
|
||||
def levelIcon: if . == "critical" then "🔴" elif . == "high" then "🟠" elif . == "medium" then "🟡" else "🔵" end;
|
||||
(if type=="array" then .[] else . end) | .vulnerabilities[]? |
|
||||
"| `\(.packageName)@\(.version)` | \(levelIcon) \(.severity) | [\(.title)](\(.url)) | \(.fixedIn // ["无 / N/A"] | join(", ") | if . == "无 / N/A" then . else "Upgrade to `\(.)`" end) |"
|
||||
' snyk_result.json | sort -u >> $REPORT_FILE
|
||||
|
||||
elif [ "$SNYK_RES" == "WARN" ]; then
|
||||
# Fallback 显示 txt 结果
|
||||
echo "\`\`\`text"
|
||||
grep -E -i "Tested|found|severity|Vulnerabilit|Upgrade|Path|Issue" snyk_result.txt || echo "Please check snyk_result.txt for details."
|
||||
echo "\`\`\`"
|
||||
else
|
||||
echo "No Snyk logs found."
|
||||
echo "> ✅ **SAFE**: 未发现第三方依赖包包含已知安全漏洞。"
|
||||
fi
|
||||
echo "\`\`\`"
|
||||
|
||||
echo "</details>"
|
||||
echo ""
|
||||
echo "### 🔑 凭证泄露检查 (Secret Leaks)"
|
||||
|
||||
echo "### 🔑 凭证泄露检查"
|
||||
echo "该项探测代码历史记录中是否存在硬编码的 API Key、密码或其他敏感令牌。"
|
||||
echo "<details open>"
|
||||
echo "<summary><b>扫描结论 / Audit Verdict</b></summary>"
|
||||
echo "<summary><b>扫描结论</b></summary>"
|
||||
echo ""
|
||||
echo "> ✅ **SAFE**: 未发现代码中包含硬编码的敏感凭证。"
|
||||
echo "> ✅ **SAFE**: No secrets detected in the audit scope."
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "🔍 *审计范围:全量代码变更与 Git 历史 (Gitleaks Full Scan)*"
|
||||
echo "</details>"
|
||||
echo ""
|
||||
echo "### 🔍 静态代码安全分析细节 (CodeQL Technical Details)"
|
||||
echo "### 📂 审计文件列表"
|
||||
echo "<details>"
|
||||
echo "<summary><b>点击展开 100+ 项已检查规则 / Click to expand rules</b></summary>"
|
||||
echo "<summary><b>GitHub Actions ($WF_FILES 份流水线文件)</b></summary>"
|
||||
echo ""
|
||||
echo "$WF_TABLE"
|
||||
echo ""
|
||||
echo "</details>"
|
||||
echo ""
|
||||
echo "<details>"
|
||||
echo "<summary><b>JavaScript ($JS_FILES 个前端文件)</b></summary>"
|
||||
echo ""
|
||||
echo "$JS_TABLE"
|
||||
echo ""
|
||||
echo "</details>"
|
||||
echo ""
|
||||
echo "<details>"
|
||||
echo "<summary><b>TypeScript ($TS_FILES 个后端文件)</b></summary>"
|
||||
echo ""
|
||||
echo "$TS_TABLE"
|
||||
echo ""
|
||||
echo "</details>"
|
||||
echo ""
|
||||
echo "### 🔍 CodeQL静态代码安全分析细节"
|
||||
echo "<details open>"
|
||||
echo "<summary><b>点击查看发现的代码问题</b></summary>"
|
||||
echo ""
|
||||
} > $REPORT_FILE
|
||||
|
||||
if [ -n "$SARIF_FILE" ]; then
|
||||
ALERTS_COUNT=$(jq '[.runs[].results[]?] | length' "$SARIF_FILE")
|
||||
RULES_COUNT=$(jq '[.runs[].tool.driver.rules[]?, .runs[].tool.extensions[].rules[]?] | unique_by(.id) | length' "$SARIF_FILE")
|
||||
if [ -n "$CODEQL_SARIFS" ]; then
|
||||
ALERTS_COUNT=$(jq -s '[.[].runs[].results[]?] | length' $CODEQL_SARIFS)
|
||||
ERRORS=$(jq -s '[.[].runs[].results[]? | select((.level // "warning") == "error")] | length' $CODEQL_SARIFS)
|
||||
WARNINGS=$(jq -s '[.[].runs[].results[]? | select((.level // "warning") == "warning")] | length' $CODEQL_SARIFS)
|
||||
NOTES=$(jq -s '[.[].runs[].results[]? | select((.level // "warning") == "note")] | length' $CODEQL_SARIFS)
|
||||
RULES_COUNT=$(jq -s '[.[].runs[].tool.driver.rules[]?, .[].runs[].tool.extensions[].rules[]?] | unique_by(.id) | length' $CODEQL_SARIFS)
|
||||
|
||||
{
|
||||
echo "#### 审计摘要 / Audit Summary"
|
||||
echo "- **总扫描规则 / Total Rules Checked**: \`$RULES_COUNT\`"
|
||||
echo "- **风险警告 / Security Alerts Found**: \`$ALERTS_COUNT\`"
|
||||
echo "#### 审计摘要"
|
||||
echo "- **检查规则总数**: \`$RULES_COUNT\` 项"
|
||||
echo "- **发现的问题总数**: \`$ALERTS_COUNT\` 处"
|
||||
echo "- 🔴 高危 (Error): \`$ERRORS\` | 🟠 中危 (Warning): \`$WARNINGS\` | 🔵 建议 (Note): \`$NOTES\` "
|
||||
echo ""
|
||||
echo "| 规则 ID / Rule ID | 描述 / Description |"
|
||||
echo "| :--- | :--- |"
|
||||
echo "| 规则 ID | 严重程度 | 触发位置 | 问题描述 |"
|
||||
echo "| :--- | :---: | :--- | :--- |"
|
||||
} >> $REPORT_FILE
|
||||
|
||||
jq -r '
|
||||
[.runs[].tool.driver.rules[]?, .runs[].tool.extensions[].rules[]?]
|
||||
| unique_by(.id)
|
||||
| .[]
|
||||
| "| [\(.id)](https://codeql.github.com/codeql-query-help/javascript/\(.id | gsub("/";"-"))/) | \(.shortDescription.text // .description.text // "No description") |"
|
||||
' "$SARIF_FILE" >> $REPORT_FILE
|
||||
if [ "$ALERTS_COUNT" -gt 0 ]; then
|
||||
jq -s -r '
|
||||
def lang: .ruleId | split("/")[0] | if . == "js" then "javascript" elif . == "ts" then "typescript" else . end;
|
||||
def levelIcon: (.level // "warning") | if . == "error" then "🔴" elif . == "warning" then "🟠" else "🔵" end;
|
||||
.[].runs[].results[]? |
|
||||
"| [\(.ruleId)](https://codeql.github.com/codeql-query-help/\(lang)/\(.ruleId | gsub("/";"-"))/) | \(levelIcon) \(.level // "warning") | `\(.locations[0].physicalLocation.artifactLocation.uri // "Project-level"):\(.locations[0].physicalLocation.region.startLine // "-")` | \(.message.text // "No description") |"
|
||||
' $CODEQL_SARIFS >> $REPORT_FILE
|
||||
else
|
||||
echo "| - | ✅ 完美 | - | 未检测到任何代码安全与质量问题 |" >> $REPORT_FILE
|
||||
fi
|
||||
else
|
||||
echo "CodeQL results not available." >> $REPORT_FILE
|
||||
echo "> ⚠️ CodeQL SARIF report not found." >> $REPORT_FILE
|
||||
fi
|
||||
|
||||
{
|
||||
@@ -200,13 +288,10 @@ jobs:
|
||||
echo ""
|
||||
echo "## ⚠️ 行动指引 | Steps to Take"
|
||||
echo ""
|
||||
echo "如果您看到状态显示为 **FAIL (失败)** 或 **CRITICAL (致命)**:"
|
||||
echo "1. **开发者**:请立即检查 [GitHub Actions 日志]($RUN_URL) 获取完整路径。"
|
||||
echo "2. **贡献者**:请提交 Pull Request 修复对应依赖项或通过环境变量覆盖密钥。"
|
||||
echo ""
|
||||
echo "If you see a **FAIL** or **CRITICAL** status:"
|
||||
echo "1. **Developers**: Check [GitHub Actions Logs]($RUN_URL) for full trace."
|
||||
echo "2. **Contributors**: Submit a PR to patch dependencies or rotate secrets via env vars."
|
||||
echo "如果您看到状态显示为 **FAIL** 或发现严重代码问题:"
|
||||
echo "1. **开发者**:请立即通过上方表格的 **触发位置** 定位到具体文件和行号。"
|
||||
echo "2. **修复**:参考静态分析细节中的官方文档链接了解漏洞详情并提交修复。"
|
||||
echo "3. **追溯**:完整的 \`.sarif\` 原始数据已附在本分支,可下载后导入本地 IDE 插件进行分析。"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "💡 *Powered by Gitleaks, Snyk & CodeQL. Transparency is our commitment.*"
|
||||
@@ -214,24 +299,48 @@ jobs:
|
||||
|
||||
cat $REPORT_FILE >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# 5. 将报告推送到独立的 security-audit 分支 (Orphan Branch)
|
||||
# 5. 将报告、重命名的 SARIF 文件及 Snyk 结果推送到孤儿分支
|
||||
- name: Push to Audit Branch
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
# 创建临时文件夹
|
||||
mkdir audit_temp
|
||||
cp SECURITY.md audit_temp/
|
||||
|
||||
cp README.md audit_temp/
|
||||
|
||||
if [ -f "snyk_result.txt" ]; then
|
||||
cp snyk_result.txt audit_temp/
|
||||
fi
|
||||
|
||||
# 🚀 UX优化:同步保存 Snyk JSON 文件到审计分支
|
||||
if [ -f "snyk_result.json" ]; then
|
||||
cp snyk_result.json audit_temp/
|
||||
fi
|
||||
|
||||
GITLEAKS_FILE=$(find . -name "results.sarif" | head -n 1)
|
||||
if [ -n "$GITLEAKS_FILE" ]; then
|
||||
cp "$GITLEAKS_FILE" audit_temp/Gitleaks_results.sarif
|
||||
fi
|
||||
|
||||
for f in sarif-results/*.sarif; do
|
||||
if [ -f "$f" ]; then
|
||||
fname=$(basename "$f")
|
||||
cp "$f" "audit_temp/CodeQL_${fname}"
|
||||
fi
|
||||
done
|
||||
|
||||
cd audit_temp
|
||||
git init
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
|
||||
# 创建孤儿分支
|
||||
git checkout --orphan security-audit
|
||||
git add SECURITY.md
|
||||
git commit -m "chore: update security audit report [skip ci]"
|
||||
|
||||
# 强制推送到远程的 security-audit 分支
|
||||
git add README
|
||||
[ -f "snyk_result.txt" ] && git add snyk_result.txt
|
||||
[ -f "snyk_result.json" ] && git add snyk_result.json
|
||||
ls *.sarif >/dev/null 2>&1 && git add *.sarif
|
||||
|
||||
git commit -m "chore: archive security report, snyk logs and sarif data [skip ci]"
|
||||
|
||||
git remote add origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
|
||||
git push -f origin security-audit
|
||||
@@ -257,7 +257,7 @@ docker run -d --name 2fauth-worker \
|
||||
4. **端到端隔离**:前端生成的敏感操作逻辑经过严格过滤,严禁任何 XSS (跨站脚本) 攻击。
|
||||
5. **隐私第一**:本项目不包含任何追踪代码、分析工具或第三方统计插件。你的数据只属于你。
|
||||
|
||||
> 📊 [**点击查看由 GitHub Actions 自动生成的实时安全审计报告**](https://github.com/nap0o/2fauth-worker/blob/security-audit/SECURITY.md)
|
||||
> 📊 [**点击查看由 GitHub Actions 自动生成的实时安全审计报告**](https://github.com/nap0o/2fauth-worker/blob/security-audit/README.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
# 2FAuth Worker
|
||||
|
||||
English | [中文](README.md)
|
||||
|
||||
**High-security, lightweight Two-Factor Authentication (2FA/TOTP) management system supporting both Cloudflare Serverless and Docker private deployment.**
|
||||
|
||||
[](https://workers.cloudflare.com/)
|
||||
[](https://hub.docker.com/r/nap0o/2fauth-worker)
|
||||
[](https://github.com/nap0o/2fauth-worker/actions)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
|
||||
[**Click here for LIVE DEMO**](https://2fa.nezha.pp.ua)
|
||||
|
||||
> 💡 **Tip**: The demo site has open enrollment enabled; data can be modified or deleted. For official deployment, please ensure you configure the allowed users whitelist.
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Why choose 2FAuth Worker?
|
||||
|
||||
Whether you're leveraging Cloudflare's free tier or deploying on your own NAS/mini-PC, 2FAuth Worker provides the **most secure** and convenient 2FA management experience.
|
||||
|
||||
### 🕊️ True "Dual-Mode" Deployment
|
||||
* **Cloudflare Lovers**: One-click deployment to Cloudflare Workers – zero cost, zero maintenance, and global acceleration.
|
||||
* **Private Cloud Geeks**: Supports Docker deployment. Keep your data locked on your own hardware; it even works in offline local networks.
|
||||
|
||||
### 📱 Smooth PWA Experience
|
||||
* **Immersive Experience**: Install it to your desktop or mobile home screen for a distraction-free environment without browser bars.
|
||||
* **Offline Ready**: Thanks to advanced Service Worker caching, it opens instantly and generates accurate codes even in extreme environments without internet (like basements or airplanes).
|
||||
|
||||
### 🛡️ "Smart Shield" Security (Health Shield)
|
||||
* Built-in intelligent health checks. If the system detects critical missing keys or improper configurations, it will immediately "shut down" access to prevent your data from being exposed in an insecure environment.
|
||||
|
||||
### 📦 The Ultimate "Undo Button" (Multi-channel Backup)
|
||||
* Supports automatic backups via WebDAV, S3 cloud storage, or Telegram bots. If you lose your phone, reinstall your OS, or even lose your Cloudflare account, you can recover everything instantly from your backup.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Feature Comparison: Why Choose 2FAuth Worker?
|
||||
|
||||
| Feature | **2FAuth Worker** (This Project) | Google / MS Auth | Authy | 2FAS / Aegis | 1Password / Bitwarden |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| Data Ownership | ✅ Full Control (Private/NAS) | ❌ Tied to Big Tech | ❌ Vendor-locked | ✅ Local/Private Cloud | ⚠️ Third-party Hosted |
|
||||
| Cross-Platform | ✅ Web+PWA (Universal) | ⚠️ Mobile App only | ❌ Desktop App closed | ⚠️ Mobile App only | ✅ Full Platform Support |
|
||||
| Recovery | ✅ Auto Backups (TG/S3) | ⚠️ Manually Exporting | ✅ Cloud Sync | ⚠️ Manage Files Manually | ✅ Cloud Sync |
|
||||
| Privacy | ✅ Anonymous, No Tracking | ⚠️ Tied to Big Tech ID | ⚠️ Forced Phone Bind | ✅ No Tracking | ✅ Safe but high profile |
|
||||
| Offline Support | ✅ Yes (PWA Cache) | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
|
||||
| Cost | ✅ $0 (CF Serverless) | ✅ Free | ✅ Free | ✅ Free | 💰 Subscription based |
|
||||
| Vendor Risk | ✅ Source in Hand | ⚠️ Forced migration risks | 🚨 Feature shutdowns (PC) | ✅ Open-source control | ⚠️ Subscription/Closed |
|
||||
|
||||
> 🎯 **Summary**: **2FAuth Worker** combines the multi-device convenience of 1Password with the privacy of open-source apps, and the zero cost of Serverless. **Automatic Telegram backup** completely eliminates "data loss anxiety" common in both web and mobile apps, making security truly accessible.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment Guide (Choose One)
|
||||
|
||||
### Option A: Cloudflare Worker (Fastest, Recommended for Beginners)
|
||||
|
||||
The most hassle-free way. No server required; Cloudflare hosts it for you for free.
|
||||
|
||||
#### 1. One-click Auto Deploy
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/nap0o/2fauth-worker)
|
||||
|
||||
#### 2. Notes & Authorization
|
||||
* During the deployment wizard, you'll need to authorize Cloudflare to read this repository (it will automatically create a private fork in your account).
|
||||
|
||||
<details>
|
||||
<summary>Click to view: Authorization Guide Diagrams</summary>
|
||||
<img height="200" src="https://github.com/user-attachments/assets/cb64bc2f-6dcc-40cb-a781-3bc2c7bc5b28" /><br/>
|
||||
<img height="400" src="https://github.com/user-attachments/assets/3f186ea6-80f5-4d78-b90f-724af33a73ae" />
|
||||
<img height="400" src="https://github.com/user-attachments/assets/c1f2d5ee-2f3f-47c2-969f-00308cadff21" />
|
||||
</details>
|
||||
|
||||
#### 3. Add the following Variables and Secrets
|
||||
* `ENCRYPTION_KEY`: A random key with at least 32 characters.
|
||||
* `JWT_SECRET`: A random JWT secret with at least 32 characters.
|
||||
* `OAUTH_ALLOWED_USERS`: your_email@example.com
|
||||
* `OAUTH_GITHUB_CLIENT_ID`: Your Client ID
|
||||
* `OAUTH_GITHUB_CLIENT_SECRET`: Your Client Secret
|
||||
* `OAUTH_GITHUB_REDIRECT_URI`: Your Callback URL
|
||||
|
||||
<details>
|
||||
<summary>Click to view: Add Variables and Secrets Diagram</summary>
|
||||
<img height="600" src="https://github.com/user-attachments/assets/51d6e702-142e-4f58-8f02-c4a0bbcf009c" />
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Option B: Docker (For NAS / Private Server Users)
|
||||
|
||||
If you want absolute control over your data or need to run it in a local network.
|
||||
|
||||
#### 1. Using Docker Compose (Recommended)
|
||||
Create `docker-compose.yml` on your server:
|
||||
```yaml
|
||||
services:
|
||||
2fauth:
|
||||
image: nap0o/2fauth-worker:latest
|
||||
container_name: 2fauth-worker
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- ENCRYPTION_KEY=Your_32_Character_Random_Key
|
||||
- JWT_SECRET=Your_Random_JWT_Secret
|
||||
- OAUTH_ALLOWED_USERS=your_email@example.com
|
||||
# Configure at least one provider (GitHub example)
|
||||
- OAUTH_GITHUB_CLIENT_ID=your_id
|
||||
- OAUTH_GITHUB_CLIENT_SECRET=your_secret
|
||||
- OAUTH_GITHUB_REDIRECT_URI=https://your-domain.com/oauth/callback
|
||||
restart: unless-stopped
|
||||
```
|
||||
Run `docker compose up -d`.
|
||||
|
||||
#### 2. Using Docker Run
|
||||
```bash
|
||||
docker run -d --name 2fauth-worker \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
-e ENCRYPTION_KEY=Your_32_Character_Random_Key \
|
||||
-e JWT_SECRET=Your_32_Character_Random_JWT_Secret \
|
||||
-e OAUTH_ALLOWED_USERS=your_email@example.com \
|
||||
-e OAUTH_GITHUB_CLIENT_ID= \
|
||||
-e OAUTH_GITHUB_CLIENT_SECRET= \
|
||||
-e OAUTH_GITHUB_CLIENT_REDIRECT_URI= \
|
||||
nap0o/2fauth-worker:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Option C: GitHub Actions (For Advanced Users)
|
||||
|
||||
Choose this for continuous deployment or more precise database management.
|
||||
|
||||
#### 1. Prerequisites
|
||||
* Obtain a **Cloudflare API Token** (requires `Edit Cloudflare Workers` permissions).
|
||||
<details>
|
||||
<summary>Click to view: Steps to obtain Cloudflare Worker Token</summary>
|
||||
|
||||
1. Log in to Cloudflare Dashboard
|
||||
2. [Go to API Tokens](https://dash.cloudflare.com/profile/api-tokens)
|
||||
3. Click "Create Token"
|
||||
4. Choose "Edit Cloudflare Workers" template
|
||||
5. Configure "Account Resources" and "Zone Resources"
|
||||
6. Click "Continue to summary" and "Create Token"
|
||||
7. Copy the generated token
|
||||
|
||||
<img width="500" alt="image" src="https://github.com/user-attachments/assets/6487aa6e-e505-4980-aef4-e08172116746" /><br />
|
||||
|
||||
<img width="800" alt="image" src="https://github.com/user-attachments/assets/d4c737f7-2d9f-4cfb-a712-b1af416c8ef6" />
|
||||
</details>
|
||||
|
||||
* Create a D1 database named `2fauth-db` in `Storage & Databases` -> `D1` and record its **Database ID**.
|
||||
|
||||
#### 2. Repository Configuration
|
||||
1. **Fork** this repository.
|
||||
2. Go to `Settings` -> `Secrets and variables` -> `Actions`.
|
||||
3. Add the following secrets:
|
||||
* `CLOUDFLARE_ACCOUNT_ID`: Your CF Account ID.
|
||||
* `CLOUDFLARE_API_TOKEN`: Your API Token.
|
||||
* `CLOUDFLARE_D1_DATABASE_ID`: Your D1 Database ID.
|
||||
* `CLOUDFLARE_D1_DATABASE_NAME`: `2fauth-db`.
|
||||
* `ENCRYPTION_KEY`: A 32+ character random key.
|
||||
* `JWT_SECRET`: A 32+ character random JWT secret.
|
||||
* `OAUTH_ALLOWED_USERS`: your_email@example.com
|
||||
* `OAUTH_GITHUB_CLIENT_ID`: your_id
|
||||
* `OAUTH_GITHUB_CLIENT_SECRET`: your_secret
|
||||
* `OAUTH_GITHUB_REDIRECT_URI`: your_callback_url
|
||||
|
||||
<details>
|
||||
<summary>Click to view: Secrets Configuration Example</summary>
|
||||
<img width="600" alt="Secrets Config" src="https://github.com/user-attachments/assets/ef907021-303d-4fd5-ba3e-913e8b0014a5" />
|
||||
</details>
|
||||
|
||||
#### 3. Trigger Deployment
|
||||
* Go to the `Actions` page, manually run the `Deploy to Cloudflare Workers` workflow, or push code to the `main` branch.
|
||||
|
||||
<details>
|
||||
<summary>Click to view: Manual Deployment Example</summary>
|
||||
<img width="600" alt="Manual Trigger" src="https://github.com/user-attachments/assets/b2891365-5c1a-4a46-83c6-5cd53dd4b895" />
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Environment Variables Configuration
|
||||
|
||||
Regardless of the deployment method, these parameters are critical:
|
||||
|
||||
| Variable | Function | Recommendation |
|
||||
| :--- | :--- | :--- |
|
||||
| `ENCRYPTION_KEY` | **Core**: Database encryption key | **DO NOT CHANGE** after setup! Requirement: 32+ characters. |
|
||||
| `JWT_SECRET` | Auth Token Secret | Requirement: 32+ characters. |
|
||||
| `OAUTH_ALLOWED_USERS` | **Whitelist**: Access Control | Emails or Telegram IDs, separated by commas. |
|
||||
|
||||
### Variable Mapping for Different Providers:
|
||||
|
||||
| Provider | Client ID Variable | Client Secret Variable | Redirect URI (Example: `https://xxx.dev/oauth/callback`) |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **GitHub** | `OAUTH_GITHUB_CLIENT_ID` | `OAUTH_GITHUB_CLIENT_SECRET` | `OAUTH_GITHUB_REDIRECT_URI` |
|
||||
| **Telegram** | `OAUTH_TELEGRAM_BOT_NAME` | `OAUTH_TELEGRAM_BOT_TOKEN` | *Not required for Telegram* |
|
||||
| **Google** | `OAUTH_GOOGLE_CLIENT_ID` | `OAUTH_GOOGLE_CLIENT_SECRET" | `OAUTH_GOOGLE_REDIRECT_URI` |
|
||||
| **Cloudflare Access** | `OAUTH_CLOUDFLARE_CLIENT_ID` | `OAUTH_CLOUDFLARE_CLIENT_SECRET` | `OAUTH_CLOUDFLARE_REDIRECT_URI` (requires `OAUTH_CLOUDFLARE_ORG_DOMAIN`) |
|
||||
| **Gitee** | `OAUTH_GITEE_CLIENT_ID` | `OAUTH_GITEE_CLIENT_SECRET" | `OAUTH_GITEE_REDIRECT_URI` |
|
||||
| **NodeLoc** | `OAUTH_NODELOC_CLIENT_ID` | `OAUTH_NODELOC_CLIENT_SECRET" | `OAUTH_NODELOC_REDIRECT_URI` |
|
||||
|
||||
---
|
||||
|
||||
#### How to configure GitHub OAuth Login (Recommended)?
|
||||
1. Visit GitHub `Settings` -> `Developer Settings` -> `OAuth Apps` -> **New OAuth App**.
|
||||
2. **Authorization callback URL** must be: `https://your-domain.com/oauth/callback`.
|
||||
3. Fill `Client ID`, `Client Secret` and `OAUTH_GITHUB_REDIRECT_URI` into environment variables.
|
||||
|
||||
<details>
|
||||
<summary>Click to view: Github OAuth Configuration Diagram</summary>
|
||||
<img width="600" alt="Github OAuth Config" src="https://github.com/user-attachments/assets/aa03b15f-deb2-4e48-bf4b-e57be342adbb" />
|
||||
</details>
|
||||
|
||||
#### How to configure Telegram Login?
|
||||
1. Search and add **[@BotFather](https://t.me/BotFather)** on Telegram, following prompts to create a bot.
|
||||
2. Record the **Token** (`OAUTH_TELEGRAM_BOT_TOKEN`) and **Username** (`OAUTH_TELEGRAM_BOT_NAME`).
|
||||
3. You MUST send the `/setdomain` command to @BotFather, select your bot, and enter your **application domain** (without `https://`).
|
||||
4. Since this is a Serverless architecture, you need to manually register the Webhook. Replace `<Token>` and `<Domain>` in the link below and visit it once in your browser:
|
||||
`https://api.telegram.org/bot<Token>/setWebhook?url=https://<Domain>/api/telegram/webhook`
|
||||
5. Fill `OAUTH_TELEGRAM_BOT_TOKEN`, `OAUTH_TELEGRAM_BOT_NAME` and `OAUTH_TELEGRAM_REDIRECT_URI` into environment variables.
|
||||
|
||||
#### How to configure Cloudflare Access Login?
|
||||
1. Enter **Cloudflare Zero Trust Dashboard** -> **Access** -> **Applications**.
|
||||
2. Create a **SaaS** application (Select "SaaS").
|
||||
3. Configure **Application**:
|
||||
- **Name**: `Anything, e.g., 2fauth`
|
||||
- **Authentication Protocol**: `OIDC`
|
||||
- **Redirect URL**: `https://your-domain.com/oauth/callback`
|
||||
4. Copy and save `Client ID` and `Client Secret`.
|
||||
5. Access Policy -> Click create new policy -> Add policy:
|
||||
- **Policy**: `Allow`
|
||||
- **Selector**: `Everyone`
|
||||
6. No additional configuration needed, click next through to completion.
|
||||
7. Fill `OAUTH_CLOUDFLARE_CLIENT_ID`, `OAUTH_CLOUDFLARE_CLIENT_SECRET`, `OAUTH_CLOUDFLARE_REDIRECT_URI` and `OAUTH_CLOUDFLARE_ORG_DOMAIN` into environment variables.
|
||||
|
||||
<details>
|
||||
<summary>Click to view: Cloudflare Access OAuth Configuration Diagram</summary>
|
||||
<img height="500" src="https://github.com/user-attachments/assets/1e315f8f-1932-4c90-a2d7-0edf8049529f" />
|
||||
<img height="400" src="https://github.com/user-attachments/assets/c6101ee8-f3c3-44f6-9286-f17865f8fb10" />
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Auditing
|
||||
|
||||
To protect your account security, 2FAuth Worker implements a "Multi-Lock" strategy:
|
||||
|
||||
1. **At-Rest Encryption**: All TOTP seeds stored in the database are encrypted using AES. Even if the database file is leaked, unauthorized parties cannot obtain raw keys without the `ENCRYPTION_KEY`.
|
||||
2. **Whitelist-Only Access**: Traditional registration is disabled. Only accounts in the `OAUTH_ALLOWED_USERS` list can access the system.
|
||||
3. **Smart Shield Persistence (Health Shield)**: The system continuously monitors the runtime environment. If any anomalies are detected, it enters protection mode and refuses to generate verification codes.
|
||||
4. **End-to-End Isolation**: Sensitive frontend logic is strictly filtered to prevent XSS (Cross-Site Scripting) attacks.
|
||||
5. **Privacy First**: This project contains no tracking code, analytics tools, or third-party cookies. Your data is yours alone.
|
||||
|
||||
> 📊 [**Click to view the real-time security audit report generated by GitHub Actions**](https://github.com/nap0o/2fauth-worker/blob/security-audit/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Local Development
|
||||
|
||||
```bash
|
||||
# 1. Clone and install dependencies
|
||||
git clone https://github.com/nap0o/2fauth-worker.git
|
||||
cd 2fauth-worker
|
||||
npm install
|
||||
|
||||
# 2. Configure environment
|
||||
cp example.dev.vars .dev.vars
|
||||
|
||||
# 3. Initialize local SQLite Sandbox
|
||||
npx wrangler d1 execute 2fauth-db-dev --local --env dev --file=backend/schema.sql
|
||||
|
||||
# 4. Start dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
This project is open-sourced under the [GNU AGPL v3](LICENSE) license. Since this software involves 2FA security and network services, we maintain open-source fairness: if you run a modified version of this project on a server and provide services to the public, you must open your source code to the users.
|
||||
|
||||
@@ -7,7 +7,7 @@ import { EnvBindings, CSP_POLICY } from '@/app/config';
|
||||
// 稍后我们会在这里引入拆分好的路由模块
|
||||
import authRoutes from '@/features/auth/authRoutes';
|
||||
import vaultRoutes from '@/features/vault/vaultRoutes';
|
||||
import backupRoutes, { handleScheduledBackup } from '@/features/backup/backupRoutes';
|
||||
import backupRoutes from '@/features/backup/backupRoutes';
|
||||
import telegramRoutes from '@/features/telegram/telegramRoutes';
|
||||
import toolsRoutes from '@/features/tools/toolsRoutes';
|
||||
import healthRoutes from '@/features/health/healthRoutes';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { EnvBindings, AppError } from '@/app/config';
|
||||
import { BackupRepository } from '@/shared/db/repositories/backupRepository';
|
||||
import { encryptData, decryptData, encryptBackupFile } from '@/shared/utils/crypto';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from 'hono';
|
||||
import { EnvBindings, AppError } from '@/app/config';
|
||||
import { EnvBindings } from '@/app/config';
|
||||
import { runHealthCheck } from '@/shared/utils/health';
|
||||
|
||||
const health = new Hono<{ Bindings: EnvBindings }>();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from 'hono';
|
||||
import { EnvBindings, AppError } from '@/app/config';
|
||||
import { EnvBindings } from '@/app/config';
|
||||
import { authMiddleware } from '@/shared/middleware/auth';
|
||||
import { VaultService } from '@/features/vault/vaultService';
|
||||
import { VaultRepository } from '@/shared/db/repositories/vaultRepository';
|
||||
@@ -18,8 +18,6 @@ vault.use('/*', authMiddleware);
|
||||
|
||||
// 获取所有账户 (分页+搜索)
|
||||
vault.get('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
|
||||
// 解析查询参数
|
||||
const page = parseInt(c.req.query('page') || '1', 10);
|
||||
const limit = parseInt(c.req.query('limit') || '12', 10);
|
||||
@@ -51,7 +49,6 @@ vault.post('/', async (c) => {
|
||||
|
||||
// 更新账户
|
||||
vault.put('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
const id = c.req.param('id');
|
||||
const data = await c.req.json();
|
||||
const service = getService(c);
|
||||
@@ -61,7 +58,6 @@ vault.put('/:id', async (c) => {
|
||||
|
||||
// 删除账户
|
||||
vault.delete('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
const id = c.req.param('id');
|
||||
const service = getService(c);
|
||||
await service.deleteAccount(id);
|
||||
@@ -70,7 +66,6 @@ vault.delete('/:id', async (c) => {
|
||||
|
||||
// 批量删除账户
|
||||
vault.post('/batch-delete', async (c) => {
|
||||
const user = c.get('user');
|
||||
const { ids } = await c.req.json();
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return c.json({ success: false, error: 'ids must be a non-empty array' }, 400);
|
||||
@@ -82,7 +77,6 @@ vault.post('/batch-delete', async (c) => {
|
||||
|
||||
// 导出账户
|
||||
vault.post('/export', async (c) => {
|
||||
const user = c.get('user');
|
||||
const service = getService(c);
|
||||
const { type, password } = await c.req.json();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, inArray, desc, like, or, and, sql } from 'drizzle-orm';
|
||||
import { eq, inArray, desc, like, or, sql } from 'drizzle-orm';
|
||||
import { vault, type VaultItem, type NewVaultItem } from '@/shared/db/schema';
|
||||
|
||||
export class VaultRepository {
|
||||
|
||||
+19
-11
@@ -31,22 +31,23 @@ const checkMobile = () => {
|
||||
layoutStore.isMobile = window.innerWidth < 768
|
||||
}
|
||||
|
||||
const isOffline = ref(false)
|
||||
const setOnline = () => { isOffline.value = false }
|
||||
const setOffline = () => { isOffline.value = true }
|
||||
const userClosedOfflineBanner = ref(false)
|
||||
const showOfflineBanner = computed(() => layoutStore.isOffline && !userClosedOfflineBanner.value)
|
||||
|
||||
watch(() => layoutStore.isOffline, (offline) => {
|
||||
if (offline) {
|
||||
userClosedOfflineBanner.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile()
|
||||
isOffline.value = !navigator.onLine
|
||||
layoutStore.initNetworkStatus()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
window.addEventListener('online', setOnline)
|
||||
window.addEventListener('offline', setOffline)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
window.removeEventListener('online', setOnline)
|
||||
window.removeEventListener('offline', setOffline)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -54,9 +55,16 @@ onBeforeUnmount(() => {
|
||||
<el-config-provider :locale="elementLocale">
|
||||
<div class="app-container">
|
||||
<!-- 方案A: 全局离线横幅 -->
|
||||
<div v-if="isOffline" class="global-offline-banner" style="background-color: #fdf6ec; color: #e6a23c; padding: 10px 15px; text-align: center; font-size: 14px; display: flex; align-items: center; justify-content: center; gap: 6px; border-bottom: 1px solid #faecd8; position: sticky; top: 0; z-index: 9999; box-shadow: 0 2px 4px rgba(0,0,0,0.05);">
|
||||
<el-icon><Warning /></el-icon> <span>{{ $t('common.offline_mode') }}</span>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="showOfflineBanner"
|
||||
:title="$t('common.offline_mode')"
|
||||
type="warning"
|
||||
show-icon
|
||||
center
|
||||
closable
|
||||
class="global-offline-banner"
|
||||
@close="userClosedOfflineBanner = true"
|
||||
/>
|
||||
|
||||
<!-- 登录页通常不显示头部,可以通过路由 meta 控制,这里简单示例默认显示 -->
|
||||
<TheHeader v-if="!route.meta.hideHeader" />
|
||||
|
||||
+30
-1232
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,74 @@
|
||||
/* =========================================
|
||||
0. 强制同步修复 (Force Sync)
|
||||
========================================= */
|
||||
/* 核心修复:强制禁用所有元素的过渡动画,确保深浅色切换瞬间完成,消除不同步感 */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
1. 全局基础样式 (Global Reset & Base)
|
||||
========================================= */
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-color: var(--el-bg-color-page);
|
||||
color: var(--el-text-color-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* 移动端特殊适配:释放根部滚动以支持原生下拉刷新 */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
/* 滚动条优化 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
/* 通用组件样式 */
|
||||
.tab-card-wrapper {
|
||||
background: var(--el-bg-color);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
min-height: 400px;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* =========================================
|
||||
0.1 全局语义化变量 (Design Tokens)
|
||||
========================================= */
|
||||
:root {
|
||||
/* 玻璃拟态卡片 (登录/加载) */
|
||||
--glass-card-bg: rgba(255, 255, 255, 0.95);
|
||||
--glass-card-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
|
||||
|
||||
/* GitHub 按钮 */
|
||||
--github-btn-bg: #24292e;
|
||||
--github-btn-border: #24292e;
|
||||
|
||||
/* 状态色块 */
|
||||
--success-tip-bg: var(--el-color-success-light-9);
|
||||
--vault-selected-bg: var(--el-color-primary-light-9);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/* Login.vue */
|
||||
.login-container {
|
||||
margin: 25px 0px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 92%;
|
||||
max-width: 400px;
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--glass-card-shadow);
|
||||
background: var(--glass-card-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.logo-container h2 {
|
||||
margin: 16px 0 8px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.oauth-btn {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.oauth-btn:hover,
|
||||
.oauth-btn:focus {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.footer-tips {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* OAuthCallback.vue */
|
||||
.callback-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
width: 92%;
|
||||
max-width: 400px;
|
||||
border-radius: 16px;
|
||||
background: var(--glass-card-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Backup Feature Styles */
|
||||
.backup-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.backup-header-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.backup-provider-card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.backup-provider-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.backup-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backup-provider-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.backup-status-text {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.backup-action-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.backup-form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.backup-success-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--el-color-success);
|
||||
font-size: 13px;
|
||||
padding: 10px;
|
||||
background-color: var(--success-tip-bg);
|
||||
border-radius: 4px;
|
||||
/*
|
||||
width: 100%;
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/* HealthCheck.vue */
|
||||
.health-check-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.health-card {
|
||||
width: 95%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:global(html.dark) .health-card {
|
||||
background-color: #1a1a1a;
|
||||
border-color: #2c2c2c;
|
||||
}
|
||||
|
||||
.health-header {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.health-header .alert-title {
|
||||
margin: 16px 0 0 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.health-header .alert-divider {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
background-color: var(--el-color-danger);
|
||||
border-radius: 2px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.health-header .subtitle {
|
||||
margin: 15px 0px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.health-header .subtitle :deep(.el-alert__content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.health-header .subtitle :deep(.el-alert__title) {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.issue-alert {
|
||||
margin-bottom: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.issue-content {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.issue-desc {
|
||||
font-size: 14px;
|
||||
margin-bottom: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.suggestion-box {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
border-left: 4px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
:global(html.dark) .suggestion-box {
|
||||
background-color: rgba(64, 158, 255, 0.08);
|
||||
border: 1px solid rgba(64, 158, 255, 0.2);
|
||||
border-left: 4px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
:global(html.dark) .issue-alert {
|
||||
background-color: rgba(245, 108, 108, 0.05);
|
||||
border: 1px solid rgba(245, 108, 108, 0.2);
|
||||
}
|
||||
|
||||
:global(html.dark) .missing-fields-text {
|
||||
background-color: rgba(245, 108, 108, 0.1) !important;
|
||||
border: 1px solid rgba(245, 108, 108, 0.3) !important;
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.fix-action p {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.deploy-method-guide {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--el-border-color);
|
||||
}
|
||||
|
||||
.deploy-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-danger p {
|
||||
color: var(--el-color-danger);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.key-generator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mono-font :deep(.el-input__inner) {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.passed-checks-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px dashed var(--el-border-color);
|
||||
}
|
||||
|
||||
.passed-checks-section h3 {
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.passed-checks-section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.passed-checks-section li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.missing-fields-text {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
color: var(--el-color-danger);
|
||||
background-color: var(--el-color-danger-light-9);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/* Migration & Backup (DataImport.vue & DataExport.vue) */
|
||||
|
||||
.migration-export-groups {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.migration-export-group-card {
|
||||
padding: 20px;
|
||||
background-color: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.migration-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.migration-group-header .el-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 18px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.migration-button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.migration-button-row .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.migration-button-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.migration-button-with-icon .el-icon {
|
||||
margin-right: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 账号选择列表样式 */
|
||||
.migration-account-select-toolbar {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.migration-toolbar-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.migration-selected-count {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.migration-account-list-container {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.migration-account-item {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.migration-account-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.migration-account-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 8px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.migration-account-item .service-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.migration-account-item .account-name {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
/* DataImport.vue Specific */
|
||||
.migration-import-upload {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.migration-import-upload .el-upload-dragger {
|
||||
padding: 40px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
|
||||
.migration-import-upload .el-upload-dragger:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.migration-import-upload .el-icon--upload {
|
||||
font-size: 48px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.migration-import-tips {
|
||||
text-align: left;
|
||||
margin-top: 25px;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
padding: 0px 20px 20px 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
}
|
||||
|
||||
.migration-format-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.migration-format-group h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.migration-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.migration-tags .el-tag {
|
||||
padding: 4px 8px;
|
||||
height: auto;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: default;
|
||||
border-color: transparent;
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.migration-tags .el-tag .el-icon {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.migration-ga-tip {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.migration-ga-tip span {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--el-color-primary);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.migration-ga-tip p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.migration-ms-tip {
|
||||
background-color: var(--el-color-success-light-9);
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--el-color-success);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.migration-ms-tip span {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--el-color-success);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.migration-ms-tip p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-regular);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.migration-ms-tip code {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* dataMigration.vue */
|
||||
.migration-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.migration-tabs :deep(.el-tabs__nav-wrap::after) {
|
||||
height: 1px;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/* Shared Component Patterns */
|
||||
|
||||
/* 1. Confirmation Box (Used in ElMessageBox with h() function) */
|
||||
.confirmation-box {
|
||||
text-align: left;
|
||||
background: var(--el-fill-color-light);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.confirmation-row {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.confirmation-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.confirmation-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
width: 70px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.confirmation-value {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.confirmation-value.mono {
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
background: var(--el-fill-color-darker);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.confirmation-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.confirmation-tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.confirmation-tag-info {
|
||||
background: var(--el-color-info-light-9);
|
||||
color: var(--el-color-info);
|
||||
border-color: var(--el-color-info-light-7);
|
||||
}
|
||||
|
||||
.confirmation-tag-success {
|
||||
background: var(--el-color-success-light-9);
|
||||
color: var(--el-color-success);
|
||||
border-color: var(--el-color-success-light-7);
|
||||
}
|
||||
|
||||
.confirmation-tag-warning {
|
||||
background: var(--el-color-warning-light-9);
|
||||
color: var(--el-color-warning);
|
||||
border-color: var(--el-color-warning-light-7);
|
||||
}
|
||||
|
||||
/* 2. Global Offline Banner (App.vue) */
|
||||
.global-offline-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 9999;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
padding: 10px 16px !important;
|
||||
}
|
||||
|
||||
.global-offline-banner .el-alert__content {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.global-offline-banner .el-alert__title {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.global-offline-banner .el-alert__close-btn {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
html.dark .global-offline-banner {
|
||||
background-color: rgba(230, 162, 60, 0.1);
|
||||
border-bottom-color: rgba(230, 162, 60, 0.2);
|
||||
}
|
||||
|
||||
/* 3. QrScanner Component */
|
||||
.qr-scanner-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-scanner-selector {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.camera-placeholder {
|
||||
height: 250px;
|
||||
border: 2px dashed var(--el-border-color);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: var(--el-fill-color-lighter);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.camera-placeholder:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.video-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.scan-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 2px solid rgba(64, 158, 252, 0.4);
|
||||
box-shadow: inset 0 0 100px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.scan-line {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #409eff;
|
||||
top: 0;
|
||||
box-shadow: 0 0 10px #409eff;
|
||||
animation: scan-anim 2s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes scan-anim {
|
||||
0% {
|
||||
top: 0%;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
height: 250px;
|
||||
border: 2px dashed var(--el-border-color);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
/* =========================================
|
||||
4. UtilityTools 组件 (工具箱)
|
||||
========================================= */
|
||||
.tools-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tools-header .back-btn {
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.tools-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.tool-card .el-card__body {
|
||||
padding: 24px !important;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-card-totp-secret .icon-wrapper {
|
||||
background-color: var(--el-color-success-light-9) !important;
|
||||
}
|
||||
|
||||
.tool-card-totp-secret .el-icon {
|
||||
color: #67C23A !important;
|
||||
}
|
||||
|
||||
.tool-card-apps-review .icon-wrapper {
|
||||
background-color: var(--el-color-info-light-9) !important;
|
||||
}
|
||||
|
||||
.tool-card-apps-review .el-icon {
|
||||
color: #9c27b0 !important;
|
||||
}
|
||||
|
||||
.tool-card-password .icon-wrapper {
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
|
||||
.tool-card-password .el-icon {
|
||||
color: #409EFF !important;
|
||||
}
|
||||
|
||||
.tool-card-time-sync .icon-wrapper {
|
||||
background-color: var(--el-color-warning-light-9) !important;
|
||||
}
|
||||
|
||||
.tool-card-time-sync .el-icon {
|
||||
color: #E6A23C !important;
|
||||
}
|
||||
|
||||
.tool-card-qr-parser .icon-wrapper {
|
||||
background-color: var(--el-color-danger-light-9) !important;
|
||||
}
|
||||
|
||||
.tool-card-qr-parser .el-icon {
|
||||
color: #F56C6C !important;
|
||||
}
|
||||
|
||||
.tool-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.text-info h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.text-info p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
5. UtilityTools 子组件详情页样式
|
||||
========================================= */
|
||||
|
||||
/* 通用容器 */
|
||||
.tool-pane {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
/* --- ToolTimeSync (时间校准) --- */
|
||||
.time-sync-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.clocks-wrapper {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.clock-card {
|
||||
flex: 1;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.clock-card.local {
|
||||
border-top: 4px solid #409EFF;
|
||||
}
|
||||
|
||||
.clock-card.server {
|
||||
border-top: 4px solid #E6A23C;
|
||||
}
|
||||
|
||||
.clock-label {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.clock-time {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.clock-date {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.diff-display {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.diff-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.diff-desc {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sync-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.sync-tips {
|
||||
margin-top: 40px;
|
||||
text-align: left;
|
||||
background: var(--el-fill-color-lighter);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sync-tips h4 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.sync-tips ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* --- ToolQrParser (二维码解析) --- */
|
||||
.qr-parser-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-dragger {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
border: 2px dashed var(--el-border-color);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: var(--el-fill-color-lighter);
|
||||
transition: border-color 0.3s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-dragger:hover {
|
||||
border-color: #409EFF;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.upload-placeholder p {
|
||||
margin-top: 10px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- ToolPassword (密码生成) --- */
|
||||
.result-box {
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.password-display {
|
||||
font-family: monospace;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-all;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
background: var(--el-bg-color);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.control-row.checkboxes {
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-row .label {
|
||||
min-width: 60px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.clocks-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.control-row.checkboxes {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- ToolTotpSecret (TOTP 密钥工具) --- */
|
||||
.totp-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.preview-top-section {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.preview-top-section {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qr-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.uri-box {
|
||||
margin-top: 15px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.uri-text {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-row .el-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.advanced-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.totp-code {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
letter-spacing: 6px;
|
||||
font-family: monospace;
|
||||
margin-bottom: 5px;
|
||||
transition: filter 0.3s;
|
||||
}
|
||||
|
||||
.totp-code.blur {
|
||||
filter: blur(4px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.totp-timer {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.totp-timer.urgent {
|
||||
color: #F56C6C;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
/* --- ToolAppsReview (App 评测) --- */
|
||||
.apps-review-container {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.apps-review-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.apps-review-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.apps-review-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.apps-review-card-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.apps-review-card-name {
|
||||
margin: 0px 0px 12px 0px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.apps-review-card-logo {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.apps-review-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
/* 空间不够时允许换行 */
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.apps-review-platform-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 16px;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.apps-review-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.apps-review-detail-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.apps-review-detail-item .label {
|
||||
font-weight: 500;
|
||||
min-width: 85px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.apps-review-summary-box {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
.apps-review-summary-danger {
|
||||
background-color: var(--el-color-danger-light-9);
|
||||
border-left: 3px solid var(--el-color-danger);
|
||||
}
|
||||
|
||||
:global(html.dark) .apps-review-summary-danger {
|
||||
background-color: rgba(245, 108, 108, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.apps-review-header {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.apps-review-detail-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.apps-review-detail-item .label {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/* 搜索栏吸顶过渡动画 */
|
||||
.vault-list-toolbar {
|
||||
transition: padding 0.3s ease, background-color 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 共有吸顶属性 */
|
||||
.vault-list-toolbar.is-affixed {
|
||||
z-index: 2000;
|
||||
margin-bottom: 0 !important;
|
||||
background-color: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
/* 移动端吸顶效果:全屏、背景、边框 */
|
||||
@media (max-width: 767px) {
|
||||
.vault-list-toolbar.is-affixed {
|
||||
width: 100vw !important;
|
||||
margin-left: -20px !important;
|
||||
/* 抵消 el-main 的内边距 */
|
||||
padding: 14px 20px !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* PC 端吸顶效果:保持原有宽度,无背景色和下边框 */
|
||||
@media (min-width: 768px) {
|
||||
.vault-list-toolbar.is-affixed {
|
||||
border-bottom: none !important;
|
||||
padding: 20px 0px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* VaultList.vue */
|
||||
.vault-card {
|
||||
border-radius: 12px;
|
||||
/* 全局规则已覆盖 transition,此处只需定义样式 */
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
cursor: pointer;
|
||||
background-color: var(--el-bg-color);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vault-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.vault-list-wrapper .card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Flex 容器:限制宽度以支持内部文本截断 */
|
||||
.service-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
/* 占据剩余空间 */
|
||||
min-width: 0;
|
||||
/* 关键:允许 Flex 子项收缩 */
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
/* 标题截断逻辑 */
|
||||
.service-name {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 让卡片内容撑开,底部对齐 */
|
||||
.vault-card .el-card__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vault-name {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 14px;
|
||||
margin: 0 0 10px 24px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.more-icon {
|
||||
cursor: pointer;
|
||||
color: var(--el-text-color-secondary);
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.more-icon:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.vault-card.is-selected {
|
||||
border: 1px solid #409EFF;
|
||||
background-color: var(--vault-selected-bg);
|
||||
}
|
||||
|
||||
.batch-text {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.code-display-area {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
padding-left: 24px;
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
.code-left {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.current-code {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
letter-spacing: 2px;
|
||||
line-height: 1;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.current-code[data-digits="8"] {
|
||||
font-size: 20px;
|
||||
letter-spacing: 0px;
|
||||
}
|
||||
|
||||
.next-code {
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.next-code[data-digits="8"] {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0px;
|
||||
}
|
||||
|
||||
.timer-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.el-progress__text {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
/* VaultList - QR Dialog */
|
||||
.qr-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-info {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.qr-service {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 20px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.qr-account {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.qr-image-wrapper {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.qr-code-img {
|
||||
display: block;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.qr-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
margin: 10px 0 20px 0;
|
||||
}
|
||||
|
||||
.secret-section {
|
||||
width: 100%;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.secret-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--el-fill-color-light);
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.secret-text {
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.secret-actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.action-icon:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.uri-link-wrapper {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* AddVaultManual.vue */
|
||||
.vault-manual-form-wrapper {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.vault-manual-submit-btn {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/* App.vue */
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
main {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Home.vue */
|
||||
.home-container {
|
||||
height: 100%;
|
||||
background-color: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
.main-body {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
.home-container,
|
||||
.main-body {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
width: 100%;
|
||||
background-color: var(--el-bg-color-page);
|
||||
/* 原为 auto,修改为保留滚动空间防止因高度变化产生的抖动,或直接将滚动条悬浮覆盖 */
|
||||
overflow-y: overlay;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
overflow: visible !important;
|
||||
overflow-y: visible !important;
|
||||
overflow-x: visible !important;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* TheFooter.vue */
|
||||
.global-footer {
|
||||
width: 100%;
|
||||
padding: 0.25rem 0;
|
||||
background-color: var(--el-bg-color);
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 0 0.5rem;
|
||||
color: var(--el-border-color);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/* TheHeader.vue */
|
||||
.header {
|
||||
background: var(--el-bg-color);
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.logo,
|
||||
.user-actions,
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo h2 {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
gap: 10px;
|
||||
background: var(--el-fill-color-light);
|
||||
padding: 4px 15px 4px 4px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.logo h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- TheHeader Button & Link --- */
|
||||
.header-menu-btn {
|
||||
margin-right: 10px;
|
||||
padding: 5px 8px !important;
|
||||
border: 1px solid var(--el-border-color) !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.header-home-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/* LeftAside, SideMenu */
|
||||
.left-aside {
|
||||
background: var(--el-bg-color);
|
||||
border-right: 1px solid var(--el-border-color-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 视觉优化:隐藏侧边栏滚动条,保持界面清爽 */
|
||||
.left-aside::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.side-menu {
|
||||
border-right: none;
|
||||
padding-top: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-footer .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.sidebar-footer.is-collapsed {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* --- Mobile Drawer User Info Card --- */
|
||||
.user-info-card {
|
||||
margin: 0px 20px 0px 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 0.2px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.user-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--el-color-primary-light-8);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.user-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background-color: #909399;
|
||||
/* 默认灰色 */
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.is-online {
|
||||
background-color: #67C23A;
|
||||
/* 在线绿色 */
|
||||
box-shadow: 0 0 4px rgba(103, 194, 58, 0.5);
|
||||
}
|
||||
|
||||
.user-provider {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color);
|
||||
padding: 1px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* Flexbox Utilities */
|
||||
.flex {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex !important;
|
||||
justify-content: space-between !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.flex-items-center {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.flex-wrap {
|
||||
flex-wrap: wrap !important;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
.gap-5 {
|
||||
gap: 5px !important;
|
||||
}
|
||||
|
||||
.gap-8 {
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.gap-10 {
|
||||
gap: 10px !important;
|
||||
}
|
||||
|
||||
.gap-15 {
|
||||
gap: 15px !important;
|
||||
}
|
||||
|
||||
.gap-20 {
|
||||
gap: 20px !important;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* Miscellaneous Utilities */
|
||||
.pointer-help {
|
||||
cursor: help !important;
|
||||
}
|
||||
|
||||
.pointer-none {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.display-none {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.display-block {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.display-inline-b {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.overflow-hidden {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.overflow-visible {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.overflow-auto {
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
/* Border Utilities */
|
||||
.border-none {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.border-1 {
|
||||
border: 1px solid var(--el-border-color) !important;
|
||||
}
|
||||
|
||||
.rounded-4 {
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.rounded-6 {
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.rounded-8 {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.rounded-10 {
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
.rounded-12 {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.rounded-16 {
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
.shadow-light {
|
||||
box-shadow: var(--el-box-shadow-light) !important;
|
||||
}
|
||||
|
||||
.shadow-none {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ls-1 {
|
||||
letter-spacing: 1px !important;
|
||||
}
|
||||
|
||||
.ls-2 {
|
||||
letter-spacing: 2px !important;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/* Sizing Utilities */
|
||||
.m-auto {
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
|
||||
.w-80 {
|
||||
width: 80px !important;
|
||||
}
|
||||
|
||||
.w-100 {
|
||||
width: 100px !important;
|
||||
}
|
||||
|
||||
.w-120 {
|
||||
width: 120px !important;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.max-w-400 {
|
||||
max-width: 400px !important;
|
||||
}
|
||||
|
||||
.max-w-600 {
|
||||
max-width: 600px !important;
|
||||
}
|
||||
|
||||
.max-w-100p {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.min-h-100 {
|
||||
min-height: 100px !important;
|
||||
}
|
||||
|
||||
.min-h-150 {
|
||||
min-height: 150px !important;
|
||||
}
|
||||
|
||||
.min-h-200 {
|
||||
min-height: 200px !important;
|
||||
}
|
||||
|
||||
.min-h-400 {
|
||||
min-height: 400px !important;
|
||||
}
|
||||
|
||||
.h-32 {
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
.h-auto {
|
||||
height: auto !important;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/* Margin Utilities */
|
||||
.mt-5 {
|
||||
margin-top: 5px !important;
|
||||
}
|
||||
|
||||
.mt-10 {
|
||||
margin-top: 10px !important;
|
||||
}
|
||||
|
||||
.mt-15 {
|
||||
margin-top: 15px !important;
|
||||
}
|
||||
|
||||
.mt-20 {
|
||||
margin-top: 20px !important;
|
||||
}
|
||||
|
||||
.mt-30 {
|
||||
margin-top: 30px !important;
|
||||
}
|
||||
|
||||
.mt-40 {
|
||||
margin-top: 40px !important;
|
||||
}
|
||||
|
||||
.mb-5 {
|
||||
margin-bottom: 5px !important;
|
||||
}
|
||||
|
||||
.mb-10 {
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
.mb-15 {
|
||||
margin-bottom: 15px !important;
|
||||
}
|
||||
|
||||
.mb-20 {
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
|
||||
.mb-30 {
|
||||
margin-bottom: 30px !important;
|
||||
}
|
||||
|
||||
.ml-5 {
|
||||
margin-left: 5px !important;
|
||||
}
|
||||
|
||||
.ml-10 {
|
||||
margin-left: 10px !important;
|
||||
}
|
||||
|
||||
.mr-10 {
|
||||
margin-right: 10px !important;
|
||||
}
|
||||
|
||||
.mx-15 {
|
||||
margin-left: 15px !important;
|
||||
margin-right: 15px !important;
|
||||
}
|
||||
|
||||
.my-10 {
|
||||
margin-top: 10px !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
/* Padding Utilities */
|
||||
.p-0 {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.p-5 {
|
||||
padding: 5px !important;
|
||||
}
|
||||
|
||||
.p-10 {
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.p-16 {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.p-20 {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
.py-10 {
|
||||
padding-top: 10px !important;
|
||||
padding-bottom: 10px !important;
|
||||
}
|
||||
|
||||
.py-20 {
|
||||
padding-top: 20px !important;
|
||||
padding-bottom: 20px !important;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* Typography Utilities */
|
||||
.text-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right !important;
|
||||
}
|
||||
|
||||
.text-12 {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.text-13 {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.text-14 {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.text-15 {
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
.text-16 {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.text-18 {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.text-24 {
|
||||
font-size: 24px !important;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--el-text-color-primary) !important;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--el-text-color-secondary) !important;
|
||||
}
|
||||
|
||||
.text-regular {
|
||||
color: var(--el-text-color-regular) !important;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--el-color-warning) !important;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--el-color-danger) !important;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: bold !important;
|
||||
}
|
||||
|
||||
.font-600 {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: monospace !important;
|
||||
}
|
||||
|
||||
.ls-1 {
|
||||
letter-spacing: 1px !important;
|
||||
}
|
||||
|
||||
.ls-2 {
|
||||
letter-spacing: 2px !important;
|
||||
}
|
||||
|
||||
.no-underline {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.color-inherit {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.word-break-all {
|
||||
word-break: break-all !important;
|
||||
}
|
||||
@@ -7,12 +7,12 @@
|
||||
<p class="subtitle">{{ $t('auth.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isFetchingProviders" class="loading-state" style="display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 150px; color: var(--el-text-color-secondary);">
|
||||
<el-icon class="is-loading" :size="38" style="margin-bottom: 20px; color: var(--el-color-primary);"><Loading /></el-icon>
|
||||
<p style="font-size: 15px; letter-spacing: 1px;">{{ $t('common.loading_data') }}</p>
|
||||
<div v-if="isFetchingProviders" class="flex-column flex-center min-h-150 text-secondary">
|
||||
<el-icon class="is-loading mb-20 text-primary" :size="38"><Loading /></el-icon>
|
||||
<p class="text-15 ls-1">{{ $t('common.loading_data') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="action-container" style="min-height: 100px; border-radius: 8px;">
|
||||
<div v-else class="action-container min-h-100 rounded-8">
|
||||
<template v-for="provider in providers" :key="provider.id">
|
||||
<el-button
|
||||
type="primary"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="backup-container">
|
||||
<!-- 顶部操作 -->
|
||||
<div class="header-actions">
|
||||
<div class="backup-header-actions">
|
||||
<h3>{{ $t('backup.management') }}</h3>
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon> {{ $t('backup.add_source') }}
|
||||
@@ -9,23 +9,23 @@
|
||||
</div>
|
||||
|
||||
<!-- 全局加载状态 -->
|
||||
<div v-if="isLoading && providers.length === 0" class="loading-state" style="display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 200px; color: var(--el-text-color-secondary);">
|
||||
<el-icon class="is-loading" :size="48" style="margin-bottom: 20px; color: var(--el-color-primary);"><Loading /></el-icon>
|
||||
<p style="font-size: 16px; letter-spacing: 1px;">{{ $t('backup.fetching_sources') }}</p>
|
||||
<div v-if="isLoading && providers.length === 0" class="flex-column flex-center min-h-200 text-secondary">
|
||||
<el-icon class="is-loading mb-20 text-primary" :size="48"><Loading /></el-icon>
|
||||
<p class="text-16 ls-1">{{ $t('backup.fetching_sources') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 列表区域 (有数据情况) -->
|
||||
<el-row v-else-if="providers.length > 0" :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="8" v-for="provider in providers" :key="provider.id" style="margin-bottom: 20px;">
|
||||
<el-card shadow="hover" class="provider-card">
|
||||
<el-col :xs="24" :sm="12" :md="8" v-for="provider in providers" :key="provider.id" class="mb-20">
|
||||
<el-card shadow="hover" class="backup-provider-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="provider-info">
|
||||
<div class="provider-title">
|
||||
<div class="backup-card-header">
|
||||
<div class="flex flex-items-center">
|
||||
<div class="backup-provider-title flex-wrap">
|
||||
<el-tag size="small" effect="dark" :type="getProviderTypeTag(provider.type)">{{ provider.type.toUpperCase() }}</el-tag>
|
||||
<span class="provider-name">{{ provider.name }}</span>
|
||||
<span class="font-bold text-16">{{ provider.name }}</span>
|
||||
<el-tooltip v-if="provider.auto_backup" :content="$t('backup.auto_backup_on')" placement="top">
|
||||
<el-icon color="#67C23A" size="16" style="cursor: help"><Timer /></el-icon>
|
||||
<el-icon color="#67C23A" size="16" class="pointer-help"><Timer /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,16 +37,16 @@
|
||||
</template>
|
||||
|
||||
<div class="card-content">
|
||||
<p class="status-text">
|
||||
<p class="backup-status-text">
|
||||
{{ $t('backup.last_backup') }}
|
||||
<span v-if="provider.lastBackupAt">{{ new Date(provider.lastBackupAt).toLocaleString() }}</span>
|
||||
<span v-else>{{ $t('backup.never_backed_up') }}</span>
|
||||
<el-tag v-if="provider.lastBackupStatus" size="small" :type="provider.lastBackupStatus === 'success' ? 'success' : 'danger'" style="margin-left: 5px;">
|
||||
<el-tag v-if="provider.lastBackupStatus" size="small" :type="provider.lastBackupStatus === 'success' ? 'success' : 'danger'" class="ml-5">
|
||||
{{ provider.lastBackupStatus }}
|
||||
</el-tag>
|
||||
</p>
|
||||
|
||||
<div class="action-buttons">
|
||||
<div class="backup-action-buttons">
|
||||
<el-button type="success" plain size="small" @click="openBackupDialog(provider)">{{ $t('backup.backup_now') }}</el-button>
|
||||
<el-button type="warning" plain size="small" @click="openRestoreDialog(provider)">{{ $t('backup.restore_data') }}</el-button>
|
||||
</div>
|
||||
@@ -57,7 +57,7 @@
|
||||
</el-row>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="empty-state" style="margin-top: 40px;">
|
||||
<div v-else class="empty-state mt-40">
|
||||
<el-empty :description="$t('backup.empty_source')" />
|
||||
</div>
|
||||
|
||||
@@ -90,8 +90,8 @@
|
||||
<el-input v-model="form.config.accessKeyId" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Secret Access Key">
|
||||
<div v-if="isEditing && !isEditingS3Secret" style="display: flex; align-items: center; justify-content: space-between; background-color: var(--el-fill-color-light); padding: 0 15px; border-radius: 4px; border: 1px solid var(--el-border-color); width: 100%; height: 32px;">
|
||||
<span style="font-family: monospace; letter-spacing: 2px;">******</span>
|
||||
<div v-if="isEditing && !isEditingS3Secret" class="flex flex-items-center flex-between bg-fill p-10 rounded-4 border-1 w-full h-32">
|
||||
<span class="font-mono ls-2">******</span>
|
||||
<el-button link type="primary" @click="isEditingS3Secret = true; form.config.secretAccessKey = ''">{{ $t('backup.modify') }}</el-button>
|
||||
</div>
|
||||
<el-input v-else v-model="form.config.secretAccessKey" type="password" show-password />
|
||||
@@ -104,15 +104,15 @@
|
||||
<!-- Telegram 配置 -->
|
||||
<template v-if="form.type === 'telegram'">
|
||||
<el-form-item :label="$t('backup.telegram_bot_token')">
|
||||
<div v-if="isEditing && !isEditingTelegramToken" style="display: flex; align-items: center; justify-content: space-between; background-color: var(--el-fill-color-light); padding: 0 15px; border-radius: 4px; border: 1px solid var(--el-border-color); width: 100%; height: 32px;">
|
||||
<span style="font-family: monospace; letter-spacing: 2px;">******</span>
|
||||
<div v-if="isEditing && !isEditingTelegramToken" class="flex flex-items-center flex-between bg-fill p-10 rounded-4 border-1 w-full h-32">
|
||||
<span class="font-mono ls-2">******</span>
|
||||
<el-button link type="primary" @click="isEditingTelegramToken = true; form.config.botToken = ''">{{ $t('backup.modify') }}</el-button>
|
||||
</div>
|
||||
<el-input v-else v-model="form.config.botToken" type="password" show-password :placeholder="$t('backup.telegram_bot_token_placeholder')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('backup.telegram_chat_id')">
|
||||
<el-input v-model="form.config.chatId" :placeholder="$t('backup.telegram_chat_id_placeholder')" />
|
||||
<div class="form-tip">
|
||||
<div class="backup-form-tip">
|
||||
<strong>{{ $t('backup.telegram_chat_id_tip_title') }}</strong><br/>
|
||||
<span>{{ $t('backup.telegram_chat_id_tip_1') }}</span><br/>
|
||||
<span>{{ $t('backup.telegram_chat_id_tip_2') }}</span>
|
||||
@@ -129,8 +129,8 @@
|
||||
<el-input v-model="form.config.username" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('backup.password')">
|
||||
<div v-if="isEditing && !isEditingWebdavPwd" style="display: flex; align-items: center; justify-content: space-between; background-color: var(--el-fill-color-light); padding: 0 15px; border-radius: 4px; border: 1px solid var(--el-border-color); width: 100%; height: 32px;">
|
||||
<span style="font-family: monospace; letter-spacing: 2px;">******</span>
|
||||
<div v-if="isEditing && !isEditingWebdavPwd" class="flex flex-items-center flex-between bg-fill p-10 rounded-4 border-1 w-full h-32">
|
||||
<span class="font-mono ls-2">******</span>
|
||||
<el-button link type="primary" @click="isEditingWebdavPwd = true; form.config.password = ''">{{ $t('backup.modify') }}</el-button>
|
||||
</div>
|
||||
<el-input v-else v-model="form.config.password" type="password" show-password />
|
||||
@@ -147,21 +147,21 @@
|
||||
<el-form-item :label="$t('backup.encrypt_password')" v-if="form.autoBackup">
|
||||
<div v-if="isEditing && hasExistingAutoPwd" style="margin-bottom: 15px; width: 100%;">
|
||||
<el-radio-group v-model="configUseExistingAutoPwd">
|
||||
<el-radio :label="true">{{ $t('backup.keep_old_pwd') }}</el-radio>
|
||||
<el-radio :label="false">{{ $t('backup.set_new_pwd') }}</el-radio>
|
||||
<el-radio :value="true">{{ $t('backup.keep_old_pwd') }}</el-radio>
|
||||
<el-radio :value="false">{{ $t('backup.set_new_pwd') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div v-if="!(isEditing && hasExistingAutoPwd && configUseExistingAutoPwd)" style="width: 100%;">
|
||||
<div v-if="!(isEditing && hasExistingAutoPwd && configUseExistingAutoPwd)" class="w-full">
|
||||
<el-input v-model="form.autoBackupPassword" type="password" show-password :placeholder="$t('backup.input_encrypt_pwd')" />
|
||||
<div class="form-tip"><span style="color: #F56C6C;">*</span> {{ $t('backup.password_length_req') }}</div>
|
||||
<div class="backup-form-tip"><span class="text-danger">*</span> {{ $t('backup.password_length_req') }}</div>
|
||||
</div>
|
||||
<div v-else class="success-tip">
|
||||
<div v-else class="backup-success-tip">
|
||||
<el-icon><CircleCheck /></el-icon><span>{{ $t('backup.continue_use_old_pwd') }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('backup.retain_count_label')" v-if="form.autoBackup">
|
||||
<el-input-number v-model="form.autoBackupRetain" :min="0" :max="999" :label="$t('backup.retain_count_label')"></el-input-number>
|
||||
<div class="form-tip" style="width: 100%">{{ $t('backup.retain_zero_tip') }}</div>
|
||||
<div class="backup-form-tip w-full">{{ $t('backup.retain_zero_tip') }}</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -172,12 +172,12 @@
|
||||
|
||||
<!-- 备份弹窗 -->
|
||||
<el-dialog v-model="showBackupDialog" :title="$t('backup.encrypted_backup')" :width="layoutStore.isMobile ? '90%' : '400px'">
|
||||
<el-alert :title="$t('common.data_security')" type="info" :description="$t('backup.backup_security_tip')" show-icon :closable="false" style="margin-bottom: 20px;" />
|
||||
<el-alert :title="$t('common.data_security')" type="info" :description="$t('backup.backup_security_tip')" show-icon :closable="false" class="mb-20" />
|
||||
|
||||
<div v-if="currentActionProvider?.auto_backup" style="margin-bottom: 15px;">
|
||||
<el-radio-group v-model="useAutoPassword">
|
||||
<el-radio :label="true">{{ $t('backup.use_auto_pwd') }}</el-radio>
|
||||
<el-radio :label="false">{{ $t('backup.use_new_pwd') }}</el-radio>
|
||||
<el-radio :value="true">{{ $t('backup.use_auto_pwd') }}</el-radio>
|
||||
<el-radio :value="false">{{ $t('backup.use_new_pwd') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
|
||||
<!-- 恢复确认弹窗 -->
|
||||
<el-dialog v-model="showRestoreConfirmDialog" :title="$t('backup.decrypt_restore')" :width="layoutStore.isMobile ? '90%' : '400px'">
|
||||
<el-alert :title="$t('common.warning')" type="warning" :description="$t('backup.restore_warning')" show-icon :closable="false" style="margin-bottom: 15px;" />
|
||||
<el-alert :title="$t('common.warning')" type="warning" :description="$t('backup.restore_warning')" show-icon :closable="false" class="mb-15" />
|
||||
<el-input v-model="restorePassword" type="password" show-password :placeholder="$t('backup.input_restore_pwd')" />
|
||||
<template #footer>
|
||||
<el-button @click="showRestoreConfirmDialog = false">{{ $t('common.cancel') }}</el-button>
|
||||
@@ -251,68 +251,4 @@ const formatSize = (bytes) => {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.backup-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.provider-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.provider-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.success-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--el-color-success);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="data-backup-wrapper">
|
||||
<div class="tab-card-wrapper">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<div class="text-center mb-30">
|
||||
<h2>{{ $t('backup.center_title') }}</h2>
|
||||
<p style="color: var(--el-text-color-secondary);">{{ $t('backup.center_desc') }}</p>
|
||||
<p class="text-secondary">{{ $t('backup.center_desc') }}</p>
|
||||
</div>
|
||||
<BackupSettings @restore-success="$emit('success')" />
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
<template #header>
|
||||
<div class="health-header">
|
||||
<h2 class="alert-title">
|
||||
<h2 class="alert-title text-24 font-600 text-primary ls-1 mt-15 mb-0">
|
||||
<el-icon :size="24" color="var(--el-color-danger)"><WarningFilled /></el-icon>
|
||||
{{ $t('healthCheck.title') }}
|
||||
</h2>
|
||||
<el-alert type="warning" :closable="false" center class="subtitle">{{ $t('healthCheck.subtitle') }}</el-alert>
|
||||
<el-alert type="warning" :closable="false" center class="subtitle my-15 p-10">{{ $t('healthCheck.subtitle') }}</el-alert>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="success" plain size="small" @click="generateNewKey(issue.field)" class="mt-2">
|
||||
<el-button type="success" plain size="small" @click="generateNewKey(issue.field)" class="mt-10">
|
||||
<el-icon><Refresh /></el-icon> {{ $t('healthCheck.generate_new') }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -75,7 +75,7 @@
|
||||
<!-- 默认建议展示 -->
|
||||
<div v-else class="fix-action">
|
||||
<p>{{ $t(`healthCheck.suggestions.${issue.suggestion}`) }}</p>
|
||||
<div v-if="issue.message.includes('incomplete')" class="doc-link mt-2">
|
||||
<div v-if="issue.message.includes('incomplete')" class="doc-link mt-10">
|
||||
<el-link type="primary" href="https://github.com/nap0o/2fauth-worker#三配置第三方登录" target="_blank">
|
||||
<el-icon><Document /></el-icon> {{ $t('healthCheck.view_docs') }}
|
||||
</el-link>
|
||||
@@ -193,196 +193,3 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.health-check-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.health-card {
|
||||
width: 95%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:global(html.dark) .health-card {
|
||||
background-color: #1a1a1a;
|
||||
border-color: #2c2c2c;
|
||||
}
|
||||
|
||||
.health-header {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
margin: 16px 0 0 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.alert-divider {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
background-color: var(--el-color-danger);
|
||||
border-radius: 2px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 15px 0px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.subtitle :deep(.el-alert__content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.subtitle :deep(.el-alert__title) {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.issue-alert {
|
||||
margin-bottom: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.issue-content {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.issue-desc {
|
||||
font-size: 14px;
|
||||
margin-bottom: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.suggestion-box {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
border-left: 4px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
:global(html.dark) .suggestion-box {
|
||||
background-color: rgba(64, 158, 255, 0.08);
|
||||
border: 1px solid rgba(64, 158, 255, 0.2);
|
||||
border-left: 4px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
:global(html.dark) .issue-alert {
|
||||
background-color: rgba(245, 108, 108, 0.05);
|
||||
border: 1px solid rgba(245, 108, 108, 0.2);
|
||||
}
|
||||
|
||||
:global(html.dark) .missing-fields-text {
|
||||
background-color: rgba(245, 108, 108, 0.1) !important;
|
||||
border: 1px solid rgba(245, 108, 108, 0.3) !important;
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.fix-action p {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.deploy-method-guide {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--el-border-color);
|
||||
}
|
||||
|
||||
.deploy-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-danger p {
|
||||
color: var(--el-color-danger);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.key-generator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mono-font :deep(.el-input__inner) {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.passed-checks-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px dashed var(--el-border-color);
|
||||
}
|
||||
|
||||
.passed-checks-section h3 {
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.passed-checks-section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.passed-checks-section li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.missing-fields-text {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
color: var(--el-color-danger);
|
||||
background-color: var(--el-color-danger-light-9);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,6 @@ export function useDataImport(emitFn) {
|
||||
const showDecryptDialog = ref(false)
|
||||
const importPassword = ref('')
|
||||
const importingJobs = ref(0)
|
||||
const isImporting = computed(() => importingJobs.value > 0)
|
||||
const isDecrypting = ref(false)
|
||||
const isDialogHandled = ref(false)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { vaultService } from '@/features/vault/service/vaultService'
|
||||
import { encryptDataWithPassword, decryptDataWithPassword } from '@/shared/utils/crypto'
|
||||
import { parseOtpUri } from '@/shared/utils/totp'
|
||||
import { tryParseJSON, base64ToBytes } from '@/shared/utils/encoding'
|
||||
import { tryParseJSON } from '@/shared/utils/encoding'
|
||||
import { gaMigrationStrategy } from '@/shared/utils/serializers/gauthStrategy'
|
||||
import { csvStrategy } from '@/shared/utils/serializers/csvStrategy'
|
||||
import { aegisStrategy } from '@/shared/utils/serializers/aegisStrategy'
|
||||
@@ -507,7 +507,6 @@ export const dataMigrationService = {
|
||||
if (!actualSalt || !actualIv || !actualCipher) {
|
||||
// 简单规则失败时回退到旧的排列检测逻辑
|
||||
let found = false
|
||||
const allBufs = bufs.slice()
|
||||
const permutations = [
|
||||
{ salt: bufs[1], iv: bufs[2], cipher: bufs[0], name: 'bufs[1]=salt, bufs[2]=iv, bufs[0]=cipher' },
|
||||
{
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
<template>
|
||||
<div class="data-export-wrapper">
|
||||
<div class="tab-card-wrapper">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<div class="text-center mb-30">
|
||||
<h2>{{ $t('migration.export') }}</h2>
|
||||
<p style="color: var(--el-text-color-secondary);">{{ $t('migration.export_desc') }}</p>
|
||||
<p class="text-secondary">{{ $t('migration.export_desc') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="export-groups" v-loading="isExporting" :element-loading-text="loadingText">
|
||||
<div class="migration-export-groups" v-loading="isExporting" :element-loading-text="loadingText">
|
||||
|
||||
<!-- 1. 本系统备份 -->
|
||||
<div class="export-group-card">
|
||||
<div class="group-header">
|
||||
<div class="migration-export-group-card">
|
||||
<div class="migration-group-header">
|
||||
<el-icon><Lock /></el-icon>
|
||||
<span>{{ $t('migration.system_backup') }}</span>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<el-button plain @click="openExportDialog('encrypted')" class="button-with-icon">
|
||||
<div class="migration-button-row">
|
||||
<el-button plain @click="openExportDialog('encrypted')" class="migration-button-with-icon">
|
||||
<el-icon><Lock /></el-icon> {{ $t('migration.encrypted_json') }}
|
||||
</el-button>
|
||||
<el-button plain @click="openWarningDialog('json')" class="button-with-icon">
|
||||
<el-button plain @click="openWarningDialog('json')" class="migration-button-with-icon">
|
||||
<el-icon><Unlock /></el-icon> {{ $t('migration.plaintext_json') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 移动端 2FA App -->
|
||||
<div class="export-group-card">
|
||||
<div class="group-header">
|
||||
<div class="migration-export-group-card">
|
||||
<div class="migration-group-header">
|
||||
<el-icon><Iphone /></el-icon>
|
||||
<span>{{ $t('migration.mobile_app') }}</span>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<el-button plain @click="openWarningDialog('2fas')" class="button-with-icon">
|
||||
<div class="migration-button-row">
|
||||
<el-button plain @click="openWarningDialog('2fas')" class="migration-button-with-icon">
|
||||
<el-icon><icon2FAS /></el-icon> 2FAS (.2fas)
|
||||
</el-button>
|
||||
<el-button plain @click="openWarningDialog('aegis')" class="button-with-icon">
|
||||
<el-button plain @click="openWarningDialog('aegis')" class="migration-button-with-icon">
|
||||
<el-icon><iconAegis /></el-icon> Aegis (.json)
|
||||
</el-button>
|
||||
<el-button plain @click="openGaDialogDirectly" class="button-with-icon">
|
||||
<el-button plain @click="openGaDialogDirectly" class="migration-button-with-icon">
|
||||
<el-icon><iconGoogleAuth /></el-icon> {{ $t('migration.migrate_ga') }}
|
||||
</el-button>
|
||||
<el-button plain @click="openWarningDialog('bwauth')" class="button-with-icon">
|
||||
<el-button plain @click="openWarningDialog('bwauth')" class="migration-button-with-icon">
|
||||
<el-icon><iconBitwarden /></el-icon> Bitwarden Auth (.json)
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 通用格式 -->
|
||||
<div class="export-group-card">
|
||||
<div class="group-header">
|
||||
<div class="migration-export-group-card">
|
||||
<div class="migration-group-header">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('migration.generic_format') }}</span>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<el-button plain @click="openWarningDialog('generic_json')" class="button-with-icon">
|
||||
<div class="migration-button-row">
|
||||
<el-button plain @click="openWarningDialog('generic_json')" class="migration-button-with-icon">
|
||||
<el-icon><Document /></el-icon> {{ $t('migration.generic_format') }} (.json)
|
||||
</el-button>
|
||||
<el-button plain @click="openWarningDialog('text')" class="button-with-icon">
|
||||
<el-button plain @click="openWarningDialog('text')" class="migration-button-with-icon">
|
||||
<el-icon><Tickets /></el-icon> {{ $t('migration.otpauth_txt') }}
|
||||
</el-button>
|
||||
<el-button plain @click="openWarningDialog('csv', 'generic')" class="button-with-icon">
|
||||
<el-button plain @click="openWarningDialog('csv', 'generic')" class="migration-button-with-icon">
|
||||
<el-icon><Grid /></el-icon> {{ $t('migration.spreadsheet_csv') }}
|
||||
</el-button>
|
||||
<el-button plain @click="openWarningDialog('html')" class="button-with-icon">
|
||||
<el-button plain @click="openWarningDialog('html')" class="migration-button-with-icon">
|
||||
<el-icon><Monitor /></el-icon> {{ $t('migration.html_page') }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -97,32 +97,32 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- Google Auth 二维码弹窗 -->
|
||||
<el-dialog v-model="showGaDialog" :title="$t('migration.ga_title')" width="450px" style="text-align: center" destroy-on-close>
|
||||
<el-dialog v-model="showGaDialog" :title="$t('migration.ga_title')" width="450px" class="text-center" destroy-on-close>
|
||||
<div v-if="gaQrDataUrls.length > 0">
|
||||
<p style="margin-bottom: 10px; color: var(--el-text-color-secondary);">
|
||||
<p class="mb-10 text-secondary">
|
||||
{{ $t('migration.ga_desc_1') }}<br/>
|
||||
<span v-if="gaQrDataUrls.length > 1" style="color: var(--el-color-warning); font-weight: bold;">
|
||||
<span v-if="gaQrDataUrls.length > 1" class="font-bold" style="color: var(--el-color-warning);">
|
||||
{{ $t('migration.ga_desc_multiple') }}
|
||||
</span>
|
||||
</p>
|
||||
<div v-loading="isExporting" style="min-height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center;">
|
||||
<img :src="gaQrDataUrls[gaCurrentIndex]" alt="Google Auth Migration QR" style="max-width: 100%; padding: 10px; margin-bottom: 15px;" />
|
||||
<div v-loading="isExporting" class="min-h-200 flex-column flex-center">
|
||||
<img :src="gaQrDataUrls[gaCurrentIndex]" alt="Google Auth Migration QR" class="max-w-100p p-10 mb-15" />
|
||||
|
||||
<div v-if="gaQrDataUrls.length > 1" style="display: flex; align-items: center; gap: 15px; justify-content: center;">
|
||||
<div v-if="gaQrDataUrls.length > 1" class="flex-center gap-15">
|
||||
<el-button :disabled="gaCurrentIndex === 0" @click="gaCurrentIndex--" size="small">{{ $t('migration.btn_prev') }}</el-button>
|
||||
<span style="font-size: 14px; font-weight: bold;">{{ gaCurrentIndex + 1 }} / {{ gaQrDataUrls.length }}</span>
|
||||
<span class="font-bold" style="font-size: 14px;">{{ gaCurrentIndex + 1 }} / {{ gaQrDataUrls.length }}</span>
|
||||
<el-button :disabled="gaCurrentIndex === gaQrDataUrls.length - 1" @click="gaCurrentIndex++" size="small" type="primary">{{ $t('migration.btn_next') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else v-loading="isExporting" style="min-height: 200px;"></div>
|
||||
<div v-else v-loading="isExporting" class="min-h-200"></div>
|
||||
<template #footer>
|
||||
<el-button @click="showGaDialog = false">{{ $t('migration.finish') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 账号选择弹窗 (用于 Google Auth 选择性导出) -->
|
||||
<el-dialog v-model="showAccountSelectDialog" :title="$t('migration.select_account_title')" width="450px" destroy-on-close>
|
||||
<div class="account-select-toolbar">
|
||||
<div class="migration-account-select-toolbar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
:placeholder="$t('migration.search_account')"
|
||||
@@ -130,7 +130,7 @@
|
||||
:prefix-icon="Search"
|
||||
style="margin-bottom: 15px;"
|
||||
/>
|
||||
<div class="toolbar-actions">
|
||||
<div class="migration-toolbar-actions">
|
||||
<el-checkbox
|
||||
v-model="isAllSelected"
|
||||
:indeterminate="isIndeterminate"
|
||||
@@ -138,19 +138,19 @@
|
||||
>
|
||||
{{ selectAllText }}
|
||||
</el-checkbox>
|
||||
<span class="selected-count">{{ $t('migration.selected_count', { selected: selectedAccountIds.length, total: fullVault.length }) }}</span>
|
||||
<span class="migration-selected-count">{{ $t('migration.selected_count', { selected: selectedAccountIds.length, total: fullVault.length }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="account-list-container">
|
||||
<div class="migration-account-list-container">
|
||||
<el-checkbox-group v-model="selectedAccountIds">
|
||||
<el-scrollbar max-height="300px">
|
||||
<template v-if="filteredVault.length > 0">
|
||||
<div v-for="acc in filteredVault" :key="acc.id" class="account-item">
|
||||
<div v-for="acc in filteredVault" :key="acc.id" class="migration-account-item">
|
||||
<el-checkbox :label="acc.id" size="large">
|
||||
<div class="account-item-content">
|
||||
<span class="service-name">{{ acc.service || 'Unknown Service' }}</span>
|
||||
<span v-if="acc.account" class="account-name">{{ acc.account }}</span>
|
||||
<div class="migration-account-item-content">
|
||||
<span class="service-name text-15 font-600 text-primary">{{ acc.service || 'Unknown Service' }}</span>
|
||||
<span v-if="acc.account" class="account-name text-13 text-regular">{{ acc.account }}</span>
|
||||
</div>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
@@ -233,113 +233,3 @@ const isIndeterminate = computed(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.export-groups {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.export-group-card {
|
||||
padding: 20px;
|
||||
background-color: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.group-header .el-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 18px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.button-row .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.button-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button-with-icon .el-icon {
|
||||
margin-right: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 账号选择列表样式 */
|
||||
.account-select-toolbar {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.account-list-container {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.account-item {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.account-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.account-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 8px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.account-name {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
/* 修复由于 el-checkbox content-box 导致的文字变形 */
|
||||
:deep(.el-checkbox__label) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div class="data-import-wrapper">
|
||||
<div class="tab-card-wrapper">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<div class="text-center mb-30">
|
||||
<h2>{{ $t('migration.center_title') }}</h2>
|
||||
<p style="color: var(--el-text-color-secondary);">{{ $t('migration.center_desc') }}</p>
|
||||
<p class="text-secondary">{{ $t('migration.center_desc') }}</p>
|
||||
</div>
|
||||
|
||||
<div style="max-width: 100%; margin: 0 auto;">
|
||||
<div class="max-w-100p m-auto">
|
||||
|
||||
<!-- 统一的拖拽上传区域 -->
|
||||
<el-upload
|
||||
class="import-upload"
|
||||
class="migration-import-upload"
|
||||
drag
|
||||
action="#"
|
||||
multiple
|
||||
@@ -24,20 +24,20 @@
|
||||
<p><span v-html="$t('migration.drag_drop_tip')"></span></p>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="import-tips">
|
||||
<div class="migration-import-tips">
|
||||
<h4>{{ $t('migration.support_desc') }}</h4>
|
||||
<div class="format-groups">
|
||||
<div class="format-group">
|
||||
<div class="migration-format-groups">
|
||||
<div class="migration-format-group">
|
||||
<h4>📁 {{ $t('migration.system_backup_format') }}</h4>
|
||||
<div class="tags">
|
||||
<div class="migration-tags">
|
||||
<el-tag type="info" effect="light"><el-icon><Lock /></el-icon> {{ $t('migration.encrypted_backup_json') }}</el-tag>
|
||||
<el-tag type="info" effect="light"><el-icon><Unlock /></el-icon> {{ $t('migration.plaintext_backup_json') }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="format-group">
|
||||
<div class="migration-format-group">
|
||||
<h4>📱 {{ $t('migration.mobile_app_format') }}</h4>
|
||||
<div class="tags">
|
||||
<div class="migration-tags">
|
||||
<el-tag type="info" effect="light"><icon2FAS /> 2FAS (.2fas)</el-tag>
|
||||
<el-tag type="info" effect="light"><iconAegis /> Aegis (.json/.txt)</el-tag>
|
||||
<el-tag type="info" effect="light"><iconBitwarden /> Bitwarden Auth (.json/.csv)</el-tag>
|
||||
@@ -46,11 +46,11 @@
|
||||
<el-tag type="info" effect="light"><iconGoogleAuth /> Google Auth (.png/.jpg)</el-tag>
|
||||
<el-tag type="info" effect="light"><iconMicrosoftAuth /> Microsoft Auth (PhoneFactor)</el-tag>
|
||||
</div>
|
||||
<div class="ga-tip">
|
||||
<div class="migration-ga-tip">
|
||||
<span>Google Authenticator</span>
|
||||
<p>{{ $t('migration.ga_auth_desc') }}</p>
|
||||
</div>
|
||||
<div class="ms-tip">
|
||||
<div class="migration-ms-tip">
|
||||
<span>{{ $t('migration.ms_auth_desc') }}</span>
|
||||
<p>{{ $t('migration.ms_auth_detail') }}:<br />
|
||||
<code>/data/data/com.azure.authenticator/databases/PhoneFactor</code><br />
|
||||
@@ -60,9 +60,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="format-group">
|
||||
<div class="migration-format-group">
|
||||
<h4>📄 {{ $t('migration.generic_format') }}</h4>
|
||||
<div class="tags">
|
||||
<div class="migration-tags">
|
||||
<el-tag type="info" effect="light"><el-icon><Document /></el-icon> {{ $t('migration.generic_json') }}</el-tag>
|
||||
<el-tag type="info" effect="light"><el-icon><Tickets /></el-icon> OTPAuth URI (.txt)</el-tag>
|
||||
<el-tag type="info" effect="light"><el-icon><Grid /></el-icon> {{ $t('migration.spreadsheet_csv') }}</el-tag>
|
||||
@@ -85,16 +85,16 @@
|
||||
:close-on-press-escape="false"
|
||||
:show-close="false"
|
||||
>
|
||||
<div style="text-align: center; padding: 10px 0;">
|
||||
<div class="text-center py-10">
|
||||
<el-progress
|
||||
type="dashboard"
|
||||
:percentage="batchProgressPercent"
|
||||
:status="batchProgressPercent === 100 ? 'success' : ''"
|
||||
/>
|
||||
<h3 style="margin-top: 20px;">
|
||||
<h3 class="mt-20">
|
||||
{{ $t('migration.batch_progress', { processed: batchProcessedJobs, total: batchTotalJobs }) }}
|
||||
</h3>
|
||||
<p style="color: var(--el-text-color-secondary); margin-top: 10px;">
|
||||
<p class="text-secondary mt-10">
|
||||
{{ batchCurrentTaskName }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -102,11 +102,11 @@
|
||||
|
||||
<!-- 加密文件密码输入弹窗 -->
|
||||
<el-dialog v-model="showDecryptDialog" :title="$t('migration.decrypt_backup_title')" width="400px" @close="handleDecryptDialogClose" destroy-on-close>
|
||||
<el-alert v-if="currentImportType === 'aegis_encrypted'" :title="$t('migration.detect_aegis')" type="warning" :closable="false" style="margin-bottom: 15px;" />
|
||||
<el-alert v-else-if="currentImportType === 'proton_encrypted'" :title="$t('migration.detect_proton')" type="warning" :closable="false" style="margin-bottom: 15px;" />
|
||||
<el-alert v-else-if="currentImportType === '2fas_encrypted'" :title="$t('migration.detect_2fas')" type="warning" :closable="false" style="margin-bottom: 15px;" />
|
||||
<el-alert v-else-if="currentImportType === 'ente_encrypted'" :title="$t('migration.detect_ente')" type="warning" :closable="false" style="margin-bottom: 15px;" />
|
||||
<el-alert v-else :title="$t('migration.detect_system')" type="success" :closable="false" style="margin-bottom: 15px;" />
|
||||
<el-alert v-if="currentImportType === 'aegis_encrypted'" :title="$t('migration.detect_aegis')" type="warning" :closable="false" class="mb-15" />
|
||||
<el-alert v-else-if="currentImportType === 'proton_encrypted'" :title="$t('migration.detect_proton')" type="warning" :closable="false" class="mb-15" />
|
||||
<el-alert v-else-if="currentImportType === '2fas_encrypted'" :title="$t('migration.detect_2fas')" type="warning" :closable="false" class="mb-15" />
|
||||
<el-alert v-else-if="currentImportType === 'ente_encrypted'" :title="$t('migration.detect_ente')" type="warning" :closable="false" class="mb-15" />
|
||||
<el-alert v-else :title="$t('migration.detect_system')" type="success" :closable="false" class="mb-15" />
|
||||
<el-form label-position="top">
|
||||
<el-form-item :label="$t('migration.input_decrypt_pwd_label')">
|
||||
<el-input v-model="importPassword" type="password" show-password @keyup.enter="submitEncryptedData" :placeholder="$t('migration.input_decrypt_pwd_placeholder')" />
|
||||
@@ -152,115 +152,3 @@ const {
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.import-upload {
|
||||
margin-top: 20px;
|
||||
}
|
||||
:deep(.el-upload-dragger) {
|
||||
padding: 40px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
:deep(.el-upload-dragger:hover) {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
:deep(.el-icon--upload) {
|
||||
font-size: 48px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 提示区美化 */
|
||||
.import-tips {
|
||||
text-align: left;
|
||||
margin-top: 25px;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
padding: 0px 20px 20px 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
}
|
||||
|
||||
.format-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.format-group h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tags span.el-tag {
|
||||
padding: 4px 8px; /* 减小内边距,使其不那么像按钮 */
|
||||
height: auto;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: default; /* 明确鼠标状态不是可点击的 */
|
||||
border-color: transparent; /* 去除边框,使其更像高亮文本 */
|
||||
background-color: var(--el-fill-color-light); /* 更浅的背景色 */
|
||||
}
|
||||
|
||||
.tags span i.el-icon {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
margin-right: 4px; /* 图标和文字之间增加一点点间距 */
|
||||
}
|
||||
|
||||
.ga-tip {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ga-tip span {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--el-color-primary);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ga-tip p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ms-tip {
|
||||
background-color: var(--el-color-success-light-9);
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--el-color-success);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ms-tip span {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--el-color-success);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ms-tip p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-regular);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ms-tip p code {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -20,11 +20,3 @@ const emit = defineEmits(['success'])
|
||||
const activeTab = ref('export')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.migration-container {
|
||||
padding: 10px;
|
||||
}
|
||||
.migration-tabs :deep(.el-tabs__nav-wrap::after) {
|
||||
height: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div class="apps-review-container">
|
||||
<div class="review-header">
|
||||
<div class="apps-review-header">
|
||||
<el-alert
|
||||
:title="$t('tools.apps_review_tool_desc')"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
class="mb-20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" class="cards-grid">
|
||||
<el-row :gutter="20" class="flex flex-wrap">
|
||||
<el-col
|
||||
v-for="app in appList"
|
||||
:key="app.id"
|
||||
@@ -19,81 +19,80 @@
|
||||
:md="12"
|
||||
:lg="12"
|
||||
:xl="8"
|
||||
class="card-col"
|
||||
class="mb-20"
|
||||
>
|
||||
<el-card shadow="hover" class="app-card" :class="{'security-alert': app.securityIssue}">
|
||||
<div class="app-header">
|
||||
<h3 class="app-name">
|
||||
<el-icon :size="28" class="app-logo">
|
||||
<el-card shadow="hover" class="apps-review-card" :class="{'security-alert': app.securityIssue}">
|
||||
<div class="apps-review-card-header">
|
||||
<h3 class="apps-review-card-name">
|
||||
<el-icon :size="28" class="apps-review-card-logo">
|
||||
<component :is="app.icon" />
|
||||
</el-icon>
|
||||
{{ app.name }}
|
||||
<el-tag v-if="app.recommended" type="success" effect="dark" size="small" class="ml-2">🏅 Recommended</el-tag>
|
||||
<el-tag v-if="app.securityIssue" type="danger" effect="dark" size="small" class="ml-2">⚠️ Warning</el-tag>
|
||||
<el-tag v-if="app.recommended" type="success" effect="dark" size="small">🏅 Recommended</el-tag>
|
||||
<el-tag v-if="app.securityIssue" type="danger" effect="dark" size="small">⚠️ Warning</el-tag>
|
||||
</h3>
|
||||
<div class="rating-box">
|
||||
<el-rate v-model="app.rating" disabled allow-half show-score text-color="#ff9900" score-template="{value}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-meta">
|
||||
<!-- 官网链接带有类图标表现 -->
|
||||
<el-link v-if="app.link" :href="app.link" target="_blank" type="primary" :underline="false" class="website-link">
|
||||
{{ $t('tools.apps_review_table.website') }}
|
||||
<div class="apps-review-card-meta">
|
||||
<el-link :href="app.link" target="_blank" type="primary" :underline="false">
|
||||
{{ $t('tools.apps_review_table.website') }}
|
||||
</el-link>
|
||||
|
||||
<span class="downloads-label" v-if="app.downloads">{{ $t('tools.apps_review_table.platforms') }}:</span>
|
||||
<span class="downloads-label text-13 text-regular ml-5" v-if="app.downloads">{{ $t('tools.apps_review_table.platforms') }}:</span>
|
||||
|
||||
<div class="platform-buttons" v-if="app.downloads">
|
||||
<div class="flex flex-wrap gap-8 flex-items-center" v-if="app.downloads">
|
||||
<el-tooltip v-if="app.downloads.ios" content="App Store" placement="top">
|
||||
<el-link :href="app.downloads.ios" target="_blank" :underline="false" class="platform-btn">
|
||||
<el-link :href="app.downloads.ios" target="_blank" :underline="false" class="apps-review-platform-btn">
|
||||
<el-icon><Apple /></el-icon>
|
||||
</el-link>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="app.downloads.android" content="Google Play" placement="top">
|
||||
<el-link :href="app.downloads.android" target="_blank" :underline="false" class="platform-btn">
|
||||
<el-link :href="app.downloads.android" target="_blank" :underline="false" class="apps-review-platform-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="1em" height="1em" fill="currentColor">
|
||||
<path d="M 5.4160156 2.328125 L 13.296875 10.535156 L 15.626953 8.109375 C 11.611953 5.838375 6.3020156 2.834125 5.4160156 2.328125 z M 3.140625 2.8476562 C 3.055625 3.0456562 3 3.2629063 3 3.5039062 L 3 20.591797 C 3 20.788797 3.044375 20.970625 3.109375 21.140625 L 11.910156 11.978516 L 3.140625 2.8476562 z M 17.423828 9.1269531 L 14.683594 11.978516 L 17.402344 14.810547 C 19.071344 13.865547 20.226562 13.210937 20.226562 13.210938 C 20.725562 12.907937 21.005047 12.441594 20.998047 11.933594 C 20.991047 11.438594 20.702609 10.981938 20.224609 10.710938 C 20.159609 10.673937 19.031828 10.035953 17.423828 9.1269531 z M 13.296875 13.423828 L 5.4746094 21.566406 C 6.8216094 20.800406 11.797469 17.982172 15.605469 15.826172 L 13.296875 13.423828 z"/>
|
||||
</svg>
|
||||
</el-link>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="app.downloads.github" content="GitHub Releases" placement="top">
|
||||
<el-link :href="app.downloads.github" target="_blank" :underline="false" class="platform-btn">
|
||||
<el-link :href="app.downloads.github" target="_blank" :underline="false" class="apps-review-platform-btn">
|
||||
<svg viewBox="0 0 24 24" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34-.45-1.15-1.11-1.46-1.11-1.46-.9-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.87 1.52 2.34 1.07 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.92 0-1.11.38-2 1.03-2.71-.1-.25-.45-1.29.1-2.64 0 0 .84-.27 2.75 1.02.79-.22 1.65-.33 2.5-.33.85 0 1.71.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.39.1 2.64.65.71 1.03 1.6 1.03 2.71 0 3.82-2.34 4.66-4.57 4.91.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0012 2z"/>
|
||||
</svg>
|
||||
</el-link>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="app.downloads.fdroid" content="F-Droid" placement="top">
|
||||
<el-link :href="app.downloads.fdroid" target="_blank" :underline="false" class="platform-btn">
|
||||
<el-link :href="app.downloads.fdroid" target="_blank" :underline="false" class="apps-review-platform-btn">
|
||||
<span style="font-weight: bold; font-family: monospace;">FD</span>
|
||||
</el-link>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="app.downloads.extension || app.downloads.desktop" content="Extension / Desktop" placement="top">
|
||||
<el-link :href="app.downloads.extension || app.downloads.desktop" target="_blank" :underline="false" class="platform-btn">
|
||||
<el-link :href="app.downloads.extension || app.downloads.desktop" target="_blank" :underline="false" class="apps-review-platform-btn">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</el-link>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider border-style="dashed" class="meta-divider" />
|
||||
<el-divider border-style="dashed" class="my-10" />
|
||||
|
||||
<div class="app-details">
|
||||
<div class="detail-item pros">
|
||||
<span class="label"><el-icon color="#67C23A"><Select /></el-icon> {{ $t('tools.apps_review_table.pros') }}:</span>
|
||||
<span class="value">{{ $t(`tools.apps.${app.id}.pros`) }}</span>
|
||||
<div class="apps-review-details">
|
||||
<div class="apps-review-detail-item pros">
|
||||
<span class="label text-14 font-500"><el-icon color="#67C23A"><Select /></el-icon> {{ $t('tools.apps_review_table.pros') }}:</span>
|
||||
<span class="flex-1">{{ $t(`tools.apps.${app.id}.pros`) }}</span>
|
||||
</div>
|
||||
<div class="detail-item cons">
|
||||
<span class="label"><el-icon color="#909399"><CloseBold /></el-icon> {{ $t('tools.apps_review_table.cons') }}:</span>
|
||||
<span class="value">{{ $t(`tools.apps.${app.id}.cons`) }}</span>
|
||||
<div class="apps-review-detail-item cons">
|
||||
<span class="label text-14 font-500"><el-icon color="#909399"><CloseBold /></el-icon> {{ $t('tools.apps_review_table.cons') }}:</span>
|
||||
<span class="flex-1">{{ $t(`tools.apps.${app.id}.cons`) }}</span>
|
||||
</div>
|
||||
<div class="detail-item security" v-if="app.securityIssue || $t(`tools.apps.${app.id}.security`) !== '无安全事故记录'">
|
||||
<span class="label" :class="{'text-danger': app.securityIssue}"><el-icon><Warning /></el-icon> {{ $t('tools.apps_review_table.security') }}:</span>
|
||||
<span class="value" :class="{'text-danger': app.securityIssue}">{{ $t(`tools.apps.${app.id}.security`) }}</span>
|
||||
<div class="apps-review-detail-item security" v-if="app.securityIssue || $t(`tools.apps.${app.id}.security`) !== '无安全事故记录'">
|
||||
<span class="label text-14 font-500" :class="{'text-danger': app.securityIssue}"><el-icon><Warning /></el-icon> {{ $t('tools.apps_review_table.security') }}:</span>
|
||||
<span class="flex-1" :class="{'text-danger': app.securityIssue}">{{ $t(`tools.apps.${app.id}.security`) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-box" :class="{'summary-danger': app.rating <= 2}">
|
||||
<div class="apps-review-summary-box" :class="{'apps-review-summary-danger': app.rating <= 2}">
|
||||
<strong>{{ $t('tools.apps_review_table.summary') }}: </strong>
|
||||
{{ $t(`tools.apps.${app.id}.summary`) }}
|
||||
</div>
|
||||
@@ -134,195 +133,3 @@ const appList = [
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.apps-review-container {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-col {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.app-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
background-color: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rating-box {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.download-link {
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap; /* 空间不够时允许换行 */
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.website-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.website-link {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.downloads-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.platform-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.platform-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
color: var(--el-text-color-regular);
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
:global(html.dark) .platform-btn {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.meta-divider {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.app-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detail-item .label {
|
||||
font-weight: 500;
|
||||
min-width: 85px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-item .value {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--el-color-danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
.summary-danger {
|
||||
background-color: var(--el-color-danger-light-9);
|
||||
border-left: 3px solid var(--el-color-danger);
|
||||
}
|
||||
|
||||
:global(html.dark) .summary-danger {
|
||||
background-color: rgba(245, 108, 108, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.detail-item .label {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="controls">
|
||||
<div class="control-row">
|
||||
<span class="label">{{ $t('tools.length') }}: {{ length }}</span>
|
||||
<el-slider v-model="length" :min="6" :max="64" @input="generate" style="flex: 1; margin: 0 15px;" />
|
||||
<el-slider v-model="length" :min="6" :max="64" @input="generate" class="flex-1 mx-15" />
|
||||
</div>
|
||||
|
||||
<div class="control-row checkboxes">
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" size="large" @click="generate" style="width: 100%; margin-top: 20px;">
|
||||
<el-button type="primary" size="large" @click="generate" class="w-full mt-20">
|
||||
<el-icon><Refresh /></el-icon> {{ $t('tools.regenerate') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<QrScanner @scan-success="handleScanSuccess" />
|
||||
|
||||
<!-- Result Display -->
|
||||
<div v-if="scanResult" class="result-section" style="margin-top: 20px;">
|
||||
<div v-if="scanResult" class="result-section mt-20">
|
||||
<el-divider content-position="left">{{ $t('tools.qr_result') }}</el-divider>
|
||||
<el-input v-model="scanResult" type="textarea" :rows="3" readonly resize="none" />
|
||||
<el-button type="success" plain style="width: 100%; margin-top: 10px;" @click="copyResult">
|
||||
<el-button type="success" plain class="w-full mt-10" @click="copyResult">
|
||||
<el-icon><CopyDocument /></el-icon> {{ $t('common.copy') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
:description="syncStatus.desc"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 20px;"
|
||||
class="mb-20"
|
||||
/>
|
||||
|
||||
<!-- 时钟仪表盘 -->
|
||||
@@ -30,7 +30,7 @@
|
||||
<p>{{ $t('tools.network_latency') }}: <span>{{ rtt !== null ? `${rtt} ms` : '--' }}</span></p>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" size="large" :loading="isSyncing" @click="syncTime" style="width: 100%; margin-top: 20px;">
|
||||
<el-button type="primary" size="large" :loading="isSyncing" @click="syncTime" class="w-full mt-20">
|
||||
<el-icon><Refresh /></el-icon> {{ $t('tools.check_now') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
<div class="totp-timer" :class="{ 'urgent': remaining < 5 }">
|
||||
<el-icon><Timer /></el-icon> {{ remaining }}s {{ $t('tools.refresh_after') }}
|
||||
</div>
|
||||
<el-button type="primary" plain size="small" @click="copyToClipboard(currentCode, $t('vault.copy_success'))" :disabled="!currentCode" style="margin-top: 10px;">
|
||||
<el-button type="primary" plain size="small" @click="copyToClipboard(currentCode, $t('vault.copy_success'))" :disabled="!currentCode" class="mt-10">
|
||||
<el-icon><CopyDocument /></el-icon> {{ $t('common.copy') }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -107,16 +107,16 @@
|
||||
<el-collapse>
|
||||
<el-collapse-item :title="$t('tools.advanced_settings')" name="1">
|
||||
<div class="advanced-row">
|
||||
<el-select v-model="algorithm" @change="updateAll('settings')" :placeholder="$t('tools.totp_algorithm')" style="flex: 1">
|
||||
<el-select v-model="algorithm" @change="updateAll('settings')" :placeholder="$t('tools.totp_algorithm')" class="flex-1">
|
||||
<el-option :label="$t('tools.totp_algo_sha1_default')" value="SHA-1" />
|
||||
<el-option label="SHA-256" value="SHA-256" />
|
||||
<el-option label="SHA-512" value="SHA-512" />
|
||||
</el-select>
|
||||
<el-select v-model="digits" @change="updateAll('settings')" :placeholder="$t('tools.totp_digits')" style="width: 100px">
|
||||
<el-select v-model="digits" @change="updateAll('settings')" :placeholder="$t('tools.totp_digits')" class="w-100">
|
||||
<el-option :label="$t('vault.digits_6')" :value="6" />
|
||||
<el-option :label="$t('vault.digits_8')" :value="8" />
|
||||
</el-select>
|
||||
<el-select v-model="period" @change="updateAll('settings')" :placeholder="$t('tools.totp_period')" style="width: 100px">
|
||||
<el-select v-model="period" @change="updateAll('settings')" :placeholder="$t('tools.totp_period')" class="w-100">
|
||||
<el-option :label="$t('vault.period_30s')" :value="30" />
|
||||
<el-option :label="$t('vault.period_60s')" :value="60" />
|
||||
</el-select>
|
||||
@@ -131,15 +131,15 @@
|
||||
<span class="label-text">{{ $t('tools.time_offset') }} (Time Travel): {{ timeOffset > 0 ? '+' : '' }}{{ timeOffset }}s</span>
|
||||
<el-button link type="primary" @click="adjustTime(0, true)" size="small">{{ $t('tools.reset_time') }}</el-button>
|
||||
</div>
|
||||
<el-button-group style="width: 100%; display: flex;">
|
||||
<el-button @click="adjustTime(-period)" style="flex:1" size="small">{{ $t('tools.prev_period') }}</el-button>
|
||||
<el-button @click="adjustTime(period)" style="flex:1" size="small">{{ $t('tools.next_period') }}</el-button>
|
||||
<el-button-group class="w-full flex">
|
||||
<el-button @click="adjustTime(-period)" class="flex-1" size="small">{{ $t('tools.prev_period') }}</el-button>
|
||||
<el-button @click="adjustTime(period)" class="flex-1" size="small">{{ $t('tools.next_period') }}</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="config-section" style="margin-top: 20px;">
|
||||
<el-button type="success" size="large" @click="saveToVault" style="width: 100%;" :loading="isSaving">
|
||||
<div class="config-section mt-20">
|
||||
<el-button type="success" size="large" @click="saveToVault" class="w-full" :loading="isSaving">
|
||||
<el-icon><CircleCheck /></el-icon> {{ $t('tools.save_to_vault') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
v-for="tool in tools"
|
||||
:key="tool.id"
|
||||
shadow="hover"
|
||||
class="tool-card"
|
||||
:class="['tool-card', `tool-card-${tool.id}`]"
|
||||
@click="currentTool = tool.id"
|
||||
>
|
||||
<div class="tool-card-content">
|
||||
<div class="icon-wrapper" :style="{ backgroundColor: tool.bgColor }">
|
||||
<el-icon :size="32" :color="tool.iconColor"><component :is="tool.icon" /></el-icon>
|
||||
<div class="icon-wrapper">
|
||||
<el-icon :size="32"><component :is="tool.icon" /></el-icon>
|
||||
</div>
|
||||
<div class="text-info">
|
||||
<h3>{{ tool.title }}</h3>
|
||||
@@ -59,41 +59,31 @@ const tools = computed(() => [
|
||||
id: 'totp-secret',
|
||||
title: t('tools.totp_secret_title'),
|
||||
desc: t('tools.totp_secret_desc'),
|
||||
icon: Lock,
|
||||
iconColor: '#67C23A',
|
||||
bgColor: 'var(--el-color-success-light-9)'
|
||||
icon: Lock
|
||||
},
|
||||
{
|
||||
id: 'apps-review',
|
||||
title: t('tools.apps_review_tool_title'),
|
||||
desc: t('tools.apps_review_tool_desc'),
|
||||
icon: Iphone,
|
||||
iconColor: '#9c27b0',
|
||||
bgColor: 'var(--el-color-info-light-9)'
|
||||
icon: Iphone
|
||||
},
|
||||
{
|
||||
id: 'password',
|
||||
title: t('tools.password_gen_title'),
|
||||
desc: t('tools.password_gen_desc'),
|
||||
icon: Key,
|
||||
iconColor: '#409EFF',
|
||||
bgColor: 'var(--el-color-primary-light-9)'
|
||||
icon: Key
|
||||
},
|
||||
{
|
||||
id: 'time-sync',
|
||||
title: t('tools.time_sync_title'),
|
||||
desc: t('tools.time_sync_desc'),
|
||||
icon: Timer,
|
||||
iconColor: '#E6A23C',
|
||||
bgColor: 'var(--el-color-warning-light-9)'
|
||||
icon: Timer
|
||||
},
|
||||
{
|
||||
id: 'qr-parser',
|
||||
title: t('tools.qr_parser_title'),
|
||||
desc: t('tools.qr_parser_desc'),
|
||||
icon: Camera,
|
||||
iconColor: '#F56C6C',
|
||||
bgColor: 'var(--el-color-danger-light-9)'
|
||||
icon: Camera
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="add-vault-wrapper">
|
||||
<div class="tab-card-wrapper">
|
||||
<h2 style="text-align: center; margin-bottom: 20px;">{{ $t('vault.manual_title') }}</h2>
|
||||
<div style="max-width: 100%; margin: 0 auto;">
|
||||
<el-form :model="newVault" label-position="top" :rules="rules" ref="addFormRef" style="padding: 10px 0;">
|
||||
<h2 class="text-center mb-20">{{ $t('vault.manual_title') }}</h2>
|
||||
<div class="m-auto w-full">
|
||||
<el-form :model="newVault" label-position="top" :rules="rules" ref="addFormRef" class="vault-manual-form-wrapper">
|
||||
<el-form-item :label="$t('common.service_name')" prop="service">
|
||||
<el-input v-model="newVault.service" :placeholder="$t('vault.input_service_placeholder')" />
|
||||
</el-form-item>
|
||||
@@ -20,7 +20,7 @@
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="$t('vault.digits_label')" prop="digits">
|
||||
<el-select v-model="newVault.digits" style="width: 100%">
|
||||
<el-select v-model="newVault.digits" class="w-full">
|
||||
<el-option :label="$t('vault.digits_6')" :value="6" />
|
||||
<el-option :label="$t('vault.digits_8')" :value="8" />
|
||||
</el-select>
|
||||
@@ -28,7 +28,7 @@
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="$t('vault.period_label')" prop="period">
|
||||
<el-select v-model="newVault.period" style="width: 100%">
|
||||
<el-select v-model="newVault.period" class="w-full">
|
||||
<el-option :label="$t('vault.period_30s')" :value="30" />
|
||||
<el-option :label="$t('vault.period_60s')" :value="60" />
|
||||
</el-select>
|
||||
@@ -36,7 +36,7 @@
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="$t('vault.algorithm_label')" prop="algorithm">
|
||||
<el-select v-model="newVault.algorithm" style="width: 100%">
|
||||
<el-select v-model="newVault.algorithm" class="w-full">
|
||||
<el-option :label="$t('vault.algo_sha1')" value="SHA1" />
|
||||
<el-option label="SHA256" value="SHA256" />
|
||||
<el-option label="SHA512" value="SHA512" />
|
||||
@@ -47,8 +47,8 @@
|
||||
<el-form-item :label="$t('common.category_optional')" prop="category">
|
||||
<el-input v-model="newVault.category" :placeholder="$t('vault.input_category_placeholder')" />
|
||||
</el-form-item>
|
||||
<el-form-item style="margin-top: 30px;">
|
||||
<el-button type="primary" :loading="submitting" @click="submitAddVault" style="width: 100%;" size="large">{{ $t('vault.confirm_add_btn') }}</el-button>
|
||||
<el-form-item class="mt-30">
|
||||
<el-button type="primary" :loading="submitting" @click="submitAddVault" class="vault-manual-submit-btn" size="large">{{ $t('vault.confirm_add_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="add-vault-wrapper">
|
||||
<div class="tab-card-wrapper">
|
||||
<h2 style="text-align: center; margin-bottom: 20px;">📷 {{ $t('vault.scan_qr') }}</h2>
|
||||
<div style="max-width: 100%; margin: 0 auto;">
|
||||
<div style="text-align: center; margin-bottom: 20px; margin-top: 10px;">
|
||||
<p style="color: var(--el-text-color-secondary);">{{ $t('vault.scan_camera_tip') }}</p>
|
||||
<h2 class="text-center mb-20">📷 {{ $t('vault.scan_qr') }}</h2>
|
||||
<div class="max-w-600 m-auto">
|
||||
<div class="text-center mb-20 mt-10">
|
||||
<p class="text-secondary">{{ $t('vault.scan_camera_tip') }}</p>
|
||||
</div>
|
||||
<QrScanner @scan-success="handleScanSuccess" />
|
||||
</div>
|
||||
@@ -35,21 +35,21 @@ const handleScanSuccess = async (uri) => {
|
||||
}
|
||||
|
||||
await ElMessageBox.confirm(
|
||||
h('div', { style: 'text-align: left; background: var(--el-fill-color-light); padding: 16px; border-radius: 8px; border: 1px solid var(--el-border-color-lighter); margin-top: 10px;' }, [
|
||||
h('div', { style: 'margin-bottom: 12px; display: flex; align-items: center;' }, [
|
||||
h('span', { style: 'color: var(--el-text-color-secondary); width: 70px; flex-shrink: 0;' }, t('vault.service_label')),
|
||||
h('span', { style: 'font-weight: 600; font-size: 15px; color: var(--el-text-color-primary); word-break: break-all;' }, acc.service || t('vault.unknown_service'))
|
||||
h('div', { class: 'confirmation-box' }, [
|
||||
h('div', { class: 'confirmation-row' }, [
|
||||
h('span', { class: 'confirmation-label' }, t('vault.service_label')),
|
||||
h('span', { class: 'confirmation-value' }, acc.service || t('vault.unknown_service'))
|
||||
]),
|
||||
h('div', { style: 'margin-bottom: 12px; display: flex; align-items: center;' }, [
|
||||
h('span', { style: 'color: var(--el-text-color-secondary); width: 70px; flex-shrink: 0;' }, t('vault.account_label')),
|
||||
h('span', { style: 'font-family: monospace; font-size: 14px; background: var(--el-fill-color-darker); padding: 4px 8px; border-radius: 6px; color: var(--el-color-primary); word-break: break-all;' }, acc.account || t('vault.unnamed_account'))
|
||||
h('div', { class: 'confirmation-row' }, [
|
||||
h('span', { class: 'confirmation-label' }, t('vault.account_label')),
|
||||
h('span', { class: 'confirmation-value mono' }, acc.account || t('vault.unnamed_account'))
|
||||
]),
|
||||
h('div', { style: 'display: flex; align-items: center;' }, [
|
||||
h('span', { style: 'color: var(--el-text-color-secondary); width: 70px; flex-shrink: 0;' }, t('vault.param_label')),
|
||||
h('div', { style: 'display: flex; gap: 8px; flex-wrap: wrap;' }, [
|
||||
h('span', { style: 'background: var(--el-color-info-light-9); color: var(--el-color-info); border: 1px solid var(--el-color-info-light-7); padding: 2px 8px; border-radius: 4px; font-size: 12px;' }, acc.algorithm || 'SHA1'),
|
||||
h('span', { style: 'background: var(--el-color-success-light-9); color: var(--el-color-success); border: 1px solid var(--el-color-success-light-7); padding: 2px 8px; border-radius: 4px; font-size: 12px;' }, `${acc.digits || 6}${t('vault.digits_suffix')}`),
|
||||
h('span', { style: 'background: var(--el-color-warning-light-9); color: var(--el-color-warning); border: 1px solid var(--el-color-warning-light-7); padding: 2px 8px; border-radius: 4px; font-size: 12px;' }, `${acc.period || 30}${t('vault.period_suffix')}`)
|
||||
h('div', { class: 'confirmation-row' }, [
|
||||
h('span', { class: 'confirmation-label' }, t('vault.param_label')),
|
||||
h('div', { class: 'confirmation-tags' }, [
|
||||
h('span', { class: 'confirmation-tag confirmation-tag-info' }, acc.algorithm || 'SHA1'),
|
||||
h('span', { class: 'confirmation-tag confirmation-tag-success' }, `${acc.digits || 6}${t('vault.digits_suffix')}`),
|
||||
h('span', { class: 'confirmation-tag confirmation-tag-warning' }, `${acc.period || 30}${t('vault.period_suffix')}`)
|
||||
])
|
||||
])
|
||||
]),
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<div class="user-detail">
|
||||
<div class="user-name-row">
|
||||
<span class="user-name">{{ authUserStore.userInfo?.username }}</span>
|
||||
<i class="status-dot" :class="{ 'is-online': authUserStore.userInfo?.online !== false }"></i>
|
||||
<i class="status-dot" :class="{ 'is-online': !layoutStore.isOffline }"></i>
|
||||
</div>
|
||||
<div class="user-provider" v-if="authUserStore.userInfo?.provider">
|
||||
{{ authUserStore.userInfo?.provider }}
|
||||
@@ -199,71 +199,3 @@ const handleLogout = async () => {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-info-card {
|
||||
margin: 0px 20px 0px 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 0.2px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.user-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--el-color-primary-light-8);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.user-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background-color: #909399; /* 默认灰色 */
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.is-online {
|
||||
background-color: #67C23A; /* 在线绿色 */
|
||||
box-shadow: 0 0 4px rgba(103, 194, 58, 0.5);
|
||||
}
|
||||
|
||||
.user-provider {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color);
|
||||
padding: 1px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div class="vault-list-wrapper" style="min-height: 400px;">
|
||||
<div class="vault-list-wrapper min-h-400">
|
||||
<div class="vault-content">
|
||||
<!-- 移动端偏移 60px (Header2), PC端偏移 61px (Header1) -->
|
||||
<el-affix :offset="layoutStore.isMobile ? 60 : 61" @change="(val) => isToolbarFixed = val">
|
||||
<div class="toolbar" :class="{ 'is-affixed': isToolbarFixed }" style="margin-bottom: 20px; display: flex; gap: 15px; align-items: center; justify-content: space-between; flex-wrap: wrap;">
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex: 1;">
|
||||
<div class="vault-list-toolbar mb-20 flex gap-15 flex-items-center flex-between flex-wrap" :class="{ 'is-affixed': isToolbarFixed }">
|
||||
<div class="flex flex-items-center gap-10 flex-1">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('common.search_placeholder')"
|
||||
clearable
|
||||
style="max-width: 400px;"
|
||||
class="max-w-400"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon v-if="isFetching && searchQuery" class="is-loading"><Loading /></el-icon>
|
||||
@@ -18,7 +18,7 @@
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="batch-actions" style="display: flex; align-items: center; gap: 10px;">
|
||||
<div class="batch-actions flex flex-items-center gap-10">
|
||||
<template v-if="selectedIds.length > 0">
|
||||
<span class="batch-text">{{ $t('common.selected_items', { count: selectedIds.length }) }}</span>
|
||||
<el-button type="danger" plain @click="handleBulkDelete" :loading="isBulkDeleting">
|
||||
@@ -32,10 +32,9 @@
|
||||
</el-affix>
|
||||
|
||||
<!-- 1. 加载状态 -->
|
||||
<div v-if="(isInitializing || isLoading || isFetching) && vault.length === 0" class="loading-state" style="display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 400px; color: var(--el-text-color-secondary);">
|
||||
|
||||
<el-icon class="is-loading" :size="48" style="margin-bottom: 20px; color: var(--el-color-primary);"><Loading /></el-icon>
|
||||
<p style="font-size: 16px; letter-spacing: 1px;">{{ $t('common.loading_data') }}</p>
|
||||
<div v-if="(isInitializing || isLoading || isFetching) && vault.length === 0" class="flex-column flex-center min-h-400 text-secondary">
|
||||
<el-icon class="is-loading mb-20 text-primary" :size="48"><Loading /></el-icon>
|
||||
<p class="text-16 ls-1">{{ $t('common.loading_data') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 2. 空状态 (明确加载完毕 + 数据真正为空 + 无搜索) -->
|
||||
@@ -47,14 +46,13 @@
|
||||
|
||||
<!-- 3. 数据列表 (已解锁) -->
|
||||
<div v-else
|
||||
class="list-container"
|
||||
style="min-height: 200px;"
|
||||
class="list-container min-h-200"
|
||||
v-infinite-scroll="handleLoadMore"
|
||||
:infinite-scroll-disabled="isLoadMoreDisabled"
|
||||
:infinite-scroll-distance="100"
|
||||
>
|
||||
<el-row :gutter="20" v-if="vault.length > 0">
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="vaultItem in vault" :key="vaultItem.id" style="margin-bottom: 20px;">
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="vaultItem in vault" :key="vaultItem.id" class="mb-20">
|
||||
<el-card class="vault-card" :class="{ 'is-selected': selectedIds.includes(vaultItem.id) }" shadow="hover">
|
||||
<div class="card-header">
|
||||
<div class="service-info">
|
||||
@@ -73,7 +71,7 @@
|
||||
<el-dropdown-item command="edit">
|
||||
<el-icon><Edit /></el-icon> {{ $t('common.edit') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" style="color: #F56C6C;">
|
||||
<el-dropdown-item command="delete" class="text-danger">
|
||||
<el-icon><Delete /></el-icon> {{ $t('common.delete') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
@@ -108,10 +106,10 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div v-if="isFetchingNextPage" style="text-align: center; padding: 20px; color: var(--el-text-color-secondary);">
|
||||
<div v-if="isFetchingNextPage" class="text-center p-20 text-secondary">
|
||||
<el-icon class="is-loading"><Loading /></el-icon> {{ $t('common.loading_more') }}
|
||||
</div>
|
||||
<div v-if="!hasNextPage && vault.length > 0" style="text-align: center; padding: 20px; color: var(--el-text-color-secondary); font-size: 12px;">
|
||||
<div v-if="!hasNextPage && vault.length > 0" class="text-center p-20 text-secondary text-12">
|
||||
{{ $t('common.no_more_accounts') }}
|
||||
</div>
|
||||
|
||||
@@ -251,37 +249,3 @@ defineExpose({ fetchVault })
|
||||
|
||||
onMounted(handleUnlocked)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 搜索栏吸顶过渡动画 */
|
||||
.toolbar {
|
||||
transition: padding 0.3s ease, background-color 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 共有吸顶属性 */
|
||||
.toolbar.is-affixed {
|
||||
z-index: 2000;
|
||||
margin-bottom: 0 !important;
|
||||
background-color: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
/* 移动端吸顶效果:全屏、背景、边框 */
|
||||
@media (max-width: 767px) {
|
||||
.toolbar.is-affixed {
|
||||
width: 100vw !important;
|
||||
margin-left: -20px !important; /* 抵消 el-main 的内边距 */
|
||||
padding: 14px 20px !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* PC 端吸顶效果:保持原有宽度,无背景色和下边框 */
|
||||
@media (min-width: 768px) {
|
||||
.toolbar.is-affixed {
|
||||
border-bottom: none !important;
|
||||
padding: 20px 0px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, shallowRef, computed, watch, triggerRef } from 'vue'
|
||||
import { ref, shallowRef, computed, watch } from 'vue'
|
||||
import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useVaultStore } from '@/features/vault/store/vaultStore'
|
||||
import { vaultService } from '@/features/vault/service/vaultService'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="qr-scanner-container">
|
||||
<el-radio-group v-model="scannerMode" style="margin-bottom: 20px; width: 100%; display: flex; justify-content: center;" @change="handleModeChange">
|
||||
<el-radio-group v-model="scannerMode" class="mb-20 w-full flex flex-center" @change="handleModeChange">
|
||||
<el-radio-button label="camera">{{ $t('tools.scanner_camera') }}</el-radio-button>
|
||||
<el-radio-button label="image">{{ $t('tools.scanner_image') }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
@@ -17,7 +17,7 @@
|
||||
<el-icon :size="48" color="#909399"><Camera /></el-icon>
|
||||
<p>{{ $t('tools.camera_click_to_open') }}</p>
|
||||
</div>
|
||||
<el-button :type="isScanning ? 'danger' : 'primary'" @click="toggleCamera" style="margin-top: 15px; width: 100%;" :loading="isStarting">
|
||||
<el-button :type="isScanning ? 'danger' : 'primary'" @click="toggleCamera" class="mt-15 w-full" :loading="isStarting">
|
||||
{{ isScanning ? $t('tools.camera_stop') : $t('tools.camera_open') }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -31,7 +31,7 @@
|
||||
<p>{{ $t('tools.image_placeholder') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" ref="fileInputRef" accept="image/*" style="display: none" @change="handleFileUpload" />
|
||||
<input type="file" ref="fileInputRef" accept="image/*" class="display-none" @change="handleFileUpload" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<el-button v-if="layoutStore.isMobile && !['/login', '/health'].includes(route.path)" @click="layoutStore.showMobileMenu = true" style="margin-right: 10px; padding: 5px 8px; border: 1px solid var(--el-border-color); height: auto;">
|
||||
<el-button v-if="layoutStore.isMobile && !['/login', '/health'].includes(route.path)" @click="layoutStore.showMobileMenu = true" class="header-menu-btn">
|
||||
<el-icon :size="14" class="menu-icon" style="color: var(--el-text-color-primary);">
|
||||
<svg viewBox="0 0 16 16" version="1.1" width="100%" height="100%" aria-hidden="true" fill="currentColor">
|
||||
<path d="M1 2.75A.75.75 0 0 1 1.75 2h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 2.75Zm0 5A.75.75 0 0 1 1.75 7h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 7.75ZM1.75 12h12.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5Z"></path>
|
||||
</svg>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<a href="#" @click.prevent="goHome" style="display: flex; align-items: center; text-decoration: none; color: inherit;">
|
||||
<a href="#" @click.prevent="goHome" class="header-home-link">
|
||||
<el-icon :size="24" color="#409EFC" style="margin-right: 10px;"><Lock /></el-icon>
|
||||
<h2>2FAuth Worker</h2>
|
||||
</a>
|
||||
|
||||
@@ -6,9 +6,19 @@ export const useLayoutStore = defineStore('layout', () => {
|
||||
const showMobileMenu = ref(false)
|
||||
const homeTabReset = ref(0) // 触发返回统一的主账号视图
|
||||
|
||||
// 网络状态监控
|
||||
const isOffline = ref(!navigator.onLine)
|
||||
|
||||
const initNetworkStatus = () => {
|
||||
window.addEventListener('online', () => { isOffline.value = false })
|
||||
window.addEventListener('offline', () => { isOffline.value = true })
|
||||
}
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
showMobileMenu,
|
||||
homeTabReset
|
||||
homeTabReset,
|
||||
isOffline,
|
||||
initNetworkStatus
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineAsyncComponent, h } from 'vue'
|
||||
import { ElLoading, ElEmpty, ElButton } from 'element-plus'
|
||||
import { ElEmpty, ElButton } from 'element-plus'
|
||||
|
||||
/**
|
||||
* 增强的异步组件工厂函数
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
// Removed router import to solve circular dependency and facilitate physical redirects
|
||||
import { useAuthUserStore } from '@/features/auth/store/authUserStore'
|
||||
import { useLayoutStore } from '@/shared/stores/layoutStore'
|
||||
import { i18n } from '@/locales'
|
||||
|
||||
// 辅助函数:从 document.cookie 中安全地读取指定的 cookie 值
|
||||
@@ -11,7 +12,6 @@ export function getCookie(name) {
|
||||
}
|
||||
|
||||
let isNavigatingToLogin = false; // 增加防重入锁,避免并发 401 触发无限弹窗和跳转
|
||||
let isNavigatingToSecurity = false; // 防重入锁,避免 403 触发无限跳转
|
||||
|
||||
export async function request(url, options = {}) {
|
||||
const headers = {
|
||||
@@ -27,7 +27,8 @@ export async function request(url, options = {}) {
|
||||
// --- 离线拦截逻辑 ---
|
||||
// 拦截破坏性/写操作,在无网络时提供温和提示并短路执行
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
if (!navigator.onLine && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
||||
const layoutStore = useLayoutStore()
|
||||
if (layoutStore.isOffline && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
||||
if (!options.silent) {
|
||||
ElMessage.warning(i18n.global.t('api_errors.offline'));
|
||||
}
|
||||
|
||||
@@ -49,8 +49,6 @@ export const gaMigrationStrategy = {
|
||||
let issuer = ''
|
||||
let algorithm = 'SHA1'
|
||||
let digits = 6
|
||||
let type = 'TOTP'
|
||||
|
||||
while (pos < endPos) {
|
||||
const pTag = dataBytes[pos++]
|
||||
const pField = pTag >> 3
|
||||
@@ -89,8 +87,7 @@ export const gaMigrationStrategy = {
|
||||
const dVal = readVarint()
|
||||
if (dVal === 2) digits = 8
|
||||
} else if (pField === 6 && pWire === 0) {
|
||||
const tVal = readVarint()
|
||||
if (tVal === 1) type = 'HOTP'
|
||||
readVarint()
|
||||
} else {
|
||||
if (pWire === 0) readVarint()
|
||||
else if (pWire === 2) pos += readVarint()
|
||||
|
||||
Reference in new issue
Block a user