mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat: refine login and document updates
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
# 在线更新方案
|
||||
|
||||
本文档定义 Codex Proxy RS 的应用包在线更新方案。目标是支持管理端检查更新,并在管理员点击按钮后完成一键更新。
|
||||
|
||||
## 目标
|
||||
|
||||
- 管理端可以显示当前版本、最新版本、更新说明和部署模式。
|
||||
- 管理端可以手动检查更新。
|
||||
- 管理端可以触发一键更新。
|
||||
- Docker 部署是主要场景。
|
||||
- 裸机二进制部署可以作为后续兼容场景。
|
||||
- 更新过程必须保留数据卷、配置和日志,不覆盖运行数据。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不在运行中的 Rust 进程里热加载新代码。
|
||||
- 不在 Docker 容器内部直接替换当前容器的二进制作为主方案。
|
||||
- 不默认把宿主机 Docker socket 挂进主应用容器。
|
||||
- 不在没有迁移机制前执行高风险数据库结构变更。
|
||||
|
||||
## 部署模式
|
||||
|
||||
### Docker 模式
|
||||
|
||||
Docker 模式下,一键更新应更新镜像并重建容器:
|
||||
|
||||
1. 管理端调用后端更新接口。
|
||||
2. 后端完成管理员鉴权和并发锁控制。
|
||||
3. 后端调用 updater。
|
||||
4. updater 在宿主机或 sidecar 中执行镜像更新。
|
||||
5. updater 拉取新镜像。
|
||||
6. updater 重新创建 `codex-proxy-rs` 服务。
|
||||
7. 数据卷继续挂载,SQLite、日志和配置保持不变。
|
||||
|
||||
推荐结构:
|
||||
|
||||
```text
|
||||
browser
|
||||
-> codex-proxy-rs admin api
|
||||
-> updater sidecar / host updater
|
||||
-> docker pull
|
||||
-> docker compose up -d codex-proxy-rs
|
||||
```
|
||||
|
||||
主应用容器不应直接拥有 Docker daemon 权限。updater 可以是一个独立容器,也可以是宿主机 systemd 服务。updater 只暴露给同一 Docker network 或 `127.0.0.1`,并使用共享 token 鉴权。
|
||||
|
||||
当前 `docker-compose.yml` 使用 `build: .`,这种模式无法可靠在线更新到远端版本。要支持 Docker 一键更新,compose 应改为使用远端镜像:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
codex-proxy-rs:
|
||||
image: ghcr.io/<owner>/codex-proxy-rs:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
environment:
|
||||
CPR_DEPLOYMENT_MODE: docker
|
||||
CPR_VERSION: 0.1.0
|
||||
CPR_UPDATE_CHANNEL: stable
|
||||
CPR_UPDATER_URL: http://codex-proxy-rs-updater:8090
|
||||
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN}
|
||||
```
|
||||
|
||||
如果继续使用 `build: .`,管理端只能检查上游是否有新版本,不能安全地完成远端包更新。
|
||||
|
||||
### 二进制模式
|
||||
|
||||
二进制模式可以采用 Sub2API 类似方案:
|
||||
|
||||
1. 请求 GitHub Releases latest。
|
||||
2. 匹配当前 OS/arch 的压缩包。
|
||||
3. 下载 release asset。
|
||||
4. 校验 `checksums.txt`。
|
||||
5. 解压新二进制和 `web/dist`。
|
||||
6. 备份旧二进制和旧 `web/dist`。
|
||||
7. 原子替换。
|
||||
8. 返回 `needRestart: true`。
|
||||
9. 管理端触发重启,进程退出后由 systemd/supervisor 拉起。
|
||||
|
||||
二进制模式不是当前 Docker 部署的主路径,但可以复用检查更新、版本展示、更新锁和前端 UI。
|
||||
|
||||
## 版本与发布产物
|
||||
|
||||
发布时应同时产出 Docker 镜像和 release 元数据:
|
||||
|
||||
- Git tag:`v0.1.0`
|
||||
- Docker image:`ghcr.io/<owner>/codex-proxy-rs:0.1.0`
|
||||
- Docker image:`ghcr.io/<owner>/codex-proxy-rs:latest`
|
||||
- Release archive:`codex-proxy-rs_0.1.0_linux_x86_64.tar.gz`
|
||||
- Checksum:`checksums.txt`
|
||||
|
||||
后端应在构建时注入版本信息:
|
||||
|
||||
- `CPR_VERSION`
|
||||
- `CPR_GIT_SHA`
|
||||
- `CPR_BUILD_TIME`
|
||||
- `CPR_DEPLOYMENT_MODE`
|
||||
- `CPR_IMAGE_REPOSITORY`
|
||||
- `CPR_IMAGE_TAG`
|
||||
|
||||
Docker 模式下,检查更新可以优先使用 GitHub Releases 的 tag 做语义版本比较;真正更新时由 updater 拉取指定镜像 tag 或 digest。
|
||||
|
||||
## 后端 API
|
||||
|
||||
建议新增管理端接口:
|
||||
|
||||
```http
|
||||
GET /api/admin/system/version
|
||||
GET /api/admin/system/check-updates?force=true
|
||||
POST /api/admin/system/update
|
||||
POST /api/admin/system/rollback
|
||||
POST /api/admin/system/restart
|
||||
```
|
||||
|
||||
所有接口必须要求管理员会话或管理员 API Key。
|
||||
|
||||
`GET /api/admin/system/version` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"gitSha": "abc1234",
|
||||
"buildTime": "2026-07-01T00:00:00Z",
|
||||
"deploymentMode": "docker",
|
||||
"image": "ghcr.io/<owner>/codex-proxy-rs:0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
`GET /api/admin/system/check-updates` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"currentVersion": "0.1.0",
|
||||
"latestVersion": "0.2.0",
|
||||
"hasUpdate": true,
|
||||
"deploymentMode": "docker",
|
||||
"releaseUrl": "https://github.com/<owner>/codex-proxy-rs/releases/tag/v0.2.0",
|
||||
"notes": "...",
|
||||
"cached": false,
|
||||
"updateSupported": true,
|
||||
"unsupportedReason": null
|
||||
}
|
||||
```
|
||||
|
||||
`POST /api/admin/system/update` 在 Docker 模式下返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"operationId": "sysop-...",
|
||||
"deploymentMode": "docker",
|
||||
"message": "Update started",
|
||||
"needReconnect": true
|
||||
}
|
||||
```
|
||||
|
||||
如果当前部署不支持一键更新,应返回明确原因:
|
||||
|
||||
```json
|
||||
{
|
||||
"updateSupported": false,
|
||||
"unsupportedReason": "Docker one-click update requires CPR_UPDATER_URL and a remote image deployment"
|
||||
}
|
||||
```
|
||||
|
||||
## Updater 服务
|
||||
|
||||
Docker 一键更新的关键是 updater。updater 负责真正操作 Docker:
|
||||
|
||||
```http
|
||||
POST /update
|
||||
Authorization: Bearer <CPR_UPDATER_TOKEN>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"service": "codex-proxy-rs",
|
||||
"image": "ghcr.io/<owner>/codex-proxy-rs:0.2.0",
|
||||
"composeProject": "codex-proxy-rs"
|
||||
}
|
||||
```
|
||||
|
||||
updater 行为:
|
||||
|
||||
1. 校验 token。
|
||||
2. 校验 image 仓库白名单。
|
||||
3. 执行 `docker pull ghcr.io/<owner>/codex-proxy-rs:0.2.0`。
|
||||
4. 执行 `docker compose up -d codex-proxy-rs` 或 Docker API 等价操作。
|
||||
5. 记录当前镜像 digest,支持失败诊断。
|
||||
|
||||
updater 不应暴露到公网。若使用 sidecar 容器,建议挂载 Docker socket 到 updater,而不是主应用:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
codex-proxy-rs-updater:
|
||||
image: ghcr.io/<owner>/codex-proxy-rs-updater:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./docker-compose.yml:/workspace/docker-compose.yml:ro
|
||||
environment:
|
||||
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN}
|
||||
CPR_ALLOWED_IMAGE_REPOSITORY: ghcr.io/<owner>/codex-proxy-rs
|
||||
networks:
|
||||
- default
|
||||
```
|
||||
|
||||
如果后续不想维护自研 updater,可以评估 Watchtower 的 HTTP API 模式,但仍需要后端把“检查更新、按钮触发、操作结果展示”封装到管理端。
|
||||
|
||||
## 安全约束
|
||||
|
||||
- 所有更新接口必须走管理员鉴权。
|
||||
- 更新任务必须有全局锁,避免并发更新。
|
||||
- GitHub release 下载 URL 必须限制为可信 HTTPS host。
|
||||
- Docker image 仓库必须白名单校验。
|
||||
- 下载包必须限制大小。
|
||||
- 二进制包必须校验 checksum。
|
||||
- Docker 更新必须保留 `data`、`logs`、`config.yaml` 挂载。
|
||||
- 不允许通过管理端传入任意 shell 命令。
|
||||
- updater token 只能通过环境变量或密钥文件配置,不能写入前端。
|
||||
|
||||
## 前端交互
|
||||
|
||||
管理端系统页建议包含:
|
||||
|
||||
- 当前版本。
|
||||
- 部署模式。
|
||||
- 最新版本。
|
||||
- 更新说明。
|
||||
- 检查更新按钮。
|
||||
- 一键更新按钮。
|
||||
- 更新中状态。
|
||||
- 更新后重连提示。
|
||||
- 不支持一键更新时的明确原因。
|
||||
|
||||
Docker 更新会导致容器重建,前端连接会短暂断开。UI 应在触发更新后轮询 `/api/admin/system/version`,服务恢复后提示已更新。
|
||||
|
||||
## 数据库迁移约束
|
||||
|
||||
当前项目启动时执行 `schema.sql`,主要依赖 `create table if not exists`。在加入在线更新前,建议补充迁移版本表,例如:
|
||||
|
||||
```sql
|
||||
create table if not exists schema_migrations (
|
||||
version integer primary key,
|
||||
applied_at text not null
|
||||
);
|
||||
```
|
||||
|
||||
发布新版本时:
|
||||
|
||||
- 向前兼容的字段新增可以自动迁移。
|
||||
- 删除字段、重命名字段、不可逆数据迁移需要显式 release note。
|
||||
- 一键更新前可以在检查结果里标注 `requiresBackup: true`。
|
||||
|
||||
## 推荐落地顺序
|
||||
|
||||
1. 建立 GitHub Release 和 GHCR 镜像发布流程。
|
||||
2. 为后端注入版本、commit、构建时间和部署模式。
|
||||
3. 增加系统版本与检查更新 API。
|
||||
4. 增加管理端系统更新页。
|
||||
5. 增加 Docker updater sidecar。
|
||||
6. 接入一键更新按钮。
|
||||
7. 补充数据库迁移机制。
|
||||
8. 再考虑二进制模式的原子替换和 rollback。
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
<script setup lang="ts">
|
||||
type RouteCluster = {
|
||||
id: string
|
||||
labels: readonly string[]
|
||||
className: string
|
||||
}
|
||||
|
||||
const ingressProtocols = ['HTTP JSON', 'HTTP SSE', 'WS JSON', 'WS SSE'] as const
|
||||
const upstreamProtocols = ['RESPONSES', 'CHAT', 'REALTIME', 'TRACE'] as const
|
||||
const routeClusters: readonly RouteCluster[] = [
|
||||
{ id: 'edge', labels: ['edge', 'cache', 'keys'], className: 'login-route-cluster--edge' },
|
||||
{ id: 'retry', labels: ['retry', 'trace', 'sse'], className: 'login-route-cluster--retry' },
|
||||
{ id: 'audit', labels: ['audit', 'events', 'logs'], className: 'login-route-cluster--audit' },
|
||||
{ id: 'model', labels: ['model', 'tools', 'quota'], className: 'login-route-cluster--model' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-none absolute inset-0 z-[-1] overflow-hidden" aria-hidden="true">
|
||||
<div class="login-bg-base" />
|
||||
<div class="login-bg-grid" />
|
||||
<div class="login-bg-striations" />
|
||||
<div class="login-bg-grain" />
|
||||
|
||||
<span
|
||||
class="login-watermark absolute top-[9.63%] left-[6.15%] whitespace-nowrap font-mono text-[13px] leading-none font-medium text-(--login-watermark)"
|
||||
>
|
||||
CODEX_PROXY_RS // ROUTE_TOPOLOGY // AUTH_GATEWAY
|
||||
</span>
|
||||
|
||||
<div class="login-protocol-stack login-protocol-stack--ingress">
|
||||
<span
|
||||
v-for="protocol in ingressProtocols"
|
||||
:key="protocol"
|
||||
class="inline-flex w-fit items-center gap-2 rounded-sm bg-(--login-stack-bg) px-2.5 py-1.75 opacity-(--login-stack-item-opacity) before:size-1.5 before:shrink-0 before:rounded-full before:bg-(--login-stack-dot) before:content-['']"
|
||||
>
|
||||
{{ protocol }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="login-protocol-stack login-protocol-stack--upstream">
|
||||
<span
|
||||
v-for="protocol in upstreamProtocols"
|
||||
:key="protocol"
|
||||
class="inline-flex w-fit items-center gap-2 rounded-sm bg-(--login-stack-bg) px-2.5 py-1.75 opacity-(--login-stack-item-opacity) before:size-1.5 before:shrink-0 before:rounded-full before:bg-(--login-stack-dot) before:content-['']"
|
||||
>
|
||||
{{ protocol }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
class="login-topology"
|
||||
viewBox="0 0 1920 1080"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g class="login-route-lines">
|
||||
<path
|
||||
class="login-route-line--bundle"
|
||||
d="M0 32c132 0 166 48 270 84 94 32 202 32 370 30 172 0 276-8 410-28 148-22 229-74 329-76m-1379 50c112 0 170 12 274 36 96 22 222 22 366 18 178 0 286 4 426 0 148-4 262-30 313-32m-1379 42c132 0 184-12 294-14 126-2 216 4 346 4 176 0 282 22 426 38 166 20 280 36 313 36m-1379 4c160 0 202-34 316-60 108-24 204-18 324-18 140 0 262 12 378 26 102 12 216 22 361 16"
|
||||
transform="translate(286 382)"
|
||||
/>
|
||||
<path
|
||||
class="login-route-line--stream"
|
||||
d="M1030 48c-170-30-320-20-478 30-160 52-292 48-552 10m870-26c-150 10-242 42-358 64-126 24-260 28-440 16"
|
||||
transform="translate(510 316)"
|
||||
/>
|
||||
<path
|
||||
class="login-route-line--audit"
|
||||
d="M980 24c-196 54-352 66-512 34-138-28-294-16-468 30m690-24c-134 26-262 30-380 12-106-16-194-8-276 20"
|
||||
transform="translate(440 704)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g class="login-particle-tracks">
|
||||
<path
|
||||
id="login-track-a"
|
||||
d="M286 414 C418 414 452 462 556 498 C650 530 758 530 926 528 C1098 528 1202 520 1336 500 C1484 478 1565 426 1665 424"
|
||||
/>
|
||||
<path
|
||||
id="login-track-b"
|
||||
d="M286 474 C398 474 456 486 560 510 C656 532 782 532 926 528 C1104 528 1212 532 1352 528 C1500 524 1614 498 1665 496"
|
||||
/>
|
||||
<path
|
||||
id="login-track-c"
|
||||
d="M286 538 C418 538 470 526 580 524 C706 522 796 528 926 528 C1102 528 1208 550 1352 566 C1518 586 1632 602 1665 602"
|
||||
/>
|
||||
<path
|
||||
id="login-track-d"
|
||||
d="M286 606 C446 606 488 572 602 546 C710 522 806 528 926 528 C1066 528 1188 540 1304 554 C1406 566 1520 576 1665 570"
|
||||
/>
|
||||
<path
|
||||
id="login-track-return"
|
||||
d="M1540 364 C1370 334 1220 344 1062 394 C902 446 770 442 510 404"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g class="login-particle">
|
||||
<circle r="3.2" />
|
||||
<animateMotion dur="7.2s" repeatCount="indefinite" rotate="auto">
|
||||
<mpath href="#login-track-a" />
|
||||
</animateMotion>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;1;0"
|
||||
keyTimes="0;0.12;0.82;1"
|
||||
dur="7.2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
<g class="login-particle">
|
||||
<circle r="2.8" />
|
||||
<animateMotion dur="8.8s" begin="-2.1s" repeatCount="indefinite" rotate="auto">
|
||||
<mpath href="#login-track-b" />
|
||||
</animateMotion>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0.82;0.82;0"
|
||||
keyTimes="0;0.14;0.82;1"
|
||||
dur="8.8s"
|
||||
begin="-2.1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
<g class="login-particle">
|
||||
<circle r="2.6" />
|
||||
<animateMotion dur="9.4s" begin="-4.4s" repeatCount="indefinite" rotate="auto">
|
||||
<mpath href="#login-track-c" />
|
||||
</animateMotion>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0.78;0.78;0"
|
||||
keyTimes="0;0.13;0.84;1"
|
||||
dur="9.4s"
|
||||
begin="-4.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
<g class="login-particle">
|
||||
<circle r="2.5" />
|
||||
<animateMotion dur="10.8s" begin="-5.2s" repeatCount="indefinite" rotate="auto">
|
||||
<mpath href="#login-track-d" />
|
||||
</animateMotion>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0.7;0.7;0"
|
||||
keyTimes="0;0.16;0.8;1"
|
||||
dur="10.8s"
|
||||
begin="-5.2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
<g class="login-particle">
|
||||
<circle r="2.3" />
|
||||
<animateMotion dur="11.4s" begin="-1.8s" repeatCount="indefinite" rotate="auto">
|
||||
<mpath href="#login-track-return" />
|
||||
</animateMotion>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0.55;0.55;0"
|
||||
keyTimes="0;0.16;0.78;1"
|
||||
dur="11.4s"
|
||||
begin="-1.8s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g class="login-packets login-packets--semantic">
|
||||
<circle cx="420" cy="410" r="3" />
|
||||
<circle cx="626" cy="524" r="3" />
|
||||
<circle cx="812" cy="524" r="3" />
|
||||
<circle cx="1262" cy="502" r="3" />
|
||||
<circle cx="1540" cy="422" r="3" />
|
||||
<circle cx="614" cy="459" r="2.5" />
|
||||
<circle cx="780" cy="528" r="2.5" />
|
||||
<circle cx="982" cy="528" r="2.5" />
|
||||
<circle cx="1390" cy="496" r="2.5" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
v-for="cluster in routeClusters"
|
||||
:key="cluster.id"
|
||||
class="login-route-cluster absolute grid gap-1.5 font-mono text-[10px] leading-none font-medium text-(--login-cluster-text)"
|
||||
:class="cluster.className"
|
||||
>
|
||||
<span
|
||||
v-for="label in cluster.labels"
|
||||
:key="label"
|
||||
class="inline-flex w-fit items-center gap-2 rounded-sm bg-(--login-cluster-bg) px-2 py-1"
|
||||
>
|
||||
<i
|
||||
class="inline-block h-0.5 w-4.5 rounded-xs bg-(--login-cluster-pulse)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-bg-base,
|
||||
.login-bg-base::after,
|
||||
.login-bg-grid,
|
||||
.login-bg-striations,
|
||||
.login-bg-grain,
|
||||
.login-topology {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.login-bg-base {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 59% 48% at 55% 48%,
|
||||
var(--login-base-a) 0%,
|
||||
var(--login-base-b) 50%,
|
||||
var(--login-base-c) 100%
|
||||
),
|
||||
var(--login-base-c);
|
||||
}
|
||||
|
||||
.login-bg-base::after {
|
||||
content: '';
|
||||
background: radial-gradient(
|
||||
ellipse 52.5% 44% at 54% 47%,
|
||||
transparent 0%,
|
||||
var(--login-edge-mid) 72%,
|
||||
var(--login-edge-end) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.login-bg-grid {
|
||||
background:
|
||||
repeating-linear-gradient(90deg, var(--login-grid) 0 1px, transparent 1px 80px),
|
||||
repeating-linear-gradient(180deg, var(--login-grid) 0 1px, transparent 1px 80px);
|
||||
}
|
||||
|
||||
.login-bg-striations {
|
||||
background:
|
||||
linear-gradient(var(--login-striation), var(--login-striation)) 0 126px / 100% 1px no-repeat,
|
||||
linear-gradient(var(--login-striation), var(--login-striation)) 0 214px / 100% 1px no-repeat,
|
||||
linear-gradient(var(--login-striation), var(--login-striation)) 0 338px / 100% 1px no-repeat,
|
||||
linear-gradient(var(--login-striation), var(--login-striation)) 0 462px / 100% 1px no-repeat,
|
||||
linear-gradient(var(--login-striation), var(--login-striation)) 0 586px / 100% 1px no-repeat,
|
||||
linear-gradient(var(--login-striation), var(--login-striation)) 0 714px / 100% 1px no-repeat,
|
||||
linear-gradient(var(--login-striation), var(--login-striation)) 0 846px / 100% 1px no-repeat,
|
||||
linear-gradient(var(--login-striation), var(--login-striation)) 0 982px / 100% 1px no-repeat;
|
||||
}
|
||||
|
||||
.login-bg-grain {
|
||||
opacity: 0.68;
|
||||
background-image:
|
||||
linear-gradient(var(--login-grain), var(--login-grain)) 139px 109px / 3px 2px no-repeat,
|
||||
linear-gradient(var(--login-grain-alt-a), var(--login-grain-alt-a)) 288px 192px / 1px 1px
|
||||
no-repeat,
|
||||
linear-gradient(var(--login-grain-alt-b), var(--login-grain-alt-b)) 437px 275px / 1px 1px
|
||||
no-repeat,
|
||||
linear-gradient(var(--login-grain), var(--login-grain)) 586px 358px / 2px 1px no-repeat,
|
||||
linear-gradient(var(--login-grain-alt-a), var(--login-grain-alt-a)) 735px 441px / 1px 2px
|
||||
no-repeat,
|
||||
linear-gradient(var(--login-grain-alt-b), var(--login-grain-alt-b)) 884px 524px / 3px 1px
|
||||
no-repeat,
|
||||
linear-gradient(var(--login-grain), var(--login-grain)) 1033px 607px / 2px 1px no-repeat,
|
||||
linear-gradient(var(--login-grain-alt-a), var(--login-grain-alt-a)) 1182px 690px / 1px 1px
|
||||
no-repeat,
|
||||
linear-gradient(var(--login-grain-alt-b), var(--login-grain-alt-b)) 1331px 773px / 1px 2px
|
||||
no-repeat,
|
||||
linear-gradient(var(--login-grain), var(--login-grain)) 1480px 856px / 2px 1px no-repeat,
|
||||
linear-gradient(var(--login-grain-alt-a), var(--login-grain-alt-a)) 1629px 939px / 3px 1px
|
||||
no-repeat,
|
||||
linear-gradient(var(--login-grain-alt-b), var(--login-grain-alt-b)) 1778px 67px / 1px 1px
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
.login-protocol-stack {
|
||||
position: absolute;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: 156px;
|
||||
color: var(--login-stack-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.login-protocol-stack--ingress {
|
||||
top: 36.1111%;
|
||||
left: 6.5625%;
|
||||
--login-stack-item-opacity: var(--login-stack-opacity);
|
||||
}
|
||||
|
||||
.login-protocol-stack--upstream {
|
||||
top: 36.1111%;
|
||||
right: 3.4375%;
|
||||
--login-stack-item-opacity: var(--login-stack-upstream-opacity);
|
||||
}
|
||||
|
||||
.login-topology {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.login-topology path,
|
||||
.login-topology ellipse,
|
||||
.login-topology circle {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.login-route-lines path {
|
||||
fill: transparent;
|
||||
stroke-width: 1;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.login-route-line--bundle {
|
||||
stroke: var(--login-route-bundle);
|
||||
}
|
||||
|
||||
.login-route-line--stream {
|
||||
stroke: var(--login-route-stream);
|
||||
}
|
||||
|
||||
.login-route-line--audit {
|
||||
stroke: var(--login-route-audit);
|
||||
}
|
||||
|
||||
.login-particle-tracks path {
|
||||
fill: transparent;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.login-particle {
|
||||
opacity: 0;
|
||||
filter: drop-shadow(0 0 8px var(--login-particle-glow));
|
||||
}
|
||||
|
||||
.login-particle circle {
|
||||
fill: var(--login-particle);
|
||||
}
|
||||
|
||||
.login-packets--semantic circle {
|
||||
fill: var(--login-semantic);
|
||||
}
|
||||
|
||||
.login-route-cluster--edge {
|
||||
top: 28.2407%;
|
||||
left: 23.4375%;
|
||||
}
|
||||
|
||||
.login-route-cluster--retry {
|
||||
top: 26.2963%;
|
||||
left: 63.0208%;
|
||||
}
|
||||
|
||||
.login-route-cluster--audit {
|
||||
top: 69.4444%;
|
||||
left: 23.6979%;
|
||||
}
|
||||
|
||||
.login-route-cluster--model {
|
||||
top: 68.7037%;
|
||||
left: 66.6667%;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.login-watermark,
|
||||
.login-protocol-stack,
|
||||
.login-route-cluster {
|
||||
opacity: 0.48;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-particle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,340 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight, Cat, CircleAlert, Eye, EyeOff, KeyRound, Mail, Moon, Sun } from '@lucide/vue'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
|
||||
type ThemeName = 'light' | 'dark'
|
||||
type PasswordInputType = 'password' | 'text'
|
||||
|
||||
const props = defineProps<{
|
||||
error?: string | null
|
||||
loading: boolean
|
||||
submitDisabled: boolean
|
||||
effectiveTheme: ThemeName
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: []
|
||||
toggleTheme: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const username = defineModel<string>('username', { required: true })
|
||||
const password = defineModel<string>('password', { required: true })
|
||||
const isPasswordVisible = shallowRef(false)
|
||||
|
||||
const passwordType = computed<PasswordInputType>(() =>
|
||||
isPasswordVisible.value ? 'text' : 'password',
|
||||
)
|
||||
const passwordToggleLabel = computed<string>(() =>
|
||||
isPasswordVisible.value ? '隐藏密码' : '显示密码',
|
||||
)
|
||||
const submitLabel = computed<string>(() => (props.loading ? '正在进入...' : '进入控制台'))
|
||||
const themeToggleLabel = computed<string>(() =>
|
||||
props.effectiveTheme === 'dark' ? '切换浅色模式' : '切换暗黑模式',
|
||||
)
|
||||
const themeToggleClasses = computed<Record<string, boolean>>(() => ({
|
||||
'is-dark': props.effectiveTheme === 'dark',
|
||||
}))
|
||||
|
||||
function togglePasswordVisible(): void {
|
||||
isPasswordVisible.value = !isPasswordVisible.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard
|
||||
as="form"
|
||||
:padded="false"
|
||||
variant="elevated"
|
||||
class="login-form relative grid min-h-129.25 w-[min(450px,100%)] gap-4 rounded-lg px-7.5 pt-6.5 pb-6 max-[560px]:min-h-auto max-[560px]:gap-4 max-[560px]:p-5.5"
|
||||
@submit.prevent="emit('submit')"
|
||||
>
|
||||
<div class="login-form-line" aria-hidden="true" />
|
||||
|
||||
<header class="flex min-w-0 items-center justify-between gap-4.5 max-[560px]:gap-3.5">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="login-logo">
|
||||
<Cat :size="20" :stroke-width="2.1" />
|
||||
</span>
|
||||
<span class="grid min-w-0 gap-1">
|
||||
<strong
|
||||
class="text-[17px] leading-[1.12] font-semibold text-(--login-brand-title) max-[560px]:text-[15px]"
|
||||
>
|
||||
Codex Proxy RS
|
||||
</strong>
|
||||
<span
|
||||
class="font-mono text-[10px] leading-[1.2] font-normal text-(--login-brand-caption)"
|
||||
>
|
||||
ADMIN REALM
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="login-theme-toggle"
|
||||
:class="themeToggleClasses"
|
||||
type="button"
|
||||
:aria-label="themeToggleLabel"
|
||||
:title="themeToggleLabel"
|
||||
@click="emit('toggleTheme', $event)"
|
||||
>
|
||||
<Sun :size="16" />
|
||||
<span class="login-theme-knob" aria-hidden="true" />
|
||||
<Moon :size="16" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="grid min-w-0 gap-2.5" aria-labelledby="login-title">
|
||||
<h1
|
||||
id="login-title"
|
||||
class="m-0 text-[34px] leading-[1.02] font-semibold text-(--login-title) max-[560px]:text-[30px]"
|
||||
>
|
||||
控制台登录
|
||||
</h1>
|
||||
<p class="m-0 text-sm leading-[1.45] font-normal text-(--login-description)">
|
||||
使用管理员账号进入 Codex Proxy RS,管理路由、密钥、账号池与链路观测。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div v-if="props.error" class="login-error" role="alert">
|
||||
<CircleAlert :size="16" />
|
||||
<p>{{ props.error }}</p>
|
||||
</div>
|
||||
|
||||
<label class="grid min-w-0 gap-2">
|
||||
<span class="text-[13px] leading-[1.1] font-bold text-(--login-label)">管理员账号</span>
|
||||
<BaseInput v-model="username" placeholder="admin@example.com" autocomplete="username">
|
||||
<template #prefix>
|
||||
<Mail :size="17" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</label>
|
||||
|
||||
<label class="grid min-w-0 gap-2">
|
||||
<span class="text-[13px] leading-[1.1] font-bold text-(--login-label)">访问密钥</span>
|
||||
<BaseInput
|
||||
v-model="password"
|
||||
placeholder="输入会话密钥"
|
||||
:type="passwordType"
|
||||
autocomplete="current-password"
|
||||
>
|
||||
<template #prefix>
|
||||
<KeyRound :size="17" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="login-password-toggle"
|
||||
:label="passwordToggleLabel"
|
||||
@mousedown.prevent
|
||||
@click="togglePasswordVisible"
|
||||
>
|
||||
<EyeOff v-if="isPasswordVisible" :size="16" />
|
||||
<Eye v-else :size="16" />
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseInput>
|
||||
</label>
|
||||
|
||||
<div class="min-w-0 mb-2">
|
||||
<BaseButton
|
||||
size="lg"
|
||||
type="submit"
|
||||
class="login-submit"
|
||||
:loading="props.loading"
|
||||
:disabled="props.submitDisabled"
|
||||
>
|
||||
<span>{{ submitLabel }}</span>
|
||||
<ArrowRight v-if="!props.loading" :size="18" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-form {
|
||||
--cp-bg-surface: transparent;
|
||||
--cp-bg-subtle: var(--login-toggle-bg);
|
||||
--cp-bg-muted: var(--login-input-bg);
|
||||
--cp-text-primary: var(--login-title);
|
||||
--cp-text-secondary: var(--login-description);
|
||||
--cp-text-muted: var(--login-placeholder);
|
||||
--cp-info: var(--login-button);
|
||||
--cp-info-hover: var(--login-button-hover);
|
||||
--cp-info-pressed: var(--login-button-active);
|
||||
--cp-info-on: var(--cp-white);
|
||||
--cp-info-border: var(--login-input-icon);
|
||||
--cp-danger-bg: var(--login-error-bg);
|
||||
--cp-danger-border: transparent;
|
||||
--cp-danger-text: var(--login-error-text);
|
||||
--cp-danger: var(--login-error-icon);
|
||||
--cp-disabled-bg: var(--login-disabled-bg);
|
||||
--cp-disabled-text: var(--login-disabled-text);
|
||||
--cp-disabled-icon: var(--login-disabled-text);
|
||||
--cp-button-radius-base: 6px;
|
||||
--cp-icon-button-radius: 6px;
|
||||
--cp-shadow-control: none;
|
||||
--cp-shadow-popover: none;
|
||||
--cp-input-height-default: 43px;
|
||||
|
||||
background:
|
||||
linear-gradient(
|
||||
118deg,
|
||||
var(--login-form-bg-a),
|
||||
var(--login-form-bg-b) 56%,
|
||||
var(--login-form-bg-c)
|
||||
),
|
||||
var(--login-form-bg-b);
|
||||
box-shadow: 0 18px 38px -20px var(--login-form-shadow);
|
||||
backdrop-filter: blur(18px) saturate(1.08);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.08);
|
||||
}
|
||||
|
||||
.login-form-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 22px;
|
||||
width: calc(100% - 44px);
|
||||
height: 2px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--cp-transparent),
|
||||
var(--login-form-line),
|
||||
var(--cp-transparent)
|
||||
);
|
||||
opacity: 0.42;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark'] .login-form-line) {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
display: inline-flex;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--login-logo-bg);
|
||||
color: var(--login-logo-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.login-theme-toggle {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
width: 66px;
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
background: var(--login-toggle-bg);
|
||||
color: var(--login-toggle-moon);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition:
|
||||
background 0.16s ease,
|
||||
color 0.16s ease;
|
||||
}
|
||||
|
||||
.login-theme-toggle:focus-visible {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--login-input-icon) 46%, transparent);
|
||||
}
|
||||
|
||||
.login-theme-toggle > svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-theme-toggle > svg:first-child {
|
||||
color: var(--login-toggle-sun);
|
||||
}
|
||||
|
||||
.login-theme-toggle > svg:last-child {
|
||||
color: var(--login-toggle-moon);
|
||||
}
|
||||
|
||||
.login-theme-knob {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--login-toggle-knob);
|
||||
box-shadow: 0 0 10px var(--login-toggle-shadow);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.login-theme-toggle.is-dark .login-theme-knob {
|
||||
transform: translateX(34px);
|
||||
}
|
||||
|
||||
.login-error {
|
||||
display: flex;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--login-error-bg);
|
||||
padding: 0 12px;
|
||||
color: var(--login-error-icon);
|
||||
}
|
||||
|
||||
.login-error p {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--login-error-text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.login-password-toggle {
|
||||
--cp-bg-subtle: color-mix(in srgb, var(--cp-input-context-bg-hover) 62%, transparent);
|
||||
--cp-bg-muted: color-mix(in srgb, var(--cp-input-context-bg-hover) 88%, transparent);
|
||||
|
||||
color: var(--login-placeholder);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.login-password-toggle:hover {
|
||||
color: var(--login-title);
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
box-shadow: 0 14px 24px -18px var(--login-button-shadow);
|
||||
}
|
||||
|
||||
.login-submit:disabled {
|
||||
background: var(--login-disabled-bg);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-theme-knob,
|
||||
.login-submit,
|
||||
.login-theme-toggle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+137
-1003
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,6 @@ import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
function shouldIgnoreRolldownLog(log: { code?: string; id?: string; loc?: { file?: string } }) {
|
||||
const source = `${String(log.id ?? '')} ${String(log.loc?.file ?? '')} ${JSON.stringify(log)}`
|
||||
return log.code === 'INVALID_ANNOTATION' && source.includes('@vueuse/core')
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
base: '/',
|
||||
plugins: [vue(), tailwindcss()],
|
||||
@@ -31,13 +26,6 @@ export default defineConfig({
|
||||
assetsDir: 'assets',
|
||||
chunkSizeWarningLimit: 600,
|
||||
rolldownOptions: {
|
||||
onLog(level, log, handler) {
|
||||
if (level === 'warn' && shouldIgnoreRolldownLog(log)) {
|
||||
return
|
||||
}
|
||||
|
||||
handler(level, log)
|
||||
},
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: [
|
||||
|
||||
Reference in New Issue
Block a user