feat: add workspace and claude messages support

This commit is contained in:
boos
2026-05-22 05:57:37 +08:00
parent c1074ddf71
commit 7cdb02ef1d
24 changed files with 3282 additions and 479 deletions
+1
View File
@@ -74,6 +74,7 @@ bytes = "1"
once_cell = "1" once_cell = "1"
parking_lot = "0.12" parking_lot = "0.12"
mime = "0.3" mime = "0.3"
base64 = "0.22"
# WebSocket client (used by /comfy/* WS proxy to forward upstream to per-user containers) # WebSocket client (used by /comfy/* WS proxy to forward upstream to per-user containers)
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect"] } tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect"] }
BIN
View File
Binary file not shown.
@@ -0,0 +1,33 @@
-- Admin-authored announcements for normal users.
CREATE TABLE IF NOT EXISTS user_announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
status TEXT NOT NULL DEFAULT 'published',
audience TEXT NOT NULL DEFAULT 'all',
starts_at TEXT,
ends_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
published_at TEXT
);
CREATE INDEX IF NOT EXISTS user_announcements_status_created_at_idx
ON user_announcements (status, created_at);
CREATE INDEX IF NOT EXISTS user_announcements_active_window_idx
ON user_announcements (starts_at, ends_at);
CREATE TABLE IF NOT EXISTS user_announcement_reads (
announcement_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
read_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (announcement_id, user_id),
FOREIGN KEY (announcement_id) REFERENCES user_announcements(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS user_announcement_reads_user_id_idx
ON user_announcement_reads (user_id);
@@ -0,0 +1,34 @@
-- Workspace assets: local archive for creative outputs from Picture Agent,
-- ComfyUI Agent, Video Agent and ComfyUI workspaces.
CREATE TABLE IF NOT EXISTS workspace_assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
user_id INTEGER,
source TEXT NOT NULL,
kind TEXT NOT NULL,
title TEXT,
prompt TEXT,
model TEXT,
status TEXT NOT NULL DEFAULT 'completed',
mime_type TEXT,
file_name TEXT,
file_path TEXT,
remote_url TEXT,
thumbnail_path TEXT,
size_bytes INTEGER NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_user_created
ON workspace_assets(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_source_created
ON workspace_assets(source, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_kind_created
ON workspace_assets(kind, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_deleted
ON workspace_assets(deleted_at);
+191
View File
@@ -6,6 +6,13 @@
// 3. Transforms the response back to OpenAI-compatible format // 3. Transforms the response back to OpenAI-compatible format
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::transformers::{
anthropic::{
format_messages_request_with_compat, parse_messages_response, parse_stream_event,
AnthropicMessagesCompatibility,
},
canonical::CanonicalRequest,
};
use crate::AppState; use crate::AppState;
use reqwest::Client; use reqwest::Client;
use serde_json::Value; use serde_json::Value;
@@ -193,6 +200,190 @@ pub async fn execute_chat_completions_stream(
execute_streaming_request(route, "/chat/completions", body).await execute_streaming_request(route, "/chat/completions", body).await
} }
/// Execute an Anthropic Messages API request against an Anthropic-shaped upstream.
pub async fn execute_anthropic_messages(
route: UpstreamRoute,
request: CanonicalRequest,
) -> AppResult<Value> {
let client = http_client();
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
let compatibility = AnthropicMessagesCompatibility::default();
let body = format_messages_request_with_compat(&request, &compatibility);
let mut builder = client
.post(&upstream_url)
.header("x-api-key", &route.api_key)
.header("Authorization", format!("Bearer {}", route.api_key))
.header("Content-Type", "application/json");
if let Some(version) = compatibility.anthropic_version.as_deref() {
builder = builder.header("anthropic-version", version);
}
let resp = builder
.json(&body)
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(format!("Failed to read upstream response: {e}")))?;
if !status.is_success() {
return Err(AppError::UpstreamError(format!(
"Upstream returned {status}: {text}"
)));
}
let json = parse_upstream_json(&text, &upstream_url, status)?;
let canonical = parse_messages_response(&json)
.map_err(|err| AppError::UpstreamError(format!("Invalid Anthropic response: {err}")))?;
Ok(canonical
.metadata
.unwrap_or_else(|| serde_json::json!({ "content": [] })))
}
/// Execute a streaming Anthropic Messages request and return Anthropic SSE frames.
pub async fn execute_anthropic_messages_stream(
route: UpstreamRoute,
request: CanonicalRequest,
) -> AppResult<Vec<String>> {
let client = http_client();
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
let compatibility = AnthropicMessagesCompatibility::default();
let body = format_messages_request_with_compat(&request, &compatibility);
let mut builder = client
.post(&upstream_url)
.header("x-api-key", &route.api_key)
.header("Authorization", format!("Bearer {}", route.api_key))
.header("Content-Type", "application/json");
if let Some(version) = compatibility.anthropic_version.as_deref() {
builder = builder.header("anthropic-version", version);
}
let resp = builder
.json(&body)
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
let status = resp.status();
if !status.is_success() {
let text = resp
.text()
.await
.unwrap_or_else(|_| "unknown error".to_string());
return Err(AppError::UpstreamError(format!(
"Upstream returned {status}: {text}"
)));
}
let mut frames = Vec::new();
let mut stream = resp.bytes_stream();
use futures::StreamExt;
while let Some(chunk_result) = stream.next().await {
let bytes = chunk_result
.map_err(|e| AppError::UpstreamError(format!("Stream error: {e}")))?;
let text = String::from_utf8_lossy(&bytes).to_string();
frames.extend(normalize_anthropic_sse_frames(&text));
}
if !frames
.iter()
.any(|frame| frame.starts_with("event: message_stop"))
{
frames.push("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n".to_string());
}
Ok(frames)
}
fn normalize_anthropic_sse_frames(text: &str) -> Vec<String> {
let mut frames = Vec::new();
let mut current = String::new();
for line in text.lines() {
if line.trim().is_empty() {
if !current.trim().is_empty() {
if let Some(frame) = normalize_anthropic_sse_frame(&current) {
frames.push(frame);
}
current.clear();
}
} else {
current.push_str(line);
current.push('\n');
}
}
if !current.trim().is_empty() {
if let Some(frame) = normalize_anthropic_sse_frame(&current) {
frames.push(frame);
}
}
frames
}
fn normalize_anthropic_sse_frame(frame: &str) -> Option<String> {
let event = parse_stream_event(frame)?;
if event.choices.is_empty() {
if frame.contains("message_stop") {
return Some("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n".to_string());
}
return None;
}
let choice = &event.choices[0];
if choice.delta.role.is_some() {
let id = event.id.unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple()));
return Some(format!(
"event: message_start\ndata: {}\n\n",
serde_json::json!({
"type": "message_start",
"message": {
"id": id,
"type": "message",
"role": "assistant",
"content": [],
"model": "",
"stop_reason": null,
"stop_sequence": null,
"usage": event.usage,
}
})
));
}
if let Some(content) = choice.delta.content.as_deref() {
return Some(format!(
"event: content_block_delta\ndata: {}\n\n",
serde_json::json!({
"type": "content_block_delta",
"index": choice.index,
"delta": {
"type": "text_delta",
"text": content,
}
})
));
}
if let Some(finish_reason) = choice.finish_reason.as_deref() {
return Some(format!(
"event: message_delta\ndata: {}\n\n",
serde_json::json!({
"type": "message_delta",
"delta": {
"stop_reason": denormalize_anthropic_stop_reason(finish_reason),
"stop_sequence": null,
},
"usage": event.usage,
})
));
}
None
}
fn denormalize_anthropic_stop_reason(reason: &str) -> &str {
match reason {
"stop" => "end_turn",
"length" => "max_tokens",
"tool_calls" => "tool_use",
other => other,
}
}
/// Execute a streaming request against any upstream path (e.g. /responses). /// Execute a streaming request against any upstream path (e.g. /responses).
/// Returns SSE `data: ...` lines verbatim. /// Returns SSE `data: ...` lines verbatim.
pub async fn execute_streaming_request( pub async fn execute_streaming_request(
+2
View File
@@ -31,6 +31,7 @@ mod tasks;
mod update_center; mod update_center;
mod user; mod user;
mod user_api_keys; mod user_api_keys;
mod workspace;
// Two implementations of /api/video-agent/*: legacy singleton (default) // Two implementations of /api/video-agent/*: legacy singleton (default)
// vs per-user pool. Exactly one is mounted, picked at compile time by // vs per-user pool. Exactly one is mounted, picked at compile time by
// the `multi_tenant_comfy` feature flag. // the `multi_tenant_comfy` feature flag.
@@ -71,6 +72,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(update_center::routes()) .merge(update_center::routes())
.merge(user::routes()) .merge(user::routes())
.merge(user_api_keys::routes()) .merge(user_api_keys::routes())
.merge(workspace::routes())
.merge(video_agent::routes()) .merge(video_agent::routes())
.with_state(state); .with_state(state);
+54 -5
View File
@@ -318,7 +318,31 @@ fn proxy_client() -> Client {
.unwrap_or_else(|_| Client::new()) .unwrap_or_else(|_| Client::new())
} }
async fn upstream_response_to_axum(response: reqwest::Response) -> AppResult<Response<Body>> { async fn archive_picture_agent_response(
state: &AppState,
auth: &PictureAgentAuth,
prompt: Option<String>,
model: Option<String>,
payload: &Value,
) {
crate::services::workspace::record_image_assets_from_response(
state,
auth.user_id,
"picture-agent",
prompt,
model,
payload,
)
.await;
}
async fn upstream_json_response_to_axum_and_archive(
state: &AppState,
auth: &PictureAgentAuth,
response: reqwest::Response,
prompt: Option<String>,
model: Option<String>,
) -> AppResult<Response<Body>> {
let status = StatusCode::from_u16(response.status().as_u16()) let status = StatusCode::from_u16(response.status().as_u16())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let content_type = response let content_type = response
@@ -332,6 +356,12 @@ async fn upstream_response_to_axum(response: reqwest::Response) -> AppResult<Res
.await .await
.map_err(|err| AppError::UpstreamError(err.to_string()))?; .map_err(|err| AppError::UpstreamError(err.to_string()))?;
if status.is_success() {
if let Ok(payload) = serde_json::from_slice::<Value>(&bytes) {
archive_picture_agent_response(state, auth, prompt, model, &payload).await;
}
}
Ok(Response::builder() Ok(Response::builder()
.status(status) .status(status)
.header(header::CONTENT_TYPE, content_type) .header(header::CONTENT_TYPE, content_type)
@@ -345,15 +375,27 @@ async fn images_generations(
Json(body): Json<Value>, Json(body): Json<Value>,
) -> AppResult<Response<Body>> { ) -> AppResult<Response<Body>> {
let auth = resolve_picture_agent_auth(&headers, &state).await?; let auth = resolve_picture_agent_auth(&headers, &state).await?;
let prompt = body
.get("prompt")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let model = body
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let url = format!("{}/images/generations", local_v1_base(&state)); let url = format!("{}/images/generations", local_v1_base(&state));
let response = proxy_client() let response = proxy_client()
.post(url) .post(url)
.bearer_auth(auth.outbound_key) .bearer_auth(auth.outbound_key.clone())
.json(&body) .json(&body)
.send() .send()
.await .await
.map_err(|err| AppError::UpstreamError(err.to_string()))?; .map_err(|err| AppError::UpstreamError(err.to_string()))?;
upstream_response_to_axum(response).await upstream_json_response_to_axum_and_archive(&state, &auth, response, prompt, model).await
} }
async fn images_edits( async fn images_edits(
@@ -363,6 +405,8 @@ async fn images_edits(
) -> AppResult<Response<Body>> { ) -> AppResult<Response<Body>> {
let auth = resolve_picture_agent_auth(&headers, &state).await?; let auth = resolve_picture_agent_auth(&headers, &state).await?;
let mut form = reqwest::multipart::Form::new(); let mut form = reqwest::multipart::Form::new();
let mut prompt: Option<String> = None;
let mut model: Option<String> = None;
while let Some(field) = multipart while let Some(field) = multipart
.next_field() .next_field()
@@ -401,6 +445,11 @@ async fn images_edits(
.text() .text()
.await .await
.map_err(|err| AppError::BadRequest(format!("failed to read field: {err}")))?; .map_err(|err| AppError::BadRequest(format!("failed to read field: {err}")))?;
if name == "prompt" {
prompt = Some(text.clone());
} else if name == "model" {
model = Some(text.clone());
}
form = form.text(name, text); form = form.text(name, text);
} }
} }
@@ -408,10 +457,10 @@ async fn images_edits(
let url = format!("{}/images/edits", local_v1_base(&state)); let url = format!("{}/images/edits", local_v1_base(&state));
let response = proxy_client() let response = proxy_client()
.post(url) .post(url)
.bearer_auth(auth.outbound_key) .bearer_auth(auth.outbound_key.clone())
.multipart(form) .multipart(form)
.send() .send()
.await .await
.map_err(|err| AppError::UpstreamError(err.to_string()))?; .map_err(|err| AppError::UpstreamError(err.to_string()))?;
upstream_response_to_axum(response).await upstream_json_response_to_axum_and_archive(&state, &auth, response, prompt, model).await
} }
+233
View File
@@ -51,6 +51,7 @@ pub enum DownstreamSource {
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
.route("/v1/chat/completions", post(chat_completions)) .route("/v1/chat/completions", post(chat_completions))
.route("/v1/messages", post(messages_handler))
.route("/v1/responses", post(responses_handler)) .route("/v1/responses", post(responses_handler))
.route("/v1/completions", post(completions_handler)) .route("/v1/completions", post(completions_handler))
.route("/v1/embeddings", post(embeddings_handler)) .route("/v1/embeddings", post(embeddings_handler))
@@ -88,6 +89,31 @@ pub fn routes() -> Router<AppState> {
// ── Handlers ─────────────────────────────────────────────────────────── // ── Handlers ───────────────────────────────────────────────────────────
/// POST /v1/messages — Anthropic Claude Messages API compatibility.
async fn messages_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Response, AppError> {
info!("POST /v1/messages");
let key_id = check_auth(&state, &headers).await?;
let canonical = crate::transformers::anthropic::messages::parse_messages_request(&body)
.map_err(AppError::BadRequest)?;
let model = canonical.model.clone();
if canonical.stream {
let chunks = execute_messages_stream_with_conductor(state, key_id, model, canonical).await?;
let sse = Sse::new(stream::iter(chunks).map(|chunk| {
Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk))
}));
Ok(sse.into_response())
} else {
let result = execute_messages_with_conductor(state, key_id, model, canonical).await?;
Ok(Json(result).into_response())
}
}
/// POST /v1/responses — OpenAI Responses API (non-stream + stream). /// POST /v1/responses — OpenAI Responses API (non-stream + stream).
/// ///
/// Behaves like /v1/chat/completions: routes through ProxyConductor for /// Behaves like /v1/chat/completions: routes through ProxyConductor for
@@ -722,6 +748,213 @@ fn upstream_status_from_error(err: &AppError) -> Option<u16> {
.and_then(|value| value.parse::<u16>().ok()) .and_then(|value| value.parse::<u16>().ok())
} }
async fn execute_messages_with_conductor(
state: AppState,
key_id: Option<i64>,
model: String,
request: crate::transformers::canonical::CanonicalRequest,
) -> AppResult<Value> {
let started_at = Instant::now();
let conductor = build_conductor(&state);
let billing_state = state.clone();
let billing_model = model.clone();
let result = conductor
.execute(ExecuteInput {
requested_model: model,
downstream_policy: None,
attempt_fn: Box::new(move |ctx| {
let mut request = request.clone();
request.model = ctx.selected.actual_model.clone();
async move {
match executors::execute_anthropic_messages(
selected_channel_to_route(ctx.selected),
request,
)
.await
{
Ok(response) => AttemptResult::Success {
response,
latency_ms: None,
cost: None,
},
Err(err) => AttemptResult::Failure {
action: crate::proxy::conductor::AttemptFailureAction::Failover,
status: upstream_status_from_error(&err),
raw_error_text: Some(err.to_string()),
},
}
}
.boxed()
}),
on_terminal_failure: None,
})
.await;
match result {
ExecuteResult::Success {
selected,
response,
attempts,
} => {
let (prompt, completion) = extract_usage(&response);
record_proxy_log_success(
&billing_state,
key_id,
&selected,
&billing_model,
Some(elapsed_ms_since(started_at)),
prompt,
completion,
0.0,
attempts,
false,
)
.await;
record_billing(
&billing_state,
key_id,
&billing_model,
prompt,
completion,
0.0,
)
.await;
Ok(response)
}
ExecuteResult::Failure {
reason,
selected,
status,
raw_error_text,
attempts,
} => {
record_proxy_log_failure(
&billing_state,
key_id,
selected.as_ref(),
&billing_model,
status,
Some(elapsed_ms_since(started_at)),
raw_error_text.as_deref(),
attempts,
false,
)
.await;
Err(AppError::UpstreamError(format!(
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
status,
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
)))
}
}
}
async fn execute_messages_stream_with_conductor(
state: AppState,
key_id: Option<i64>,
model: String,
request: crate::transformers::canonical::CanonicalRequest,
) -> AppResult<Vec<String>> {
let started_at = Instant::now();
let conductor = build_conductor(&state);
let billing_state = state.clone();
let billing_model = model.clone();
let result = conductor
.execute(ExecuteInput {
requested_model: model,
downstream_policy: None,
attempt_fn: Box::new(move |ctx| {
let mut request = request.clone();
request.model = ctx.selected.actual_model.clone();
async move {
match executors::execute_anthropic_messages_stream(
selected_channel_to_route(ctx.selected),
request,
)
.await
{
Ok(chunks) => AttemptResult::Success {
response: Value::Array(chunks.into_iter().map(Value::String).collect()),
latency_ms: None,
cost: None,
},
Err(err) => AttemptResult::Failure {
action: crate::proxy::conductor::AttemptFailureAction::Failover,
status: upstream_status_from_error(&err),
raw_error_text: Some(err.to_string()),
},
}
}
.boxed()
}),
on_terminal_failure: None,
})
.await;
let (selected, attempts, chunks) = match result {
ExecuteResult::Success {
selected,
response,
attempts,
} => match response {
Value::Array(chunks) => (
selected,
attempts,
chunks
.into_iter()
.filter_map(|chunk| chunk.as_str().map(str::to_string))
.collect::<Vec<_>>(),
),
_ => {
return Err(AppError::UpstreamError(
"Invalid streaming response from conductor".into(),
))
}
},
ExecuteResult::Failure {
reason,
selected,
status,
raw_error_text,
attempts,
} => {
record_proxy_log_failure(
&billing_state,
key_id,
selected.as_ref(),
&billing_model,
status,
Some(elapsed_ms_since(started_at)),
raw_error_text.as_deref(),
attempts,
true,
)
.await;
return Err(AppError::UpstreamError(format!(
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
status,
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
)));
}
};
record_proxy_log_success(
&billing_state,
key_id,
&selected,
&billing_model,
Some(elapsed_ms_since(started_at)),
0,
0,
0.0,
attempts,
true,
)
.await;
record_billing(&billing_state, key_id, &billing_model, 0, 0, 0.0).await;
Ok(chunks)
}
async fn execute_chat_completions_with_conductor( async fn execute_chat_completions_with_conductor(
state: AppState, state: AppState,
key_id: Option<i64>, key_id: Option<i64>,
+489 -121
View File
@@ -1,9 +1,14 @@
// Site Announcements routes — upstream provider notifications per site //! User announcement routes.
//!
//! The public route names keep the old `site-announcements` path for frontend
//! compatibility, but the resource is now admin-authored announcements shown to
//! normal users.
use crate::{db::models::site_announcement::SiteAnnouncement, error::AppResult, AppState}; use crate::{auth::jwt::validate_jwt, error::AppResult, AppState};
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
routing::{get, post}, http::HeaderMap,
routing::{get, post, put},
Json, Router, Json, Router,
}; };
use serde::Deserialize; use serde::Deserialize;
@@ -14,159 +19,522 @@ use tracing::info;
pub struct ListAnnouncementsQuery { pub struct ListAnnouncementsQuery {
limit: Option<i64>, limit: Option<i64>,
offset: Option<i64>, offset: Option<i64>,
site_id: Option<i64>, read: Option<String>,
platform: Option<String>, status: Option<String>,
read: Option<String>, // "true" | "false" }
status: Option<String>, // "active" | "expired" | "dismissed"
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AnnouncementPayload {
title: String,
content: String,
level: Option<String>,
status: Option<String>,
#[serde(alias = "startsAt")]
starts_at: Option<String>,
#[serde(alias = "endsAt")]
ends_at: Option<String>,
} }
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
.route( .route(
"/api/site-announcements", "/api/site-announcements",
get(list_announcements).delete(delete_all), get(list_admin_announcements)
.post(create_announcement)
.delete(delete_all),
)
.route(
"/api/site-announcements/{id}",
put(update_announcement).delete(delete_announcement),
)
.route("/api/site-announcements/{id}/read", post(mark_read_admin))
.route("/api/site-announcements/read-all", post(mark_all_read_admin))
.route("/api/user/announcements", get(list_user_announcements))
.route("/api/user/announcements/{id}/read", post(mark_read_user))
.route(
"/api/user/announcements/read-all",
post(mark_all_read_user),
) )
.route("/api/site-announcements/{id}/read", post(mark_read))
.route("/api/site-announcements/read-all", post(mark_all_read))
} }
/// GET /api/site-announcements — list announcements with optional filters async fn list_admin_announcements(
async fn list_announcements(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListAnnouncementsQuery>, Query(q): Query<ListAnnouncementsQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
info!("Listing site announcements"); require_admin(&headers, &state)?;
let limit = q.limit.unwrap_or(50).clamp(1, 500); info!("Listing user announcements for admin");
let limit = q.limit.unwrap_or(100).clamp(1, 500);
let offset = q.offset.unwrap_or(0).max(0); let offset = q.offset.unwrap_or(0).max(0);
let status = q
.status
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let mut sql = "SELECT * FROM site_announcements WHERE 1=1".to_string(); let mut sql = "SELECT
let mut params: Vec<String> = vec![]; a.id, a.title, a.content, a.level, a.status, a.audience,
let mut bind_idx = 0; a.starts_at, a.ends_at, a.created_at, a.updated_at, a.published_at,
COUNT(r.user_id) AS read_count
if let Some(site_id) = q.site_id { FROM user_announcements a
bind_idx += 1; LEFT JOIN user_announcement_reads r ON r.announcement_id = a.id
sql.push_str(&format!(" AND site_id = ?{}", bind_idx)); WHERE 1=1"
params.push(site_id.to_string()); .to_string();
} if status.is_some() {
if let Some(ref platform) = q.platform { sql.push_str(" AND a.status = ?");
if !platform.is_empty() {
bind_idx += 1;
sql.push_str(&format!(" AND platform = ?{}", bind_idx));
params.push(platform.clone());
}
}
if let Some(ref r) = q.read {
if r == "true" {
sql.push_str(" AND read_at IS NOT NULL");
} else if r == "false" {
sql.push_str(" AND read_at IS NULL");
}
} }
sql.push_str(
" GROUP BY a.id
ORDER BY a.created_at DESC
LIMIT ? OFFSET ?",
);
// status filter: active = NOT dismissed AND (ends_at IS NULL OR ends_at > now) let mut query = sqlx::query_as::<_, AnnouncementRow>(&sql);
// We fetch all and apply status filter in Rust for simplicity if let Some(status) = status {
// (the "expired" vs "active" logic references ends_at which is a datetime comparison) query = query.bind(status);
sql.push_str(" ORDER BY first_seen_at DESC");
let mut query = sqlx::query_as::<_, SiteAnnouncement>(&sql);
for p in &params {
query = query.bind(p);
} }
let announcements: Vec<SiteAnnouncement> = query.fetch_all(&*state.db).await?; let rows = query
.bind(limit)
.bind(offset)
.fetch_all(&*state.db)
.await?;
// Apply status filter in-memory (matches Node.js behaviour) Ok(Json(Value::Array(
let filtered = if let Some(ref status) = q.status { rows.into_iter().map(|row| row.into_admin_json()).collect(),
let now = chrono::Utc::now().timestamp_millis(); )))
let status = status.trim().to_lowercase();
announcements
.into_iter()
.filter(|a| {
if status == "dismissed" {
a.dismissed_at.is_some()
} else if status == "expired" {
a.dismissed_at.is_none()
&& a.ends_at.as_ref().is_some_and(|e| parse_dt_millis(e) < now)
} else if status == "active" {
a.dismissed_at.is_none()
&& a.ends_at.as_ref().is_none_or(|e| parse_dt_millis(e) >= now)
} else {
true
}
})
.collect()
} else {
announcements
};
// Apply pagination in-memory
let paginated: Vec<Value> = filtered
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.map(|a| {
json!({
"id": a.id,
"siteId": a.site_id,
"platform": a.platform,
"sourceKey": a.source_key,
"title": a.title,
"content": a.content,
"level": a.level,
"sourceUrl": a.source_url,
"startsAt": a.starts_at,
"endsAt": a.ends_at,
"upstreamCreatedAt": a.upstream_created_at,
"upstreamUpdatedAt": a.upstream_updated_at,
"firstSeenAt": a.first_seen_at,
"lastSeenAt": a.last_seen_at,
"readAt": a.read_at,
"dismissedAt": a.dismissed_at,
"rawPayload": a.raw_payload,
})
})
.collect();
Ok(Json(Value::Array(paginated)))
} }
/// POST /api/site-announcements/{id}/read async fn list_user_announcements(
async fn mark_read(State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> { State(state): State<AppState>,
info!("Marking site announcement {} as read", id); headers: HeaderMap,
sqlx::query("UPDATE site_announcements SET read_at = datetime('now') WHERE id = ?") Query(q): Query<ListAnnouncementsQuery>,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let limit = q.limit.unwrap_or(50).clamp(1, 200);
let offset = q.offset.unwrap_or(0).max(0);
let read_filter = q.read.as_deref().map(str::trim);
let mut sql = "SELECT
a.id, a.title, a.content, a.level, a.status, a.audience,
a.starts_at, a.ends_at, a.created_at, a.updated_at, a.published_at,
CASE WHEN r.read_at IS NULL THEN 0 ELSE 1 END AS read,
r.read_at AS read_at
FROM user_announcements a
LEFT JOIN user_announcement_reads r
ON r.announcement_id = a.id AND r.user_id = ?
WHERE a.status = 'published'
AND (a.starts_at IS NULL OR a.starts_at <= datetime('now'))
AND (a.ends_at IS NULL OR a.ends_at >= datetime('now'))"
.to_string();
if read_filter == Some("true") {
sql.push_str(" AND r.read_at IS NOT NULL");
} else if read_filter == Some("false") {
sql.push_str(" AND r.read_at IS NULL");
}
sql.push_str(" ORDER BY COALESCE(a.published_at, a.created_at) DESC, a.id DESC LIMIT ? OFFSET ?");
let rows: Vec<UserAnnouncementRow> = sqlx::query_as(&sql)
.bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(&*state.db)
.await?;
Ok(Json(Value::Array(
rows.into_iter().map(|row| row.into_user_json()).collect(),
)))
}
async fn create_announcement(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<AnnouncementPayload>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &state)?;
let payload = normalize_payload(payload)?;
let result = sqlx::query(
"INSERT INTO user_announcements
(title, content, level, status, audience, starts_at, ends_at, published_at)
VALUES (?, ?, ?, ?, 'all', ?, ?, CASE WHEN ? = 'published' THEN datetime('now') ELSE NULL END)",
)
.bind(&payload.title)
.bind(&payload.content)
.bind(&payload.level)
.bind(&payload.status)
.bind(&payload.starts_at)
.bind(&payload.ends_at)
.bind(&payload.status)
.execute(&*state.db)
.await?;
let row = fetch_admin_row(&state, result.last_insert_rowid()).await?;
Ok(Json(row.into_admin_json()))
}
async fn update_announcement(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
Json(payload): Json<AnnouncementPayload>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &state)?;
let payload = normalize_payload(payload)?;
let result = sqlx::query(
"UPDATE user_announcements
SET title = ?,
content = ?,
level = ?,
status = ?,
starts_at = ?,
ends_at = ?,
published_at = CASE
WHEN status != 'published' AND ? = 'published' THEN datetime('now')
ELSE published_at
END,
updated_at = datetime('now')
WHERE id = ?",
)
.bind(&payload.title)
.bind(&payload.content)
.bind(&payload.level)
.bind(&payload.status)
.bind(&payload.starts_at)
.bind(&payload.ends_at)
.bind(&payload.status)
.bind(id)
.execute(&*state.db)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::AppError::NotFound(
"announcement not found".into(),
));
}
let row = fetch_admin_row(&state, id).await?;
Ok(Json(row.into_admin_json()))
}
async fn delete_announcement(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &state)?;
let result = sqlx::query("DELETE FROM user_announcements WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::AppError::NotFound(
"announcement not found".into(),
));
}
Ok(Json(json!({ "success": true })))
}
async fn mark_read_admin(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &state)?;
sqlx::query(
"INSERT INTO user_announcement_reads (announcement_id, user_id, read_at)
SELECT ?, id, datetime('now') FROM users WHERE role = 'user'
ON CONFLICT(announcement_id, user_id) DO UPDATE SET read_at = excluded.read_at",
)
.bind(id) .bind(id)
.execute(&*state.db) .execute(&*state.db)
.await?; .await?;
Ok(Json(json!({ "success": true }))) Ok(Json(json!({ "success": true })))
} }
/// POST /api/site-announcements/read-all async fn mark_all_read_admin(
async fn mark_all_read(State(state): State<AppState>) -> AppResult<Json<Value>> { State(state): State<AppState>,
info!("Marking all site announcements as read"); headers: HeaderMap,
sqlx::query("UPDATE site_announcements SET read_at = datetime('now') WHERE read_at IS NULL") ) -> AppResult<Json<Value>> {
require_admin(&headers, &state)?;
sqlx::query(
"INSERT INTO user_announcement_reads (announcement_id, user_id, read_at)
SELECT a.id, u.id, datetime('now')
FROM user_announcements a
CROSS JOIN users u
WHERE u.role = 'user'
ON CONFLICT(announcement_id, user_id) DO UPDATE SET read_at = excluded.read_at",
)
.execute(&*state.db) .execute(&*state.db)
.await?; .await?;
Ok(Json(json!({ "success": true }))) Ok(Json(json!({ "success": true })))
} }
/// DELETE /api/site-announcements async fn mark_read_user(
async fn delete_all(State(state): State<AppState>) -> AppResult<Json<Value>> { State(state): State<AppState>,
info!("Deleting all site announcements"); headers: HeaderMap,
sqlx::query("DELETE FROM site_announcements") Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
ensure_visible_to_user(&state, id).await?;
sqlx::query(
"INSERT INTO user_announcement_reads (announcement_id, user_id, read_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(announcement_id, user_id) DO UPDATE SET read_at = excluded.read_at",
)
.bind(id)
.bind(user_id)
.execute(&*state.db) .execute(&*state.db)
.await?; .await?;
Ok(Json(json!({ "success": true }))) Ok(Json(json!({ "success": true })))
} }
/// Parse a datetime string like "2026-05-15 10:30:00" or ISO to milliseconds since epoch. async fn mark_all_read_user(
fn parse_dt_millis(s: &str) -> i64 { State(state): State<AppState>,
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%d %H:%M:%S") { headers: HeaderMap,
dt.and_utc().timestamp_millis() ) -> AppResult<Json<Value>> {
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s.trim()) { let user_id = current_user_id(&headers, &state)?;
dt.timestamp_millis() sqlx::query(
} else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%dT%H:%M:%S") { "INSERT INTO user_announcement_reads (announcement_id, user_id, read_at)
dt.and_utc().timestamp_millis() SELECT id, ?, datetime('now')
} else { FROM user_announcements
0 WHERE status = 'published'
AND (starts_at IS NULL OR starts_at <= datetime('now'))
AND (ends_at IS NULL OR ends_at >= datetime('now'))
ON CONFLICT(announcement_id, user_id) DO UPDATE SET read_at = excluded.read_at",
)
.bind(user_id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
async fn delete_all(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
require_admin(&headers, &state)?;
info!("Deleting all user announcements");
sqlx::query("DELETE FROM user_announcements")
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
async fn fetch_admin_row(state: &AppState, id: i64) -> AppResult<AnnouncementRow> {
let row = sqlx::query_as::<_, AnnouncementRow>(
"SELECT
a.id, a.title, a.content, a.level, a.status, a.audience,
a.starts_at, a.ends_at, a.created_at, a.updated_at, a.published_at,
COUNT(r.user_id) AS read_count
FROM user_announcements a
LEFT JOIN user_announcement_reads r ON r.announcement_id = a.id
WHERE a.id = ?
GROUP BY a.id",
)
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(row)
}
async fn ensure_visible_to_user(state: &AppState, id: i64) -> AppResult<()> {
let exists: Option<i64> = sqlx::query_scalar(
"SELECT id
FROM user_announcements
WHERE id = ?
AND status = 'published'
AND (starts_at IS NULL OR starts_at <= datetime('now'))
AND (ends_at IS NULL OR ends_at >= datetime('now'))",
)
.bind(id)
.fetch_optional(&*state.db)
.await?;
if exists.is_none() {
return Err(crate::error::AppError::NotFound(
"announcement not found".into(),
));
}
Ok(())
}
fn current_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| {
crate::error::AppError::Unauthorized("Missing or invalid Authorization header".into())
})?;
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| crate::error::AppError::Unauthorized("Invalid or expired token".into()))?;
if claims.role != "user" && claims.role != "admin" {
return Err(crate::error::AppError::Forbidden(
"user account is required".into(),
));
}
claims
.sub
.parse()
.map_err(|_| crate::error::AppError::Unauthorized("Invalid user ID in token".into()))
}
fn require_admin(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| {
crate::error::AppError::Unauthorized("Missing or invalid Authorization header".into())
})?;
if auth == state.config.auth.auth_token {
return Ok(());
}
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| crate::error::AppError::Unauthorized("Invalid admin token".into()))?;
if claims.role != "admin" {
return Err(crate::error::AppError::Forbidden(
"Admin account is required for this route".into(),
));
}
Ok(())
}
fn normalize_payload(payload: AnnouncementPayload) -> AppResult<NormalizedAnnouncementPayload> {
let title = payload.title.trim().to_string();
let content = payload.content.trim().to_string();
if title.is_empty() {
return Err(crate::error::AppError::BadRequest("title is required".into()));
}
if content.is_empty() {
return Err(crate::error::AppError::BadRequest("content is required".into()));
}
let level = payload
.level
.unwrap_or_else(|| "info".to_string())
.trim()
.to_lowercase();
if !["info", "warning", "error"].contains(&level.as_str()) {
return Err(crate::error::AppError::BadRequest(
"level must be info, warning, or error".into(),
));
}
let status = payload
.status
.unwrap_or_else(|| "published".to_string())
.trim()
.to_lowercase();
if !["draft", "published", "archived"].contains(&status.as_str()) {
return Err(crate::error::AppError::BadRequest(
"status must be draft, published, or archived".into(),
));
}
Ok(NormalizedAnnouncementPayload {
title,
content,
level,
status,
starts_at: payload.starts_at.filter(|s| !s.trim().is_empty()),
ends_at: payload.ends_at.filter(|s| !s.trim().is_empty()),
})
}
struct NormalizedAnnouncementPayload {
title: String,
content: String,
level: String,
status: String,
starts_at: Option<String>,
ends_at: Option<String>,
}
#[derive(sqlx::FromRow)]
struct AnnouncementRow {
id: i64,
title: String,
content: String,
level: String,
status: String,
audience: String,
starts_at: Option<String>,
ends_at: Option<String>,
created_at: String,
updated_at: String,
published_at: Option<String>,
read_count: i64,
}
impl AnnouncementRow {
fn into_admin_json(self) -> Value {
json!({
"id": self.id,
"siteId": 0,
"platform": "boosapi",
"sourceKey": format!("user-announcement:{}", self.id),
"title": self.title,
"content": self.content,
"level": self.level,
"status": self.status,
"audience": self.audience,
"startsAt": self.starts_at,
"endsAt": self.ends_at,
"firstSeenAt": self.created_at,
"lastSeenAt": self.updated_at,
"publishedAt": self.published_at,
"readCount": self.read_count,
"readAt": null,
"dismissedAt": if self.status == "archived" { Some(self.updated_at) } else { None },
})
}
}
#[derive(sqlx::FromRow)]
struct UserAnnouncementRow {
id: i64,
title: String,
content: String,
level: String,
status: String,
audience: String,
starts_at: Option<String>,
ends_at: Option<String>,
created_at: String,
updated_at: String,
published_at: Option<String>,
read: i64,
read_at: Option<String>,
}
impl UserAnnouncementRow {
fn into_user_json(self) -> Value {
json!({
"id": self.id,
"siteId": 0,
"platform": "boosapi",
"sourceKey": format!("user-announcement:{}", self.id),
"title": self.title,
"content": self.content,
"level": self.level,
"status": self.status,
"audience": self.audience,
"startsAt": self.starts_at,
"endsAt": self.ends_at,
"firstSeenAt": self.published_at.clone().unwrap_or(self.created_at),
"lastSeenAt": self.updated_at,
"publishedAt": self.published_at,
"read": self.read != 0,
"readAt": self.read_at,
})
} }
} }
+18 -1
View File
@@ -200,8 +200,25 @@ async fn get_user_logs(
let items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>( let items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
r#" r#"
SELECT l.* FROM proxy_logs l SELECT
l.id, l.route_id, l.channel_id, l.account_id, l.downstream_api_key_id,
l.model_requested, l.model_actual, l.status, l.http_status,
l.latency_ms, l.first_byte_latency_ms,
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
l.error_message, l.retry_count, l.is_stream,
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.created_at,
a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
s.name AS site_name,
s.url AS site_url,
k.name AS downstream_key_name,
k.group_name AS downstream_key_group_name,
k.tags AS downstream_key_tags
FROM proxy_logs l
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
LEFT JOIN accounts a ON a.id = l.account_id
LEFT JOIN sites s ON s.id = a.site_id
WHERE k.user_id = ? WHERE k.user_id = ?
AND (? IS NULL OR l.model_requested LIKE ?) AND (? IS NULL OR l.model_requested LIKE ?)
AND (? IS NULL OR l.status = ?) AND (? IS NULL OR l.status = ?)
+390
View File
@@ -0,0 +1,390 @@
use crate::{
auth::jwt::validate_jwt,
error::{AppError, AppResult},
AppState,
};
use axum::{
body::Body,
extract::{Path, Query, State},
http::{header, HeaderMap, Response, StatusCode},
routing::get,
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::path::PathBuf;
#[derive(Debug, Clone)]
enum WorkspaceCaller {
Admin,
User { user_id: i64 },
}
#[derive(Debug, Deserialize)]
struct AssetQuery {
source: Option<String>,
kind: Option<String>,
q: Option<String>,
user_id: Option<i64>,
#[serde(alias = "userId")]
user_id_camel: Option<i64>,
page: Option<i64>,
#[serde(alias = "pageSize")]
page_size: Option<i64>,
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/workspace/assets", get(list_admin_assets))
.route("/api/workspace/assets/{public_id}", get(get_admin_asset).delete(delete_admin_asset))
.route("/api/workspace/assets/{public_id}/file", get(get_admin_asset_file))
.route("/api/user/workspace/assets", get(list_user_assets))
.route("/api/user/workspace/assets/{public_id}", get(get_user_asset).delete(delete_user_asset))
.route("/api/user/workspace/assets/{public_id}/file", get(get_user_asset_file))
}
fn bearer(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| {
value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
})
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn resolve_caller(headers: &HeaderMap, state: &AppState, admin_required: bool) -> AppResult<WorkspaceCaller> {
let token = bearer(headers)
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
return Ok(WorkspaceCaller::Admin);
}
let claims = validate_jwt(&state.config.auth.jwt_secret, token)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
if claims.role == "admin" {
return Ok(WorkspaceCaller::Admin);
}
if claims.role == "user" && !admin_required {
let user_id = claims
.sub
.parse::<i64>()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
return Ok(WorkspaceCaller::User { user_id });
}
Err(AppError::Forbidden("Workspace access denied".into()))
}
fn normalize_query(query: &AssetQuery) -> (i64, i64, Option<String>, Option<String>, Option<String>, Option<i64>) {
let page_size = query.page_size.unwrap_or(36).clamp(1, 120);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * page_size;
let source = query
.source
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "all")
.map(str::to_string);
let kind = query
.kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "all")
.map(str::to_string);
let q = query
.q
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("%{value}%"));
let user_id = query.user_id.or(query.user_id_camel);
(page_size, offset, source, kind, q, user_id)
}
fn row_to_json(row: sqlx::sqlite::SqliteRow) -> Value {
use sqlx::Row;
let metadata_raw: String = row.try_get("metadata").unwrap_or_else(|_| "{}".to_string());
let metadata = serde_json::from_str::<Value>(&metadata_raw).unwrap_or_else(|_| json!({}));
let public_id: String = row.try_get("public_id").unwrap_or_default();
let file_path: Option<String> = row.try_get("file_path").ok();
json!({
"id": row.try_get::<i64, _>("id").unwrap_or_default(),
"publicId": public_id,
"userId": row.try_get::<Option<i64>, _>("user_id").unwrap_or(None),
"source": row.try_get::<String, _>("source").unwrap_or_default(),
"kind": row.try_get::<String, _>("kind").unwrap_or_default(),
"title": row.try_get::<Option<String>, _>("title").unwrap_or(None),
"prompt": row.try_get::<Option<String>, _>("prompt").unwrap_or(None),
"model": row.try_get::<Option<String>, _>("model").unwrap_or(None),
"status": row.try_get::<String, _>("status").unwrap_or_else(|_| "completed".to_string()),
"mimeType": row.try_get::<Option<String>, _>("mime_type").unwrap_or(None),
"fileName": row.try_get::<Option<String>, _>("file_name").unwrap_or(None),
"hasLocalFile": file_path.as_deref().map(|value| !value.is_empty()).unwrap_or(false),
"remoteUrl": row.try_get::<Option<String>, _>("remote_url").unwrap_or(None),
"sizeBytes": row.try_get::<i64, _>("size_bytes").unwrap_or_default(),
"metadata": metadata,
"createdAt": row.try_get::<String, _>("created_at").unwrap_or_default(),
"updatedAt": row.try_get::<String, _>("updated_at").unwrap_or_default(),
})
}
async fn list_assets(
state: AppState,
caller: WorkspaceCaller,
query: AssetQuery,
) -> AppResult<Json<Value>> {
let (page_size, offset, source, kind, q, requested_user_id) = normalize_query(&query);
let scoped_user_id = match caller {
WorkspaceCaller::Admin => requested_user_id,
WorkspaceCaller::User { user_id } => Some(user_id),
};
let items = sqlx::query(
"SELECT *
FROM workspace_assets
WHERE deleted_at IS NULL
AND (? IS NULL OR user_id = ?)
AND (? IS NULL OR source = ?)
AND (? IS NULL OR kind = ?)
AND (
? IS NULL
OR title LIKE ?
OR prompt LIKE ?
OR model LIKE ?
OR source LIKE ?
)
ORDER BY datetime(created_at) DESC, id DESC
LIMIT ? OFFSET ?",
)
.bind(scoped_user_id)
.bind(scoped_user_id)
.bind(&source)
.bind(&source)
.bind(&kind)
.bind(&kind)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(page_size)
.bind(offset)
.map(row_to_json)
.fetch_all(&*state.db)
.await?;
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*)
FROM workspace_assets
WHERE deleted_at IS NULL
AND (? IS NULL OR user_id = ?)
AND (? IS NULL OR source = ?)
AND (? IS NULL OR kind = ?)
AND (
? IS NULL
OR title LIKE ?
OR prompt LIKE ?
OR model LIKE ?
OR source LIKE ?
)",
)
.bind(scoped_user_id)
.bind(scoped_user_id)
.bind(&source)
.bind(&source)
.bind(&kind)
.bind(&kind)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
"items": items,
"total": total,
"page": query.page.unwrap_or(1).max(1),
"pageSize": page_size,
})))
}
async fn get_asset_row(
state: &AppState,
public_id: &str,
caller: WorkspaceCaller,
) -> AppResult<sqlx::sqlite::SqliteRow> {
let scoped_user_id = match caller {
WorkspaceCaller::Admin => None,
WorkspaceCaller::User { user_id } => Some(user_id),
};
let row = sqlx::query(
"SELECT *
FROM workspace_assets
WHERE public_id = ?
AND deleted_at IS NULL
AND (? IS NULL OR user_id = ?)
LIMIT 1",
)
.bind(public_id)
.bind(scoped_user_id)
.bind(scoped_user_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("Workspace asset not found".into()))?;
Ok(row)
}
async fn get_asset(
state: AppState,
caller: WorkspaceCaller,
public_id: String,
) -> AppResult<Json<Value>> {
Ok(Json(json!({
"success": true,
"asset": row_to_json(get_asset_row(&state, &public_id, caller).await?),
})))
}
async fn delete_asset(
state: AppState,
caller: WorkspaceCaller,
public_id: String,
) -> AppResult<StatusCode> {
let scoped_user_id = match caller {
WorkspaceCaller::Admin => None,
WorkspaceCaller::User { user_id } => Some(user_id),
};
let result = sqlx::query(
"UPDATE workspace_assets
SET deleted_at = datetime('now'), updated_at = datetime('now')
WHERE public_id = ?
AND deleted_at IS NULL
AND (? IS NULL OR user_id = ?)",
)
.bind(public_id)
.bind(scoped_user_id)
.bind(scoped_user_id)
.execute(&*state.db)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Workspace asset not found".into()));
}
Ok(StatusCode::NO_CONTENT)
}
async fn get_asset_file(
state: AppState,
caller: WorkspaceCaller,
public_id: String,
) -> AppResult<Response<Body>> {
use sqlx::Row;
let row = get_asset_row(&state, &public_id, caller).await?;
let file_path = row
.try_get::<Option<String>, _>("file_path")
.ok()
.flatten()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| AppError::NotFound("Workspace asset file is not archived locally".into()))?;
let path = PathBuf::from(file_path);
let bytes = tokio::fs::read(&path)
.await
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
let mime_type = row
.try_get::<Option<String>, _>("mime_type")
.ok()
.flatten()
.unwrap_or_else(|| "application/octet-stream".to_string());
let file_name = row
.try_get::<Option<String>, _>("file_name")
.ok()
.flatten()
.unwrap_or_else(|| format!("{public_id}.bin"));
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime_type)
.header(
header::CONTENT_DISPOSITION,
format!("inline; filename=\"{}\"", file_name.replace('"', "")),
)
.body(Body::from(bytes))
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))
}
async fn list_admin_assets(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<AssetQuery>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state, true)?;
list_assets(state, caller, query).await
}
async fn get_admin_asset(
State(state): State<AppState>,
headers: HeaderMap,
Path(public_id): Path<String>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state, true)?;
get_asset(state, caller, public_id).await
}
async fn get_admin_asset_file(
State(state): State<AppState>,
headers: HeaderMap,
Path(public_id): Path<String>,
) -> AppResult<Response<Body>> {
let caller = resolve_caller(&headers, &state, true)?;
get_asset_file(state, caller, public_id).await
}
async fn delete_admin_asset(
State(state): State<AppState>,
headers: HeaderMap,
Path(public_id): Path<String>,
) -> AppResult<StatusCode> {
let caller = resolve_caller(&headers, &state, true)?;
delete_asset(state, caller, public_id).await
}
async fn list_user_assets(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<AssetQuery>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state, false)?;
list_assets(state, caller, query).await
}
async fn get_user_asset(
State(state): State<AppState>,
headers: HeaderMap,
Path(public_id): Path<String>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state, false)?;
get_asset(state, caller, public_id).await
}
async fn get_user_asset_file(
State(state): State<AppState>,
headers: HeaderMap,
Path(public_id): Path<String>,
) -> AppResult<Response<Body>> {
let caller = resolve_caller(&headers, &state, false)?;
get_asset_file(state, caller, public_id).await
}
async fn delete_user_asset(
State(state): State<AppState>,
headers: HeaderMap,
Path(public_id): Path<String>,
) -> AppResult<StatusCode> {
let caller = resolve_caller(&headers, &state, false)?;
delete_asset(state, caller, public_id).await
}
+1
View File
@@ -11,3 +11,4 @@ pub mod oauth;
pub mod route_rebuild; pub mod route_rebuild;
pub mod shared; pub mod shared;
pub mod update_center; pub mod update_center;
pub mod workspace;
+330
View File
@@ -0,0 +1,330 @@
use crate::{error::AppError, AppState};
use base64::{engine::general_purpose, Engine as _};
use chrono::Utc;
use reqwest::header::CONTENT_TYPE;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct WorkspaceAssetInput {
pub user_id: Option<i64>,
pub source: String,
pub kind: String,
pub title: Option<String>,
pub prompt: Option<String>,
pub model: Option<String>,
pub remote_url: Option<String>,
pub metadata: Value,
}
#[derive(Debug, Clone)]
pub struct StoredWorkspaceAsset {
pub public_id: String,
pub file_path: Option<String>,
pub remote_url: Option<String>,
pub status: String,
}
fn workspace_root() -> PathBuf {
std::env::var("BOOSAPI_WORKSPACE_DATA_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/data/boosapi-workspace"))
}
fn safe_segment(value: &str) -> String {
let mut out = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'-'
}
})
.collect::<String>();
while out.contains("--") {
out = out.replace("--", "-");
}
let trimmed = out.trim_matches('-').to_string();
if trimmed.is_empty() {
"asset".to_string()
} else {
trimmed.chars().take(48).collect()
}
}
fn extension_for_mime(mime: &str) -> &'static str {
let mime = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
match mime.as_str() {
"image/jpeg" | "image/jpg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
"image/svg+xml" => "svg",
"video/mp4" => "mp4",
"video/webm" => "webm",
"audio/mpeg" | "audio/mp3" => "mp3",
"audio/wav" => "wav",
"application/json" => "json",
_ if mime.starts_with("image/") => "png",
_ if mime.starts_with("video/") => "mp4",
_ if mime.starts_with("audio/") => "mp3",
_ => "bin",
}
}
fn extension_from_url(url: &str) -> Option<String> {
let without_query = url.split(['?', '#']).next().unwrap_or(url);
let ext = Path::new(without_query)
.extension()
.and_then(|value| value.to_str())?
.trim()
.to_ascii_lowercase();
if ext.len() <= 8 && ext.chars().all(|ch| ch.is_ascii_alphanumeric()) {
Some(ext)
} else {
None
}
}
async fn write_asset_file(
user_id: Option<i64>,
source: &str,
public_id: &str,
ext: &str,
bytes: &[u8],
) -> Result<PathBuf, AppError> {
let now = Utc::now();
let user_segment = user_id
.map(|id| id.to_string())
.unwrap_or_else(|| "admin".to_string());
let dir = workspace_root()
.join(user_segment)
.join(safe_segment(source))
.join(now.format("%Y-%m").to_string());
tokio::fs::create_dir_all(&dir)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!("create workspace dir: {err}")))?;
let path = dir.join(format!("{public_id}.{ext}"));
let mut file = tokio::fs::File::create(&path)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!("create workspace asset: {err}")))?;
file.write_all(bytes)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!("write workspace asset: {err}")))?;
Ok(path)
}
async fn insert_asset(
state: &AppState,
input: &WorkspaceAssetInput,
public_id: &str,
status: &str,
mime_type: Option<&str>,
file_path: Option<&Path>,
size_bytes: i64,
metadata: Value,
) -> Result<StoredWorkspaceAsset, AppError> {
let file_name = file_path
.and_then(|path| path.file_name())
.and_then(|value| value.to_str())
.map(str::to_string);
let file_path_string = file_path.map(|path| path.to_string_lossy().to_string());
sqlx::query(
"INSERT INTO workspace_assets
(public_id, user_id, source, kind, title, prompt, model, status,
mime_type, file_name, file_path, remote_url, size_bytes, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(public_id)
.bind(input.user_id)
.bind(&input.source)
.bind(&input.kind)
.bind(&input.title)
.bind(&input.prompt)
.bind(&input.model)
.bind(status)
.bind(mime_type)
.bind(&file_name)
.bind(&file_path_string)
.bind(&input.remote_url)
.bind(size_bytes)
.bind(metadata.to_string())
.execute(&*state.db)
.await?;
Ok(StoredWorkspaceAsset {
public_id: public_id.to_string(),
file_path: file_path_string,
remote_url: input.remote_url.clone(),
status: status.to_string(),
})
}
pub async fn record_remote_asset(
state: &AppState,
input: WorkspaceAssetInput,
) -> Result<StoredWorkspaceAsset, AppError> {
let public_id = format!("wa_{}", Uuid::new_v4().simple());
let Some(url) = input.remote_url.as_deref() else {
return insert_asset(
state,
&input,
&public_id,
"failed",
None,
None,
0,
json!({ "error": "remote_url is required", "metadata": input.metadata }),
)
.await;
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
match client.get(url).send().await {
Ok(response) if response.status().is_success() => {
let mime_type = response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_string)
.unwrap_or_else(|| "application/octet-stream".to_string());
let bytes = response
.bytes()
.await
.map_err(|err| AppError::UpstreamError(format!("download workspace asset: {err}")))?;
let ext = extension_from_url(url).unwrap_or_else(|| extension_for_mime(&mime_type).to_string());
let path = write_asset_file(input.user_id, &input.source, &public_id, &ext, &bytes).await?;
insert_asset(
state,
&input,
&public_id,
"completed",
Some(&mime_type),
Some(&path),
bytes.len() as i64,
input.metadata.clone(),
)
.await
}
Ok(response) => {
let status = response.status().as_u16();
insert_asset(
state,
&input,
&public_id,
"failed",
None,
None,
0,
json!({ "error": format!("download returned HTTP {status}"), "metadata": input.metadata }),
)
.await
}
Err(err) => {
insert_asset(
state,
&input,
&public_id,
"failed",
None,
None,
0,
json!({ "error": err.to_string(), "metadata": input.metadata }),
)
.await
}
}
}
pub async fn record_base64_asset(
state: &AppState,
mut input: WorkspaceAssetInput,
value: &str,
) -> Result<StoredWorkspaceAsset, AppError> {
let public_id = format!("wa_{}", Uuid::new_v4().simple());
let trimmed = value.trim();
let (mime_type, encoded) = if let Some(rest) = trimmed.strip_prefix("data:") {
let (meta, data) = rest
.split_once(',')
.ok_or_else(|| AppError::BadRequest("invalid data URL".into()))?;
let mime = meta.split(';').next().unwrap_or("image/png").trim();
(mime.to_string(), data)
} else {
("image/png".to_string(), trimmed)
};
input.remote_url = None;
let bytes = general_purpose::STANDARD
.decode(encoded)
.map_err(|err| AppError::BadRequest(format!("invalid base64 asset: {err}")))?;
let ext = extension_for_mime(&mime_type);
let path = write_asset_file(input.user_id, &input.source, &public_id, ext, &bytes).await?;
insert_asset(
state,
&input,
&public_id,
"completed",
Some(&mime_type),
Some(&path),
bytes.len() as i64,
input.metadata.clone(),
)
.await
}
pub async fn record_image_assets_from_response(
state: &AppState,
user_id: Option<i64>,
source: &str,
prompt: Option<String>,
model: Option<String>,
response: &Value,
) {
let Some(items) = response.get("data").and_then(Value::as_array) else {
return;
};
for (index, item) in items.iter().enumerate() {
let title = prompt
.as_deref()
.map(|value| value.chars().take(36).collect::<String>())
.filter(|value| !value.trim().is_empty())
.or_else(|| Some(format!("图片产出 #{}", index + 1)));
if let Some(url) = item.get("url").and_then(Value::as_str).filter(|value| !value.trim().is_empty()) {
let input = WorkspaceAssetInput {
user_id,
source: source.to_string(),
kind: "image".to_string(),
title,
prompt: prompt.clone(),
model: model.clone(),
remote_url: Some(url.trim().to_string()),
metadata: json!({ "responseItem": item }),
};
if let Err(err) = record_remote_asset(state, input).await {
tracing::warn!(%err, "failed to archive remote image asset");
}
} else if let Some(b64) = item
.get("b64_json")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
{
let input = WorkspaceAssetInput {
user_id,
source: source.to_string(),
kind: "image".to_string(),
title,
prompt: prompt.clone(),
model: model.clone(),
remote_url: None,
metadata: json!({ "responseItem": item }),
};
if let Err(err) = record_base64_asset(state, input, b64).await {
tracing::warn!(%err, "failed to archive base64 image asset");
}
}
}
}
+191 -1
View File
@@ -1,8 +1,75 @@
// CanonicalRequest -> Anthropic /v1/messages JSON formatter // Anthropic /v1/messages request parser + CanonicalRequest -> Anthropic formatter
use crate::transformers::anthropic::messages::AnthropicMessagesCompatibility; use crate::transformers::anthropic::messages::AnthropicMessagesCompatibility;
use crate::transformers::canonical::*; use crate::transformers::canonical::*;
use serde_json::{json, Value}; use serde_json::{json, Value};
/// Parse an Anthropic Messages API request body into canonical format.
pub fn parse_messages_request(body: &Value) -> Result<CanonicalRequest, String> {
let model = body["model"]
.as_str()
.ok_or_else(|| "model is required".to_string())?
.to_string();
let stream = body["stream"].as_bool().unwrap_or(false);
let mut messages = Vec::new();
if let Some(system) = body.get("system") {
let system_parts = parse_anthropic_content(system)?;
if !system_parts.is_empty() {
messages.push(CanonicalMessage {
role: Role::System,
content: system_parts,
name: None,
tool_calls: None,
tool_call_id: None,
});
}
}
let raw_messages = body["messages"]
.as_array()
.ok_or_else(|| "messages is required".to_string())?;
for message in raw_messages {
messages.push(parse_anthropic_message(message)?);
}
let stop = body["stop_sequences"].as_array().map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect::<Vec<_>>()
});
let tools = body["tools"].as_array().map(|items| {
items
.iter()
.filter_map(parse_anthropic_tool)
.collect::<Vec<_>>()
});
let tool_choice = parse_anthropic_tool_choice(body.get("tool_choice"));
let reasoning = body.get("thinking").and_then(|thinking| {
if thinking["type"].as_str() == Some("enabled") {
Some(ReasoningConfig {
effort: Some("medium".to_string()),
})
} else {
None
}
});
Ok(CanonicalRequest {
model,
messages,
stream,
max_tokens: body["max_tokens"].as_i64().map(|value| value as i32),
temperature: body["temperature"].as_f64(),
top_p: body["top_p"].as_f64(),
stop,
tools,
tool_choice,
reasoning,
metadata: body.get("metadata").cloned(),
})
}
/// Format a canonical request into Anthropic Messages API JSON. /// Format a canonical request into Anthropic Messages API JSON.
pub fn format_messages_request(request: &CanonicalRequest) -> Value { pub fn format_messages_request(request: &CanonicalRequest) -> Value {
format_messages_request_with_compat(request, &AnthropicMessagesCompatibility::default()) format_messages_request_with_compat(request, &AnthropicMessagesCompatibility::default())
@@ -203,6 +270,129 @@ fn merge_extra_fields(target: &mut Value, extra: &Value) {
} }
} }
fn parse_anthropic_message(message: &Value) -> Result<CanonicalMessage, String> {
let role = match message["role"].as_str() {
Some("user") => Role::User,
Some("assistant") => Role::Assistant,
Some(other) => return Err(format!("Unknown role: {other}")),
None => return Err("Missing role in message".to_string()),
};
let (content, tool_calls, tool_call_id) = parse_message_content(message.get("content"))?;
Ok(CanonicalMessage {
role,
content,
name: None,
tool_calls,
tool_call_id,
})
}
fn parse_message_content(
content: Option<&Value>,
) -> Result<(Vec<ContentPart>, Option<Vec<ToolCall>>, Option<String>), String> {
let Some(content) = content else {
return Ok((Vec::new(), None, None));
};
if let Some(text) = content.as_str() {
return Ok((vec![ContentPart::Text { text: text.to_string() }], None, None));
}
let mut parts = Vec::new();
let mut tool_calls = Vec::new();
let mut tool_call_id = None;
let Some(items) = content.as_array() else {
return Ok((parts, None, None));
};
for item in items {
match item["type"].as_str() {
Some("text") => {
if let Some(text) = item["text"].as_str() {
parts.push(ContentPart::Text { text: text.to_string() });
}
}
Some("image") => {
if let Some(url) = item["source"]["url"].as_str() {
parts.push(ContentPart::Image {
url: url.to_string(),
detail: None,
});
} else if item["source"]["type"].as_str() == Some("base64") {
let media_type = item["source"]["media_type"]
.as_str()
.unwrap_or("image/png");
if let Some(data) = item["source"]["data"].as_str() {
parts.push(ContentPart::Image {
url: format!("data:{media_type};base64,{data}"),
detail: None,
});
}
}
}
Some("tool_use") => {
if let (Some(id), Some(name)) = (item["id"].as_str(), item["name"].as_str()) {
tool_calls.push(ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: item["input"].clone(),
});
}
}
Some("tool_result") => {
tool_call_id = item["tool_use_id"].as_str().map(str::to_string);
match &item["content"] {
Value::String(text) => parts.push(ContentPart::Text { text: text.clone() }),
Value::Array(_) => parts.extend(parse_anthropic_content(&item["content"])?),
_ => {}
}
}
_ => {}
}
}
Ok((
parts,
if tool_calls.is_empty() { None } else { Some(tool_calls) },
tool_call_id,
))
}
fn parse_anthropic_content(value: &Value) -> Result<Vec<ContentPart>, String> {
if let Some(text) = value.as_str() {
return Ok(vec![ContentPart::Text { text: text.to_string() }]);
}
let Some(items) = value.as_array() else {
return Ok(Vec::new());
};
let mut parts = Vec::new();
for item in items {
if item["type"].as_str() == Some("text") {
if let Some(text) = item["text"].as_str() {
parts.push(ContentPart::Text { text: text.to_string() });
}
}
}
Ok(parts)
}
fn parse_anthropic_tool(value: &Value) -> Option<ToolDefinition> {
let name = value["name"].as_str()?.to_string();
Some(ToolDefinition {
name,
description: value["description"].as_str().map(str::to_string),
input_schema: value["input_schema"].clone(),
})
}
fn parse_anthropic_tool_choice(value: Option<&Value>) -> Option<ToolChoice> {
let value = value?;
match value["type"].as_str()? {
"auto" => Some(ToolChoice::Auto),
"any" => Some(ToolChoice::Required),
"tool" => value["name"].as_str().map(|name| ToolChoice::Specific(name.to_string())),
"none" => Some(ToolChoice::None),
_ => None,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+1 -1
View File
@@ -5,6 +5,6 @@ pub mod outbound;
pub mod stream; pub mod stream;
pub use compatibility::AnthropicMessagesCompatibility; pub use compatibility::AnthropicMessagesCompatibility;
pub use inbound::{format_messages_request, format_messages_request_with_compat}; pub use inbound::{format_messages_request, format_messages_request_with_compat, parse_messages_request};
pub use outbound::parse_messages_response; pub use outbound::parse_messages_response;
pub use stream::parse_stream_event; pub use stream::parse_stream_event;
+169 -2
View File
@@ -4,7 +4,7 @@ import { ToastProvider, useToast } from './components/Toast.js';
import SearchModal from './components/SearchModal.js'; import SearchModal from './components/SearchModal.js';
import NotificationPanel from './components/NotificationPanel.js'; import NotificationPanel from './components/NotificationPanel.js';
import TooltipLayer from './components/TooltipLayer.js'; import TooltipLayer from './components/TooltipLayer.js';
import { api } from './api.js'; import { api, userApi } from './api.js';
import { clearSession, getSessionToken } from './authSession.js'; import { clearSession, getSessionToken } from './authSession.js';
import { import {
FIRST_USE_DOC_REMINDER_KEY, FIRST_USE_DOC_REMINDER_KEY,
@@ -20,6 +20,7 @@ import { MobileDrawer } from './components/MobileDrawer.js';
import CenteredModal from './components/CenteredModal.js'; import CenteredModal from './components/CenteredModal.js';
import AdminLoginPage from './pages/auth/AdminLoginPage.js'; import AdminLoginPage from './pages/auth/AdminLoginPage.js';
import UserLoginPage from './pages/auth/UserLoginPage.js'; import UserLoginPage from './pages/auth/UserLoginPage.js';
import { SiteAnnouncementContent } from './pages/helpers/siteAnnouncementPresentation.js';
const Dashboard = lazy(() => import('./pages/Dashboard.js')); const Dashboard = lazy(() => import('./pages/Dashboard.js'));
const Sites = lazy(() => import('./pages/Sites.js')); const Sites = lazy(() => import('./pages/Sites.js'));
const Accounts = lazy(() => import('./pages/Accounts.js')); const Accounts = lazy(() => import('./pages/Accounts.js'));
@@ -38,6 +39,7 @@ const ModelTester = lazy(() => import('./pages/ModelTester.js'));
const ComfyUI = lazy(() => import('./pages/ComfyUI.js')); const ComfyUI = lazy(() => import('./pages/ComfyUI.js'));
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js')); const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
const PictureAgent = lazy(() => import('./pages/PictureAgent.js')); const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
const Workspace = lazy(() => import('./pages/Workspace.js'));
const Monitors = lazy(() => import('./pages/Monitors.js')); const Monitors = lazy(() => import('./pages/Monitors.js'));
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js')); const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js')); const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
@@ -46,6 +48,7 @@ const UserDashboard = lazy(() => import('./pages/UserDashboard.js'));
const UsageRecords = lazy(() => import('./pages/UsageRecords.js')); const UsageRecords = lazy(() => import('./pages/UsageRecords.js'));
const UserKeys = lazy(() => import('./pages/UserKeys.js')); const UserKeys = lazy(() => import('./pages/UserKeys.js'));
const UserProfile = lazy(() => import('./pages/UserProfile.js')); const UserProfile = lazy(() => import('./pages/UserProfile.js'));
const UserAnnouncements = lazy(() => import('./pages/UserAnnouncements.js'));
const UserManagement = lazy(() => import('./pages/UserManagement.js')); const UserManagement = lazy(() => import('./pages/UserManagement.js'));
type ThemeMode = 'system' | 'light' | 'dark'; type ThemeMode = 'system' | 'light' | 'dark';
type UserProfile = { type UserProfile = {
@@ -53,6 +56,14 @@ type UserProfile = {
avatarSeed: string; avatarSeed: string;
avatarStyle: string; avatarStyle: string;
}; };
type UserAnnouncementPopupRow = {
id: number;
title: string;
content: string;
level?: 'info' | 'warning' | 'error' | string;
publishedAt?: string | null;
firstSeenAt?: string | null;
};
const DICEBEAR_STYLES = [ const DICEBEAR_STYLES = [
'pixel-art', 'pixel-art',
'pixel-art-neutral', 'pixel-art-neutral',
@@ -240,13 +251,96 @@ function UserProfileModal({
</CenteredModal> </CenteredModal>
); );
} }
function UserAnnouncementPopup({
rows,
pending,
onClose,
onMarkRead,
onMarkAllRead,
t,
}: {
rows: UserAnnouncementPopupRow[];
pending: boolean;
onClose: () => void;
onMarkRead: (id: number) => void;
onMarkAllRead: () => void;
t: (text: string) => string;
}) {
const [index, setIndex] = useState(0);
useEffect(() => {
setIndex((current) => Math.min(current, Math.max(0, rows.length - 1)));
}, [rows.length]);
const row = rows[index] || null;
const level = row?.level === 'warning' || row?.level === 'error' ? row.level : 'info';
const levelLabel = level === 'error' ? t('紧急') : level === 'warning' ? t('重要') : t('普通');
return (
<CenteredModal
open={rows.length > 0}
onClose={onClose}
title={t('用户公告')}
maxWidth={640}
closeOnBackdrop
closeOnEscape
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
{rows.length > 1 && (
<button
type="button"
className="btn btn-ghost"
onClick={() => setIndex((current) => (current + 1) % rows.length)}
disabled={pending}
>
{t('下一条')} {index + 1}/{rows.length}
</button>
)}
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={pending}>
{t('稍后')}
</button>
<button type="button" className="btn btn-ghost" onClick={onMarkAllRead} disabled={pending}>
{pending ? t('处理中...') : t('全部已读')}
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => row && onMarkRead(row.id)}
disabled={pending || !row}
>
{pending ? t('处理中...') : t('我知道了')}
</button>
</>
)}
>
{row && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span className={`badge ${level === 'error' ? 'badge-error' : level === 'warning' ? 'badge-warning' : 'badge-info'}`}>
{levelLabel}
</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{row.publishedAt || row.firstSeenAt || ''}
</span>
</div>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text-primary)' }}>
{row.title}
</div>
<div style={{ maxHeight: 360, overflow: 'auto' }}>
<SiteAnnouncementContent content={row.content} />
</div>
</>
)}
</CenteredModal>
);
}
export const sidebarGroups = [ export const sidebarGroups = [
{ {
label: '控制台', label: '控制台',
items: [ items: [
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> }, { to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
{ to: '/sites', label: '站点管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> }, { to: '/sites', label: '站点管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
{ to: '/site-announcements', label: '站点公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> }, { to: '/site-announcements', label: '用户公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> }, { to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
{ to: '/oauth', label: 'OAuth 管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a3 3 0 106 0 3 3 0 00-6 0zM3 17a3 3 0 106 0 3 3 0 00-6 0zM15 17a3 3 0 106 0 3 3 0 00-6 0zM6 14V10m0 0a3 3 0 113-3m-3 3a3 3 0 003 3h6" /></svg> }, { to: '/oauth', label: 'OAuth 管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a3 3 0 106 0 3 3 0 00-6 0zM3 17a3 3 0 106 0 3 3 0 00-6 0zM15 17a3 3 0 106 0 3 3 0 00-6 0zM6 14V10m0 0a3 3 0 113-3m-3 3a3 3 0 003 3h6" /></svg> },
{ to: '/downstream-keys', label: '下游密钥', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a4 4 0 11-8 0 4 4 0 018 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 21a6 6 0 0110.8-3.6M15.5 18.5l2-2m0 0l2 2m-2-2V21" /></svg> }, { to: '/downstream-keys', label: '下游密钥', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a4 4 0 11-8 0 4 4 0 018 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 21a6 6 0 0110.8-3.6M15.5 18.5l2-2m0 0l2 2m-2-2V21" /></svg> },
@@ -256,6 +350,7 @@ export const sidebarGroups = [
{ to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> }, { to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/video-agent', label: '视频工作台', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> }, { to: '/video-agent', label: '视频工作台', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> },
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> }, { to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/workspace', label: '工作空间', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H6a2 2 0 01-2-2V7z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 13h8M8 16h5" /></svg> },
], ],
}, },
{ {
@@ -275,6 +370,8 @@ export const userSidebarGroups = [
items: [ items: [
{ to: '/user/dashboard', label: '个人中心', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg> }, { to: '/user/dashboard', label: '个人中心', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg> },
{ to: '/user/usage', label: '使用记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> }, { to: '/user/usage', label: '使用记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
{ to: '/user/announcements', label: '用户公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/user/about', label: '关于', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M13 16h-1v-4h-1m1-4h.01M12 3a9 9 0 110 18 9 9 0 010-18z" /></svg> },
], ],
}, },
{ {
@@ -283,6 +380,7 @@ export const userSidebarGroups = [
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> }, { to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/user/video-agent', label: '视频工作台', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> }, { to: '/user/video-agent', label: '视频工作台', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> },
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> }, { to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/user/workspace', label: '工作空间', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H6a2 2 0 01-2-2V7z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 13h8M8 16h5" /></svg> },
], ],
}, },
]; ];
@@ -292,6 +390,7 @@ const topNavItems = [
{ label: '生图助手', to: '/picture-agent' }, { label: '生图助手', to: '/picture-agent' },
{ label: '视频工作台', to: '/video-agent' }, { label: '视频工作台', to: '/video-agent' },
{ label: '视频助手', to: '/comfyui-agent' }, { label: '视频助手', to: '/comfyui-agent' },
{ label: '工作空间', to: '/workspace' },
{ label: '关于', to: '/about' }, { label: '关于', to: '/about' },
]; ];
function PageTransition({ children }: { children: React.ReactNode }) { function PageTransition({ children }: { children: React.ReactNode }) {
@@ -322,6 +421,9 @@ function AppShell() {
const [showProfileModal, setShowProfileModal] = useState(false); const [showProfileModal, setShowProfileModal] = useState(false);
const [showSearch, setShowSearch] = useState(false); const [showSearch, setShowSearch] = useState(false);
const [showNotifications, setShowNotifications] = useState(false); const [showNotifications, setShowNotifications] = useState(false);
const [announcementPopupRows, setAnnouncementPopupRows] = useState<UserAnnouncementPopupRow[]>([]);
const [announcementPopupHidden, setAnnouncementPopupHidden] = useState(false);
const [announcementPopupPending, setAnnouncementPopupPending] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const themeMenuPresence = useAnimatedVisibility(showThemeMenu, 160); const themeMenuPresence = useAnimatedVisibility(showThemeMenu, 160);
const userMenuPresence = useAnimatedVisibility(showUserMenu, 160); const userMenuPresence = useAnimatedVisibility(showUserMenu, 160);
@@ -439,6 +541,29 @@ function AppShell() {
localStorage.setItem(FIRST_USE_DOC_REMINDER_KEY, '1'); localStorage.setItem(FIRST_USE_DOC_REMINDER_KEY, '1');
toast.info(`${t('首次使用建议先阅读站点文档:')}${SITE_DOCS_URL}`); toast.info(`${t('首次使用建议先阅读站点文档:')}${SITE_DOCS_URL}`);
}, [authed, t, toast]); }, [authed, t, toast]);
useEffect(() => {
if (!userAuthed || !location.pathname.startsWith('/user/') || location.pathname === '/user/login') {
setAnnouncementPopupRows([]);
setAnnouncementPopupHidden(false);
return;
}
let cancelled = false;
const loadUnreadAnnouncements = async () => {
try {
const data = await userApi.getAnnouncements('read=false&limit=5');
if (cancelled) return;
const rows = Array.isArray(data) ? data : [];
setAnnouncementPopupRows(rows);
setAnnouncementPopupHidden(false);
} catch {
// Do not block the user dashboard if announcements fail to load.
}
};
void loadUnreadAnnouncements();
return () => {
cancelled = true;
};
}, [location.pathname, userAuthed]);
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) { if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
@@ -469,6 +594,33 @@ function AppShell() {
setShowProfileModal(false); setShowProfileModal(false);
toast.success(t('个人信息已保存')); toast.success(t('个人信息已保存'));
}; };
const handleAnnouncementPopupClose = () => {
setAnnouncementPopupHidden(true);
};
const handleAnnouncementPopupMarkRead = async (id: number) => {
setAnnouncementPopupPending(true);
try {
await userApi.markAnnouncementRead(id);
setAnnouncementPopupRows((current) => current.filter((row) => row.id !== id));
toast.success(t('已标记为已读'));
} catch (error: any) {
toast.error(error?.message || t('标记失败'));
} finally {
setAnnouncementPopupPending(false);
}
};
const handleAnnouncementPopupMarkAllRead = async () => {
setAnnouncementPopupPending(true);
try {
await userApi.markAllAnnouncementsRead();
setAnnouncementPopupRows([]);
toast.success(t('已全部标记为已读'));
} catch (error: any) {
toast.error(error?.message || t('标记失败'));
} finally {
setAnnouncementPopupPending(false);
}
};
const isUserPage = location.pathname.startsWith('/user/'); const isUserPage = location.pathname.startsWith('/user/');
const isAdminLoginPage = location.pathname === '/boos'; const isAdminLoginPage = location.pathname === '/boos';
const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname); const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname);
@@ -525,8 +677,11 @@ function AppShell() {
const currentSidebarGroups = isUserPage ? userSidebarGroups : sidebarGroups; const currentSidebarGroups = isUserPage ? userSidebarGroups : sidebarGroups;
const currentTopNavItems = isUserPage ? [ const currentTopNavItems = isUserPage ? [
{ label: '个人中心', to: '/user/dashboard' }, { label: '个人中心', to: '/user/dashboard' },
{ label: '用户公告', to: '/user/announcements' },
{ label: '视频工作台', to: '/user/video-agent' }, { label: '视频工作台', to: '/user/video-agent' },
{ label: '视频助手', to: '/user/comfyui-agent' }, { label: '视频助手', to: '/user/comfyui-agent' },
{ label: '工作空间', to: '/user/workspace' },
{ label: '关于', to: '/user/about' },
] : topNavItems; ] : topNavItems;
return ( return (
<> <>
@@ -764,6 +919,7 @@ function AppShell() {
<Route path="/picture-agent" element={<PictureAgent />} /> <Route path="/picture-agent" element={<PictureAgent />} />
<Route path="/video-agent" element={<ComfyUI />} /> <Route path="/video-agent" element={<ComfyUI />} />
<Route path="/comfyui-agent" element={<ComfyUIAgent />} /> <Route path="/comfyui-agent" element={<ComfyUIAgent />} />
<Route path="/workspace" element={<Workspace />} />
<Route path="/about" element={<About />} /> <Route path="/about" element={<About />} />
<Route path="/users" element={<UserManagement />} /> <Route path="/users" element={<UserManagement />} />
@@ -775,9 +931,12 @@ function AppShell() {
<Route path="/user/dashboard/keys" element={<UserKeys />} /> <Route path="/user/dashboard/keys" element={<UserKeys />} />
<Route path="/user/dashboard/profile" element={<UserProfile />} /> <Route path="/user/dashboard/profile" element={<UserProfile />} />
<Route path="/user/usage" element={<UsageRecords />} /> <Route path="/user/usage" element={<UsageRecords />} />
<Route path="/user/announcements" element={<UserAnnouncements />} />
<Route path="/user/picture-agent" element={<PictureAgent />} /> <Route path="/user/picture-agent" element={<PictureAgent />} />
<Route path="/user/video-agent" element={<ComfyUI />} /> <Route path="/user/video-agent" element={<ComfyUI />} />
<Route path="/user/comfyui-agent" element={<ComfyUIAgent />} /> <Route path="/user/comfyui-agent" element={<ComfyUIAgent />} />
<Route path="/user/workspace" element={<Workspace />} />
<Route path="/user/about" element={<About />} />
<Route path="*" element={<Navigate to={isUserPage ? "/user/dashboard" : "/"} />} /> <Route path="*" element={<Navigate to={isUserPage ? "/user/dashboard" : "/"} />} />
</Routes> </Routes>
@@ -792,6 +951,14 @@ function AppShell() {
onSave={handleSaveProfile} onSave={handleSaveProfile}
t={t} t={t}
/> />
<UserAnnouncementPopup
rows={announcementPopupHidden ? [] : announcementPopupRows}
pending={announcementPopupPending}
onClose={handleAnnouncementPopupClose}
onMarkRead={(id) => { void handleAnnouncementPopupMarkRead(id); }}
onMarkAllRead={() => { void handleAnnouncementPopupMarkAllRead(); }}
t={t}
/>
<SearchModal open={showSearch} onClose={() => setShowSearch(false)} /> <SearchModal open={showSearch} onClose={() => setShowSearch(false)} />
</> </>
); );
+77
View File
@@ -1044,6 +1044,43 @@ export type DownstreamApiKeyTrendResponse = {
buckets: DownstreamApiKeyTrendBucket[]; buckets: DownstreamApiKeyTrendBucket[];
}; };
export type WorkspaceAsset = {
id: number;
publicId: string;
userId?: number | null;
source: string;
kind: string;
title?: string | null;
prompt?: string | null;
model?: string | null;
status: string;
mimeType?: string | null;
fileName?: string | null;
hasLocalFile?: boolean;
remoteUrl?: string | null;
sizeBytes?: number;
createdAt?: string;
updatedAt?: string;
metadata?: unknown;
};
export type WorkspaceAssetsResponse = {
success: boolean;
items: WorkspaceAsset[];
total: number;
page: number;
pageSize: number;
};
export type WorkspaceAssetQuery = {
source?: string;
kind?: string;
q?: string;
userId?: number;
page?: number;
pageSize?: number;
};
export const api = { export const api = {
// Sites // Sites
getSites: async () => { getSites: async () => {
@@ -1549,6 +1586,32 @@ export const api = {
clearEvents: () => request("/api/events", { method: "DELETE" }), clearEvents: () => request("/api/events", { method: "DELETE" }),
getSiteAnnouncements: (params?: string) => getSiteAnnouncements: (params?: string) =>
request(`/api/site-announcements${params ? "?" + params : ""}`), request(`/api/site-announcements${params ? "?" + params : ""}`),
createSiteAnnouncement: (payload: {
title: string;
content: string;
level?: string;
status?: string;
startsAt?: string | null;
endsAt?: string | null;
}) =>
request("/api/site-announcements", {
method: "POST",
body: JSON.stringify(payload),
}),
updateSiteAnnouncement: (id: number, payload: {
title: string;
content: string;
level?: string;
status?: string;
startsAt?: string | null;
endsAt?: string | null;
}) =>
request(`/api/site-announcements/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
deleteSiteAnnouncement: (id: number) =>
request(`/api/site-announcements/${id}`, { method: "DELETE" }),
markSiteAnnouncementRead: (id: number) => markSiteAnnouncementRead: (id: number) =>
request(`/api/site-announcements/${id}/read`, { method: "POST" }), request(`/api/site-announcements/${id}/read`, { method: "POST" }),
markAllSiteAnnouncementsRead: () => markAllSiteAnnouncementsRead: () =>
@@ -1872,6 +1935,10 @@ export const api = {
request(`/api/admin/users/${userId}/keys/${keyId}/reset`, { method: "POST" }), request(`/api/admin/users/${userId}/keys/${keyId}/reset`, { method: "POST" }),
updateUserKey: (userId: number, keyId: number, data: any) => updateUserKey: (userId: number, keyId: number, data: any) =>
request(`/api/admin/users/${userId}/keys/${keyId}`, { method: "PATCH", body: JSON.stringify(data) }), request(`/api/admin/users/${userId}/keys/${keyId}`, { method: "PATCH", body: JSON.stringify(data) }),
getWorkspaceAssets: (params?: WorkspaceAssetQuery) =>
request<WorkspaceAssetsResponse>(`/api/workspace/assets${buildQueryString(params)}`),
deleteWorkspaceAsset: (publicId: string) =>
request(`/api/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
}; };
export const userApi = { export const userApi = {
register: (data: { username: string; email: string; password: string }) => register: (data: { username: string; email: string; password: string }) =>
@@ -1893,4 +1960,14 @@ export const userApi = {
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) => getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
userRequest(`/api/user/logs${buildQueryString(params)}`), userRequest(`/api/user/logs${buildQueryString(params)}`),
getSummary: () => userRequest("/api/user/summary"), getSummary: () => userRequest("/api/user/summary"),
getAnnouncements: (params?: string) =>
userRequest(`/api/user/announcements${params ? "?" + params : ""}`),
markAnnouncementRead: (id: number) =>
userRequest(`/api/user/announcements/${id}/read`, { method: "POST" }),
markAllAnnouncementsRead: () =>
userRequest("/api/user/announcements/read-all", { method: "POST" }),
getWorkspaceAssets: (params?: Omit<WorkspaceAssetQuery, "userId">) =>
userRequest<WorkspaceAssetsResponse>(`/api/user/workspace/assets${buildQueryString(params)}`),
deleteWorkspaceAsset: (publicId: string) =>
userRequest(`/api/user/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
}; };
+2 -106
View File
@@ -1,10 +1,4 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
import { SITE_DOCS_URL } from '../docsLink.js';
import { buildUpdateReminder } from './helpers/updateCenterPresentation.js';
const VERSION = '1.3.0'; const VERSION = '1.3.0';
@@ -30,14 +24,9 @@ const TECH_STACK = [
{ name: 'ComfyUI / Docker', desc: '视频工作台和多租户工作流容器运行环境。' }, { name: 'ComfyUI / Docker', desc: '视频工作台和多租户工作流容器运行环境。' },
]; ];
const LINKS = [
{ label: 'GitHub', href: 'https://github.com/cita-777/boosapi', icon: 'GH' },
{ label: 'Docker Hub', href: 'https://hub.docker.com/r/1467078763/boosapi', icon: 'DH' },
{ label: '站点文档', href: SITE_DOCS_URL, icon: 'DOC' },
];
const ENDPOINTS = [ const ENDPOINTS = [
{ path: '/v1/chat/completions', desc: 'OpenAI 兼容对话代理,支持非流式与流式。' }, { path: '/v1/chat/completions', desc: 'OpenAI 兼容对话代理,支持非流式与流式。' },
{ path: '/v1/messages', desc: 'Claude Messages API 兼容入口,支持 Anthropic 请求、响应和流式事件。' },
{ path: '/v1/responses', desc: 'Responses API 兼容代理,可接入支持该协议的上游。' }, { path: '/v1/responses', desc: 'Responses API 兼容代理,可接入支持该协议的上游。' },
{ path: '/v1/images/generations', desc: '图像生成代理,供生图助手和 ComfyUI 图像节点使用。' }, { path: '/v1/images/generations', desc: '图像生成代理,供生图助手和 ComfyUI 图像节点使用。' },
{ path: '/v1/videos', desc: '视频生成任务入口,已适配火山方舟任务创建。' }, { path: '/v1/videos', desc: '视频生成任务入口,已适配火山方舟任务创建。' },
@@ -46,49 +35,6 @@ const ENDPOINTS = [
]; ];
export default function About() { export default function About() {
const [currentVersion, setCurrentVersion] = useState(`v${VERSION}`);
const [latestGitHubVersion, setLatestGitHubVersion] = useState('');
const [latestDockerHubVersion, setLatestDockerHubVersion] = useState('');
const [updateReminder, setUpdateReminder] = useState(() => buildUpdateReminder({
currentVersion: VERSION,
helper: null,
githubRelease: null,
dockerHubTag: null,
}));
useEffect(() => {
let cancelled = false;
const loadStatus = async () => {
try {
const status = await api.getUpdateCenterStatus() as {
currentVersion?: string;
githubRelease?: { normalizedVersion?: string; displayVersion?: string; tagName?: string | null; digest?: string | null } | null;
dockerHubTag?: { normalizedVersion?: string; displayVersion?: string; tagName?: string | null; digest?: string | null } | null;
helper?: { imageTag?: string | null; imageDigest?: string | null } | null;
};
const resolvedCurrentVersion = String(status.currentVersion || VERSION);
if (cancelled) return;
setCurrentVersion(`v${resolvedCurrentVersion}`);
setLatestGitHubVersion(String(status.githubRelease?.displayVersion || status.githubRelease?.normalizedVersion || ''));
setLatestDockerHubVersion(String(status.dockerHubTag?.displayVersion || status.dockerHubTag?.normalizedVersion || ''));
setUpdateReminder(buildUpdateReminder({
currentVersion: resolvedCurrentVersion,
helper: status.helper,
githubRelease: status.githubRelease,
dockerHubTag: status.dockerHubTag,
}));
} catch {
// ignore update-center lookup failures on about page
}
};
void loadStatus();
return () => {
cancelled = true;
};
}, []);
return ( return (
<div className="animate-fade-in" style={{ maxWidth: 860 }}> <div className="animate-fade-in" style={{ maxWidth: 860 }}>
{/* Header */} {/* Header */}
@@ -106,7 +52,7 @@ export default function About() {
/> />
<div> <div>
<div style={{ fontSize: 18, fontWeight: 700 }}>BoosAPI</div> <div style={{ fontSize: 18, fontWeight: 700 }}>BoosAPI</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>{currentVersion}</div> <div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>v{VERSION}</div>
</div> </div>
</div> </div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}> <div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
@@ -131,27 +77,6 @@ export default function About() {
</div> </div>
</div> </div>
<div className="card animate-slide-up stagger-1" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>更新提醒</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
<span className={`${updateReminder.badgeClassName} ${updateReminder.highlight ? 'stat-value-glow' : ''}`.trim()}>
{updateReminder.label}
</span>
<span className={updateReminder.highlight ? 'stat-value-glow' : ''} style={{ fontSize: 13, color: 'var(--color-text-primary)', fontWeight: 600 }}>
{updateReminder.detail}
</span>
</div>
<div style={{ display: 'grid', gap: 8, fontSize: 13 }}>
<div>GitHub 稳定版:{latestGitHubVersion || '暂无数据'}</div>
<div>Docker Hub:{latestDockerHubVersion || '暂无数据'}</div>
<div>
<Link to="/settings" style={{ color: 'var(--color-primary)', textDecoration: 'none', fontWeight: 600 }}>
前往更新中心
</Link>
</div>
</div>
</div>
{/* Features */} {/* Features */}
<div className="card animate-slide-up stagger-2" style={{ padding: 22, marginBottom: 14 }}> <div className="card animate-slide-up stagger-2" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('核心特色')}</h3> <h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('核心特色')}</h3>
@@ -242,35 +167,6 @@ export default function About() {
</div> </div>
</div> </div>
{/* Links */}
<div className="card animate-slide-up stagger-5" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('项目链接')}</h3>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{LINKS.map((l) => (
<a
key={l.label}
href={l.href}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 16px', borderRadius: 8,
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg-secondary)',
color: 'var(--color-text-primary)',
textDecoration: 'none', fontSize: 13, fontWeight: 500,
transition: 'border-color 0.15s',
}}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--color-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--color-border-light)')}
>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{l.icon}</span>
<span>{tr(l.label)}</span>
</a>
))}
</div>
</div>
{/* Privacy */} {/* Privacy */}
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}> <div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3> <h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
+10 -37
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'; import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
@@ -28,32 +28,12 @@ async function flushMicrotasks() {
}); });
} }
describe('About update center', () => { describe('About page', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getUpdateCenterStatus.mockResolvedValue({
currentVersion: '1.2.3',
githubRelease: {
normalizedVersion: '1.3.0',
displayVersion: '1.3.0',
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0',
},
dockerHubTag: {
normalizedVersion: 'latest',
displayVersion: 'latest @ sha256:efb2ee655386',
},
helper: {
imageTag: 'latest',
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
},
});
});
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('shows current version, newer release summaries, and a highlighted update reminder', async () => { it('shows product information without update prompts or project links', async () => {
let root!: ReactTestRenderer; let root!: ReactTestRenderer;
try { try {
await act(async () => { await act(async () => {
@@ -66,20 +46,13 @@ describe('About update center', () => {
await flushMicrotasks(); await flushMicrotasks();
const text = collectText(root.root); const text = collectText(root.root);
expect(text).toContain('v1.2.3'); expect(text).toContain('关于 BoosAPI');
expect(text).toContain('GitHub 稳定版'); expect(text).toContain('核心特色');
expect(text).toContain('1.3.0'); expect(text).toContain('兼容入口');
expect(text).toContain('Docker Hub'); expect(text).not.toContain('更新提醒');
expect(text).toContain('latest @ sha256:efb2ee655386'); expect(text).not.toContain('前往更新中心');
expect(text).toContain('发现新版本'); expect(text).not.toContain('项目链接');
expect(text).toContain('前往更新中心'); expect(apiMock.getUpdateCenterStatus).not.toHaveBeenCalled();
const highlightedReminder = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('stat-value-glow')
&& collectText(node).includes('发现新版本')
));
expect(collectText(highlightedReminder)).toContain('发现新版本');
} finally { } finally {
root?.unmount(); root?.unmount();
} }
+235 -134
View File
@@ -1,77 +1,82 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import { clearFocusParams, readFocusAnnouncementId } from './helpers/navigationFocus.js'; import { SiteAnnouncementContent } from './helpers/siteAnnouncementPresentation.js';
import {
formatSiteAnnouncementSeenAt,
readClientTimeZone,
resolveSiteAnnouncementTimeZone,
SiteAnnouncementContent,
} from './helpers/siteAnnouncementPresentation.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
type SiteAnnouncementRow = { type AnnouncementLevel = 'info' | 'warning' | 'error';
type AnnouncementStatus = 'draft' | 'published' | 'archived';
type AnnouncementRow = {
id: number; id: number;
siteId: number;
platform: string;
sourceKey: string;
title: string; title: string;
content: string; content: string;
level: 'info' | 'warning' | 'error'; level: AnnouncementLevel;
status?: AnnouncementStatus;
firstSeenAt?: string | null; firstSeenAt?: string | null;
lastSeenAt?: string | null; lastSeenAt?: string | null;
readAt?: string | null; publishedAt?: string | null;
readCount?: number | null;
}; };
type SiteRow = { type AnnouncementForm = {
id: number; id: number | null;
name: string; title: string;
platform?: string | null; content: string;
level: AnnouncementLevel;
status: AnnouncementStatus;
}; };
const emptyForm: AnnouncementForm = {
id: null,
title: '',
content: '',
level: 'info',
status: 'published',
};
const levelLabels: Record<AnnouncementLevel, string> = {
info: '普通',
warning: '重要',
error: '紧急',
};
const statusLabels: Record<AnnouncementStatus, string> = {
draft: '草稿',
published: '已发布',
archived: '已归档',
};
function normalizeStatus(value: string | undefined | null): AnnouncementStatus {
return value === 'draft' || value === 'archived' ? value : 'published';
}
function normalizeLevel(value: string | undefined | null): AnnouncementLevel {
return value === 'warning' || value === 'error' ? value : 'info';
}
export default function SiteAnnouncements() { export default function SiteAnnouncements() {
const location = useLocation();
const navigate = useNavigate();
const toast = useToast(); const toast = useToast();
const [rows, setRows] = useState<SiteAnnouncementRow[]>([]); const [rows, setRows] = useState<AnnouncementRow[]>([]);
const [sites, setSites] = useState<SiteRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [clearing, setClearing] = useState(false); const [clearing, setClearing] = useState(false);
const [markingAll, setMarkingAll] = useState(false); const [form, setForm] = useState<AnnouncementForm>(emptyForm);
const [serverTimeZone, setServerTimeZone] = useState<string | undefined>(undefined);
const [highlightAnnouncementId, setHighlightAnnouncementId] = useState<number | null>(null);
const rowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const viewerTimeZone = useMemo(() => readClientTimeZone(), []);
const displayTimeZone = resolveSiteAnnouncementTimeZone(viewerTimeZone, serverTimeZone);
const siteNameById = useMemo(() => { const publishedCount = useMemo(
const map = new Map<number, string>(); () => rows.filter((row) => normalizeStatus(row.status) === 'published').length,
for (const site of sites) { [rows],
map.set(site.id, site.name); );
}
return map;
}, [sites]);
const load = async (silent = false) => { const load = async (silent = false) => {
if (silent) setRefreshing(true); if (silent) setRefreshing(true);
else setLoading(true); else setLoading(true);
try { try {
const [announcementRows, siteRows, runtimeInfo] = await Promise.all([ const announcementRows = await api.getSiteAnnouncements();
api.getSiteAnnouncements(),
api.getSites(),
api.getRuntimeSettings().catch(() => null),
]);
setRows(Array.isArray(announcementRows) ? announcementRows : []); setRows(Array.isArray(announcementRows) ? announcementRows : []);
setSites(Array.isArray(siteRows) ? siteRows : []);
const nextServerTimeZone = typeof runtimeInfo?.serverTimeZone === 'string'
? runtimeInfo.serverTimeZone.trim()
: '';
setServerTimeZone(nextServerTimeZone || undefined);
} catch (error: any) { } catch (error: any) {
toast.error(error?.message || '加载站点公告失败'); toast.error(error?.message || '加载用户公告失败');
} finally { } finally {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
@@ -80,47 +85,75 @@ export default function SiteAnnouncements() {
useEffect(() => { useEffect(() => {
void load(); void load();
return () => {
const win = globalThis as typeof globalThis & {
clearTimeout?: typeof clearTimeout;
};
if (highlightTimerRef.current && typeof win.clearTimeout === 'function') {
win.clearTimeout(highlightTimerRef.current);
}
};
}, []); }, []);
useEffect(() => { const resetForm = () => setForm(emptyForm);
const focusAnnouncementId = readFocusAnnouncementId(location.search);
if (!focusAnnouncementId || loading) return; const editRow = (row: AnnouncementRow) => {
const row = rowRefs.current.get(focusAnnouncementId); setForm({
const nextSearch = clearFocusParams(location.search); id: row.id,
const win = globalThis as typeof globalThis & { title: row.title || '',
setTimeout?: typeof setTimeout; content: row.content || '',
clearTimeout?: typeof clearTimeout; level: normalizeLevel(row.level),
status: normalizeStatus(row.status),
});
}; };
if (!row) {
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true }); const submit = async () => {
const title = form.title.trim();
const content = form.content.trim();
if (!title) {
toast.error('请填写公告标题');
return; return;
} }
row.scrollIntoView?.({ behavior: 'smooth', block: 'center' }); if (!content) {
setHighlightAnnouncementId(focusAnnouncementId); toast.error('请填写公告内容');
if (highlightTimerRef.current && typeof win.clearTimeout === 'function') { return;
win.clearTimeout(highlightTimerRef.current);
} }
if (typeof win.setTimeout === 'function') {
highlightTimerRef.current = win.setTimeout(() => { setSaving(true);
setHighlightAnnouncementId((current) => (current === focusAnnouncementId ? null : current)); try {
}, 2200); const payload = {
title,
content,
level: form.level,
status: form.status,
};
if (form.id) {
await api.updateSiteAnnouncement(form.id, payload);
toast.success('公告已更新');
} else {
await api.createSiteAnnouncement(payload);
toast.success(form.status === 'published' ? '公告已发布' : '草稿已保存');
} }
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true }); resetForm();
}, [loading, location.pathname, location.search, navigate, rows]); await load(true);
} catch (error: any) {
toast.error(error?.message || '保存公告失败');
} finally {
setSaving(false);
}
};
const removeRow = async (row: AnnouncementRow) => {
if (typeof window !== 'undefined' && !window.confirm(`确定删除公告「${row.title}」吗?`)) return;
try {
await api.deleteSiteAnnouncement(row.id);
setRows((current) => current.filter((item) => item.id !== row.id));
if (form.id === row.id) resetForm();
toast.success('公告已删除');
} catch (error: any) {
toast.error(error?.message || '删除公告失败');
}
};
const clearAll = async () => { const clearAll = async () => {
if (typeof window !== 'undefined' && !window.confirm('确定清空全部用户公告吗?')) return;
setClearing(true); setClearing(true);
try { try {
await api.clearSiteAnnouncements(); await api.clearSiteAnnouncements();
setRows([]); setRows([]);
resetForm();
toast.success('公告已清空'); toast.success('公告已清空');
} catch (error: any) { } catch (error: any) {
toast.error(error?.message || '清空公告失败'); toast.error(error?.message || '清空公告失败');
@@ -129,35 +162,27 @@ export default function SiteAnnouncements() {
} }
}; };
const markAllRead = async () => { const inputStyle: React.CSSProperties = {
setMarkingAll(true); width: '100%',
try { padding: '10px 12px',
await api.markAllSiteAnnouncementsRead(); border: '1px solid var(--color-border)',
setRows((current) => current.map((row) => ({ borderRadius: 8,
...row, background: 'var(--color-bg)',
readAt: row.readAt || new Date().toISOString(), color: 'var(--color-text-primary)',
}))); fontSize: 13,
toast.success('已标记全部为已读'); outline: 'none',
} catch (error: any) { boxSizing: 'border-box',
toast.error(error?.message || '标记失败');
} finally {
setMarkingAll(false);
}
};
const triggerSync = async () => {
try {
await api.syncSiteAnnouncements();
toast.success('公告同步任务已启动');
} catch (error: any) {
toast.error(error?.message || '启动同步失败');
}
}; };
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
<div className="page-header"> <div className="page-header">
<h2 className="page-title">{tr('站点公告')}</h2> <div>
<h2 className="page-title">{tr('用户公告')}</h2>
<div style={{ marginTop: 4, fontSize: 12, color: 'var(--color-text-tertiary)' }}>
管理员发布给普通用户的站内公告,会在普通用户面板中展示。
</div>
</div>
<div className="page-actions"> <div className="page-actions">
<button <button
onClick={() => load(true)} onClick={() => load(true)}
@@ -167,24 +192,9 @@ export default function SiteAnnouncements() {
> >
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'} {refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
</button> </button>
<button
onClick={markAllRead}
disabled={markingAll}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
</button>
<button
onClick={triggerSync}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
手动同步
</button>
<button <button
onClick={clearAll} onClick={clearAll}
disabled={clearing} disabled={clearing || rows.length === 0}
className="btn btn-link btn-link-danger" className="btn btn-link btn-link-danger"
> >
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空公告'} {clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空公告'}
@@ -192,7 +202,86 @@ export default function SiteAnnouncements() {
</div> </div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 360px) 1fr', gap: 14, alignItems: 'start' }}>
<div className="card" style={{ padding: 18 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>
{form.id ? '编辑公告' : '发布公告'}
</h3>
<div style={{ display: 'grid', gap: 12 }}>
<label style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>标题</span>
<input
value={form.title}
onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))}
placeholder="例如:系统维护通知"
style={inputStyle}
/>
</label>
<label style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>公告内容</span>
<textarea
value={form.content}
onChange={(event) => setForm((current) => ({ ...current, content: event.target.value }))}
placeholder="写给普通用户看的公告内容,支持简单 Markdown。"
rows={8}
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
/>
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<label style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>级别</span>
<select
value={form.level}
onChange={(event) => setForm((current) => ({ ...current, level: event.target.value as AnnouncementLevel }))}
style={inputStyle}
>
<option value="info">普通</option>
<option value="warning">重要</option>
<option value="error">紧急</option>
</select>
</label>
<label style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>状态</span>
<select
value={form.status}
onChange={(event) => setForm((current) => ({ ...current, status: event.target.value as AnnouncementStatus }))}
style={inputStyle}
>
<option value="published">发布</option>
<option value="draft">草稿</option>
<option value="archived">归档</option>
</select>
</label>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{form.id && (
<button type="button" className="btn btn-ghost" onClick={resetForm}>
取消编辑
</button>
)}
<button type="button" className="btn btn-primary" onClick={submit} disabled={saving}>
{saving ? <><span className="spinner spinner-sm" /> 保存中...</> : form.id ? '保存修改' : '发布公告'}
</button>
</div>
</div>
</div>
<div className="card" style={{ padding: 0 }}> <div className="card" style={{ padding: 0 }}>
<div
style={{
padding: '14px 18px',
borderBottom: '1px solid var(--color-border-light)',
display: 'flex',
justifyContent: 'space-between',
gap: 12,
alignItems: 'center',
}}
>
<div style={{ fontSize: 14, fontWeight: 600 }}>公告列表</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
共 {rows.length} 条,已发布 {publishedCount} 条
</div>
</div>
{loading ? ( {loading ? (
<div style={{ padding: 24, textAlign: 'center' }}> <div style={{ padding: 24, textAlign: 'center' }}>
<span className="spinner spinner-sm" /> <span className="spinner spinner-sm" />
@@ -200,41 +289,53 @@ export default function SiteAnnouncements() {
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<div style={{ padding: 32, textAlign: 'center' }}> <div style={{ padding: 32, textAlign: 'center' }}>
<div className="empty-state-title">暂无公告</div> <div className="empty-state-title">暂无公告</div>
<div className="empty-state-desc">当前没有可显示的站点公告。</div> <div className="empty-state-desc">发布后普通用户可以在用户面板查看。</div>
</div> </div>
) : ( ) : (
rows.map((row, index) => ( rows.map((row, index) => {
const status = normalizeStatus(row.status);
const level = normalizeLevel(row.level);
return (
<div <div
key={row.id} key={row.id}
ref={(node) => { className={`animate-slide-up stagger-${Math.min(index + 1, 5)}`}
if (node) rowRefs.current.set(row.id, node);
else rowRefs.current.delete(row.id);
}}
className={`animate-slide-up stagger-${Math.min(index + 1, 5)} ${highlightAnnouncementId === row.id ? 'row-focus-highlight' : ''}`.trim()}
style={{ style={{
padding: '16px 18px', padding: '16px 18px',
borderBottom: index === rows.length - 1 ? 'none' : '1px solid var(--color-border-light)', borderBottom: index === rows.length - 1 ? 'none' : '1px solid var(--color-border-light)',
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 6 }}> <div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', marginBottom: 8 }}>
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>{row.title}</div> <div style={{ fontSize: 15, fontWeight: 600 }}>{row.title}</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ marginTop: 5, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<span className="badge badge-muted">{siteNameById.get(row.siteId) || `站点 #${row.siteId}`}</span> <span className={`badge ${level === 'error' ? 'badge-error' : level === 'warning' ? 'badge-warning' : 'badge-info'}`}>
<span className="badge badge-info">{row.platform}</span> {levelLabels[level]}
<span className={`badge ${row.readAt ? 'badge-muted' : 'badge-warning'}`}>{row.readAt ? '已读' : '未读'}</span> </span>
<span className={`badge ${status === 'published' ? 'badge-success' : status === 'draft' ? 'badge-muted' : 'badge-warning'}`}>
{statusLabels[status]}
</span>
<span className="badge badge-muted">已读 {row.readCount || 0}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button type="button" className="btn btn-link btn-link-primary" onClick={() => editRow(row)}>
编辑
</button>
<button type="button" className="btn btn-link btn-link-danger" onClick={() => removeRow(row)}>
删除
</button>
</div> </div>
</div> </div>
<SiteAnnouncementContent content={row.content} /> <SiteAnnouncementContent content={row.content} />
<div <div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }} 发布时间:{row.publishedAt || row.firstSeenAt || '-'}
title={displayTimeZone ? `本地时区:${displayTimeZone}` : undefined}
>
首次发现:{formatSiteAnnouncementSeenAt(row.firstSeenAt || row.lastSeenAt || '', displayTimeZone)}
</div> </div>
</div> </div>
)) );
})
)} )}
</div> </div>
</div> </div>
</div>
); );
} }
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react';
import { userApi } from '../api.js';
import { useToast } from '../components/Toast.js';
import { SiteAnnouncementContent } from './helpers/siteAnnouncementPresentation.js';
type UserAnnouncementRow = {
id: number;
title: string;
content: string;
level: 'info' | 'warning' | 'error';
publishedAt?: string | null;
firstSeenAt?: string | null;
read?: boolean;
readAt?: string | null;
};
const levelLabels = {
info: '普通',
warning: '重要',
error: '紧急',
} as const;
export default function UserAnnouncements() {
const toast = useToast();
const [rows, setRows] = useState<UserAnnouncementRow[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [markingAll, setMarkingAll] = useState(false);
const [pending, setPending] = useState<Record<number, boolean>>({});
const load = async (silent = false) => {
if (silent) setRefreshing(true);
else setLoading(true);
try {
const data = await userApi.getAnnouncements();
setRows(Array.isArray(data) ? data : []);
} catch (error: any) {
toast.error(error?.message || '加载公告失败');
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
void load();
}, []);
const markRead = async (id: number) => {
setPending((current) => ({ ...current, [id]: true }));
try {
await userApi.markAnnouncementRead(id);
setRows((current) => current.map((row) => (
row.id === id ? { ...row, read: true, readAt: new Date().toISOString() } : row
)));
} catch (error: any) {
toast.error(error?.message || '标记失败');
} finally {
setPending((current) => ({ ...current, [id]: false }));
}
};
const markAllRead = async () => {
setMarkingAll(true);
try {
await userApi.markAllAnnouncementsRead();
const now = new Date().toISOString();
setRows((current) => current.map((row) => ({ ...row, read: true, readAt: row.readAt || now })));
toast.success('已全部标记为已读');
} catch (error: any) {
toast.error(error?.message || '标记失败');
} finally {
setMarkingAll(false);
}
};
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">用户公告</h2>
<div className="page-actions">
<button
type="button"
onClick={() => load(true)}
disabled={refreshing}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
</button>
<button
type="button"
onClick={markAllRead}
disabled={markingAll || rows.length === 0}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
</button>
</div>
</div>
<div className="card" style={{ padding: 0 }}>
{loading ? (
<div style={{ padding: 24, textAlign: 'center' }}>
<span className="spinner spinner-sm" />
</div>
) : rows.length === 0 ? (
<div style={{ padding: 32, textAlign: 'center' }}>
<div className="empty-state-title">暂无公告</div>
<div className="empty-state-desc">管理员发布的公告会显示在这里。</div>
</div>
) : (
rows.map((row, index) => {
const level = row.level === 'warning' || row.level === 'error' ? row.level : 'info';
return (
<div
key={row.id}
className={`animate-slide-up stagger-${Math.min(index + 1, 5)}`}
style={{
padding: '16px 18px',
borderBottom: index === rows.length - 1 ? 'none' : '1px solid var(--color-border-light)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', marginBottom: 8 }}>
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>{row.title}</div>
<div style={{ marginTop: 5, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<span className={`badge ${level === 'error' ? 'badge-error' : level === 'warning' ? 'badge-warning' : 'badge-info'}`}>
{levelLabels[level]}
</span>
<span className={`badge ${row.read ? 'badge-muted' : 'badge-warning'}`}>
{row.read ? '已读' : '未读'}
</span>
</div>
</div>
{!row.read && (
<button
type="button"
className="btn btn-link btn-link-primary"
disabled={!!pending[row.id]}
onClick={() => markRead(row.id)}
>
{pending[row.id] ? <span className="spinner spinner-sm" /> : '标记已读'}
</button>
)}
</div>
<SiteAnnouncementContent content={row.content} />
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
发布时间:{row.publishedAt || row.firstSeenAt || '-'}
</div>
</div>
);
})
)}
</div>
</div>
);
}
+45 -12
View File
@@ -54,23 +54,48 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
if (!checkAuth()) return; if (!checkAuth()) return;
setLoading(true);
setError('');
try { try {
const [profileRes, keysRes, summaryRes, logsRes] = await Promise.all([ const profileRes = await userApi.me();
userApi.me(), setProfile(profileRes.user);
} catch (err: any) {
setError(err?.message || '加载失败');
if (String(err?.message || '').includes('401') || String(err?.message || '').includes('Unauthorized')) {
clearSession('user', localStorage);
navigate('/user/login', { replace: true });
}
setLoading(false);
return;
}
const [keysRes, summaryRes, logsRes] = await Promise.allSettled([
userApi.getApiKeys(), userApi.getApiKeys(),
userApi.getSummary(), userApi.getSummary(),
userApi.getLogs({ limit: 5 }), userApi.getLogs({ limit: 5 }),
]); ]);
setProfile(profileRes.user);
setKeys(Array.isArray(keysRes) ? keysRes : (keysRes.keys || [])); if (keysRes.status === 'fulfilled') {
setSummary(summaryRes); const payload: any = keysRes.value;
setRecentLogs(logsRes.items || []); setKeys(Array.isArray(payload) ? payload : (payload.keys || []));
} catch (err: any) { } else {
setError(err?.message || '加载失败'); setKeys([]);
} finally {
setLoading(false);
} }
}, [checkAuth]);
if (summaryRes.status === 'fulfilled') {
setSummary(summaryRes.value as UserSummary);
} else {
setSummary({ totalRequests: 0, successRequests: 0, totalCost: 0 });
}
if (logsRes.status === 'fulfilled') {
const payload: any = logsRes.value;
setRecentLogs(Array.isArray(payload?.items) ? payload.items : []);
} else {
setRecentLogs([]);
}
setLoading(false);
}, [checkAuth, navigate]);
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
@@ -88,7 +113,15 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
); );
} }
if (!profile) return null; if (!profile) {
return (
<div className="page-container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
<div className="card" style={{ padding: 20, color: 'var(--color-danger)' }}>
{error || '用户信息加载失败'}
</div>
</div>
);
}
const successRate = summary && summary.totalRequests > 0 const successRate = summary && summary.totalRequests > 0
? ((summary.successRequests / summary.totalRequests) * 100).toFixed(1) ? ((summary.successRequests / summary.totalRequests) * 100).toFixed(1)
+572
View File
@@ -0,0 +1,572 @@
import React, { CSSProperties, useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { api, fetchSessionResponse, userApi, type WorkspaceAsset } from '../api.js';
import type { SessionAudience } from '../authSession.js';
const SOURCE_OPTIONS = [
{ value: 'all', label: '全部来源' },
{ value: 'picture-agent', label: '生图助手' },
{ value: 'comfyui-agent', label: '视频助手' },
{ value: 'video-agent', label: '视频工作台' },
{ value: 'comfyui', label: 'ComfyUI' },
];
const KIND_OPTIONS = [
{ value: 'all', label: '全部类型' },
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
{ value: 'audio', label: '音频' },
{ value: 'workflow', label: '工作流' },
{ value: 'file', label: '文件' },
];
function formatBytes(value?: number) {
const bytes = Number(value || 0);
if (!bytes) return '未记录';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function sourceLabel(value: string) {
return SOURCE_OPTIONS.find((item) => item.value === value)?.label || value || '未知来源';
}
function kindLabel(value: string) {
return KIND_OPTIONS.find((item) => item.value === value)?.label || value || '文件';
}
function statusLabel(value?: string) {
if (value === 'failed') return '归档失败';
if (value === 'pending') return '处理中';
return '已归档';
}
function assetTitle(asset: WorkspaceAsset) {
return asset.title || asset.prompt || asset.fileName || asset.publicId;
}
export default function Workspace() {
const location = useLocation();
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const [items, setItems] = useState<WorkspaceAsset[]>([]);
const [total, setTotal] = useState(0);
const [source, setSource] = useState('all');
const [kind, setKind] = useState('all');
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
const [selectedId, setSelectedId] = useState('');
const selected = items.find((item) => item.publicId === selectedId) || items[0] || null;
const sourceStats = useMemo(() => {
const counts = new Map<string, number>();
items.forEach((item) => counts.set(item.source, (counts.get(item.source) || 0) + 1));
return Array.from(counts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 4);
}, [items]);
const localCount = useMemo(() => items.filter((item) => item.hasLocalFile).length, [items]);
const params = useMemo(() => ({
source: source === 'all' ? undefined : source,
kind: kind === 'all' ? undefined : kind,
q: query.trim() || undefined,
page: 1,
pageSize: 60,
}), [kind, query, source]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const response = audience === 'user'
? await userApi.getWorkspaceAssets(params)
: await api.getWorkspaceAssets(params);
setItems(Array.isArray(response.items) ? response.items : []);
setTotal(Number(response.total || 0));
setSelectedId((current) => {
if (current && response.items?.some((item) => item.publicId === current)) return current;
return response.items?.[0]?.publicId || '';
});
} catch (err: any) {
setError(err?.message || '工作空间加载失败');
} finally {
setLoading(false);
}
}, [audience, params]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
let cancelled = false;
const objectUrls: string[] = [];
async function hydratePreviews() {
const candidates = items
.filter((item) => item.hasLocalFile && (item.kind === 'image' || item.kind === 'video' || item.kind === 'audio'))
.slice(0, 24);
const next: Record<string, string> = {};
await Promise.all(candidates.map(async (asset) => {
try {
const response = await fetchSessionResponse(
audience,
`${audience === 'user' ? '/api/user' : '/api'}/workspace/assets/${encodeURIComponent(asset.publicId)}/file`,
{ timeoutMs: 30_000 },
);
if (!response.ok) return;
const blob = await response.blob();
const url = URL.createObjectURL(blob);
objectUrls.push(url);
next[asset.publicId] = url;
} catch {
// Preview hydration is best-effort; the asset row still remains usable.
}
}));
if (!cancelled) setPreviewUrls(next);
}
void hydratePreviews();
return () => {
cancelled = true;
objectUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [audience, items]);
const handleDelete = async (asset: WorkspaceAsset) => {
if (!window.confirm(`删除「${assetTitle(asset)}」?`)) return;
try {
if (audience === 'user') {
await userApi.deleteWorkspaceAsset(asset.publicId);
} else {
await api.deleteWorkspaceAsset(asset.publicId);
}
await load();
} catch (err: any) {
setError(err?.message || '删除失败');
}
};
const handleDownload = async (asset: WorkspaceAsset) => {
const previewUrl = previewUrls[asset.publicId];
const href = previewUrl || asset.remoteUrl || '';
if (!href) return;
const link = document.createElement('a');
link.href = href;
link.download = asset.fileName || `${asset.publicId}`;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
};
return (
<div>
<div className="page-header">
<div>
<h1 className="page-title">工作空间</h1>
<p className="page-subtitle">
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'} · 自动归档生图助手、视频助手和 ComfyUI 产出
</p>
</div>
<div className="page-actions">
<button type="button" className="btn btn-ghost" onClick={() => void load()} disabled={loading}>
{loading ? '刷新中...' : '刷新'}
</button>
</div>
</div>
<div style={summaryStyle}>
<div style={summaryItemStyle}>
<span style={summaryLabelStyle}>当前结果</span>
<strong style={summaryValueStyle}>{total}</strong>
</div>
<div style={summaryItemStyle}>
<span style={summaryLabelStyle}>本地文件</span>
<strong style={summaryValueStyle}>{localCount}</strong>
</div>
<div style={summaryItemStyle}>
<span style={summaryLabelStyle}>主要来源</span>
<strong style={{ ...summaryValueStyle, fontSize: 14 }}>
{sourceStats.length > 0 ? sourceStats.map(([name, count]) => `${sourceLabel(name)} ${count}`).join(' · ') : '暂无'}
</strong>
</div>
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
<div style={layoutStyle}>
<section>
<div style={toolbarStyle}>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索标题、提示词、模型或来源"
style={inputStyle}
/>
<div style={filterRowStyle}>
{SOURCE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
style={source === option.value ? activeChipStyle : chipStyle}
onClick={() => setSource(option.value)}
>
{option.label}
</button>
))}
</div>
<div style={filterRowStyle}>
{KIND_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
style={kind === option.value ? activeChipStyle : chipStyle}
onClick={() => setKind(option.value)}
>
{option.label}
</button>
))}
</div>
</div>
<div style={gridStyle}>
{items.length === 0 ? (
<div className="empty-state" style={{ minHeight: 360, gridColumn: '1 / -1' }}>
<div className="empty-state-title">{loading ? '正在加载工作空间' : '暂无成品产出'}</div>
<div className="empty-state-desc">生图助手、视频助手和 ComfyUI 的产出会归档到这里。</div>
</div>
) : items.map((asset) => {
const preview = previewUrls[asset.publicId] || asset.remoteUrl || '';
const active = selected?.publicId === asset.publicId;
return (
<button
type="button"
key={asset.publicId}
style={{
...cardStyle,
borderColor: active ? 'var(--color-primary)' : 'var(--color-border)',
boxShadow: active ? '0 14px 32px color-mix(in srgb, var(--color-primary) 16%, transparent)' : undefined,
}}
onClick={() => setSelectedId(asset.publicId)}
>
<div style={thumbStyle}>
{asset.kind === 'image' && preview ? (
<img src={preview} alt="" style={mediaStyle} />
) : asset.kind === 'video' && preview ? (
<video src={preview} style={mediaStyle} muted />
) : asset.kind === 'audio' ? (
<div style={placeholderStyle}>音频</div>
) : (
<div style={placeholderStyle}>{kindLabel(asset.kind)}</div>
)}
<span style={typeBadgeStyle}>{kindLabel(asset.kind)}</span>
</div>
<div style={cardBodyStyle}>
<strong style={cardTitleStyle}>{assetTitle(asset)}</strong>
<span style={metaStyle}>{sourceLabel(asset.source)} · {asset.model || '未记录模型'}</span>
<div style={cardFooterStyle}>
<span style={statusPillStyle}>{statusLabel(asset.status)}</span>
<span style={metaStyle}>{asset.createdAt || ''}</span>
</div>
</div>
</button>
);
})}
</div>
</section>
<aside style={detailStyle}>
{selected ? (
<>
<div style={detailHeaderStyle}>
<div>
<div style={detailEyebrowStyle}>{sourceLabel(selected.source)} · {kindLabel(selected.kind)}</div>
<h2 style={{ margin: '6px 0 0', fontSize: 18 }}>{assetTitle(selected)}</h2>
</div>
<span style={statusPillStyle}>{statusLabel(selected.status)}</span>
</div>
<div style={detailPreviewStyle}>
{selected.kind === 'image' && (previewUrls[selected.publicId] || selected.remoteUrl) ? (
<img src={previewUrls[selected.publicId] || selected.remoteUrl || ''} alt="" style={mediaStyle} />
) : selected.kind === 'video' && (previewUrls[selected.publicId] || selected.remoteUrl) ? (
<video src={previewUrls[selected.publicId] || selected.remoteUrl || ''} controls style={mediaStyle} />
) : selected.kind === 'audio' && previewUrls[selected.publicId] ? (
<audio src={previewUrls[selected.publicId]} controls style={{ width: '100%' }} />
) : (
<div style={placeholderStyle}>{kindLabel(selected.kind)}</div>
)}
</div>
<div style={detailMetaStyle}>
<div><span>用户</span><strong>{selected.userId ?? '管理员/系统'}</strong></div>
<div><span>模型</span><strong>{selected.model || '未记录'}</strong></div>
<div><span>大小</span><strong>{formatBytes(selected.sizeBytes)}</strong></div>
<div><span>文件</span><strong>{selected.hasLocalFile ? '本地归档' : '远程引用'}</strong></div>
</div>
{selected.prompt && (
<div style={promptPanelStyle}>
<span>提示词</span>
<p style={promptStyle}>{selected.prompt}</p>
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button type="button" className="btn btn-primary" onClick={() => void handleDownload(selected)}>
下载
</button>
<button type="button" className="btn btn-ghost" onClick={() => void handleDelete(selected)}>
删除
</button>
</div>
</>
) : (
<div className="empty-state" style={{ minHeight: 260 }}>
<div className="empty-state-title">选择一个成品</div>
<div className="empty-state-desc">详情、预览和下载操作会显示在这里。</div>
</div>
)}
</aside>
</div>
</div>
);
}
const toolbarStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 12,
marginBottom: 16,
padding: 14,
border: '1px solid var(--color-border)',
borderRadius: 8,
background: 'var(--color-bg-card)',
};
const inputStyle: CSSProperties = {
height: 40,
border: '1px solid var(--color-border)',
borderRadius: 6,
padding: '0 12px',
background: 'var(--color-bg-card)',
color: 'var(--color-text-primary)',
};
const layoutStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(300px, 380px)',
gap: 16,
alignItems: 'start',
};
const gridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))',
gap: 14,
};
const cardStyle: CSSProperties = {
textAlign: 'left',
border: '1px solid var(--color-border)',
borderRadius: 8,
overflow: 'hidden',
background: 'var(--color-bg-card)',
color: 'var(--color-text-primary)',
padding: 0,
cursor: 'pointer',
transition: 'border-color 140ms ease, transform 140ms ease, box-shadow 140ms ease',
};
const thumbStyle: CSSProperties = {
position: 'relative',
aspectRatio: '16 / 11',
background: 'var(--color-bg-subtle)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
};
const mediaStyle: CSSProperties = {
width: '100%',
height: '100%',
objectFit: 'contain',
};
const placeholderStyle: CSSProperties = {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-muted)',
fontWeight: 700,
background: 'linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card)), var(--color-bg-card))',
};
const cardBodyStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
padding: 12,
};
const cardTitleStyle: CSSProperties = {
fontSize: 13,
lineHeight: 1.35,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
};
const metaStyle: CSSProperties = {
fontSize: 12,
color: 'var(--color-text-muted)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
};
const detailStyle: CSSProperties = {
border: '1px solid var(--color-border)',
borderRadius: 8,
background: 'var(--color-bg-card)',
padding: 16,
position: 'sticky',
top: 84,
};
const detailPreviewStyle: CSSProperties = {
aspectRatio: '16 / 11',
borderRadius: 6,
overflow: 'hidden',
background: 'var(--color-bg-subtle)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
const detailMetaStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 10,
fontSize: 12,
color: 'var(--color-text-muted)',
marginTop: 14,
};
const promptStyle: CSSProperties = {
margin: '6px 0 0',
color: 'var(--color-text-secondary)',
fontSize: 13,
lineHeight: 1.6,
maxHeight: 160,
overflow: 'auto',
};
const summaryStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: '160px 160px minmax(0, 1fr)',
gap: 12,
marginBottom: 14,
};
const summaryItemStyle: CSSProperties = {
border: '1px solid var(--color-border)',
borderRadius: 8,
background: 'var(--color-bg-card)',
padding: '12px 14px',
minHeight: 70,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 4,
};
const summaryLabelStyle: CSSProperties = {
fontSize: 12,
color: 'var(--color-text-muted)',
};
const summaryValueStyle: CSSProperties = {
fontSize: 22,
color: 'var(--color-text-primary)',
lineHeight: 1.2,
};
const filterRowStyle: CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
gap: 8,
};
const chipStyle: CSSProperties = {
border: '1px solid var(--color-border)',
borderRadius: 999,
background: 'var(--color-bg)',
color: 'var(--color-text-secondary)',
padding: '7px 12px',
cursor: 'pointer',
fontSize: 12,
};
const activeChipStyle: CSSProperties = {
...chipStyle,
borderColor: 'var(--color-primary)',
background: 'var(--color-primary-light)',
color: 'var(--color-primary)',
fontWeight: 700,
};
const typeBadgeStyle: CSSProperties = {
position: 'absolute',
left: 10,
top: 10,
borderRadius: 999,
padding: '4px 8px',
background: 'rgba(0, 0, 0, 0.56)',
color: '#fff',
fontSize: 12,
fontWeight: 700,
};
const cardFooterStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
};
const statusPillStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
borderRadius: 999,
padding: '4px 8px',
background: 'color-mix(in srgb, var(--color-success) 12%, var(--color-bg-card))',
color: 'var(--color-success)',
fontSize: 12,
fontWeight: 700,
whiteSpace: 'nowrap',
};
const detailHeaderStyle: CSSProperties = {
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
marginBottom: 12,
};
const detailEyebrowStyle: CSSProperties = {
fontSize: 12,
color: 'var(--color-text-muted)',
};
const promptPanelStyle: CSSProperties = {
marginTop: 14,
border: '1px solid var(--color-border-light)',
borderRadius: 8,
padding: 12,
background: 'var(--color-bg)',
fontSize: 12,
color: 'var(--color-text-muted)',
};
+17 -31
View File
@@ -8,12 +8,12 @@ import { sidebarGroups } from '../App.js';
const { apiMock } = vi.hoisted(() => ({ const { apiMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
getSiteAnnouncements: vi.fn(), getSiteAnnouncements: vi.fn(),
getSites: vi.fn(), createSiteAnnouncement: vi.fn(),
getRuntimeSettings: vi.fn(), updateSiteAnnouncement: vi.fn(),
deleteSiteAnnouncement: vi.fn(),
markSiteAnnouncementRead: vi.fn(), markSiteAnnouncementRead: vi.fn(),
markAllSiteAnnouncementsRead: vi.fn(), markAllSiteAnnouncementsRead: vi.fn(),
clearSiteAnnouncements: vi.fn(), clearSiteAnnouncements: vi.fn(),
syncSiteAnnouncements: vi.fn(),
}, },
})); }));
@@ -47,44 +47,38 @@ function collectText(node: any): string {
describe('SiteAnnouncements page', () => { describe('SiteAnnouncements page', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
apiMock.getSites.mockResolvedValue([
{ id: 9, name: 'Sub Site', platform: 'sub2api' },
]);
apiMock.getRuntimeSettings.mockResolvedValue({
serverTimeZone: 'UTC',
});
apiMock.getSiteAnnouncements.mockResolvedValue([ apiMock.getSiteAnnouncements.mockResolvedValue([
{ {
id: 12, id: 12,
siteId: 9, title: '维护通知',
platform: 'sub2api', content: '今晚 10:00 维护',
sourceKey: 'announcement:12',
title: 'Maintenance',
content: 'Window starts at 10:00',
level: 'info', level: 'info',
status: 'published',
firstSeenAt: '2026-03-20 04:23:27', firstSeenAt: '2026-03-20 04:23:27',
lastSeenAt: '2026-03-20 04:23:27', publishedAt: '2026-03-20 04:23:27',
readAt: null, readCount: 3,
}, },
]); ]);
apiMock.clearSiteAnnouncements.mockResolvedValue({ success: true }); apiMock.clearSiteAnnouncements.mockResolvedValue({ success: true });
apiMock.markAllSiteAnnouncementsRead.mockResolvedValue({ success: true }); apiMock.markAllSiteAnnouncementsRead.mockResolvedValue({ success: true });
apiMock.syncSiteAnnouncements.mockResolvedValue({ success: true, queued: true, taskId: 'task-1' }); apiMock.createSiteAnnouncement.mockResolvedValue({ success: true });
apiMock.updateSiteAnnouncement.mockResolvedValue({ success: true });
apiMock.deleteSiteAnnouncement.mockResolvedValue({ success: true });
}); });
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('exposes the renamed sidebar item and the site announcements entry', () => { it('exposes the user announcements sidebar entry', () => {
const consoleGroup = sidebarGroups.find((group) => group.label === '控制台'); const consoleGroup = sidebarGroups.find((group) => group.label === '控制台');
const labels = (consoleGroup?.items || []).map((item) => item.label); const labels = (consoleGroup?.items || []).map((item) => item.label);
expect(labels).toContain('站点管理'); expect(labels).toContain('站点管理');
expect(labels).toContain('站点公告'); expect(labels).toContain('用户公告');
}); });
it('loads announcements, highlights the focused row, and clears local rows', async () => { it('loads admin announcements and clears local rows', async () => {
let root!: WebTestRenderer; let root!: WebTestRenderer;
try { try {
await act(async () => { await act(async () => {
@@ -111,17 +105,9 @@ describe('SiteAnnouncements page', () => {
await flushMicrotasks(); await flushMicrotasks();
expect(apiMock.getSiteAnnouncements).toHaveBeenCalled(); expect(apiMock.getSiteAnnouncements).toHaveBeenCalled();
expect(apiMock.getSites).toHaveBeenCalled(); expect(JSON.stringify(root!.toJSON())).toContain('用户公告');
expect(apiMock.getRuntimeSettings).toHaveBeenCalled(); expect(JSON.stringify(root!.toJSON())).toContain('维护通知');
expect(JSON.stringify(root!.toJSON())).toContain('站点公告'); expect(JSON.stringify(root!.toJSON())).toContain('今晚 10:00 维护');
expect(JSON.stringify(root!.toJSON())).toContain('Maintenance');
expect(JSON.stringify(root!.toJSON())).toContain('2026/03/20 12:23:27');
const highlightedRows = root!.root.findAll((node) => {
const className = typeof node.props.className === 'string' ? node.props.className : '';
return className.includes('row-focus-highlight') && collectText(node).includes('Maintenance');
});
expect(highlightedRows).toHaveLength(1);
const clearButton = root!.root.find((node) => ( const clearButton = root!.root.find((node) => (
node.type === 'button' node.type === 'button'