feat: refine login and document updates

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-07-01 11:34:02 +08:00
co-authored by Codex
parent 6dc8b33483
commit 65d9154b21
5 changed files with 1128 additions and 1015 deletions
+269
View File
@@ -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>
File diff suppressed because it is too large Load Diff
-12
View File
@@ -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: [