onSetEnabled(item.key)}
diff --git a/src/components/models/RoleAssignment.tsx b/src/components/models/RoleAssignment.tsx
index 44a7ea1..9fe145f 100644
--- a/src/components/models/RoleAssignment.tsx
+++ b/src/components/models/RoleAssignment.tsx
@@ -16,7 +16,7 @@ export default function RoleAssignment({ roles, allItems, enabled, roleAssign, o
角色模型分配(可选)
{roles.map(role => {
- const roleKey = role.key || 'default'
+ const roleKey = role.key
const currentVal = roleAssign[roleKey] || { provider: '', model: '' }
const selectedProv = apiProviders.find(p => p.key === currentVal.provider)
return (
diff --git a/src/hooks/useBookCRUD.ts b/src/hooks/useBookCRUD.ts
index 3b3c610..aeaf9f3 100644
--- a/src/hooks/useBookCRUD.ts
+++ b/src/hooks/useBookCRUD.ts
@@ -29,6 +29,7 @@ export interface UseBookCRUDReturn {
handleEditClick: (book: BookItem, e: React.MouseEvent) => void
handleEditSave: () => Promise
handleImport: () => Promise
+ handleOpenFolder: (book: BookItem, e: React.MouseEvent) => Promise
closeEdit: () => void
closeDelete: () => void
}
@@ -73,11 +74,20 @@ export function useBookCRUD(): UseBookCRUDReturn {
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTarget || deleteConfirm !== '确认删除') return
- if (window.electronAPI) {
- await window.electronAPI.deleteBook(deleteTarget.id)
+ if (!window.electronAPI) return
+ try {
+ const result: any = await window.electronAPI.deleteBook(deleteTarget.id)
setDeleteTarget(null)
setDeleteConfirm('')
await loadBooks()
+ const removed = (result && result.removed) || []
+ if (Array.isArray(removed) && removed.length > 0) {
+ showToast('书籍与文件夹已删除', 'success')
+ } else {
+ showToast('书籍已删除(未找到可删文件夹或已不存在)', 'success')
+ }
+ } catch (e: any) {
+ showToast(e?.message || '删除失败', 'error')
}
}, [deleteTarget, deleteConfirm, loadBooks])
@@ -136,6 +146,21 @@ export function useBookCRUD(): UseBookCRUDReturn {
}
}, [loadBooks])
+ const handleOpenFolder = useCallback(async (book: BookItem, e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (!window.electronAPI) return
+ try {
+ const dir = await window.electronAPI.getBookDir(book.id)
+ if (!dir) {
+ showToast('未找到书籍目录', 'error')
+ return
+ }
+ await window.electronAPI.openDirectory(dir)
+ } catch (err: any) {
+ showToast(err?.message || '打开目录失败', 'error')
+ }
+ }, [])
+
const closeEdit = useCallback(() => {
setEditBook(null)
}, [])
@@ -171,6 +196,7 @@ export function useBookCRUD(): UseBookCRUDReturn {
handleEditClick,
handleEditSave,
handleImport,
+ handleOpenFolder,
closeEdit,
closeDelete,
}
diff --git a/src/pages/BookList.tsx b/src/pages/BookList.tsx
index 983fb9d..390b2e6 100644
--- a/src/pages/BookList.tsx
+++ b/src/pages/BookList.tsx
@@ -28,7 +28,7 @@ export default function BookList() {
📖
还没有书籍
-
点击"新建书籍"开始创作,或"打开目录"导入已有作品
+
点击"新建书籍"开始创作,或"导入目录"导入已有作品
)}
@@ -43,7 +43,8 @@ export default function BookList() {
navigate(`/books/${book.id}/workspace?mode=writing`)}
onEdit={(e) => crud.handleEditClick(book, e)}
- onDelete={(e) => crud.handleDelete(book.id, e)} />
+ onDelete={(e) => crud.handleDelete(book.id, e)}
+ onOpenFolder={(e) => { void crud.handleOpenFolder(book, e) }} />
))}
)}
@@ -72,7 +73,7 @@ export default function BookList() {
确认要删除 《{crud.deleteTarget.name}》 吗?
-
此操作不可恢复,所有章节、大纲、角色数据将被永久删除。
+
此操作不可恢复,数据库记录与书籍文件夹(章节、大纲、角色等)都将被永久删除。
diff --git a/src/pages/ModelsPage.tsx b/src/pages/ModelsPage.tsx
index e443dd5..f220440 100644
--- a/src/pages/ModelsPage.tsx
+++ b/src/pages/ModelsPage.tsx
@@ -30,8 +30,10 @@ const PRESETS: ProviderItem[] = [
]
const ROLES = [
- { key: '', label: '默认' }, { key: 'coordinator', label: '协调器' },
- { key: 'architect', label: '架构师' }, { key: 'writer', label: '写手' }, { key: 'editor', label: '编辑' },
+ { key: 'coordinator', label: '协调器' },
+ { key: 'architect', label: '架构师' },
+ { key: 'writer', label: '写手' },
+ { key: 'editor', label: '编辑' },
]
const inputS: React.CSSProperties = {
@@ -73,12 +75,30 @@ export default function ModelsPage() {
if (cfg.providers) {
setProviders(prev => prev.map(p => {
const saved = cfg.providers[p.key]
- return saved ? { ...p, apiKey: saved.api_key || '', baseUrl: saved.base_url || p.baseUrl, models: saved.models || [] } : p
+ return saved ? {
+ ...p,
+ apiKey: saved.api_key || '',
+ baseUrl: saved.base_url || p.baseUrl,
+ models: saved.models || [],
+ selectedModel: saved.model || p.selectedModel || '',
+ // 预设保留中文显示名;若配置里有自定义 name 也允许覆盖
+ name: saved.name || p.name,
+ } : p
}))
const extra: ProviderItem[] = []
for (const [k, v] of Object.entries(cfg.providers) as [string, ProviderConfig][]) {
if (!PRESETS.find(p => p.key === k)) {
- extra.push({ key: k, name: v.name || k, type: v.type || 'openai', baseUrl: v.base_url || '', apiKey: v.api_key || '', models: v.models || [], selectedModel: v.model || '' })
+ // 显示名优先用保存的 name,绝不默认展示 custom-时间戳(除非用户真没起名)
+ const displayName = (v.name && String(v.name).trim()) || (k.startsWith('custom-') ? '自定义 Provider' : k)
+ extra.push({
+ key: k,
+ name: displayName,
+ type: v.type || 'openai',
+ baseUrl: v.base_url || '',
+ apiKey: v.api_key || '',
+ models: v.models || [],
+ selectedModel: v.model || '',
+ })
}
}
setCustoms(extra)
@@ -87,6 +107,19 @@ export default function ModelsPage() {
if (cfg.image_provider) setImageProvider(cfg.image_provider)
if (cfg.image_model) setImageModel(cfg.image_model)
if (cfg.image_format) setImageFormat(cfg.image_format)
+ // 角色分配:仅合法 role key
+ if (cfg.roles && typeof cfg.roles === 'object') {
+ const next: Record = {}
+ for (const [k, v] of Object.entries(cfg.roles as Record)) {
+ if (!['coordinator', 'architect', 'writer', 'editor'].includes(k)) continue
+ if (!v || typeof v !== 'object') continue
+ next[k] = {
+ provider: String(v.provider || ''),
+ model: String(v.model || ''),
+ }
+ }
+ setRoleAssign(next)
+ }
})()
}, [])
@@ -118,22 +151,78 @@ export default function ModelsPage() {
async function handleSave() {
if (!window.electronAPI) return
setSaving(true); setSaveMsg('')
- const all = [...providers, ...customs]
- const providersCfg: Record = {}
- for (const p of all) {
- if (!p.apiKey) continue
- const entry: any = { type: p.type, base_url: p.baseUrl, api_key: p.apiKey }
- if (p.models.length > 0) entry.models = p.models
- providersCfg[p.key] = entry
- }
- const active = itemByKey(enabled)
- const config: Record = {
- provider: enabled, model: active?.selectedModel || '',
- reasoning_effort: 'medium', providers: providersCfg,
+ try {
+ // 与磁盘合并,避免整包覆盖冲掉 reasoning/roles/其它字段
+ const prev = (await window.electronAPI.loadProviderConfig()) || {}
+ const all = [...providers, ...customs]
+ const providersCfg: Record = { ...(prev.providers || {}) }
+
+ // 更新 UI 中有 key 的项;明确清空 key 的项从配置删除
+ const uiKeys = new Set(all.map(p => p.key))
+ for (const p of all) {
+ if (!p.apiKey) {
+ if (providersCfg[p.key]) delete providersCfg[p.key]
+ continue
+ }
+ const entry: any = {
+ ...(providersCfg[p.key] || {}),
+ name: p.name,
+ type: p.type,
+ base_url: p.baseUrl,
+ api_key: p.apiKey,
+ }
+ if (p.models.length > 0) entry.models = p.models
+ if (p.selectedModel) entry.model = p.selectedModel
+ providersCfg[p.key] = entry
+ }
+
+ // 自定义已从 UI 删除的,从配置移除(预设 key 无 key 时上面已删)
+ for (const key of Object.keys(providersCfg)) {
+ if (!uiKeys.has(key) && String(key).startsWith('custom-')) {
+ delete providersCfg[key]
+ }
+ }
+
+ const active = itemByKey(enabled)
+ if (!enabled || !active?.apiKey) {
+ setSaveMsg('请先选择并配置启用的 Provider')
+ setSaving(false)
+ return
+ }
+
+ const roles: Record = {}
+ for (const [k, v] of Object.entries(roleAssign)) {
+ if (!['coordinator', 'architect', 'writer', 'editor'].includes(k)) continue
+ if (!v?.provider) continue
+ roles[k] = { provider: v.provider, model: v.model || '' }
+ }
+
+ const config: Record = {
+ ...prev,
+ provider: enabled,
+ model: active.selectedModel || prev.model || '',
+ reasoning_effort: prev.reasoning_effort || 'medium',
+ providers: providersCfg,
+ }
+ if (Object.keys(roles).length > 0) config.roles = roles
+ else delete config.roles
+ delete config.role_models
+
+ await window.electronAPI.saveProviderConfig(config)
+
+ const { useBookStore } = await import('@/stores/useBookStore')
+ const bookId = useBookStore.getState().activeBookId
+ if (bookId && window.electronAPI.applyProviderToBook) {
+ await window.electronAPI.applyProviderToBook(bookId)
+ }
+
+ setSaveMsg('已保存')
+ setTimeout(() => setSaveMsg(''), 2500)
+ } catch (e: any) {
+ setSaveMsg(e?.message || '保存失败')
+ } finally {
+ setSaving(false)
}
- await window.electronAPI.saveProviderConfig(config)
- setSaveMsg('已保存')
- setSaving(false); setTimeout(() => setSaveMsg(''), 2500)
}
function removeCustom(key: string) {
@@ -144,9 +233,28 @@ export default function ModelsPage() {
function addCustom() {
if (!addUrl.trim()) return
- const key = 'custom-' + Date.now()
const name = addName.trim() || addUrl.replace(/^https?:\/\//, '').split('/')[0] || '自定义'
- setCustoms(prev => [...prev, { key, name, type: addType, baseUrl: addUrl.trim(), apiKey: addKey.trim(), models: [], selectedModel: '' }])
+ // key 仅作内部 id;卡片显示始终用 name
+ // 尽量用可读 slug,冲突时再追加时间戳
+ const slug = name
+ .toLowerCase()
+ .replace(/[^a-z0-9\u4e00-\u9fff_-]+/gi, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 32) || 'custom'
+ const used = new Set([...providers, ...customs].map(p => p.key))
+ let key = slug
+ if (used.has(key) || PRESETS.some(p => p.key === key)) {
+ key = `${slug}-${Date.now()}`
+ }
+ setCustoms(prev => [...prev, {
+ key,
+ name,
+ type: addType,
+ baseUrl: addUrl.trim(),
+ apiKey: addKey.trim(),
+ models: [],
+ selectedModel: '',
+ }])
setSelected(key)
setShowAdd(false); setAddName(''); setAddUrl(''); setAddKey('')
}
@@ -296,7 +404,7 @@ export default function ModelsPage() {
)}
-
{}} />
+ { if (!roleKey) return; setRoleAssign(prev => { if (!value.provider) { const next = { ...prev }; delete next[roleKey]; return next }; return { ...prev, [roleKey]: value } }) }} />