feat: 左侧导航改版与 Python 镜像管理

- 应用外壳换成 stash-ui Layout:左侧导航(任务调度 / Python 镜像)+ 页面标题区,窄屏折叠为顶部导航
- 新增 python_mirrors 表与 CRUD/设默认接口,内置清华、阿里云、中科大、PyPI 官方
- Python 脚本任务运行时注入默认镜像的 PIP_INDEX_URL / UV_INDEX_URL,任务自身环境变量优先
- 新增镜像管理页与表单,支持新增、编辑、启用/停用、设默认、删除
- 重新生成 OpenAPI 与 TS 类型;补齐后端镜像测试、前端单测与 e2e,修复窄屏页面被宽表格撑开
This commit is contained in:
2026-11-03 22:12:11 +08:00
parent d7a6e17531
commit 29fc579275
25 changed files with 2491 additions and 47 deletions
+215
View File
@@ -0,0 +1,215 @@
use chrono::{DateTime, Utc};
use sqlx::{FromRow, SqlitePool};
use uuid::Uuid;
use crate::{
error::{ApiError, ApiResult},
python_mirrors::{
model::{DefaultPythonMirror, PythonMirrorResponse},
service::ValidatedMirror,
},
};
#[derive(Debug, FromRow)]
struct MirrorRow {
id: String,
name: String,
index_url: String,
trusted_host: Option<String>,
enabled: bool,
is_default: bool,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl From<MirrorRow> for PythonMirrorResponse {
fn from(row: MirrorRow) -> Self {
Self {
id: row.id,
name: row.name,
index_url: row.index_url,
trusted_host: row.trusted_host,
enabled: row.enabled,
is_default: row.is_default,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
}
pub async fn list(pool: &SqlitePool) -> ApiResult<Vec<PythonMirrorResponse>> {
let rows = sqlx::query_as::<_, MirrorRow>(
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
FROM python_mirrors
ORDER BY is_default DESC, enabled DESC, name COLLATE NOCASE ASC",
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
let row = sqlx::query_as::<_, MirrorRow>(
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
FROM python_mirrors WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| ApiError::mirror_not_found(format!("镜像 {id} 不存在")))?;
Ok(row.into())
}
pub async fn create(pool: &SqlitePool, input: ValidatedMirror) -> ApiResult<PythonMirrorResponse> {
if index_url_exists(pool, &input.index_url, None).await? {
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
}
let id = Uuid::new_v4().simple().to_string();
let now = Utc::now();
sqlx::query(
"INSERT INTO python_mirrors
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
)
.bind(&id)
.bind(&input.name)
.bind(&input.index_url)
.bind(input.trusted_host.as_deref())
.bind(input.enabled)
.bind(now)
.bind(now)
.execute(pool)
.await
.map_err(map_mirror_error)?;
get(pool, &id).await
}
pub async fn update(
pool: &SqlitePool,
id: &str,
input: ValidatedMirror,
) -> ApiResult<PythonMirrorResponse> {
if index_url_exists(pool, &input.index_url, Some(id)).await? {
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
}
let now = Utc::now();
let result = sqlx::query(
"UPDATE python_mirrors
SET name = ?, index_url = ?, trusted_host = ?, enabled = ?,
is_default = CASE WHEN ? = 0 THEN 0 ELSE is_default END,
updated_at = ?
WHERE id = ?",
)
.bind(&input.name)
.bind(&input.index_url)
.bind(input.trusted_host.as_deref())
.bind(input.enabled)
.bind(input.enabled)
.bind(now)
.bind(id)
.execute(pool)
.await
.map_err(map_mirror_error)?;
if result.rows_affected() == 0 {
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
}
get(pool, id).await
}
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<()> {
let result = sqlx::query("DELETE FROM python_mirrors WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
}
Ok(())
}
/// 设为默认:同一事务里先清掉旧默认,再把目标设为默认并启用。
pub async fn set_default(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
let now = Utc::now();
let mut transaction = pool.begin().await?;
let exists: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM python_mirrors WHERE id = ?")
.bind(id)
.fetch_optional(&mut *transaction)
.await?;
if exists.is_none() {
transaction.rollback().await?;
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
}
sqlx::query("UPDATE python_mirrors SET is_default = 0, updated_at = ? WHERE is_default = 1")
.bind(now)
.execute(&mut *transaction)
.await?;
sqlx::query(
"UPDATE python_mirrors SET is_default = 1, enabled = 1, updated_at = ? WHERE id = ?",
)
.bind(now)
.bind(id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
get(pool, id).await
}
/// 执行器读取:只返回「启用且是默认」的镜像。
pub async fn default_enabled(pool: &SqlitePool) -> ApiResult<Option<DefaultPythonMirror>> {
let row = sqlx::query_as::<_, (String, String, Option<String>)>(
"SELECT name, index_url, trusted_host
FROM python_mirrors
WHERE enabled = 1 AND is_default = 1
LIMIT 1",
)
.fetch_optional(pool)
.await?;
Ok(
row.map(|(name, index_url, trusted_host)| DefaultPythonMirror {
name,
index_url,
trusted_host,
}),
)
}
async fn index_url_exists(
pool: &SqlitePool,
index_url: &str,
excluding_id: Option<&str>,
) -> ApiResult<bool> {
let existing: Option<(String,)> = match excluding_id {
Some(id) => {
sqlx::query_as(
"SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?) AND id <> ?",
)
.bind(index_url)
.bind(id)
.fetch_optional(pool)
.await?
}
None => {
sqlx::query_as("SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?)")
.bind(index_url)
.fetch_optional(pool)
.await?
}
};
Ok(existing.is_some())
}
fn map_mirror_error(error: sqlx::Error) -> ApiError {
if let sqlx::Error::Database(database_error) = &error
&& database_error.is_unique_violation()
{
return ApiError::mirror_conflict("该索引地址已经存在");
}
ApiError::from(error)
}