Merge pull request #444 from Open-Less/feat/marketplace-tab
feat: marketplace tab + 一键发布 + Settings 风格市场配置
This commit is contained in:
16 files changed
+1026
No files matched your search
@@ -2273,6 +2273,246 @@ pub fn export_error_log(target_path: String) -> Result<(), String> {
|
||||
#[allow(dead_code)]
|
||||
fn _ensure_snapshot_used(_: CredentialsSnapshot) {}
|
||||
|
||||
// ─────────────────────────── marketplace (Phase A) ───────────────────────────
|
||||
//
|
||||
// 客户端跟 marketplace backend 的 HTTP 客户端封装。Backend URL 走 prefs
|
||||
// `marketplace_base_url`(默认 http://127.0.0.1:8090 开发;生产用户填 https://api.<domain>)。
|
||||
// dev-mode auth:用户在 Settings 填 `marketplace_dev_login`(GitHub 风格 username),
|
||||
// 后续 OAuth 接入时换成 token 字段。
|
||||
//
|
||||
// 5 个 IPC:
|
||||
// - marketplace_list 列表 + 搜索 + 排序
|
||||
// - marketplace_detail 详情(含完整 prompt)
|
||||
// - marketplace_install 下载 ZIP + 直接调 import_from_zip 装到本地
|
||||
// - marketplace_upload 把本地某个 style pack export ZIP → multipart 上传
|
||||
// - marketplace_like 点赞
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MarketplaceListItem {
|
||||
pub id: String,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub author_login: String,
|
||||
pub version: String,
|
||||
pub base_mode: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
pub like_count: i64,
|
||||
pub download_count: i64,
|
||||
pub published_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MarketplaceDetail {
|
||||
#[serde(flatten)]
|
||||
pub summary: MarketplaceListItem,
|
||||
pub prompt: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
fn marketplace_url_from_prefs(prefs: &UserPreferences) -> String {
|
||||
let base = prefs.marketplace_base_url.trim();
|
||||
if base.is_empty() {
|
||||
"http://127.0.0.1:8090".to_string()
|
||||
} else {
|
||||
base.trim_end_matches('/').to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn marketplace_dev_user(prefs: &UserPreferences) -> String {
|
||||
let login = prefs.marketplace_dev_login.trim();
|
||||
if login.is_empty() {
|
||||
"anonymous".to_string()
|
||||
} else {
|
||||
login.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn marketplace_list(
|
||||
coord: CoordinatorState<'_>,
|
||||
query: Option<String>,
|
||||
sort: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Vec<MarketplaceListItem>, String> {
|
||||
let prefs = coord.prefs().get();
|
||||
let base = marketplace_url_from_prefs(&prefs);
|
||||
let mut url = reqwest::Url::parse(&format!("{base}/packs"))
|
||||
.map_err(|e| format!("invalid marketplace url: {e}"))?;
|
||||
if let Some(q) = query.as_deref() {
|
||||
if !q.trim().is_empty() {
|
||||
url.query_pairs_mut().append_pair("q", q.trim());
|
||||
}
|
||||
}
|
||||
if let Some(s) = sort.as_deref() {
|
||||
if !s.trim().is_empty() {
|
||||
url.query_pairs_mut().append_pair("sort", s.trim());
|
||||
}
|
||||
}
|
||||
if let Some(n) = limit {
|
||||
url.query_pairs_mut().append_pair("limit", &n.to_string());
|
||||
}
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(url)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("marketplace request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("marketplace HTTP {status}: {body}"));
|
||||
}
|
||||
let items: Vec<MarketplaceListItem> =
|
||||
resp.json().await.map_err(|e| format!("parse failed: {e}"))?;
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn marketplace_detail(
|
||||
coord: CoordinatorState<'_>,
|
||||
pack_id: String,
|
||||
) -> Result<MarketplaceDetail, String> {
|
||||
if !is_valid_session_id(&pack_id) {
|
||||
return Err("invalid pack id".into());
|
||||
}
|
||||
let prefs = coord.prefs().get();
|
||||
let base = marketplace_url_from_prefs(&prefs);
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(format!("{base}/packs/{pack_id}"))
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("marketplace request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
return Err(format!("marketplace HTTP {status}"));
|
||||
}
|
||||
resp.json::<MarketplaceDetail>()
|
||||
.await
|
||||
.map_err(|e| format!("parse failed: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn marketplace_install(
|
||||
coord: CoordinatorState<'_>,
|
||||
pack_id: String,
|
||||
) -> Result<StylePack, String> {
|
||||
// 安全校验:pack_id 来自远端 backend,可能含路径遍历 segment。
|
||||
// 用跟 read_audio_recording 同样的 UUID-v4 白名单挡住 ../ / 绝对路径等。
|
||||
// backend 当前用 Uuid::new_v4 生成所有 id,合法 id 必然匹配。
|
||||
if !is_valid_session_id(&pack_id) {
|
||||
return Err("invalid pack id".into());
|
||||
}
|
||||
let prefs = coord.prefs().get();
|
||||
let base = marketplace_url_from_prefs(&prefs);
|
||||
let client = reqwest::Client::new();
|
||||
let bytes = client
|
||||
.get(format!("{base}/packs/{pack_id}/download"))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("marketplace download failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("marketplace HTTP error: {e}"))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("read body failed: {e}"))?;
|
||||
|
||||
// pack_id 已经过 UUID 白名单,拼临时文件路径安全。
|
||||
let tmp = std::env::temp_dir().join(format!("openless-marketplace-{pack_id}.zip"));
|
||||
std::fs::write(&tmp, &bytes).map_err(|e| format!("write tmp zip: {e}"))?;
|
||||
let result = coord
|
||||
.style_packs()
|
||||
.import_from_zip(&tmp)
|
||||
.map_err(|e| e.to_string());
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn marketplace_upload(
|
||||
coord: CoordinatorState<'_>,
|
||||
pack_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
// 本地 style pack id 也是 Uuid::new_v4 字面,跟远端同形态。挡 path traversal。
|
||||
if !is_valid_session_id(&pack_id) {
|
||||
return Err("invalid pack id".into());
|
||||
}
|
||||
let prefs = coord.prefs().get();
|
||||
let base = marketplace_url_from_prefs(&prefs);
|
||||
let dev_user = marketplace_dev_user(&prefs);
|
||||
|
||||
// 先 export 本地 pack → 临时 ZIP
|
||||
let tmp = std::env::temp_dir().join(format!("openless-marketplace-upload-{pack_id}.zip"));
|
||||
coord
|
||||
.style_packs()
|
||||
.export_to_zip(&pack_id, &tmp)
|
||||
.map_err(|e| format!("export local pack failed: {e}"))?;
|
||||
let bytes = std::fs::read(&tmp).map_err(|e| format!("read exported zip: {e}"))?;
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let part = reqwest::multipart::Part::bytes(bytes)
|
||||
.file_name(format!("{pack_id}.zip"))
|
||||
.mime_str("application/zip")
|
||||
.map_err(|e| format!("multipart build failed: {e}"))?;
|
||||
let form = reqwest::multipart::Form::new().part("file", part);
|
||||
let resp = client
|
||||
.post(format!("{base}/packs"))
|
||||
.header("X-Dev-User", dev_user)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("upload request failed: {e}"))?;
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|e| format!("read body failed: {e}"))
|
||||
.clone();
|
||||
if !status.is_success() {
|
||||
return Err(format!("upload HTTP {status}: {body}"));
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(&body)
|
||||
.map_err(|e| format!("parse upload response failed: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn marketplace_like(
|
||||
coord: CoordinatorState<'_>,
|
||||
pack_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
if !is_valid_session_id(&pack_id) {
|
||||
return Err("invalid pack id".into());
|
||||
}
|
||||
let prefs = coord.prefs().get();
|
||||
let base = marketplace_url_from_prefs(&prefs);
|
||||
let dev_user = marketplace_dev_user(&prefs);
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{base}/packs/{pack_id}/like"))
|
||||
.header("X-Dev-User", dev_user)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("like request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("like HTTP {}", resp.status()));
|
||||
}
|
||||
resp.json::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(|e| format!("parse failed: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
|
||||
@@ -284,6 +284,11 @@ pub fn run() {
|
||||
commands::delete_history_entry,
|
||||
commands::clear_history,
|
||||
commands::read_audio_recording,
|
||||
commands::marketplace_list,
|
||||
commands::marketplace_detail,
|
||||
commands::marketplace_install,
|
||||
commands::marketplace_upload,
|
||||
commands::marketplace_like,
|
||||
commands::list_vocab,
|
||||
commands::add_vocab,
|
||||
commands::remove_vocab,
|
||||
|
||||
@@ -622,6 +622,14 @@ pub struct UserPreferences {
|
||||
/// 这种「文本档案多 + 录音不占盘」组合下精确控制。
|
||||
#[serde(default)]
|
||||
pub audio_recording_max_entries: Option<u32>,
|
||||
/// Style Pack Marketplace HTTP 基地址。空 = 本地开发默认 http://127.0.0.1:8090;
|
||||
/// 用户在 Settings 里填生产 URL (如 https://api.openless-marketplace.com)。
|
||||
#[serde(default)]
|
||||
pub marketplace_base_url: String,
|
||||
/// Marketplace dev-mode 模拟登录用户名(GitHub login 风格)。生产换 OAuth token 后此字段废弃。
|
||||
/// 上传 / 点赞需要带这个 header;空时上传被后端 401。
|
||||
#[serde(default)]
|
||||
pub marketplace_dev_login: String,
|
||||
}
|
||||
|
||||
fn default_local_asr_model() -> String {
|
||||
@@ -735,6 +743,10 @@ struct UserPreferencesWire {
|
||||
record_audio_for_debug: bool,
|
||||
#[serde(default)]
|
||||
audio_recording_max_entries: Option<u32>,
|
||||
#[serde(default)]
|
||||
marketplace_base_url: String,
|
||||
#[serde(default)]
|
||||
marketplace_dev_login: String,
|
||||
}
|
||||
|
||||
impl Default for UserPreferencesWire {
|
||||
@@ -785,6 +797,8 @@ impl Default for UserPreferencesWire {
|
||||
history_max_entries: prefs.history_max_entries,
|
||||
record_audio_for_debug: prefs.record_audio_for_debug,
|
||||
audio_recording_max_entries: prefs.audio_recording_max_entries,
|
||||
marketplace_base_url: prefs.marketplace_base_url,
|
||||
marketplace_dev_login: prefs.marketplace_dev_login,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -857,6 +871,8 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
||||
history_max_entries: wire.history_max_entries,
|
||||
record_audio_for_debug: wire.record_audio_for_debug,
|
||||
audio_recording_max_entries: wire.audio_recording_max_entries,
|
||||
marketplace_base_url: wire.marketplace_base_url,
|
||||
marketplace_dev_login: wire.marketplace_dev_login,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1210,6 +1226,8 @@ impl Default for UserPreferences {
|
||||
history_max_entries: None,
|
||||
record_audio_for_debug: false,
|
||||
audio_recording_max_entries: None,
|
||||
marketplace_base_url: String::new(),
|
||||
marketplace_dev_login: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Vocab } from '../pages/Vocab';
|
||||
import { Style } from '../pages/Style';
|
||||
import { Translation } from '../pages/Translation';
|
||||
import { SelectionAsk } from '../pages/SelectionAsk';
|
||||
import { Marketplace } from '../pages/Marketplace';
|
||||
// LocalAsr 不再作为主 nav tab——本地 ASR 模型管理已合并到 Settings → Advanced 中
|
||||
// 通过 <LocalAsr embedded /> 渲染。这里之前的 import 与 NAV_BASE 条目都已移除。
|
||||
import { APP_VERSION_LABEL, IS_BETA_BUILD } from '../lib/appVersion';
|
||||
@@ -44,6 +45,7 @@ const NAV_BASE: Array<Omit<NavItem, 'name'>> = [
|
||||
{ id: 'history', icon: 'history', cmp: History },
|
||||
{ id: 'vocab', icon: 'vocab', cmp: Vocab },
|
||||
{ id: 'style', icon: 'style', cmp: Style },
|
||||
{ id: 'marketplace', icon: 'cloud', cmp: Marketplace },
|
||||
{ id: 'translation', icon: 'translate', cmp: Translation },
|
||||
{ id: 'selectionAsk', icon: 'selectionAsk', cmp: SelectionAsk },
|
||||
];
|
||||
|
||||
@@ -58,10 +58,40 @@ export const en: typeof zhCN = {
|
||||
history: 'History',
|
||||
vocab: 'Vocabulary',
|
||||
style: 'Style',
|
||||
marketplace: 'Marketplace',
|
||||
translation: 'Translation',
|
||||
selectionAsk: 'Ask',
|
||||
localAsr: 'Models',
|
||||
},
|
||||
marketplace: {
|
||||
kicker: 'MARKETPLACE',
|
||||
title: 'Style Pack Marketplace',
|
||||
desc: 'Browse community style packs, install in one click, like, and share your own.',
|
||||
searchPlaceholder: 'Search name / description / tags…',
|
||||
sortPopular: 'Popular',
|
||||
sortNew: 'Newest',
|
||||
uploadBtn: 'Upload',
|
||||
uploadDisabledHint: 'Set your GitHub login in Settings → Marketplace first',
|
||||
refreshBtn: 'Refresh',
|
||||
empty: 'No style packs yet',
|
||||
emptyHint: 'Try a different keyword, or upload your own',
|
||||
loadFailed: 'Load failed: {{err}}',
|
||||
noDescription: '(no description)',
|
||||
installBtn: 'Install',
|
||||
likeBtn: 'Like',
|
||||
installed: 'Installed "{{name}}" locally',
|
||||
uploaded: 'Uploaded — waiting for review',
|
||||
uploadTitle: 'Pick a style pack to upload',
|
||||
uploadHint: 'Uploading as {{login}}. Content goes to the cloud review queue.',
|
||||
uploadNoLocal: 'No local style packs to upload',
|
||||
errors: {
|
||||
detail: 'Detail load failed: {{err}}',
|
||||
install: 'Install failed: {{err}}',
|
||||
like: 'Like failed: {{err}}',
|
||||
upload: 'Upload failed: {{err}}',
|
||||
loadLocal: 'Load local packs failed: {{err}}',
|
||||
},
|
||||
},
|
||||
shell: {
|
||||
shortcutLabel: 'Recording shortcut',
|
||||
shortcutHint: 'Start / Stop',
|
||||
@@ -368,6 +398,11 @@ export const en: typeof zhCN = {
|
||||
startMinimizedDesc: 'No main window on any launch path — menu bar / tray only.',
|
||||
autoUpdateCheckLabel: 'Auto-check for updates',
|
||||
autoUpdateCheckDesc: 'Check for new releases on main window launch and every 60 minutes. When off, only the manual "Check for updates" button in About works.',
|
||||
marketplaceGroupTitle: 'Style Pack Marketplace',
|
||||
marketplaceBaseUrlLabel: 'Backend URL',
|
||||
marketplaceBaseUrlDesc: 'Marketplace backend HTTP URL. Blank = local dev default http://127.0.0.1:8090; production: https://api.<your-domain>.',
|
||||
marketplaceDevLoginLabel: 'GitHub login (upload identity)',
|
||||
marketplaceDevLoginDesc: 'Dev-mode identifies uploader by GitHub login. When blank, upload/like is disabled. Will be replaced by OAuth later.',
|
||||
startupAtBoot: 'Launch at login',
|
||||
startupAtBootDesc: 'Start OpenLess automatically when you sign in.',
|
||||
startupAtBootError: 'Failed to toggle launch at login: {{message}}',
|
||||
|
||||
@@ -60,6 +60,7 @@ export const ja: typeof zhCN = {
|
||||
history: '履歴',
|
||||
vocab: '語彙',
|
||||
style: 'スタイル',
|
||||
marketplace: 'マーケット',
|
||||
translation: '翻訳',
|
||||
selectionAsk: '選択追問',
|
||||
localAsr: 'モデル設定',
|
||||
@@ -370,6 +371,11 @@ export const ja: typeof zhCN = {
|
||||
startMinimizedDesc: 'どの起動経路でもメインウィンドウを開かず、メニューバー / トレイのみで動作。',
|
||||
autoUpdateCheckLabel: 'アップデートを自動チェック',
|
||||
autoUpdateCheckDesc: 'メインウィンドウ起動時と 60 分ごとに新バージョンを確認します。オフ時は「バージョン情報」内の手動ボタンのみ有効。',
|
||||
marketplaceGroupTitle: 'スタイルパックマーケット',
|
||||
marketplaceBaseUrlLabel: 'バックエンド URL',
|
||||
marketplaceBaseUrlDesc: 'マーケットプレイス バックエンド HTTP URL。空 = ローカル開発 http://127.0.0.1:8090;本番は https://api.<ドメイン>。',
|
||||
marketplaceDevLoginLabel: 'GitHub ログイン名(アップロード ID)',
|
||||
marketplaceDevLoginDesc: 'dev モードでは GitHub ログイン名でアップロード者を識別。空時はアップロード/いいね不可。',
|
||||
startupAtBoot: '起動時に自動起動',
|
||||
startupAtBootDesc: 'ログイン時に OpenLess を自動起動。',
|
||||
startupAtBootError: '自動起動の切り替えに失敗:{{message}}',
|
||||
|
||||
@@ -60,6 +60,7 @@ export const ko: typeof zhCN = {
|
||||
history: '기록',
|
||||
vocab: '어휘',
|
||||
style: '스타일',
|
||||
marketplace: '마켓',
|
||||
translation: '번역',
|
||||
selectionAsk: '선택 질문',
|
||||
localAsr: '모델 설정',
|
||||
@@ -370,6 +371,11 @@ export const ko: typeof zhCN = {
|
||||
startMinimizedDesc: '모든 시작 경로에서 메인 창을 열지 않고 메뉴 막대 / 트레이에서만 실행합니다.',
|
||||
autoUpdateCheckLabel: '자동 업데이트 확인',
|
||||
autoUpdateCheckDesc: '메인 창 시작 시와 60분마다 새 버전을 확인합니다. 끄면 "정보" 패널의 수동 버튼만 동작합니다.',
|
||||
marketplaceGroupTitle: '스타일 팩 마켓플레이스',
|
||||
marketplaceBaseUrlLabel: '백엔드 URL',
|
||||
marketplaceBaseUrlDesc: '마켓플레이스 백엔드 HTTP URL. 비워두면 로컬 개발 http://127.0.0.1:8090; 프로덕션은 https://api.<도메인>.',
|
||||
marketplaceDevLoginLabel: 'GitHub 로그인 이름 (업로드 ID)',
|
||||
marketplaceDevLoginDesc: 'dev 모드에서는 GitHub 로그인 이름으로 업로더 식별. 비워두면 업로드/좋아요 불가.',
|
||||
startupAtBoot: '부팅 시 자동 시작',
|
||||
startupAtBootDesc: '로그인 시 OpenLess 자동 시작.',
|
||||
startupAtBootError: '자동 시작 전환 실패: {{message}}',
|
||||
|
||||
@@ -56,10 +56,40 @@ export const zhCN = {
|
||||
history: '历史',
|
||||
vocab: '词汇表',
|
||||
style: '风格',
|
||||
marketplace: '风格市场',
|
||||
translation: '翻译',
|
||||
selectionAsk: '划词追问',
|
||||
localAsr: '模型设置',
|
||||
},
|
||||
marketplace: {
|
||||
kicker: 'MARKETPLACE',
|
||||
title: '风格包市场',
|
||||
desc: '浏览社区风格包,一键安装到本地;点赞和上传你自己的风格包。',
|
||||
searchPlaceholder: '搜索名称 / 描述 / 标签…',
|
||||
sortPopular: '按热度',
|
||||
sortNew: '最新',
|
||||
uploadBtn: '上传',
|
||||
uploadDisabledHint: '请先在 设置 → 风格市场 配置 GitHub 用户名',
|
||||
refreshBtn: '刷新',
|
||||
empty: '还没有风格包',
|
||||
emptyHint: '换个搜索词,或自己上传一个分享给社区',
|
||||
loadFailed: '加载失败:{{err}}',
|
||||
noDescription: '(暂无描述)',
|
||||
installBtn: '安装到本地',
|
||||
likeBtn: '点赞',
|
||||
installed: '已安装「{{name}}」到本地风格包',
|
||||
uploaded: '上传成功,等待审核',
|
||||
uploadTitle: '选择要上传的风格包',
|
||||
uploadHint: '上传以 {{login}} 身份登录。包内容会发到云端审核队列。',
|
||||
uploadNoLocal: '本地没有可上传的风格包',
|
||||
errors: {
|
||||
detail: '加载详情失败:{{err}}',
|
||||
install: '安装失败:{{err}}',
|
||||
like: '点赞失败:{{err}}',
|
||||
upload: '上传失败:{{err}}',
|
||||
loadLocal: '加载本地风格包失败:{{err}}',
|
||||
},
|
||||
},
|
||||
shell: {
|
||||
shortcutLabel: '录音快捷键',
|
||||
shortcutHint: '开始 / 停止',
|
||||
@@ -366,6 +396,11 @@ export const zhCN = {
|
||||
startMinimizedDesc: '所有启动路径都不弹主窗口,仅菜单栏 / 托盘运行。',
|
||||
autoUpdateCheckLabel: '自动检查更新',
|
||||
autoUpdateCheckDesc: '主窗口启动 + 后台每 60 分钟检查云端新版本。关闭后仅"关于"的手动按钮可用。',
|
||||
marketplaceGroupTitle: '风格市场',
|
||||
marketplaceBaseUrlLabel: '云端服务地址',
|
||||
marketplaceBaseUrlDesc: '风格包市场后端 URL。留空 = 本地开发默认 http://127.0.0.1:8090;生产填 https://api.<你的域名>。',
|
||||
marketplaceDevLoginLabel: 'GitHub 用户名(上传身份)',
|
||||
marketplaceDevLoginDesc: 'dev 模式用 GitHub 用户名标识上传者;空时不能上传 / 点赞。后期接入 OAuth 后此字段废弃。',
|
||||
startupAtBoot: '开机自启',
|
||||
startupAtBootDesc: '登录系统时自动启动 OpenLess。',
|
||||
startupAtBootError: '开机自启切换失败:{{message}}',
|
||||
|
||||
@@ -58,10 +58,40 @@ export const zhTW: typeof zhCN = {
|
||||
history: '歷史',
|
||||
vocab: '詞彙表',
|
||||
style: '風格',
|
||||
marketplace: '風格市場',
|
||||
translation: '翻譯',
|
||||
selectionAsk: '劃詞追問',
|
||||
localAsr: '模型設置',
|
||||
},
|
||||
marketplace: {
|
||||
kicker: 'MARKETPLACE',
|
||||
title: '風格包市場',
|
||||
desc: '瀏覽社群風格包,一鍵安裝到本機;點讚和上傳你自己的風格包。',
|
||||
searchPlaceholder: '搜尋名稱 / 描述 / 標籤…',
|
||||
sortPopular: '按熱度',
|
||||
sortNew: '最新',
|
||||
uploadBtn: '上傳',
|
||||
uploadDisabledHint: '請先在 設定 → 風格市場 配置 GitHub 使用者名稱',
|
||||
refreshBtn: '重新整理',
|
||||
empty: '還沒有風格包',
|
||||
emptyHint: '換個搜尋詞,或自己上傳一個分享給社群',
|
||||
loadFailed: '載入失敗:{{err}}',
|
||||
noDescription: '(暫無描述)',
|
||||
installBtn: '安裝到本機',
|
||||
likeBtn: '點讚',
|
||||
installed: '已安裝「{{name}}」到本機風格包',
|
||||
uploaded: '上傳成功,等待審核',
|
||||
uploadTitle: '選擇要上傳的風格包',
|
||||
uploadHint: '以 {{login}} 身份上傳。包內容會發送到雲端審核佇列。',
|
||||
uploadNoLocal: '本機沒有可上傳的風格包',
|
||||
errors: {
|
||||
detail: '載入詳情失敗:{{err}}',
|
||||
install: '安裝失敗:{{err}}',
|
||||
like: '點讚失敗:{{err}}',
|
||||
upload: '上傳失敗:{{err}}',
|
||||
loadLocal: '載入本機風格包失敗:{{err}}',
|
||||
},
|
||||
},
|
||||
shell: {
|
||||
shortcutLabel: '錄音快捷鍵',
|
||||
shortcutHint: '開始 / 停止',
|
||||
@@ -368,6 +398,11 @@ export const zhTW: typeof zhCN = {
|
||||
startMinimizedDesc: '所有啓動路徑都不彈主窗口,僅選單欄 / 托盤運行。',
|
||||
autoUpdateCheckLabel: '自動檢查更新',
|
||||
autoUpdateCheckDesc: '主視窗啟動時 + 後台每 60 分鐘檢查雲端新版本。關閉後僅「關於」的手動按鈕可用。',
|
||||
marketplaceGroupTitle: '風格市場',
|
||||
marketplaceBaseUrlLabel: '雲端服務位址',
|
||||
marketplaceBaseUrlDesc: '風格包市場後端 URL。留空 = 本機開發預設 http://127.0.0.1:8090;生產填 https://api.<你的網域>。',
|
||||
marketplaceDevLoginLabel: 'GitHub 使用者名稱(上傳身份)',
|
||||
marketplaceDevLoginDesc: 'dev 模式用 GitHub 使用者名稱識別上傳者;空時不能上傳 / 點讚。',
|
||||
startupAtBoot: '開機自啓',
|
||||
startupAtBootDesc: '登錄系統時自動啓動 OpenLess。',
|
||||
startupAtBootError: '開機自啓切換失敗:{{message}}',
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
DictationSession,
|
||||
DictionaryEntry,
|
||||
HotkeyCapability,
|
||||
MarketplaceDetail,
|
||||
MarketplaceListItem,
|
||||
HotkeyStatus,
|
||||
MicrophoneDevice,
|
||||
PermissionStatus,
|
||||
@@ -101,6 +103,8 @@ let mockSettings: UserPreferences = {
|
||||
historyMaxEntries: null,
|
||||
recordAudioForDebug: false,
|
||||
audioRecordingMaxEntries: null,
|
||||
marketplaceBaseUrl: '',
|
||||
marketplaceDevLogin: '',
|
||||
};
|
||||
|
||||
const mockFullStylePrompts: StyleSystemPrompts = {
|
||||
@@ -954,3 +958,61 @@ export async function exportErrorLog(suggestedFileName: string): Promise<string
|
||||
}
|
||||
|
||||
export { isTauri };
|
||||
|
||||
// ── Marketplace (Phase A) ─────────────────────────────────────────────
|
||||
// 5 个 IPC wrapper —— marketplace-backend HTTP 通过 Rust IPC 转发。Mock fallback
|
||||
// 让 vite dev 在浏览器里也能预览 UI(返回空列表 / 假数据)。
|
||||
|
||||
const MOCK_MARKETPLACE: MarketplaceListItem[] = [
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
slug: 'demo-pack',
|
||||
name: '示范风格包',
|
||||
description: 'Mock 数据 - vite dev 模式下显示',
|
||||
authorLogin: 'demo',
|
||||
version: '1.0.0',
|
||||
baseMode: 'structured',
|
||||
tags: ['demo'],
|
||||
likeCount: 12,
|
||||
downloadCount: 50,
|
||||
publishedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
export function listMarketplace(
|
||||
options: { query?: string; sort?: 'new' | 'popular'; limit?: number } = {},
|
||||
): Promise<MarketplaceListItem[]> {
|
||||
return invokeOrMock('marketplace_list', options, () => MOCK_MARKETPLACE);
|
||||
}
|
||||
|
||||
export function fetchMarketplaceDetail(packId: string): Promise<MarketplaceDetail> {
|
||||
return invokeOrMock('marketplace_detail', { packId }, () => ({
|
||||
...MOCK_MARKETPLACE[0],
|
||||
prompt: '# 角色\n你是测试用 polish 助手。\n\n# 任务\n按整体意图整理转写。',
|
||||
state: 'approved' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
export function installMarketplacePack(packId: string): Promise<StylePack> {
|
||||
return invokeOrMock('marketplace_install', { packId }, () => mockStylePacks[0]);
|
||||
}
|
||||
|
||||
export function uploadMarketplacePack(
|
||||
packId: string,
|
||||
): Promise<{ id: string; state: string; message: string }> {
|
||||
return invokeOrMock('marketplace_upload', { packId }, () => ({
|
||||
id: 'mock-uploaded',
|
||||
state: 'pending',
|
||||
message: 'Mock 上传成功(vite dev)',
|
||||
}));
|
||||
}
|
||||
|
||||
export function likeMarketplacePack(
|
||||
packId: string,
|
||||
): Promise<{ likeCount: number; alreadyLiked: boolean }> {
|
||||
return invokeOrMock('marketplace_like', { packId }, () => ({
|
||||
likeCount: 13,
|
||||
alreadyLiked: false,
|
||||
}));
|
||||
}
|
||||
@@ -65,6 +65,8 @@ const previousPrefs: UserPreferences = {
|
||||
historyMaxEntries: null,
|
||||
recordAudioForDebug: false,
|
||||
audioRecordingMaxEntries: null,
|
||||
marketplaceBaseUrl: '',
|
||||
marketplaceDevLogin: '',
|
||||
};
|
||||
|
||||
const nextPrefs: UserPreferences = {
|
||||
|
||||
@@ -292,6 +292,30 @@ export interface UserPreferences {
|
||||
/** recordings/ 里保留的最近 wav 文件数。null = 跟随 200 硬上限;1..=200 之间为用户自定义。
|
||||
* 跟 historyMaxEntries 解耦——「文本档案多但 wav 只留最近 5 条」是合法组合。 */
|
||||
audioRecordingMaxEntries: number | null;
|
||||
/** Marketplace HTTP 基地址。空 = 本地开发默认 http://127.0.0.1:8090;生产填 https://api.<domain>。 */
|
||||
marketplaceBaseUrl: string;
|
||||
/** Marketplace dev-mode 模拟登录用户名(GitHub login 风格)。生产换 OAuth token 后此字段废弃。 */
|
||||
marketplaceDevLogin: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceListItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
authorLogin: string;
|
||||
version: string;
|
||||
baseMode: PolishMode;
|
||||
tags: string[];
|
||||
likeCount: number;
|
||||
downloadCount: number;
|
||||
publishedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceDetail extends MarketplaceListItem {
|
||||
prompt: string;
|
||||
state: 'pending' | 'approved' | 'rejected';
|
||||
}
|
||||
|
||||
export interface MicrophoneDevice {
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
// Marketplace.tsx — Style Pack Marketplace 浏览面板。
|
||||
//
|
||||
// Phase A 目标(goal 1.a-e):
|
||||
// (a) 后端验证 — 通过 marketplace_* IPC 跟后端通信
|
||||
// (b) 上传与拉取功能 — Install / Upload 按钮
|
||||
// (c) 单独弹窗界面 — modal-style detail 卡片
|
||||
// (d) 搜索框 — 顶部 input + server-side ?q=
|
||||
// (e) 按排名自动推荐 — 默认 sort=popular
|
||||
//
|
||||
// 后端 URL 走 prefs.marketplaceBaseUrl,dev 模式默认 http://127.0.0.1:8090;
|
||||
// 用户在 Settings 填生产 URL 后客户端自动切换。
|
||||
// dev 上传需要 prefs.marketplaceDevLogin(GitHub login 风格)—— 空时上传按钮 disabled。
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from '../components/Icon';
|
||||
import {
|
||||
fetchMarketplaceDetail,
|
||||
installMarketplacePack,
|
||||
likeMarketplacePack,
|
||||
listMarketplace,
|
||||
listStylePacks,
|
||||
uploadMarketplacePack,
|
||||
} from '../lib/ipc';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import type { MarketplaceDetail, MarketplaceListItem, StylePack } from '../lib/types';
|
||||
import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
|
||||
type SortMode = 'popular' | 'new';
|
||||
|
||||
export function Marketplace() {
|
||||
const { t } = useTranslation();
|
||||
const { prefs } = useHotkeySettings();
|
||||
|
||||
const [items, setItems] = useState<MarketplaceListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [sort, setSort] = useState<SortMode>('popular');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<MarketplaceDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [actionMsg, setActionMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [localPacks, setLocalPacks] = useState<StylePack[]>([]);
|
||||
const canUpload = (prefs?.marketplaceDevLogin ?? '').trim().length > 0;
|
||||
|
||||
// search 防抖 300ms
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setDebouncedQuery(query), 300);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [query]);
|
||||
|
||||
// 单调递增 seq 防 stale 响应覆盖:用户快速改 query / 切换 pack 时旧请求 response
|
||||
// 可能晚于新请求到达,比较 seq 丢弃过期结果。
|
||||
const reqSeqRef = useRef(0);
|
||||
const detailSeqRef = useRef(0);
|
||||
const refresh = useCallback(async () => {
|
||||
const seq = ++reqSeqRef.current;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const list = await listMarketplace({ query: debouncedQuery, sort, limit: 50 });
|
||||
if (seq !== reqSeqRef.current) return; // stale response
|
||||
setItems(list);
|
||||
} catch (error) {
|
||||
if (seq !== reqSeqRef.current) return;
|
||||
console.error('[marketplace] list failed', error);
|
||||
setLoadError(errorMessage(error));
|
||||
} finally {
|
||||
if (seq === reqSeqRef.current) setLoading(false);
|
||||
}
|
||||
}, [debouncedQuery, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const openDetail = async (id: string) => {
|
||||
const seq = ++detailSeqRef.current;
|
||||
setSelectedId(id);
|
||||
setDetail(null);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const d = await fetchMarketplaceDetail(id);
|
||||
if (seq !== detailSeqRef.current) return; // stale: 用户已切到另一个 pack
|
||||
setDetail(d);
|
||||
} catch (error) {
|
||||
if (seq !== detailSeqRef.current) return;
|
||||
console.error('[marketplace] detail failed', error);
|
||||
setActionMsg({ kind: 'err', text: t('marketplace.errors.detail', { err: errorMessage(error) }) });
|
||||
setSelectedId(null);
|
||||
} finally {
|
||||
if (seq === detailSeqRef.current) setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onInstall = async () => {
|
||||
if (!detail) return;
|
||||
try {
|
||||
await installMarketplacePack(detail.id);
|
||||
setActionMsg({ kind: 'ok', text: t('marketplace.installed', { name: detail.name }) });
|
||||
setSelectedId(null);
|
||||
} catch (error) {
|
||||
setActionMsg({ kind: 'err', text: t('marketplace.errors.install', { err: errorMessage(error) }) });
|
||||
}
|
||||
};
|
||||
|
||||
const onLike = async () => {
|
||||
if (!detail) return;
|
||||
try {
|
||||
const r = await likeMarketplacePack(detail.id);
|
||||
setDetail({ ...detail, likeCount: r.likeCount });
|
||||
setItems(prev => prev.map(p => (p.id === detail.id ? { ...p, likeCount: r.likeCount } : p)));
|
||||
} catch (error) {
|
||||
setActionMsg({ kind: 'err', text: t('marketplace.errors.like', { err: errorMessage(error) }) });
|
||||
}
|
||||
};
|
||||
|
||||
const openUploadPicker = async () => {
|
||||
try {
|
||||
const packs = await listStylePacks();
|
||||
setLocalPacks(packs);
|
||||
setShowUpload(true);
|
||||
} catch (error) {
|
||||
setActionMsg({ kind: 'err', text: t('marketplace.errors.loadLocal', { err: errorMessage(error) }) });
|
||||
}
|
||||
};
|
||||
|
||||
const onUpload = async (packId: string) => {
|
||||
try {
|
||||
await uploadMarketplacePack(packId);
|
||||
setActionMsg({ kind: 'ok', text: t('marketplace.uploaded') });
|
||||
setShowUpload(false);
|
||||
} catch (error) {
|
||||
setActionMsg({ kind: 'err', text: t('marketplace.errors.upload', { err: errorMessage(error) }) });
|
||||
}
|
||||
};
|
||||
|
||||
const sortPills = useMemo<Array<{ id: SortMode; label: string }>>(
|
||||
() => [
|
||||
{ id: 'popular', label: t('marketplace.sortPopular') },
|
||||
{ id: 'new', label: t('marketplace.sortNew') },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
|
||||
<PageHeader
|
||||
kicker={t('marketplace.kicker')}
|
||||
title={t('marketplace.title')}
|
||||
desc={t('marketplace.desc')}
|
||||
right={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Btn icon="refresh" variant="ghost" size="sm" onClick={() => void refresh()}>
|
||||
{t('common.refresh')}
|
||||
</Btn>
|
||||
<span title={canUpload ? '' : t('marketplace.uploadDisabledHint')}>
|
||||
<Btn
|
||||
icon="cloud"
|
||||
variant="blue"
|
||||
size="sm"
|
||||
onClick={() => void openUploadPicker()}
|
||||
disabled={!canUpload}
|
||||
>
|
||||
{t('marketplace.uploadBtn')}
|
||||
</Btn>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 顶部搜索 + 排序 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
padding: '4px 0 14px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 10px',
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
borderRadius: 10,
|
||||
background: 'var(--ol-surface)',
|
||||
}}
|
||||
>
|
||||
<Icon name="search" size={14} stroke="var(--ol-ink-3)" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder={t('marketplace.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
outline: 'none',
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
fontSize: 13,
|
||||
color: 'var(--ol-ink-1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{sortPills.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setSort(p.id)}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: 12,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
background: sort === p.id ? 'var(--ol-blue-soft)' : 'var(--ol-surface)',
|
||||
color: sort === p.id ? 'var(--ol-blue)' : 'var(--ol-ink-2)',
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionMsg && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
background: actionMsg.kind === 'ok' ? 'var(--ol-blue-soft)' : 'rgba(220,38,38,0.08)',
|
||||
color: actionMsg.kind === 'ok' ? 'var(--ol-blue)' : 'var(--ol-err)',
|
||||
border: '0.5px solid',
|
||||
borderColor: actionMsg.kind === 'ok' ? 'var(--ol-blue)' : 'var(--ol-err)',
|
||||
}}
|
||||
>
|
||||
{actionMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadError && (
|
||||
<Card padding={16} style={{ marginBottom: 12, borderColor: 'var(--ol-err)' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-err)' }}>
|
||||
{t('marketplace.loadFailed', { err: loadError })}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 卡片列表 */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }} className="ol-thinscroll">
|
||||
{loading ? (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--ol-ink-4)', fontSize: 13 }}>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<Card padding={28} style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--ol-ink-3)', marginBottom: 6 }}>
|
||||
{t('marketplace.empty')}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
{t('marketplace.emptyHint')}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{items.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => void openDetail(p.id)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
background: 'var(--ol-surface)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 6 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink-1)' }}>{p.name}</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-mono)' }}>
|
||||
v{p.version}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--ol-ink-3)',
|
||||
lineHeight: 1.5,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
minHeight: 36,
|
||||
}}
|
||||
>
|
||||
{p.description || t('marketplace.noDescription')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 2 }}>
|
||||
<Pill size="sm" tone="outline">{p.baseMode}</Pill>
|
||||
{p.tags.slice(0, 2).map(tag => (
|
||||
<Pill key={tag} size="sm" tone="default">{tag}</Pill>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 11,
|
||||
color: 'var(--ol-ink-4)',
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<span>by {p.authorLogin}</span>
|
||||
<span>
|
||||
❤ {p.likeCount} · ↓ {p.downloadCount}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
{selectedId && (
|
||||
<Modal onClose={() => setSelectedId(null)}>
|
||||
{detailLoading || !detail ? (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--ol-ink-4)', fontSize: 13 }}>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 6 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 650 }}>{detail.name}</h2>
|
||||
<Pill size="sm" tone="outline">{detail.baseMode}</Pill>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-mono)' }}>
|
||||
v{detail.version}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginBottom: 12 }}>
|
||||
by {detail.authorLogin} · ❤ {detail.likeCount} · ↓ {detail.downloadCount}
|
||||
</div>
|
||||
{detail.description && (
|
||||
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)', lineHeight: 1.6, marginBottom: 14 }}>
|
||||
{detail.description}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
borderRadius: 10,
|
||||
background: 'var(--ol-surface-2)',
|
||||
marginBottom: 14,
|
||||
maxHeight: 280,
|
||||
overflow: 'auto',
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--ol-font-mono)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: 'var(--ol-ink-2)',
|
||||
}}
|
||||
>
|
||||
{detail.prompt}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Btn variant="ghost" size="sm" onClick={() => void onLike()}>
|
||||
❤ {t('marketplace.likeBtn')}
|
||||
</Btn>
|
||||
<Btn variant="ghost" size="sm" onClick={() => setSelectedId(null)}>
|
||||
{t('common.cancel')}
|
||||
</Btn>
|
||||
<Btn variant="blue" size="sm" onClick={() => void onInstall()}>
|
||||
{t('marketplace.installBtn')}
|
||||
</Btn>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 上传选包器 */}
|
||||
{showUpload && (
|
||||
<Modal onClose={() => setShowUpload(false)}>
|
||||
<h2 style={{ margin: '0 0 12px', fontSize: 16, fontWeight: 650 }}>{t('marketplace.uploadTitle')}</h2>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginBottom: 12 }}>
|
||||
{t('marketplace.uploadHint', { login: prefs?.marketplaceDevLogin ?? '' })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 360, overflow: 'auto' }}>
|
||||
{localPacks.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)', textAlign: 'center', padding: 20 }}>
|
||||
{t('marketplace.uploadNoLocal')}
|
||||
</div>
|
||||
) : (
|
||||
localPacks.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => void onUpload(p.id)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: 10,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
borderRadius: 8,
|
||||
background: 'var(--ol-surface)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{p.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 2 }}>
|
||||
{p.description || t('marketplace.noDescription')}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 14 }}>
|
||||
<Btn variant="ghost" size="sm" onClick={() => setShowUpload(false)}>
|
||||
{t('common.cancel')}
|
||||
</Btn>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.22)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
zIndex: 50,
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: 'min(560px, 100%)',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
borderRadius: 16,
|
||||
background: 'var(--ol-surface)',
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
boxShadow: '0 18px 42px rgba(0,0,0,0.18)',
|
||||
padding: 22,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (typeof error === 'string') return error;
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
@@ -317,6 +317,10 @@ function RecordingSection() {
|
||||
savePrefs({ ...prefs, startMinimized });
|
||||
const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) =>
|
||||
savePrefs({ ...prefs, autoUpdateCheck });
|
||||
const onMarketplaceBaseUrlChange = (marketplaceBaseUrl: string) =>
|
||||
savePrefs({ ...prefs, marketplaceBaseUrl });
|
||||
const onMarketplaceDevLoginChange = (marketplaceDevLogin: string) =>
|
||||
savePrefs({ ...prefs, marketplaceDevLogin });
|
||||
const onRecordAudioForDebugChange = (recordAudioForDebug: boolean) =>
|
||||
savePrefs({ ...prefs, recordAudioForDebug });
|
||||
// 历史条数 200 是当前 HISTORY_CAP(persistence.rs:32),下限 5 是避免用户填 0 导致
|
||||
@@ -613,6 +617,34 @@ function RecordingSection() {
|
||||
</div>
|
||||
)}
|
||||
</Collapsible>
|
||||
|
||||
{/* ─── 风格市场(折叠) ────────────────────────────────────────── */}
|
||||
<Collapsible title={t('settings.recording.marketplaceGroupTitle')}>
|
||||
<SettingRow
|
||||
label={t('settings.recording.marketplaceBaseUrlLabel')}
|
||||
desc={t('settings.recording.marketplaceBaseUrlDesc')}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="http://127.0.0.1:8090"
|
||||
value={prefs.marketplaceBaseUrl}
|
||||
onChange={e => onMarketplaceBaseUrlChange(e.target.value)}
|
||||
style={{ ...inputStyle, width: 280 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.recording.marketplaceDevLoginLabel')}
|
||||
desc={t('settings.recording.marketplaceDevLoginDesc')}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="your-github-login"
|
||||
value={prefs.marketplaceDevLogin}
|
||||
onChange={e => onMarketplaceDevLoginChange(e.target.value)}
|
||||
style={{ ...inputStyle, width: 180 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Collapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
resetBuiltinStylePack,
|
||||
saveStylePack,
|
||||
setActiveStylePack,
|
||||
uploadMarketplacePack,
|
||||
} from '../lib/ipc';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import type { PolishMode, StylePack, StylePackExample, StylePackRuntimeDiagnostics } from '../lib/types';
|
||||
import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
import { Icon } from '../components/Icon';
|
||||
@@ -118,6 +120,8 @@ function sanitizeZipFileName(name: string) {
|
||||
export function Style() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isEnglish = i18n.language.toLowerCase().startsWith('en');
|
||||
const { prefs: marketplacePrefs } = useHotkeySettings();
|
||||
const canPublish = (marketplacePrefs?.marketplaceDevLogin ?? '').trim().length > 0;
|
||||
const copy = {
|
||||
kicker: 'STYLE PACKS',
|
||||
title: isEnglish ? 'Style Packs' : '风格包',
|
||||
@@ -128,6 +132,15 @@ export function Style() {
|
||||
importZip: isEnglish ? 'Import ZIP' : '导入 ZIP',
|
||||
exportZip: isEnglish ? 'Export ZIP' : '导出 ZIP',
|
||||
exportShort: isEnglish ? 'Export' : '导出',
|
||||
publishMarketplace: isEnglish ? 'Publish to Marketplace' : '发布到风格市场',
|
||||
publishDisabledHint: isEnglish
|
||||
? 'Configure your GitHub login in Settings → Marketplace first'
|
||||
: '请先在 设置 → 风格市场 配置 GitHub 用户名',
|
||||
publishSuccess: isEnglish
|
||||
? 'Published — pending review on marketplace'
|
||||
: '发布成功,等待 marketplace 审核',
|
||||
publishFailed: (msg: string) =>
|
||||
isEnglish ? `Publish failed: ${msg}` : `发布失败:${msg}`,
|
||||
builtin: isEnglish ? 'Built-in' : '内置',
|
||||
imported: isEnglish ? 'Imported' : '导入',
|
||||
active: isEnglish ? 'Active' : '当前',
|
||||
@@ -560,6 +573,23 @@ export function Style() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublishToMarketplace = async (pack = selectedPack) => {
|
||||
if (!pack) return;
|
||||
if (editorOpen && dirty && selectedPack && pack.id === selectedPack.id) {
|
||||
showSaveStatus('failed', copy.exportDirtyFirst);
|
||||
return;
|
||||
}
|
||||
setBusy('exporting');
|
||||
try {
|
||||
await uploadMarketplacePack(pack.id);
|
||||
showSaveStatus('saved', copy.publishSuccess, true);
|
||||
} catch (publishError) {
|
||||
showSaveStatus('failed', copy.publishFailed(String(publishError)));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportZip = async (pack = selectedPack) => {
|
||||
if (!pack) return;
|
||||
if (editorOpen && dirty && selectedPack && pack.id === selectedPack.id) {
|
||||
@@ -909,6 +939,16 @@ export function Style() {
|
||||
<Btn variant="ghost" icon="archive" onClick={() => void handleExportZip()} disabled={busy === 'exporting'}>
|
||||
{copy.exportZip}
|
||||
</Btn>
|
||||
<span title={canPublish ? '' : copy.publishDisabledHint}>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
icon="cloud"
|
||||
onClick={() => void handlePublishToMarketplace()}
|
||||
disabled={!canPublish || busy === 'exporting'}
|
||||
>
|
||||
{copy.publishMarketplace}
|
||||
</Btn>
|
||||
</span>
|
||||
<Btn
|
||||
variant={draft.active ? 'soft' : 'blue'}
|
||||
icon="check"
|
||||
|
||||
@@ -9,6 +9,7 @@ export type AppTab =
|
||||
| 'style'
|
||||
| 'translation'
|
||||
| 'selectionAsk'
|
||||
| 'marketplace'
|
||||
| 'localAsr';
|
||||
|
||||
export interface AppState {
|
||||
|
||||
Reference in new issue
Block a user