Add /careerbot base path for www.ityb.me/careerbot deployment

- Add BASE_PATH config, include all routers with prefix
- Inject {{ base }} Jinja2 global for all template URLs
- Add window.BASE_PATH for static JS files
- Update Nginx to proxy /careerbot/ path
- Add OPS_MANUAL.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ln0422 2026-04-07 22:07:34 +08:00
parent 96997daed0
commit 501f8985ec
21 changed files with 574 additions and 142 deletions

396
OPS_MANUAL.md Normal file
View File

@ -0,0 +1,396 @@
# CareerBot 线上运维手册
## 1. 服务器信息
| 项目 | 详情 |
|------|------|
| 云服务商 | 阿里云 ECS |
| 公网 IP | `39.106.14.107` |
| 操作系统 | Alibaba Cloud Linux 3 (OpenAnolis Edition) |
| 配置 | 2 核 CPU / 2GB 内存 / 40GB 磁盘 |
| Python 版本 | 3.11.13 |
| Nginx 版本 | 1.20.1 |
| Gitea 版本 | 1.22.6 |
---
## 2. 账号信息
### 2.1 服务器账号
| 账号 | 用途 | 登录方式 |
|------|------|---------|
| `root` | 系统管理 | SSH 密钥 (`~/.ssh/id_ed25519`) |
| `deploy` | 应用部署和运行 | SSH 密钥同上sudo 免密 |
| `gitea` | Gitea 服务运行 | 系统用户,不可直接登录 |
| `nginx` | Nginx 服务运行 | 系统用户,不可直接登录 |
**本地 SSH 快捷连接**(已配置在 `~/.ssh/config`
```bash
ssh ecs # 以 deploy 用户连接
ssh root@ecs # 以 root 用户连接(需将 config 中 User 临时改为 root或直接用 IP
ssh root@39.106.14.107 # root 直连
```
### 2.2 应用账号
| 系统 | 地址 | 账号 | 密码 |
|------|------|------|------|
| CareerBot 管理后台 | `http://39.106.14.107/admin/login` | `ln0422@gmail.com` | `qshs123456` |
| Gitea 代码管理 | `http://39.106.14.107:3000` | `ln0422` | `Qshs123456_` |
### 2.3 ECS 用户密码
| 用户 | 密码 |
|------|------|
| `deploy` | `CareerBot2026!` |
> **注意**:日常运维通过 SSH 密钥登录,密码仅在密钥不可用时作为备用。
---
## 3. 服务架构
```
外部访问
┌─────────────────────────────────────────┐
│ Nginx (:80) │
│ 配置: /etc/nginx/sites-enabled/ │
│ 日志: /var/log/nginx/ │
├─────────────────────────────────────────┤
│ http://39.106.14.107/ │
│ → proxy_pass http://127.0.0.1:8000 │
│ → CareerBot (uvicorn) │
├─────────────────────────────────────────┤
│ http://39.106.14.107:3000 │
│ → Gitea (直接监听,未经 Nginx 代理) │
└─────────────────────────────────────────┘
```
### 端口使用
| 端口 | 服务 | 监听地址 | 防火墙 | 安全组 |
|------|------|---------|--------|--------|
| 22 | SSH | 0.0.0.0 | 已放行 | 已放行 |
| 80 | Nginx → CareerBot | 0.0.0.0 | 已放行 | 已放行 |
| 443 | HTTPS (预留) | - | 已放行 | 已放行 |
| 3000 | Gitea | 0.0.0.0 | 已放行 | 已放行 |
| 8000 | CareerBot (uvicorn) | 127.0.0.1 | 内部 | 无需 |
---
## 4. 文件目录结构
```
/home/deploy/apps/CareerBot/ ← 项目根目录
├── venv/ ← Python 虚拟环境
├── app/ ← 应用代码
├── templates/ ← 页面模板
├── careerbot.db ← SQLite 数据库(重要数据!)
├── uploads/ ← 用户上传文件
├── requirements.txt
├── init_data.py
├── start.bat / stop.bat ← Windows 本地启动脚本(线上不用)
├── DESIGN.md ← 设计文档
└── OPS_MANUAL.md ← 本文档
/etc/systemd/system/
├── careerbot.service ← CareerBot systemd 服务
└── gitea.service ← Gitea systemd 服务
/etc/nginx/
├── nginx.conf ← Nginx 主配置
├── sites-available/
│ └── careerbot.conf ← CareerBot 站点配置
└── sites-enabled/
└── careerbot.conf → ../sites-available/careerbot.conf
/usr/local/bin/gitea ← Gitea 二进制文件
/etc/gitea/app.ini ← Gitea 配置文件
/var/lib/gitea/ ← Gitea 数据目录(仓库、数据库)
```
---
## 5. 常用运维命令
### 5.1 服务管理
```bash
# ── CareerBot ──
sudo systemctl status careerbot # 查看状态
sudo systemctl restart careerbot # 重启
sudo systemctl stop careerbot # 停止
sudo systemctl start careerbot # 启动
sudo journalctl -u careerbot -f # 实时查看日志
sudo journalctl -u careerbot --since "1 hour ago" # 查看最近1小时日志
# ── Nginx ──
sudo systemctl status nginx
sudo systemctl reload nginx # 重载配置(不中断连接)
sudo systemctl restart nginx # 重启
sudo nginx -t # 测试配置文件语法
# ── Gitea ──
sudo systemctl status gitea
sudo systemctl restart gitea
sudo journalctl -u gitea -f
```
### 5.2 代码更新部署
**从本地推送代码并部署到线上(标准流程):**
```bash
# 1. 本地:提交并推送到 Gitea
cd D:/Files/Projects/VibeCoding/CareerBot
git add .
git commit -m "描述改动"
git push origin main
# 2. 线上:拉取并重启
ssh ecs "cd ~/apps/CareerBot && git pull && sudo systemctl restart careerbot"
```
**一键部署脚本(在本地执行):**
```bash
ssh ecs "cd ~/apps/CareerBot && git pull origin main && sudo systemctl restart careerbot && sleep 2 && sudo systemctl is-active careerbot"
```
### 5.3 数据库操作
```bash
# 连接到 SQLite 数据库
ssh ecs "cd ~/apps/CareerBot && sqlite3 careerbot.db"
# 常用 SQL
.tables # 查看所有表
SELECT * FROM admin_user; # 查看管理员
SELECT token, note, max_questions, used_questions, is_active FROM access_token; # 查看令牌
SELECT * FROM recruiter_message ORDER BY created_at DESC LIMIT 10; # 最近招聘消息
SELECT COUNT(*) FROM chat_history; # 对话记录总数
.quit # 退出
```
### 5.4 系统监控
```bash
# 查看内存使用
ssh ecs "free -h"
# 查看磁盘使用
ssh ecs "df -h /"
# 查看各服务内存占用
ssh ecs "ps aux --sort=-rss | head -10"
# 查看端口监听
ssh ecs "ss -tlnp"
```
---
## 6. 配置文件详情
### 6.1 CareerBot systemd 服务
文件:`/etc/systemd/system/careerbot.service`
```ini
[Unit]
Description=CareerBot Web Application
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/apps/CareerBot
ExecStart=/home/deploy/apps/CareerBot/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=5
Environment=PATH=/home/deploy/apps/CareerBot/venv/bin:/usr/bin
[Install]
WantedBy=multi-user.target
```
关键参数:
- `--host 127.0.0.1`:只监听本地,由 Nginx 代理外部访问
- `Restart=always`:崩溃后自动重启
- `RestartSec=5`:重启间隔 5 秒
### 6.2 Nginx 站点配置
文件:`/etc/nginx/sites-available/careerbot.conf`
```nginx
server {
listen 80;
server_name 39.106.14.107;
client_max_body_size 10M;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE 流式响应支持(关键!关闭缓冲)
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
location /uploads/ {
alias /home/deploy/apps/CareerBot/uploads/;
}
}
```
关键配置说明:
- `proxy_buffering off` + `proxy_cache off`**必须关闭**,否则 SSE 流式对话无法实时返回
- `proxy_read_timeout 300s`LLM 长回复可能需要较长时间
- `client_max_body_size 10M`:允许上传最大 10MB 文件
### 6.3 防火墙规则
```bash
# 查看当前规则
sudo firewall-cmd --list-ports
# 输出: 22/tcp 80/tcp 443/tcp 3000/tcp
# 添加新端口(如未来部署新项目)
sudo firewall-cmd --permanent --add-port=PORT/tcp
sudo firewall-cmd --reload
```
> **注意**:开放端口还需要在阿里云控制台的**安全组**中同步添加入方向规则,否则外部仍无法访问。
---
## 7. 备份策略
### 7.1 数据库备份
CareerBot 的所有业务数据存储在 SQLite 文件中:
```bash
# 手动备份
ssh ecs "cp ~/apps/CareerBot/careerbot.db ~/apps/CareerBot/careerbot.db.bak.$(date +%Y%m%d)"
# 下载到本地
scp ecs:~/apps/CareerBot/careerbot.db ./careerbot_backup_$(date +%Y%m%d).db
```
### 7.2 Gitea 数据备份
```bash
# Gitea 数据和仓库
ssh ecs "sudo tar czf /tmp/gitea-backup.tar.gz /var/lib/gitea /etc/gitea"
scp ecs:/tmp/gitea-backup.tar.gz ./gitea-backup.tar.gz
```
### 7.3 需要备份的关键文件
| 文件/目录 | 说明 | 重要程度 |
|-----------|------|---------|
| `~/apps/CareerBot/careerbot.db` | CareerBot 全部业务数据 | **极高** |
| `~/apps/CareerBot/uploads/` | 用户上传的文件 | 高 |
| `/var/lib/gitea/` | Gitea 所有仓库和数据 | **极高** |
| `/etc/gitea/app.ini` | Gitea 配置 | 中 |
| `/etc/nginx/sites-available/` | Nginx 站点配置 | 中 |
| `/etc/systemd/system/careerbot.service` | 服务配置 | 低(可重建) |
---
## 8. 故障排查
### 8.1 CareerBot 无法访问
```bash
# 1. 检查服务状态
sudo systemctl status careerbot
# 2. 查看错误日志
sudo journalctl -u careerbot --since "10 min ago"
# 3. 检查端口
ss -tlnp | grep 8000
# 4. 检查 Nginx
sudo nginx -t
sudo systemctl status nginx
# 5. 手动启动测试
cd ~/apps/CareerBot
source venv/bin/activate
uvicorn app.main:app --host 127.0.0.1 --port 8000
```
### 8.2 AI 对话不工作
- 检查管理后台 LLM Config 是否已配置API URL、API Key、Model Name
- 点击 "Test Connection" 测试连通性
- 确认 ECS 能访问外网:`curl https://api.deepseek.com`
### 8.3 Gitea 无法访问
```bash
sudo systemctl status gitea
sudo journalctl -u gitea --since "10 min ago"
# 检查 3000 端口和安全组
```
### 8.4 磁盘/内存不足
```bash
# 查看磁盘
df -h /
# 清理日志
sudo journalctl --vacuum-time=7d
# 查看内存
free -h
# 查看占用最多的进程
ps aux --sort=-rss | head -10
```
---
## 9. 新项目部署指南
后续在同一台 ECS 上部署新项目的标准流程:
1. **Gitea 上创建仓库**`http://39.106.14.107:3000` → New Repository
2. **本地推送代码**
3. **ECS 上克隆并配置**
```bash
cd ~/apps
git clone http://39.106.14.107:3000/ln0422/新项目.git
# 安装依赖、初始化等
```
4. **创建 systemd 服务**(参考 careerbot.service注意更换端口号
5. **添加 Nginx 站点配置**
```bash
sudo vim /etc/nginx/sites-available/新项目.conf
sudo ln -s /etc/nginx/sites-available/新项目.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
6. **如需新端口**:防火墙 + 阿里云安全组同步放行
---
## 10. 安全注意事项
1. **SSH 密钥**:本地私钥 `~/.ssh/id_ed25519` 请妥善保管,丢失需重新生成并更新服务器
2. **Gitea 端口**3000 端口对外开放,建议设置强密码,或后续通过 Nginx 代理并限制访问
3. **数据库文件**`careerbot.db` 不应对外暴露uvicorn 仅监听 127.0.0.1 已确保安全
4. **定期更新**:定期执行 `sudo dnf update -y` 更新系统安全补丁
5. **HTTPS**:后续绑定域名后建议配置 Let's Encrypt SSL 证书

View File

@ -8,6 +8,7 @@ class Settings(BaseSettings):
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
UPLOAD_DIR: str = "./uploads"
BASE_PATH: str = "/careerbot"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}

View File

@ -1,6 +1,7 @@
import os
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from app.config import settings
@ -9,18 +10,30 @@ from app.models import AdminUser, Profile
from app.routers import auth, public, admin, chat
from app.routers.auth import hash_password
BASE = settings.BASE_PATH
app = FastAPI(title="CareerBot", description="Personal Career Showcase with AI Assistant")
# Mount static files and uploads
app.mount("/static", StaticFiles(directory="app/static"), name="static")
# Mount static files and uploads under BASE_PATH
app.mount(f"{BASE}/static", StaticFiles(directory="app/static"), name="static")
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
app.mount(f"{BASE}/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
# Include routers
app.include_router(auth.router)
app.include_router(public.router)
app.include_router(admin.router)
app.include_router(chat.router)
# Include routers with BASE_PATH prefix
app.include_router(auth.router, prefix=BASE)
app.include_router(public.router, prefix=BASE)
app.include_router(admin.router, prefix=BASE)
app.include_router(chat.router, prefix=BASE)
# Set Jinja2 template globals for BASE_PATH
admin.templates.env.globals["base"] = BASE
public.templates.env.globals["base"] = BASE
# Root redirect to CareerBot
@app.get("/")
def root_redirect():
return RedirectResponse(url=f"{BASE}/", status_code=302)
@app.on_event("startup")

View File

@ -9,6 +9,9 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.config import settings
BASE = settings.BASE_PATH
from app.database import get_db
from app.models import (
Profile, Skill, Education, WorkExperience,
@ -95,7 +98,7 @@ async def upload_photo(
if not p:
p = Profile()
db.add(p)
p.photo_url = f"/uploads/{filename}"
p.photo_url = f"{BASE}/uploads/{filename}"
db.commit()
return {"photo_url": p.photo_url}

View File

@ -12,7 +12,7 @@ from app.database import get_db
from app.models import AdminUser, AccessToken
router = APIRouter()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/login", auto_error=False)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.BASE_PATH}/api/admin/login", auto_error=False)
def hash_password(password: str) -> str:

View File

@ -3,9 +3,12 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models import Profile, Skill, Education, WorkExperience, AccessToken
BASE = settings.BASE_PATH
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@ -14,14 +17,14 @@ templates = Jinja2Templates(directory="templates")
def index_page(request: Request, db: Session = Depends(get_db)):
token = request.cookies.get("visitor_token")
if not token:
return RedirectResponse(url="/login", status_code=302)
return RedirectResponse(url=f"{BASE}/login", status_code=302)
# Allow both valid tokens and anonymous
if token != "__anonymous__":
record = db.query(AccessToken).filter(
AccessToken.token == token, AccessToken.is_active == True
).first()
if not record:
return RedirectResponse(url="/login", status_code=302)
return RedirectResponse(url=f"{BASE}/login", status_code=302)
return templates.TemplateResponse(request, "index.html")

View File

@ -1,36 +1,37 @@
// ── Admin Panel JavaScript ──
const BASE_PATH = window.BASE_PATH || '';
function authHeaders() {
return { 'Authorization': 'Bearer ' + localStorage.getItem('admin_token') };
}
async function apiGet(url) {
const resp = await fetch(url, { headers: authHeaders() });
if (resp.status === 401) { window.location.href = '/admin/login'; return null; }
const resp = await fetch(BASE_PATH + url, { headers: authHeaders() });
if (resp.status === 401) { window.location.href = BASE_PATH + '/admin/login'; return null; }
return resp.json();
}
async function apiPost(url, data) {
const resp = await fetch(url, {
const resp = await fetch(BASE_PATH + url, {
method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.status === 401) { window.location.href = '/admin/login'; return null; }
if (resp.status === 401) { window.location.href = BASE_PATH + '/admin/login'; return null; }
return resp.json();
}
async function apiPut(url, data) {
const resp = await fetch(url, {
const resp = await fetch(BASE_PATH + url, {
method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.status === 401) { window.location.href = '/admin/login'; return null; }
if (resp.status === 401) { window.location.href = BASE_PATH + '/admin/login'; return null; }
return resp.json();
}
async function apiDelete(url) {
const resp = await fetch(url, { method: 'DELETE', headers: authHeaders() });
if (resp.status === 401) { window.location.href = '/admin/login'; return null; }
const resp = await fetch(BASE_PATH + url, { method: 'DELETE', headers: authHeaders() });
if (resp.status === 401) { window.location.href = BASE_PATH + '/admin/login'; return null; }
return resp.json();
}
@ -68,7 +69,7 @@ document.addEventListener('DOMContentLoaded', () => {
function adminLogout() {
localStorage.removeItem('admin_token');
window.location.href = '/admin/login';
window.location.href = BASE_PATH + '/admin/login';
}
// ── Dashboard ──
@ -113,10 +114,10 @@ async function uploadPhoto() {
if (!input.files[0]) return;
const formData = new FormData();
formData.append('file', input.files[0]);
const resp = await fetch('/api/admin/profile/photo', {
const resp = await fetch(BASE_PATH + '/api/admin/profile/photo', {
method: 'POST', headers: authHeaders(), body: formData,
});
if (resp.status === 401) { window.location.href = '/admin/login'; return; }
if (resp.status === 401) { window.location.href = BASE_PATH + '/admin/login'; return; }
const data = await resp.json();
if (data.photo_url) {
document.getElementById('photo-preview').innerHTML = `<img src="${data.photo_url}" style="max-width:120px;border-radius:8px;">`;

View File

@ -1,4 +1,5 @@
// ── Chat Widget JavaScript ──
const BASE_PATH = window.BASE_PATH || '';
let chatSessionId = sessionStorage.getItem('chat_session');
if (!chatSessionId) {
@ -66,7 +67,7 @@ document.addEventListener('DOMContentLoaded', () => {
async function loadChatConfig() {
try {
const resp = await fetch('/api/chat/config');
const resp = await fetch(BASE_PATH + '/api/chat/config');
if (resp.ok) {
const data = await resp.json();
maxQuestions = data.max_questions || 10;
@ -96,7 +97,7 @@ async function loadChatConfig() {
async function loadChatHistory() {
try {
const resp = await fetch(`/api/chat/history/${chatSessionId}`);
const resp = await fetch(BASE_PATH + `/api/chat/history/${chatSessionId}`);
if (!resp.ok) return;
const history = await resp.json();
for (const msg of history) {
@ -147,7 +148,7 @@ async function sendMessage() {
formData.append('message', message);
if (file) formData.append('file', file);
const response = await fetch('/api/chat', {
const response = await fetch(BASE_PATH + '/api/chat', {
method: 'POST',
body: formData,
});
@ -243,7 +244,7 @@ async function enterToken() {
const token = prompt('请输入访问令牌 (Access Token):');
if (!token || !token.trim()) return;
try {
const resp = await fetch('/api/verify-token', {
const resp = await fetch(BASE_PATH + '/api/verify-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token.trim() }),

View File

@ -1,4 +1,5 @@
// ── Main Page JavaScript ──
const BASE_PATH = window.BASE_PATH || '';
document.addEventListener('DOMContentLoaded', () => {
loadProfile();
@ -8,9 +9,9 @@ document.addEventListener('DOMContentLoaded', () => {
});
async function apiFetch(url) {
const resp = await fetch(url);
const resp = await fetch(BASE_PATH + url);
if (resp.status === 401) {
window.location.href = '/login';
window.location.href = BASE_PATH + '/login';
return null;
}
return resp.json();

View File

@ -4,22 +4,22 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerBot Admin - Dashboard</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="{{ base }}/static/css/style.css">
</head>
<body>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="logo">CareerBot Admin</div>
<nav>
<a href="/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="{{ base }}/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="{{ base }}/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="{{ base }}/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="{{ base }}/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="{{ base }}/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="{{ base }}/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="{{ base }}/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="{{ base }}/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="{{ base }}/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="#" onclick="adminLogout()"><span class="icon">&#8594;</span> Logout</a>
</nav>
</aside>
@ -47,6 +47,7 @@
</div>
</main>
</div>
<script src="/static/js/admin.js"></script>
<script>window.BASE_PATH = "{{ base }}";</script>
<script src="{{ base }}/static/js/admin.js"></script>
</body>
</html>

View File

@ -4,22 +4,22 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerBot Admin - Education</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="{{ base }}/static/css/style.css">
</head>
<body>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="logo">CareerBot Admin</div>
<nav>
<a href="/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="{{ base }}/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="{{ base }}/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="{{ base }}/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="{{ base }}/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="{{ base }}/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="{{ base }}/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="{{ base }}/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="{{ base }}/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="{{ base }}/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="#" onclick="adminLogout()"><span class="icon">&#8594;</span> Logout</a>
</nav>
</aside>
@ -60,6 +60,7 @@
</div>
</div>
<script src="/static/js/admin.js"></script>
<script>window.BASE_PATH = "{{ base }}";</script>
<script src="{{ base }}/static/js/admin.js"></script>
</body>
</html>

View File

@ -4,22 +4,22 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerBot Admin - Experience</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="{{ base }}/static/css/style.css">
</head>
<body>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="logo">CareerBot Admin</div>
<nav>
<a href="/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="{{ base }}/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="{{ base }}/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="{{ base }}/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="{{ base }}/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="{{ base }}/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="{{ base }}/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="{{ base }}/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="{{ base }}/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="{{ base }}/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="#" onclick="adminLogout()"><span class="icon">&#8594;</span> Logout</a>
</nav>
</aside>
@ -61,6 +61,7 @@
</div>
</div>
<script src="/static/js/admin.js"></script>
<script>window.BASE_PATH = "{{ base }}";</script>
<script src="{{ base }}/static/js/admin.js"></script>
</body>
</html>

View File

@ -4,22 +4,22 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerBot Admin - LLM Configuration</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="{{ base }}/static/css/style.css">
</head>
<body>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="logo">CareerBot Admin</div>
<nav>
<a href="/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="{{ base }}/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="{{ base }}/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="{{ base }}/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="{{ base }}/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="{{ base }}/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="{{ base }}/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="{{ base }}/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="{{ base }}/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="{{ base }}/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="#" onclick="adminLogout()"><span class="icon">&#8594;</span> Logout</a>
</nav>
</aside>
@ -76,6 +76,7 @@
</main>
</div>
<script src="/static/js/admin.js"></script>
<script>window.BASE_PATH = "{{ base }}";</script>
<script src="{{ base }}/static/js/admin.js"></script>
</body>
</html>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerBot Admin - Login</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="{{ base }}/static/css/style.css">
</head>
<body>
<div class="login-container">
@ -18,6 +18,7 @@
</div>
</div>
<script>
const BASE = "{{ base }}";
document.getElementById('password').addEventListener('keydown', e => {
if (e.key === 'Enter') doLogin();
});
@ -35,7 +36,7 @@
}
try {
const resp = await fetch('/api/admin/login', {
const resp = await fetch(BASE + '/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
@ -43,7 +44,7 @@
if (resp.ok) {
const data = await resp.json();
localStorage.setItem('admin_token', data.access_token);
window.location.href = '/admin/dashboard';
window.location.href = BASE + '/admin/dashboard';
} else {
errEl.textContent = 'Invalid email or password';
errEl.style.display = 'block';

View File

@ -4,22 +4,22 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerBot Admin - Messages</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="{{ base }}/static/css/style.css">
</head>
<body>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="logo">CareerBot Admin</div>
<nav>
<a href="/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="{{ base }}/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="{{ base }}/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="{{ base }}/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="{{ base }}/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="{{ base }}/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="{{ base }}/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="{{ base }}/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="{{ base }}/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="{{ base }}/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="#" onclick="adminLogout()"><span class="icon">&#8594;</span> Logout</a>
</nav>
</aside>
@ -37,6 +37,7 @@
</main>
</div>
<script src="/static/js/admin.js"></script>
<script>window.BASE_PATH = "{{ base }}";</script>
<script src="{{ base }}/static/js/admin.js"></script>
</body>
</html>

View File

@ -4,22 +4,22 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerBot Admin - Profile</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="{{ base }}/static/css/style.css">
</head>
<body>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="logo">CareerBot Admin</div>
<nav>
<a href="/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="{{ base }}/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="{{ base }}/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="{{ base }}/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="{{ base }}/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="{{ base }}/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="{{ base }}/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="{{ base }}/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="{{ base }}/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="{{ base }}/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="#" onclick="adminLogout()"><span class="icon">&#8594;</span> Logout</a>
</nav>
</aside>
@ -75,6 +75,7 @@
</div>
</main>
</div>
<script src="/static/js/admin.js"></script>
<script>window.BASE_PATH = "{{ base }}";</script>
<script src="{{ base }}/static/js/admin.js"></script>
</body>
</html>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerBot Admin - Resume Generator</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="{{ base }}/static/css/style.css">
<style>
.resume-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.resume-output {
@ -28,15 +28,15 @@
<aside class="admin-sidebar">
<div class="logo">CareerBot Admin</div>
<nav>
<a href="/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="{{ base }}/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="{{ base }}/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="{{ base }}/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="{{ base }}/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="{{ base }}/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="{{ base }}/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="{{ base }}/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="{{ base }}/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="{{ base }}/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="#" onclick="adminLogout()"><span class="icon">&#8594;</span> Logout</a>
</nav>
</aside>
@ -78,8 +78,10 @@
</main>
</div>
<script src="/static/js/admin.js"></script>
<script>window.BASE_PATH = "{{ base }}";</script>
<script src="{{ base }}/static/js/admin.js"></script>
<script>
const BASE = window.BASE_PATH || '';
let resumeMarkdown = '';
async function generateResume() {
@ -105,13 +107,13 @@ async function generateResume() {
if (file) formData.append('file', file);
try {
const resp = await fetch('/api/admin/resume/generate', {
const resp = await fetch(BASE + '/api/admin/resume/generate', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('admin_token') },
body: formData,
});
if (resp.status === 401) { window.location.href = '/admin/login'; return; }
if (resp.status === 401) { window.location.href = BASE + '/admin/login'; return; }
output.innerHTML = '';
const reader = resp.body.getReader();
@ -159,7 +161,7 @@ async function downloadResume() {
formData.append('content', resumeMarkdown);
try {
const resp = await fetch('/api/admin/resume/download', {
const resp = await fetch(BASE + '/api/admin/resume/download', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('admin_token') },
body: formData,

View File

@ -4,22 +4,22 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerBot Admin - Skills</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="{{ base }}/static/css/style.css">
</head>
<body>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="logo">CareerBot Admin</div>
<nav>
<a href="/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="{{ base }}/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="{{ base }}/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="{{ base }}/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="{{ base }}/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="{{ base }}/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="{{ base }}/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="{{ base }}/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="{{ base }}/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="{{ base }}/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="#" onclick="adminLogout()"><span class="icon">&#8594;</span> Logout</a>
</nav>
</aside>
@ -52,6 +52,7 @@
</div>
</div>
<script src="/static/js/admin.js"></script>
<script>window.BASE_PATH = "{{ base }}";</script>
<script src="{{ base }}/static/js/admin.js"></script>
</body>
</html>

View File

@ -4,22 +4,22 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerBot Admin - Access Tokens</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="{{ base }}/static/css/style.css">
</head>
<body>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="logo">CareerBot Admin</div>
<nav>
<a href="/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="{{ base }}/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="{{ base }}/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="{{ base }}/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="{{ base }}/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="{{ base }}/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="{{ base }}/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="{{ base }}/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="{{ base }}/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="{{ base }}/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="#" onclick="adminLogout()"><span class="icon">&#8594;</span> Logout</a>
</nav>
</aside>
@ -74,6 +74,7 @@
</div>
</div>
<script src="/static/js/admin.js"></script>
<script>window.BASE_PATH = "{{ base }}";</script>
<script src="{{ base }}/static/js/admin.js"></script>
</body>
</html>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerBot</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="{{ base }}/static/css/style.css">
</head>
<body>
@ -74,7 +74,8 @@
</div>
</div>
<script src="/static/js/main.js"></script>
<script src="/static/js/chat.js"></script>
<script>window.BASE_PATH = "{{ base }}";</script>
<script src="{{ base }}/static/js/main.js"></script>
<script src="{{ base }}/static/js/chat.js"></script>
</body>
</html>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerBot - Access</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="{{ base }}/static/css/style.css">
</head>
<body>
<div class="login-container">
@ -21,6 +21,7 @@
</div>
</div>
<script>
const BASE = "{{ base }}";
document.getElementById('token-input').addEventListener('keydown', e => {
if (e.key === 'Enter') verifyToken();
});
@ -31,13 +32,13 @@
const errEl = document.getElementById('error-msg');
errEl.style.display = 'none';
try {
const resp = await fetch('/api/verify-token', {
const resp = await fetch(BASE + '/api/verify-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (resp.ok) {
window.location.href = '/';
window.location.href = BASE + '/';
} else {
errEl.textContent = '无效或已过期的访问令牌';
errEl.style.display = 'block';
@ -50,9 +51,9 @@
async function enterAnonymous() {
try {
const resp = await fetch('/api/anonymous-entry', { method: 'POST' });
const resp = await fetch(BASE + '/api/anonymous-entry', { method: 'POST' });
if (resp.ok) {
window.location.href = '/';
window.location.href = BASE + '/';
}
} catch (e) {
document.getElementById('error-msg').textContent = '网络错误,请稍后重试';