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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user