Merge pull request #435 from Open-Less/feat/style-pack-ui-refresh

feat(style-pack): rework UI + 模板新建 + 清理 PR #429 遗留死代码
This commit is contained in:
TRIP authored and GitHub committed 2026-05-14 15:01:38 +08:00
commit 0aa3ee64d3
18 files changed
+702 -273

No files matched your search

+299
View File
@@ -0,0 +1,299 @@
# Style Pack Marketplace — 规划文档
**状态**:规划中(API 已预留 stub,未实装)
**起草日期**:2026-05-14
**owner**:待定
## 1. 目标
把现在「ZIP 包本地导入 / 导出」的体验扩展成一个公开的风格包市场:
- 用户可以把自己调好的风格包**上传**到云端,附带名称、描述、作者署名、标签、效果示例
- 其他用户可以**浏览 / 搜索 / 下载**别人的风格包,一键安装到本地
- 后期支持**版本升级提醒**、**收藏 / 评分**等基础社交属性
非目标(v1 不做):
- 付费 / 抽成
- 风格包内嵌外部 prompt 注入 / 跨域 fetch(安全考虑,风格包始终是纯文本 prompt)
- 多人协作编辑 / fork
## 2. 架构概览
```
┌──────────────────┐ HTTPS ┌─────────────────────┐
│ OpenLess client │ ◄──────────────────► │ marketplace API │
│ (Tauri 2) │ JSON over TLS │ (TBD: Cloudflare │
│ │ │ Workers / D1 / │
│ Rust IPC → │ │ R2 for blobs) │
│ reqwest client │ │ │
└──────────────────┘ └─────────────────────┘
│ │
│ local cache (~/Library/Application │
│ Support/OpenLess/market_cache/) │
▼ ▼
StylePackStore Postgres / D1
(existing local listings + R2 blobs
persistence layer)
```
**关键约束**:
- 客户端只能上传 / 下载 ZIP **bundle**(不直接传 JSON),保持跟现有 ZIP import/export 同构
- 服务端 ZIP 验证:解压后必须能反序列化成 `StylePack`、`prompt.chars().count() <= 50_000`、没有可执行附件
- 风格包 ID 上传后由服务端分配(`{author_slug}-{name_slug}-{version}`),跟本地 ID 解耦
- 客户端始终拿 ZIP 走现有 `import_style_pack_from_zip` 路径入库 —— 不另开一条「从市场直接写 Pack」的代码路径,避免双入口
## 3. HTTP API 规约
Base URL(待定):`https://api.openless.app/v1/marketplace/`
所有响应统一信封:
```json
{
"ok": true,
"data": <T> | null,
"error": null | { "code": "ERR_XXX", "message": "..." }
}
```
### 3.1 GET `/packs` — 列表 / 搜索
Query:
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `q` | string | `""` | 关键词(名称 / 描述 / 标签) |
| `tag` | string | `""` | 单标签筛选 |
| `sort` | `recent` \| `popular` \| `name` | `recent` | 排序 |
| `cursor` | string | `null` | 分页游标 |
| `limit` | int (1-100) | `20` | 每页条数 |
Response data:
```typescript
{
packs: MarketPackListing[];
next_cursor: string | null;
}
```
`MarketPackListing`:
```typescript
{
id: string; // server-assigned, e.g. "alice-formal-v2.1"
name: string;
description: string;
author: string;
version: string; // semver
tags: string[];
base_mode: "raw" | "light" | "structured" | "professional";
recommended_model: string | null;
compatible_app_version: string | null;
downloads: number;
rating_avg: number | null;
rating_count: number;
updated_at: string; // ISO8601
zip_size_bytes: number;
zip_sha256: string; // 客户端下载后校验
}
```
### 3.2 GET `/packs/{id}` — 详情
Response data:`MarketPackListing` + 额外字段:
```typescript
{
...listing,
examples: StylePackExample[]; // 解压 ZIP 前的预览
changelog: string | null;
homepage_url: string | null;
}
```
### 3.3 GET `/packs/{id}/download` — 下载 ZIP
Response:`application/zip` 二进制流,带 `X-Pack-SHA256` header 用于校验。
服务端通过 redirect 直接指向 R2 / S3 预签 URL,避免代理流量。
### 3.4 POST `/packs` — 上传(需鉴权)
Headers:`Authorization: Bearer <api_key>`
Body:`multipart/form-data` with field `pack=@xxx.zip`
Response data:`MarketPackListing`(含新分配 id)
错误码:
- `ERR_INVALID_ZIP` — ZIP 解压失败 / 不是合法 StylePack JSON
- `ERR_PROMPT_TOO_LARGE` — prompt 字数超 50k
- `ERR_DUPLICATE_VERSION` — 同 author+name+version 已存在
- `ERR_RATE_LIMITED` — 触发限频
### 3.5 DELETE `/packs/{id}` — 撤回(需鉴权 + 必须是上传者)
### 3.6 POST `/packs/{id}/rate` — 评分(需鉴权)
Body:`{ score: 1..5, comment?: string }`
## 4. IPC 契约(Rust ↔ TS)
在 `src-tauri/src/commands.rs` 新增以下 stub(暂返回 `Err("not implemented yet")`,等服务端落地后实装):
```rust
// 列表 / 搜索
#[tauri::command]
pub async fn market_list_packs(
query: Option<String>,
tag: Option<String>,
sort: Option<String>,
cursor: Option<String>,
limit: Option<u32>,
) -> Result<MarketListResponse, String>;
// 详情
#[tauri::command]
pub async fn market_get_pack(id: String) -> Result<MarketPackDetail, String>;
// 下载 + 自动调用现有的 import_style_pack_from_zip 入库
#[tauri::command]
pub async fn market_download_pack(
coord: CoordinatorState<'_>,
app: AppHandle,
id: String,
) -> Result<StylePack, String>;
// 上传(dirty 字段 = 已编辑、未保存)
#[tauri::command]
pub async fn market_upload_pack(
coord: CoordinatorState<'_>,
pack_id: String,
api_key: String,
) -> Result<MarketPackListing, String>;
// 撤回
#[tauri::command]
pub async fn market_delete_pack(id: String, api_key: String) -> Result<(), String>;
// 评分
#[tauri::command]
pub async fn market_rate_pack(
id: String,
api_key: String,
score: u8,
comment: Option<String>,
) -> Result<(), String>;
```
DTO(在 `types.rs` 新增):
```rust
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MarketPackListing {
pub id: String,
pub name: String,
pub description: String,
pub author: String,
pub version: String,
pub tags: Vec<String>,
pub base_mode: PolishMode,
pub recommended_model: Option<String>,
pub compatible_app_version: Option<String>,
pub downloads: u64,
pub rating_avg: Option<f32>,
pub rating_count: u32,
pub updated_at: String,
pub zip_size_bytes: u64,
pub zip_sha256: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MarketPackDetail {
#[serde(flatten)]
pub listing: MarketPackListing,
pub examples: Vec<StylePackExample>,
pub changelog: Option<String>,
pub homepage_url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MarketListResponse {
pub packs: Vec<MarketPackListing>,
pub next_cursor: Option<String>,
}
```
TS wrappers(`src/lib/ipc.ts`):
```typescript
export interface MarketPackListing { /* same shape */ }
export interface MarketPackDetail extends MarketPackListing { /* + examples, changelog, homepage_url */ }
export interface MarketListResponse { packs: MarketPackListing[]; next_cursor: string | null; }
export function marketListPacks(opts: {
query?: string; tag?: string; sort?: 'recent' | 'popular' | 'name';
cursor?: string; limit?: number;
}): Promise<MarketListResponse>;
export function marketGetPack(id: string): Promise<MarketPackDetail>;
export function marketDownloadPack(id: string): Promise<StylePack>;
export function marketUploadPack(packId: string, apiKey: string): Promise<MarketPackListing>;
export function marketDeletePack(id: string, apiKey: string): Promise<void>;
export function marketRatePack(id: string, apiKey: string, score: number, comment?: string): Promise<void>;
```
## 5. 鉴权模型
**v1 简化方案**:
- 用户在设置页输入个人 API key(服务端发放)
- API key 存到 OS Keychain,账户名 `com.openless.app.market_api_key`
- 客户端在 Header 加 `Authorization: Bearer <key>`
- 服务端校验 + 限频(每小时 60 次写、600 次读)
**v2 升级路径**(暂不做):
- OAuth via GitHub / Google
- 上传时自动签名 ZIP,下载端校验签名
## 6. 缓存与版本检查
本地缓存目录:`<app_data>/market_cache/`
- `listings.json` — 上次拉的 listings(带 ETag)
- `packs/{id}.zip` — 已下载的 ZIP(按需保留,30 天自动清理)
版本升级提示:
- 启动时(带 dev-cap 24h 节流)调用 `/packs?ids=<已安装的 market_id...>` 拉对比
- 本地包记录 `installed_market_id` 和 `installed_market_version` 字段,新建 `StylePack` 时填,本地从 ZIP 安装也填
- 发现新版本 → 在 Style 页该包卡片角标显示 `New version: 2.3.0 →`
## 7. 客户端 UI 入口(v1 不做,先留位)
- Style 页头部加一个 tab:`本地 / 市场`
- 市场页:搜索栏 + tag 过滤 + 卡片列表 + 详情抽屉
- 上传:编辑某个本地包时,"导出 ZIP" 按钮旁边出现 "上传到市场"(需要先在设置里填 API key)
## 8. 安全 / 滥用对策
- ZIP 解压走 streaming,限制最大解压后大小 5 MB
- prompt 字段过滤明显的 prompt injection / 越狱(关键词预扫描 + 异步内容审核)
- 每用户每天上传上限 10 包,单包大小 ≤ 2 MB
- 上传后挂 24h 公开延迟(防恶意刷榜)
## 9. 实装 TODO(按优先级)
- [ ] 服务端选型(CF Workers + D1 + R2 vs Supabase vs 自托管 FastAPI)
- [ ] 服务端实装 + 部署环境(dev / staging / prod)
- [ ] 客户端 `types.rs` 加 DTO
- [ ] `commands.rs` 加 6 个 stub(**已完成**,返回 `not implemented yet`)
- [ ] `lib/ipc.ts` 加 wrapper(**已完成**)
- [ ] 实装 `market_download_pack`(先做单条路径打通:URL → 下载 → 走现有 import_style_pack_from_zip)
- [ ] 加凭据存储(Keychain 复用现有 `CredentialsVault`)
- [ ] UI:本地 / 市场 tab
- [ ] UI:搜索 + 卡片
- [ ] UI:详情面板
- [ ] UI:上传流程
- [ ] 升级提醒 badge
- [ ] 缓存清理 + ETag
## 10. 决策 / 风险记录
| 项 | 决策 | Why |
|---|---|---|
| ZIP 而非 JSON 上传 | 用 ZIP | 跟现有 import/export 同构;prompt 长文 + examples 用 ZIP 包压缩 |
| 服务端分配 ID | 是 | 防本地 ID 碰撞、用户重命名包不影响订阅 |
| 上传立刻可见 vs 审核 | 24h 公开延迟 | 防刷榜 + 给审核留空间 |
| API key vs OAuth | 先 API key | 简化 v1;登录态可 v2 升级 |
| 客户端缓存策略 | listings ETag + 已下载 ZIP 30 天 | 平衡流量和体验 |
| 国际化 / 跨境 | API 全英文 + 客户端 i18n | 服务端不存翻译,名称/描述支持任意 UTF-8 |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openless-app",
"version": "1.3.1",
"version": "1.3.2-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openless-app",
"version": "1.3.1",
"version": "1.3.2-1",
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-autostart": "^2.5.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "openless-app",
"private": true,
"version": "1.3.1",
"version": "1.3.2-1",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -3751,7 +3751,7 @@ dependencies = [
[[package]]
name = "openless"
version = "1.3.1"
version = "1.3.2-1"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "openless"
version = "1.3.1"
version = "1.3.2-1"
description = "OpenLess — local voice input that types where your cursor is"
authors = ["OpenLess"]
edition = "2021"
@@ -1256,6 +1256,26 @@ pub fn list_style_packs(coord: CoordinatorState<'_>) -> Result<Vec<StylePack>, S
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn create_style_pack_from_template(
coord: CoordinatorState<'_>,
app: AppHandle,
template: StylePack,
) -> Result<StylePack, String> {
log::info!(
"[style-pack] command create_from_template name={} base_mode={:?}",
template.name,
template.base_mode
);
let created = coord
.style_packs()
.create_from_template(template)
.map_err(|e| e.to_string())?;
let prefs = coord.prefs().get();
let _ = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?;
Ok(created)
}
#[tauri::command]
pub fn save_style_pack(
coord: CoordinatorState<'_>,
+1
View File
@@ -301,6 +301,7 @@ pub fn run() {
commands::inject_hotkey_click_for_dev,
commands::repolish,
commands::list_style_packs,
commands::create_style_pack_from_template,
commands::save_style_pack,
commands::preview_style_pack_runtime,
commands::set_active_style_pack,
@@ -1023,6 +1023,37 @@ impl StylePackStore {
.ok_or_else(|| anyhow!("no enabled style pack available"))
}
/// 从模板新建一个 imported 风格包("+"按钮路径)。
/// 跟 ZIP 导入不同:没有 manifest.json、没有 assets,纯空白模板。
/// 调用方负责 set `prefs.active_style_pack_id` 等高层 wiring(这里只管落盘)。
pub fn create_from_template(&self, template: StylePack) -> Result<StylePack> {
let mut packs = self.state.lock();
let base_id = if template.id.trim().is_empty() {
format!("imported-{}", Uuid::new_v4().simple())
} else {
template.id.clone()
};
let assigned_id = unique_imported_style_pack_id(&packs, &base_id);
let now = Utc::now().to_rfc3339();
let mut pack = template;
pack.id = assigned_id;
pack.kind = StylePackKind::Imported;
pack.created_at = Some(now.clone());
pack.updated_at = Some(now);
pack.active = false;
pack.enabled = true;
packs.push(pack.clone());
write_style_packs_file(&self.path, &packs)?;
log::info!(
"[style-pack] created from template id={} base_mode={:?} prompt_chars={} examples={}",
pack.id,
pack.base_mode,
pack.prompt.chars().count(),
pack.examples.len()
);
Ok(pack)
}
pub fn upsert(&self, incoming: StylePack) -> Result<StylePack> {
let mut packs = self.state.lock();
let index = packs
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenLess",
"version": "1.3.1",
"version": "1.3.2-1",
"identifier": "com.openless.app",
"build": {
"beforeDevCommand": "npm run dev",
-11
View File
@@ -384,17 +384,6 @@ export const en: typeof zhCN = {
llmProviderDesc: 'Selecting a preset auto-fills the default Base URL.',
credentialStorageNotice: 'Credentials are stored in the OS credential vault. Legacy local JSON credentials are migrated into the vault and removed after a successful write.',
codexOAuthNotice: 'Codex OAuth uses the local Codex login state (~/.codex/auth.json). OpenLess does not store an API key or Base URL for this provider.',
styleSystemPromptTitle: 'Polish system prompts',
styleSystemPromptDesc: 'Each built-in style now exposes its full system prompt here. Saved edits are shared by live polish and History repolish.',
styleSystemPromptPlaceholder: 'Enter the full system prompt for this style',
styleSystemPromptHint: 'This edits the full prompt, not just an appended suffix. Press Ctrl/Cmd+Enter to save as well.',
styleSystemPromptSave: 'Save prompt',
styleSystemPromptReset: 'Reset to default',
styleSystemPromptDirty: 'Unsaved',
styleSystemPromptSaveFailed: 'Failed to save system prompt: {{error}}',
styleSystemPromptMovedBadge: 'Migrated',
styleSystemPromptMovedDesc: 'Full system prompts are no longer edited per mode on the Settings page. They now live in the Style Pack detail panel on the Style page.',
styleSystemPromptMovedHint: 'To change runtime prompts, examples, tags, or ZIP import/export, use the Style page.',
asrProviderDesc: 'Switching providers automatically loads the matching credentials.',
asrTitle: 'ASR (transcription)',
asrDesc: 'Used to turn speech into text in real time.',
-11
View File
@@ -386,17 +386,6 @@ export const ja: typeof zhCN = {
llmProviderDesc: '選択するとデフォルトの Base URL が自動入力されます。',
credentialStorageNotice: '認証情報は OS の認証情報ストアに保存されます。旧バージョンのローカル JSON 認証情報はストアへ移行され、書き込み成功後に削除されます。',
codexOAuthNotice: 'Codex OAuth はローカルの Codex ログイン状態(~/.codex/auth.json)を使用します。OpenLess は API Key や Base URL を保存しません。',
styleSystemPromptTitle: 'Polish system prompts',
styleSystemPromptDesc: 'Each built-in style now exposes its full system prompt here. Saved edits are shared by live polish and History repolish.',
styleSystemPromptPlaceholder: 'Enter the full system prompt for this style',
styleSystemPromptHint: 'This edits the full prompt, not just an appended suffix. Press Ctrl/Cmd+Enter to save as well.',
styleSystemPromptSave: 'Save prompt',
styleSystemPromptReset: 'Reset to default',
styleSystemPromptDirty: 'Unsaved',
styleSystemPromptSaveFailed: 'Failed to save system prompt: {{error}}',
styleSystemPromptMovedBadge: '移行済み',
styleSystemPromptMovedDesc: '完全な system prompt は設定ページでモード別に直接編集せず、「スタイル」ページの Style Pack 詳細パネルに集約されました。',
styleSystemPromptMovedHint: '実行時 prompt、例、タグ、ZIP のインポート/エクスポートを変更する場合は「スタイル」ページで操作してください。',
asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。',
asrTitle: 'ASR 音声(転写)',
asrDesc: '口述をリアルタイムでテキストに転写。',
-11
View File
@@ -389,17 +389,6 @@ export const ko: typeof zhCN = {
asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.',
asrTitle: 'ASR 음성(전사)',
asrDesc: '구술을 실시간으로 텍스트로 전사합니다.',
styleSystemPromptTitle: 'Polish system prompts',
styleSystemPromptDesc: 'Each built-in style now exposes its full system prompt here. Saved edits are shared by live polish and History repolish.',
styleSystemPromptPlaceholder: 'Enter the full system prompt for this style',
styleSystemPromptHint: 'This edits the full prompt, not just an appended suffix. Press Ctrl/Cmd+Enter to save as well.',
styleSystemPromptSave: 'Save prompt',
styleSystemPromptReset: 'Reset to default',
styleSystemPromptDirty: 'Unsaved',
styleSystemPromptSaveFailed: 'Failed to save system prompt: {{error}}',
styleSystemPromptMovedBadge: '마이그레이션됨',
styleSystemPromptMovedDesc: '전체 system prompt는 더 이상 설정 페이지에서 모드별로 직접 편집하지 않고, 스타일 페이지의 Style Pack 상세 패널로 통합되었습니다.',
styleSystemPromptMovedHint: '런타임 prompt, 예시, 태그 또는 ZIP 가져오기/내보내기를 변경하려면 스타일 페이지에서 작업하세요.',
presets: {
ark: 'ARK (Volcengine Ark)',
deepseek: 'DeepSeek',
-11
View File
@@ -382,17 +382,6 @@ export const zhCN = {
llmProviderDesc: '选择后将自动填入 Base URL 默认值。',
credentialStorageNotice: '凭据保存在系统凭据库中。旧版本地 JSON 凭据会迁移到系统凭据库,并在成功写入后删除。',
codexOAuthNotice: 'Codex OAuth 使用本机 Codex 登录状态(~/.codex/auth.json),无需在 OpenLess 中保存 API Key 或 Base URL。',
styleSystemPromptTitle: '润色 System Prompt',
styleSystemPromptDesc: '每个内置风格都可以直接编辑完整 system prompt。保存后,实时润色和 History 里的 repolish 会共用这一套提示词。',
styleSystemPromptPlaceholder: '输入该风格的完整 system prompt',
styleSystemPromptHint: '这里编辑的是完整提示词,不再只是附加片段。按 Ctrl/Cmd+Enter 也可保存。',
styleSystemPromptSave: '保存 Prompt',
styleSystemPromptReset: '恢复默认',
styleSystemPromptDirty: '未保存',
styleSystemPromptSaveFailed: '保存 System Prompt 失败:{{error}}',
styleSystemPromptMovedBadge: '已迁移',
styleSystemPromptMovedDesc: '完整 system prompt 不再在设置页里按模式硬编码编辑,而是统一收敛到「风格」页的 Style Pack 详情面板。',
styleSystemPromptMovedHint: '如果你要改运行时 prompt、示例、标签或导入导出 ZIP,请去「风格」页操作。',
asrProviderDesc: '切换后将自动选用对应凭据。',
asrTitle: 'ASR 语音(转写)',
asrDesc: '用于将口述实时转写为文本。',
-11
View File
@@ -384,17 +384,6 @@ export const zhTW: typeof zhCN = {
llmProviderDesc: '選擇後將自動填入 Base URL 默認值。',
credentialStorageNotice: '憑據儲存在系統憑據庫中。舊版本機 JSON 憑據會遷移到系統憑據庫,並在成功寫入後刪除。',
codexOAuthNotice: 'Codex OAuth 使用本機 Codex 登入狀態(~/.codex/auth.json),無需在 OpenLess 中保存 API Key 或 Base URL。',
styleSystemPromptTitle: '潤色 System Prompt',
styleSystemPromptDesc: '每個內建風格都可以直接編輯完整 system prompt。儲存後,即時潤色和 History 裡的 repolish 會共用這一套提示詞。',
styleSystemPromptPlaceholder: '輸入該風格的完整 system prompt',
styleSystemPromptHint: '這裡編輯的是完整提示詞,不再只是附加片段。按 Ctrl/Cmd+Enter 也可儲存。',
styleSystemPromptSave: '儲存 Prompt',
styleSystemPromptReset: '恢復預設',
styleSystemPromptDirty: '未儲存',
styleSystemPromptSaveFailed: '儲存 System Prompt 失敗:{{error}}',
styleSystemPromptMovedBadge: '已遷移',
styleSystemPromptMovedDesc: '完整 system prompt 不再在設定頁裡按模式硬編碼編輯,而是統一收斂到「風格」頁的 Style Pack 詳情面板。',
styleSystemPromptMovedHint: '如果你要改執行時 prompt、範例、標籤或匯入匯出 ZIP,請去「風格」頁操作。',
asrProviderDesc: '切換後將自動選用對應憑據。',
asrTitle: 'ASR 語音(轉寫)',
asrDesc: '用於將口述實時轉寫爲文本。',
+16
View File
@@ -689,6 +689,22 @@ export function saveStylePack(stylePack: StylePack): Promise<StylePack> {
});
}
export function createStylePackFromTemplate(template: StylePack): Promise<StylePack> {
return invokeOrMock('create_style_pack_from_template', { template }, () => {
const created: StylePack = {
...cloneStylePack(template),
id: `imported-mock-${Date.now()}`,
kind: 'imported',
active: false,
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockStylePacks = [...mockStylePacks, created];
return cloneStylePack(created);
});
}
export function previewStylePackRuntime(stylePack: StylePack): Promise<StylePackRuntimeDiagnostics> {
return invokeOrMock('preview_style_pack_runtime', { stylePack }, () => composeMockStylePackRuntimeDiagnostics(stylePack));
}
-28
View File
@@ -1607,34 +1607,6 @@ function ProvidersSection() {
<ProviderTools key={committedLlmProvider} kind="llm" modelAccount="ark.model_id" onModelSelected={() => setLlmModelRevision(v => v + 1)} />
</Card>
<Card>
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('settings.providers.styleSystemPromptTitle')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 2 }}>
{t('settings.providers.styleSystemPromptDesc')}
</div>
</div>
<div
style={{
padding: 16,
borderRadius: 14,
border: '0.5px solid var(--ol-line)',
background: 'linear-gradient(180deg, rgba(248,250,252,0.95), rgba(255,255,255,0.98))',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<Pill tone="blue">{t('settings.providers.styleSystemPromptMovedBadge')}</Pill>
<Pill tone="outline">{t('settings.providers.styleSystemPromptTitle')}</Pill>
</div>
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-2)', lineHeight: 1.7, marginBottom: 10 }}>
{t('settings.providers.styleSystemPromptMovedDesc')}
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
{t('settings.providers.styleSystemPromptMovedHint')}
</div>
</div>
</Card>
<Card>
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('settings.providers.asrTitle')}</div>
+297 -184
View File
@@ -1,6 +1,7 @@
import { type CSSProperties, useEffect, useState } from 'react';
import { type CSSProperties, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
createStylePackFromTemplate,
deleteStylePack,
exportStylePackToZip,
importStylePackFromZip,
@@ -10,11 +11,11 @@ import {
resetBuiltinStylePack,
saveStylePack,
setActiveStylePack,
setStylePackEnabled,
} from '../lib/ipc';
import type { PolishMode, StylePack, StylePackExample, StylePackRuntimeDiagnostics } from '../lib/types';
import { Btn, Card, PageHeader, Pill } from './_atoms';
import { Icon } from '../components/Icon';
import { SavedToast, type SaveToastState } from '../components/SavedToast';
type BusyAction =
| 'loading'
@@ -22,11 +23,31 @@ type BusyAction =
| 'importing'
| 'exporting'
| 'activating'
| 'toggling'
| 'resetting'
| 'deleting'
| 'creating'
| null;
const BUILTIN_RAW_ID = 'builtin.raw';
const BUILTIN_BODY_ORDER = ['builtin.light', 'builtin.structured', 'builtin.formal'];
const NEW_PACK_TEMPLATE_BASE: Omit<StylePack, 'id' | 'createdAt' | 'updatedAt'> = {
name: '未命名风格',
description: '简短描述这个风格的使用场景。',
author: null,
version: '1.0.0',
kind: 'imported',
baseMode: 'light',
prompt: '你是 OpenLess 的润色助手。请将口语化的转写整理为顺畅、自然、可直接发送的文字,但不要扩写事实。',
examples: [],
tags: [],
iconPath: null,
enabled: true,
active: false,
recommendedModel: null,
compatibleAppVersion: null,
};
function clonePack(pack: StylePack): StylePack {
return {
...pack,
@@ -86,11 +107,7 @@ export function Style() {
builtin: isEnglish ? 'Built-in' : '内置',
imported: isEnglish ? 'Imported' : '导入',
active: isEnglish ? 'Active' : '当前',
enabled: isEnglish ? 'In Rotation' : '已加入轮换',
disabled: isEnglish ? 'Out of Rotation' : '未加入轮换',
activate: isEnglish ? 'Activate' : '激活',
enable: isEnglish ? 'Rotation ON' : '轮换 ON',
disable: isEnglish ? 'Rotation OFF' : '轮换 OFF',
edit: isEnglish ? 'Edit' : '编辑',
closeEditor: isEnglish ? 'Close' : '关闭',
unsaved: isEnglish ? 'Unsaved' : '未保存',
@@ -99,15 +116,17 @@ export function Style() {
? 'Browse and switch packs.'
: '浏览和切换风格包。',
listCount: (count: number) => (isEnglish ? `${count} packs` : `${count} 个风格包`),
addPackTileTitle: isEnglish ? 'New Pack' : '新建风格包',
addPackTileHint: isEnglish ? 'Start from a blank template.' : '从空白模板开始。',
createSuccess: isEnglish ? 'New pack created.' : '已创建新风格包',
createFailed: (message: string) => (isEnglish ? `Failed to create pack: ${message}` : `创建风格包失败:${message}`),
builtinPackEditLabel: (name: string) => (isEnglish ? `Edit "${name}"` : `编辑「${name}」`),
save: isEnglish ? 'Save' : '保存',
revert: isEnglish ? 'Revert' : '撤销',
saveSuccess: isEnglish ? 'Style pack saved.' : '风格包已保存',
saveFailed: (message: string) => (isEnglish ? `Failed to save style pack: ${message}` : `保存风格包失败:${message}`),
activateSuccess: (name: string) => (isEnglish ? `Set "${name}" as current.` : `已将“${name}”设为当前风格`),
activateFailed: (message: string) => (isEnglish ? `Failed to set current style pack: ${message}` : `设为当前风格失败:${message}`),
enableSuccess: (name: string) => (isEnglish ? `Added "${name}" to rotation.` : `已将“${name}”加入轮换`),
disableSuccess: (name: string) => (isEnglish ? `Removed "${name}" from rotation.` : `已将“${name}”移出轮换`),
toggleFailed: (message: string) => (isEnglish ? `Failed to change rotation status: ${message}` : `切换轮换状态失败:${message}`),
importSuccess: (name: string) => (isEnglish ? `Imported "${name}".` : `已导入“${name}”`),
importFailed: (message: string) => (isEnglish ? `Failed to import ZIP: ${message}` : `导入 ZIP 失败:${message}`),
exportSuccess: (path: string) => (isEnglish ? `Exported to ${path}` : `已导出到 ${path}`),
@@ -122,12 +141,6 @@ export function Style() {
: `确定删除“${name}”吗?删除后无法恢复。`),
deleteSuccess: (name: string) => (isEnglish ? `Deleted "${name}".` : `已删除“${name}”`),
deleteFailed: (message: string) => (isEnglish ? `Failed to delete pack: ${message}` : `删除风格包失败:${message}`),
summaryBuiltin: isEnglish ? 'Built-in Packs' : '内置风格',
summaryBuiltinHint: isEnglish ? 'Default product semantics with one-click reset.' : '跟随产品默认语义,可一键重置到官方基线。',
summaryImported: isEnglish ? 'Imported Packs' : '导入风格',
summaryImportedHint: isEnglish ? 'Installed from ZIP and fully portable.' : '来自 ZIP 包,可启用、编辑、导出和删除。',
summaryEnabled: isEnglish ? 'In Rotation' : '已加入轮换',
summaryCurrent: (name: string) => (isEnglish ? `Current: ${name}` : `当前启用:${name}`),
summaryCurrentEmpty: isEnglish ? 'No pack selected yet' : '还没有选中风格包',
editorTitle: isEnglish ? 'Edit Pack' : '编辑风格',
editorDesc: isEnglish
@@ -136,7 +149,6 @@ export function Style() {
metaTitle: isEnglish ? 'Installation Info' : '安装信息',
metaSource: isEnglish ? 'Source' : '来源',
metaBaseMode: isEnglish ? 'Base Mode' : '基础模式',
metaStatus: isEnglish ? 'Rotation' : '轮换状态',
metaUpdatedAt: isEnglish ? 'Updated' : '更新时间',
fieldName: isEnglish ? 'Name' : '名称',
fieldAuthor: isEnglish ? 'Author' : '作者',
@@ -194,18 +206,42 @@ export function Style() {
};
const [packs, setPacks] = useState<StylePack[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draft, setDraft] = useState<StylePack | null>(null);
const [busy, setBusy] = useState<BusyAction>('loading');
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [saveState, setSaveState] = useState<SaveToastState>('idle');
const [saveMessage, setSaveMessage] = useState('');
const statusTimer = useRef<number | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [editorClosing, setEditorClosing] = useState(false);
const editorCloseTimer = useRef<number | null>(null);
const [runtimePreview, setRuntimePreview] = useState<StylePackRuntimeDiagnostics | null>(null);
const [runtimePreviewError, setRuntimePreviewError] = useState<string | null>(null);
useEffect(() => () => {
if (statusTimer.current !== null) window.clearTimeout(statusTimer.current);
if (editorCloseTimer.current !== null) window.clearTimeout(editorCloseTimer.current);
}, []);
const showSaveStatus = (state: SaveToastState, message: string, temporary = false) => {
if (statusTimer.current !== null) {
window.clearTimeout(statusTimer.current);
statusTimer.current = null;
}
setSaveState(state);
setSaveMessage(message);
if (temporary) {
statusTimer.current = window.setTimeout(() => {
setSaveState('idle');
setSaveMessage('');
statusTimer.current = null;
}, 1600);
}
};
const loadPacks = async (preferredId?: string | null) => {
setBusy('loading');
setError(null);
try {
const next = await listStylePacks();
setPacks(next);
@@ -216,7 +252,7 @@ export function Style() {
null;
setSelectedId(nextSelectedId);
} catch (loadError) {
setError(copy.loadFailed(String(loadError)));
showSaveStatus('failed', copy.loadFailed(String(loadError)));
} finally {
setBusy(null);
}
@@ -248,6 +284,12 @@ export function Style() {
const selectedPack = packs.find(pack => pack.id === selectedId) ?? null;
const activePack = packs.find(pack => pack.active) ?? null;
const rawPack = packs.find(pack => pack.id === BUILTIN_RAW_ID) ?? null;
const otherBuiltinPacks = packs
.filter(pack => pack.kind === 'builtin' && pack.id !== BUILTIN_RAW_ID)
.sort((a, b) => BUILTIN_BODY_ORDER.indexOf(a.id) - BUILTIN_BODY_ORDER.indexOf(b.id));
const importedPacks = packs.filter(pack => pack.kind === 'imported');
const bodyPacks = [...otherBuiltinPacks, ...importedPacks];
const builtinCount = packs.filter(pack => pack.kind === 'builtin').length;
const importedCount = packs.filter(pack => pack.kind === 'imported').length;
const enabledCount = packs.filter(pack => pack.enabled).length;
@@ -284,8 +326,6 @@ export function Style() {
const focusPack = (packId: string) => {
setSelectedId(packId);
setNotice(null);
setError(null);
};
const discardDraftChanges = () => {
@@ -294,14 +334,26 @@ export function Style() {
}
};
const startEditorClose = () => {
if (editorClosing) return;
setEditorClosing(true);
if (editorCloseTimer.current !== null) window.clearTimeout(editorCloseTimer.current);
editorCloseTimer.current = window.setTimeout(() => {
setEditorOpen(false);
setEditorClosing(false);
editorCloseTimer.current = null;
}, 200);
};
const closeEditor = () => {
if (editorClosing) return;
if (dirty) {
if (!window.confirm(copy.discardCloseConfirm)) {
return;
}
discardDraftChanges();
}
setEditorOpen(false);
startEditorClose();
};
const openEditorForPack = (pack: StylePack) => {
@@ -310,14 +362,17 @@ export function Style() {
return;
}
}
if (editorCloseTimer.current !== null) {
window.clearTimeout(editorCloseTimer.current);
editorCloseTimer.current = null;
}
setEditorClosing(false);
focusPack(pack.id);
setEditorOpen(true);
};
useEffect(() => {
if (!editorOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
@@ -326,7 +381,6 @@ export function Style() {
};
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [editorOpen, dirty, selectedPack, draft]);
@@ -359,23 +413,19 @@ export function Style() {
});
};
const showSuccess = (message: string) => {
setNotice(message);
setError(null);
};
const handleSave = async () => {
if (!draft) return;
setBusy('saving');
showSaveStatus('saving', t('common.saving'));
try {
const saved = await saveStylePack({
...draft,
tags: draft.tags.filter(Boolean),
});
showSuccess(copy.saveSuccess);
showSaveStatus('saved', copy.saveSuccess, true);
await loadPacks(saved.id);
} catch (saveError) {
setError(copy.saveFailed(String(saveError)));
showSaveStatus('failed', copy.saveFailed(String(saveError)));
} finally {
setBusy(null);
}
@@ -385,23 +435,10 @@ export function Style() {
setBusy('activating');
try {
await setActiveStylePack(pack.id);
showSuccess(copy.activateSuccess(pack.name));
showSaveStatus('saved', copy.activateSuccess(pack.name), true);
await loadPacks(pack.id);
} catch (activateError) {
setError(copy.activateFailed(String(activateError)));
} finally {
setBusy(null);
}
};
const handleToggleEnabled = async (pack: StylePack) => {
setBusy('toggling');
try {
await setStylePackEnabled(pack.id, !pack.enabled);
showSuccess(pack.enabled ? copy.disableSuccess(pack.name) : copy.enableSuccess(pack.name));
await loadPacks(pack.id);
} catch (toggleError) {
setError(copy.toggleFailed(String(toggleError)));
showSaveStatus('failed', copy.activateFailed(String(activateError)));
} finally {
setBusy(null);
}
@@ -412,10 +449,30 @@ export function Style() {
setBusy('resetting');
try {
await resetBuiltinStylePack(selectedPack.id);
showSuccess(copy.resetSuccess(selectedPack.name));
showSaveStatus('saved', copy.resetSuccess(selectedPack.name), true);
await loadPacks(selectedPack.id);
} catch (resetError) {
setError(copy.resetFailed(String(resetError)));
showSaveStatus('failed', copy.resetFailed(String(resetError)));
} finally {
setBusy(null);
}
};
const handleDeleteImportedPack = async (pack: StylePack) => {
if (pack.kind !== 'imported') return;
if (!window.confirm(copy.deleteConfirm(pack.name))) {
return;
}
setBusy('deleting');
try {
await deleteStylePack(pack.id);
showSaveStatus('saved', copy.deleteSuccess(pack.name), true);
if (editorOpen && selectedId === pack.id) {
startEditorClose();
}
await loadPacks();
} catch (deleteError) {
showSaveStatus('failed', copy.deleteFailed(String(deleteError)));
} finally {
setBusy(null);
}
@@ -423,17 +480,29 @@ export function Style() {
const handleDeleteImported = async () => {
if (!selectedPack || selectedPack.kind !== 'imported') return;
if (!window.confirm(copy.deleteConfirm(selectedPack.name))) {
return;
}
setBusy('deleting');
await handleDeleteImportedPack(selectedPack);
};
const handleCreateFromTemplate = async () => {
setBusy('creating');
try {
await deleteStylePack(selectedPack.id);
showSuccess(copy.deleteSuccess(selectedPack.name));
setEditorOpen(false);
await loadPacks();
} catch (deleteError) {
setError(copy.deleteFailed(String(deleteError)));
const template: StylePack = {
...NEW_PACK_TEMPLATE_BASE,
id: '',
};
const created = await createStylePackFromTemplate(template);
showSaveStatus('saved', copy.createSuccess, true);
await loadPacks(created.id);
// Re-fetch list, then open the editor on the new pack
if (editorCloseTimer.current !== null) {
window.clearTimeout(editorCloseTimer.current);
editorCloseTimer.current = null;
}
setEditorClosing(false);
setSelectedId(created.id);
setEditorOpen(true);
} catch (createError) {
showSaveStatus('failed', copy.createFailed(String(createError)));
} finally {
setBusy(null);
}
@@ -458,10 +527,10 @@ export function Style() {
return;
}
const imported = await importStylePackFromZip(zipPath);
showSuccess(copy.importSuccess(imported.name));
showSaveStatus('saved', copy.importSuccess(imported.name), true);
await loadPacks(imported.id);
} catch (importError) {
setError(copy.importFailed(String(importError)));
showSaveStatus('failed', copy.importFailed(String(importError)));
} finally {
setBusy(null);
}
@@ -470,8 +539,7 @@ export function Style() {
const handleExportZip = async (pack = selectedPack) => {
if (!pack) return;
if (editorOpen && dirty && selectedPack && pack.id === selectedPack.id) {
setError(copy.exportDirtyFirst);
setNotice(null);
showSaveStatus('failed', copy.exportDirtyFirst);
return;
}
setBusy('exporting');
@@ -492,9 +560,9 @@ export function Style() {
return;
}
const savedPath = await exportStylePackToZip(pack.id, targetPath);
showSuccess(copy.exportSuccess(savedPath));
showSaveStatus('saved', copy.exportSuccess(savedPath), true);
} catch (exportError) {
setError(copy.exportFailed(String(exportError)));
showSaveStatus('failed', copy.exportFailed(String(exportError)));
} finally {
setBusy(null);
}
@@ -507,7 +575,7 @@ export function Style() {
title={copy.title}
desc={copy.desc}
right={(
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end', marginTop: 40 }}>
<Btn variant="ghost" icon="refresh" onClick={() => void loadPacks(selectedId)} disabled={busy === 'loading'}>
{t('common.refresh')}
</Btn>
@@ -518,66 +586,51 @@ export function Style() {
)}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12, marginBottom: 16 }}>
<Card padding={16} glassy>
<div style={{ fontSize: 11, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--ol-ink-4)', marginBottom: 8 }}>
{copy.summaryBuiltin}
</div>
<div style={{ fontSize: 24, fontWeight: 600, color: 'var(--ol-ink)' }}>{builtinCount}</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 4 }}>{copy.summaryBuiltinHint}</div>
</Card>
<Card padding={16} glassy>
<div style={{ fontSize: 11, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--ol-ink-4)', marginBottom: 8 }}>
{copy.summaryImported}
</div>
<div style={{ fontSize: 24, fontWeight: 600, color: 'var(--ol-ink)' }}>{importedCount}</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 4 }}>{copy.summaryImportedHint}</div>
</Card>
<Card padding={16} glassy>
<div style={{ fontSize: 11, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--ol-ink-4)', marginBottom: 8 }}>
{copy.summaryEnabled}
</div>
<div style={{ fontSize: 24, fontWeight: 600, color: 'var(--ol-ink)' }}>{enabledCount}</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 4 }}>
{activePack ? copy.summaryCurrent(activePack.name) : copy.summaryCurrentEmpty}
</div>
</Card>
</div>
<SavedToast saveState={saveState} message={saveMessage} />
{(notice || error) && (
<div
role={error ? 'alert' : 'status'}
style={{
marginBottom: 14,
padding: '12px 14px',
borderRadius: 12,
border: error ? '0.5px solid rgba(239,68,68,0.22)' : '0.5px solid rgba(37,99,235,0.16)',
background: error ? 'rgba(254,242,242,0.9)' : 'rgba(239,246,255,0.92)',
color: error ? 'var(--ol-red, #b91c1c)' : 'var(--ol-blue)',
fontSize: 12.5,
lineHeight: 1.55,
}}
>
{error ?? notice}
</div>
)}
<Card padding={0} style={{ overflow: 'hidden' }}>
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ol-ink)' }}>{copy.listTitle}</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 4, maxWidth: 760 }}>{copy.listDesc}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<Card padding={0} style={{ overflow: 'hidden', flex: '1 1 0', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', minWidth: 0 }}>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ol-ink)' }}>{copy.listTitle}</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 4, maxWidth: 760 }}>{copy.listDesc}</div>
</div>
{rawPack && (
<button
type="button"
onClick={() => void handleActivate(rawPack)}
disabled={rawPack.active || busy === 'activating'}
title={rawPack.name}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 12px',
borderRadius: 999,
border: '0.5px solid',
borderColor: rawPack.active ? 'var(--ol-blue)' : 'var(--ol-line-strong)',
background: rawPack.active ? 'var(--ol-blue-soft)' : 'transparent',
color: rawPack.active ? 'var(--ol-blue)' : 'var(--ol-ink-2)',
fontSize: 12.5,
fontWeight: rawPack.active ? 600 : 500,
whiteSpace: 'nowrap',
cursor: rawPack.active ? 'default' : 'pointer',
transition: 'border-color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
}}
>
<span>{rawPack.name}</span>
{rawPack.active && <span style={{ fontSize: 11, opacity: 0.85 }}>·{copy.active}</span>}
</button>
)}
</div>
<Pill tone="outline">{copy.listCount(packs.length)}</Pill>
</div>
</div>
</div>
<div style={{ padding: 18 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
{packs.map(pack => {
const selected = pack.id === selectedId;
<div className="ol-thinscroll" style={{ padding: 18, overflow: 'auto', flex: '1 1 0', minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
{bodyPacks.map(pack => {
const isBuiltin = pack.kind === 'builtin';
return (
<div
key={pack.id}
@@ -585,34 +638,31 @@ export function Style() {
display: 'flex',
flexDirection: 'column',
textAlign: 'left',
position: 'relative',
border: '0.5px solid',
borderColor: selected || pack.active ? 'var(--ol-blue)' : 'var(--ol-line)',
borderColor: pack.active ? 'var(--ol-blue)' : 'var(--ol-line)',
background: pack.active
? 'linear-gradient(180deg, rgba(239,246,255,0.92), rgba(255,255,255,0.98))'
: 'linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,250,252,0.92))',
: isBuiltin
? 'linear-gradient(180deg, rgba(248,250,252,0.92), rgba(241,245,249,0.85))'
: 'linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,250,252,0.92))',
borderRadius: 18,
padding: 16,
boxShadow: selected || pack.active ? '0 0 0 3px var(--ol-blue-ring)' : 'none',
boxShadow: pack.active ? '0 0 0 3px var(--ol-blue-ring)' : 'none',
cursor: 'default',
opacity: pack.enabled ? 1 : 0.72,
minHeight: 204,
transition: 'border-color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft), opacity 0.18s var(--ol-motion-soft)',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10, marginBottom: 10 }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)' }}>{pack.name}</div>
<Pill tone={pack.kind === 'builtin' ? 'outline' : 'blue'} size="sm">
{pack.kind === 'builtin' ? copy.builtin : copy.imported}
</Pill>
<div style={{ minWidth: 42, minHeight: 24, display: 'flex', alignItems: 'center' }}>
{pack.active ? (
<Pill tone="dark" size="sm">{copy.active}</Pill>
) : !pack.enabled ? (
<Pill tone="default" size="sm">{copy.disabled}</Pill>
) : null}
<div style={{ fontSize: 14, fontWeight: 600, color: isBuiltin && !pack.active ? 'var(--ol-ink-2)' : 'var(--ol-ink)' }}>
{pack.name}
</div>
<Pill tone={isBuiltin ? 'outline' : 'blue'} size="sm">
{isBuiltin ? copy.builtin : copy.imported}
</Pill>
{pack.active && <Pill tone="dark" size="sm">{copy.active}</Pill>}
</div>
<div
style={{
@@ -630,29 +680,41 @@ export function Style() {
{pack.description}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8, flexShrink: 0 }}>
{isBuiltin ? (
<div
aria-hidden
style={{
width: 36,
height: 36,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
width: 36, height: 36, borderRadius: 12,
display: 'grid', placeItems: 'center',
background: pack.active ? 'rgba(37,99,235,0.12)' : 'rgba(15,23,42,0.05)',
color: pack.active ? 'var(--ol-blue)' : 'var(--ol-ink-3)',
flexShrink: 0,
}}
>
<Icon name={pack.kind === 'builtin' ? 'sparkle' : 'archive'} size={16} />
<Icon name="sparkle" size={16} />
</div>
<Btn
size="sm"
variant={selected ? 'blue' : 'ghost'}
icon="expand"
onClick={() => openEditorForPack(pack)}
) : (
<button
type="button"
onClick={() => void handleDeleteImportedPack(pack)}
disabled={busy === 'deleting'}
aria-label={copy.deleteImported}
title={copy.deleteImported}
style={{
width: 36, height: 36, borderRadius: 12,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
border: '0.5px solid rgba(239,68,68,0.32)',
background: 'rgba(254,242,242,0.6)',
color: 'var(--ol-red, #ef4444)',
cursor: busy === 'deleting' ? 'wait' : 'pointer',
opacity: busy === 'deleting' ? 0.55 : 1,
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)',
}}
>
{copy.edit}
</Btn>
</div>
<Icon name="trash" size={15} />
</button>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minHeight: 24, marginBottom: 12 }}>
@@ -671,14 +733,6 @@ export function Style() {
>
{pack.active ? copy.active : copy.activate}
</Btn>
<Btn
size="sm"
variant="ghost"
disabled={busy === 'toggling'}
onClick={() => void handleToggleEnabled(pack)}
>
{pack.enabled ? copy.disable : copy.enable}
</Btn>
<Btn
size="sm"
variant="ghost"
@@ -688,10 +742,55 @@ export function Style() {
>
{copy.exportShort}
</Btn>
<Btn
size="sm"
variant="ghost"
icon="expand"
disabled={isBuiltin}
onClick={() => openEditorForPack(pack)}
>
{copy.edit}
</Btn>
</div>
</div>
);
})}
<button
type="button"
onClick={() => void handleCreateFromTemplate()}
disabled={busy === 'creating'}
aria-label={copy.addPackTileTitle}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
textAlign: 'center',
border: '0.5px dashed var(--ol-line-strong)',
borderRadius: 18,
padding: 16,
background: 'transparent',
color: 'var(--ol-ink-3)',
cursor: busy === 'creating' ? 'wait' : 'pointer',
opacity: busy === 'creating' ? 0.55 : 1,
minHeight: 204,
transition: 'border-color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
}}
>
<div
style={{
width: 44, height: 44, borderRadius: 999,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(15,23,42,0.04)',
color: 'var(--ol-ink-2)',
}}
>
<Icon name="plus" size={22} />
</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink-2)' }}>{copy.addPackTileTitle}</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)', lineHeight: 1.55, maxWidth: 220 }}>{copy.addPackTileHint}</div>
</button>
</div>
</div>
</Card>
@@ -704,10 +803,13 @@ export function Style() {
style={{
position: 'fixed',
inset: 0,
background: 'rgba(15,23,42,0.24)',
backdropFilter: 'blur(6px)',
WebkitBackdropFilter: 'blur(6px)',
background: 'rgba(15,17,22,0.32)',
backdropFilter: 'blur(8px) saturate(140%)',
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
zIndex: 40,
animation: editorClosing
? 'ol-modal-backdrop-out 0.2s var(--ol-motion-soft) forwards'
: 'ol-modal-backdrop-in 0.2s var(--ol-motion-soft) both',
}}
/>
<div
@@ -721,6 +823,9 @@ export function Style() {
bottom: 16,
width: 'min(760px, calc(100vw - 32px))',
zIndex: 41,
animation: editorClosing
? 'ol-modal-drawer-out 0.2s var(--ol-motion-soft) forwards'
: 'ol-modal-drawer-in 0.28s var(--ol-motion-spring) both',
}}
>
<Card
@@ -744,18 +849,19 @@ export function Style() {
onClick={closeEditor}
aria-label={copy.closeEditor}
style={{
width: 34,
height: 34,
borderRadius: 10,
border: '0.5px solid var(--ol-line)',
background: 'var(--ol-surface-2)',
width: 28,
height: 28,
borderRadius: 999,
border: 0,
background: 'transparent',
color: 'var(--ol-ink-3)',
display: 'grid',
placeItems: 'center',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon name="close" size={15} />
<Icon name="close" size={14} />
</button>
</div>
</div>
@@ -779,13 +885,6 @@ export function Style() {
<Btn variant="ghost" icon="archive" onClick={() => void handleExportZip()} disabled={busy === 'exporting'}>
{copy.exportZip}
</Btn>
<Btn
variant="ghost"
disabled={busy === 'toggling'}
onClick={() => void handleToggleEnabled(draft)}
>
{draft.enabled ? copy.disable : copy.enable}
</Btn>
<Btn
variant={draft.active ? 'soft' : 'blue'}
icon="check"
@@ -960,7 +1059,6 @@ export function Style() {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 10 }}>
<MetaItem label={copy.metaSource} value={draft.kind === 'builtin' ? copy.builtin : copy.imported} />
<MetaItem label={copy.metaBaseMode} value={t(`style.modes.${draft.baseMode}.name`)} />
<MetaItem label={copy.metaStatus} value={draft.enabled ? copy.enabled : copy.disabled} />
<MetaItem label={copy.metaUpdatedAt} value={draft.updatedAt || '—'} />
</div>
</div>
@@ -997,9 +1095,24 @@ export function Style() {
style={{ ...inputStyle, fontWeight: 600 }}
placeholder={copy.exampleTitlePlaceholder(index + 1)}
/>
<Btn variant="ghost" size="sm" icon="trash" onClick={() => removeExample(index)}>
{t('common.delete')}
</Btn>
<button
type="button"
onClick={() => removeExample(index)}
aria-label={t('common.delete')}
style={{
width: 32, height: 32,
flexShrink: 0,
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
background: 'transparent',
color: 'var(--ol-ink-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)',
}}
>
<Icon name="trash" size={15} />
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
+32
View File
@@ -76,3 +76,35 @@ a { color: inherit; text-decoration: none; }
filter: blur(0);
}
}
@keyframes ol-modal-backdrop-in {
from { opacity: 0; backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); }
to { opacity: 1; backdrop-filter: blur(8px) saturate(140%); -webkit-backdrop-filter: blur(8px) saturate(140%); }
}
@keyframes ol-modal-backdrop-out {
from { opacity: 1; backdrop-filter: blur(8px) saturate(140%); -webkit-backdrop-filter: blur(8px) saturate(140%); }
to { opacity: 0; backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); }
}
@keyframes ol-modal-drawer-in {
from {
opacity: 0;
transform: translate3d(12px, 0, 0) scale(0.985);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes ol-modal-drawer-out {
from {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
to {
opacity: 0;
transform: translate3d(12px, 0, 0) scale(0.985);
}
}