feat: Custom mini app (#5731)

* feat: 新增文件写入功能,支持通过 ID 写入文件并加载自定义小应用配置。

* feat(i18n): 添加自定义小程序配置的多语言支持,包括英文、简体中文和繁体中文。

* fix(minapps): 使用 await 加载自定义小应用并合并到默认应用中,同时添加日志输出以便调试

* fix(minapps): 在开发环境中添加条件日志输出,以便调试加载的默认小应用。

* refactor(miniappSettings): 移动自定义小应用编辑区域的位置,优化界面布局。

* refactor(miniappSettings): 修改自定义小应用保存逻辑,优化应用列表重新加载方式。

* feat(i18n): 修复在merge过程中丢失的语言设置。

* fix(bug_risk): Consider adding stricter validation for the JSON config on load.

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

* feat(miniapp): 添加自定义小应用功能,优化应用列表展示,支持通过模态框添加新应用。

* feat(App): enhance custom app modal to support logo upload via URL.

* feat(miniapp): add application logo support and update mini app list reloading logic.

* feat(i18n): update mini app custom settings translations for multiple languages.

* feat(miniapp): add updateDefaultMinApps function and refactor mini app list reloading logic.

* feat(miniapp): add removeCustom functionality to handle custom mini app deletion

* feat(miniapp): add duplicate ID check when adding custom mini apps.

* feat(i18n): 重构侧边栏相关翻译为结构化格式,增加删除自定义应用的翻译支持。

* feat(miniapp): 优化删除自定义应用的逻辑,使用条件渲染简化代码结构。

* feat(miniapp): 添加自定义小应用内容的空值处理,确保 JSON 格式有效。

* feat(i18n): 更新默认语言为英语,并移除多个语言文件中的默认代理字段。

* feat(i18n): 为多个语言文件添加自定义小应用配置编辑描述翻译。

* feat(i18n): add success and error messages for deleting custom mini apps in multiple language files.

* feat(i18n): update success and error messages for custom mini app operations and add placeholder text in multiple language files.

* feat(i18n): 为多个语言文件添加重复ID和冲突ID的错误信息翻译,并在自定义小应用设置中实现相关检查逻辑。

* feat(miniapp): 在添加自定义小应用时,增加对默认最小应用ID的重复检查逻辑。

* feat(i18n): update edit description for custom mini app configuration in Traditional Chinese locale

* fix(miniapp): enhance error messages for duplicate and conflicting IDs in custom mini app configuration

---------

Co-authored-by: George Zhao <georgezhao@SKJLAB>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Co-authored-by: suyao <sy20010504@gmail.com>
This commit is contained in:
George Zhao 2025-05-08 21:59:55 +08:00 committed by GitHub
parent c17fdb81aa
commit 4c3c863c7d
17 changed files with 692 additions and 67 deletions

View File

@ -104,6 +104,7 @@ export enum IpcChannel {
File_SelectFolder = 'file:selectFolder',
File_Create = 'file:create',
File_Write = 'file:write',
File_WriteWithId = 'file:writeWithId',
File_SaveImage = 'file:saveImage',
File_Base64Image = 'file:base64Image',
File_Download = 'file:download',

View File

@ -3,7 +3,7 @@ Object.defineProperty(exports, '__esModule', { value: true })
var fs = require('fs')
var path = require('path')
var translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
var baseLocale = 'zh-CN'
var baseLocale = 'en-us'
var baseFileName = ''.concat(baseLocale, '.json')
var baseFilePath = path.join(translationsDir, baseFileName)
/**

View File

@ -206,6 +206,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder)
ipcMain.handle(IpcChannel.File_Create, fileManager.createTempFile)
ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile)
ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId)
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image)
ipcMain.handle(IpcChannel.File_Base64File, fileManager.base64File)

View File

@ -28,11 +28,16 @@ class FileStorage {
}
private initStorageDir = (): void => {
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true })
try {
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true })
}
} catch (error) {
logger.error('[FileStorage] Failed to initialize storage directories:', error)
throw error
}
}
@ -475,6 +480,25 @@ class FileStorage {
throw error
}
}
public writeFileWithId = async (_: Electron.IpcMainInvokeEvent, id: string, content: string): Promise<void> => {
try {
const filePath = path.join(this.storageDir, id)
logger.info('[FileStorage] Writing file:', filePath)
// 确保目录存在
if (!fs.existsSync(this.storageDir)) {
logger.info('[FileStorage] Creating storage directory:', this.storageDir)
fs.mkdirSync(this.storageDir, { recursive: true })
}
await fs.promises.writeFile(filePath, content, 'utf8')
logger.info('[FileStorage] File written successfully:', filePath)
} catch (error) {
logger.error('[FileStorage] Failed to write file:', error)
throw error
}
}
}
export default FileStorage

View File

@ -57,6 +57,7 @@ const api = {
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
create: (fileName: string) => ipcRenderer.invoke(IpcChannel.File_Create, fileName),
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke(IpcChannel.File_Write, filePath, data),
writeWithId: (id: string, content: string) => ipcRenderer.invoke(IpcChannel.File_WriteWithId, id, content),
open: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Open, options),
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path),
save: (path: string, content: string | NodeJS.ArrayBufferView, options?: any) =>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -51,6 +51,7 @@ const MinAppsPopover: FC<Props> = ({ children }) => {
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
)}
<App isLast app={minapps[0]} onClick={handleClose} size={50} />
</AppsContainer>
</PopoverContent>
)

View File

@ -1,3 +1,4 @@
import ApplicationLogo from '@renderer/assets/images/apps/application.png?url'
import ThreeMinTopAppLogo from '@renderer/assets/images/apps/3mintop.png?url'
import AbacusLogo from '@renderer/assets/images/apps/abacus.webp?url'
import AIStudioLogo from '@renderer/assets/images/apps/aistudio.svg?url'
@ -53,7 +54,36 @@ import GroqProviderLogo from '@renderer/assets/images/providers/groq.png?url'
import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png?url'
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png?url'
import { MinAppType } from '@renderer/types'
export const DEFAULT_MIN_APPS: MinAppType[] = [
// 加载自定义小应用
const loadCustomMiniApp = async (): Promise<MinAppType[]> => {
try {
let content: string
try {
content = await window.api.file.read('customMiniAPP')
} catch (error) {
// 如果文件不存在,创建一个空的 JSON 数组
content = '[]'
await window.api.file.writeWithId('customMiniAPP', content)
}
const customApps = JSON.parse(content)
const now = new Date().toISOString()
return customApps.map((app: any) => ({
...app,
type: 'Custom',
logo: app.logo && app.logo !== '' ? app.logo : ApplicationLogo,
addTime: app.addTime || now
}))
} catch (error) {
console.error('Failed to load custom mini apps:', error)
return []
}
}
// 初始化默认小应用
const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
{
id: 'openai',
name: 'ChatGPT',
@ -420,3 +450,16 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
}
}
]
// 加载自定义小应用并合并到默认应用中
let DEFAULT_MIN_APPS = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())]
function updateDefaultMinApps(param) {
DEFAULT_MIN_APPS = param
}
if (process.env.NODE_ENV === 'development') {
console.log('DEFAULT_MIN_APPS', DEFAULT_MIN_APPS)
}
export { DEFAULT_MIN_APPS, loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateDefaultMinApps }

View File

@ -629,11 +629,26 @@
"open_link_external_on": "Current: Open links in browser",
"open_link_external_off": "Current: Open links in default window"
},
"sidebar.add.title": "Add to sidebar",
"sidebar.remove.title": "Remove from sidebar",
"sidebar.close.title": "Close",
"sidebar.closeall.title": "Close All",
"sidebar.hide.title": "Hide MinApp",
"sidebar": {
"add": {
"title": "Add to Sidebar"
},
"remove": {
"title": "Remove from Sidebar"
},
"remove_custom": {
"title": "Delete Custom App"
},
"hide": {
"title": "Hide"
},
"close": {
"title": "Close"
},
"closeall": {
"title": "Close All"
}
},
"title": "MinApp"
},
"miniwindow": {
@ -1111,6 +1126,37 @@
"display.topic.title": "Topic Settings",
"miniapps": {
"title": "Mini Apps Settings",
"custom": {
"title": "Custom Mini App",
"edit_title": "Edit Custom Mini App",
"save_success": "Custom mini app saved successfully.",
"save_error": "Failed to save custom mini app.",
"remove_success": "Custom mini app removed successfully.",
"remove_error": "Failed to remove custom mini app.",
"logo_upload_success": "Logo uploaded successfully.",
"logo_upload_error": "Failed to upload logo.",
"id": "ID",
"id_error": "ID is required.",
"id_placeholder": "Enter ID",
"name": "Name",
"name_error": "Name is required.",
"name_placeholder": "Enter name",
"url": "URL",
"url_error": "URL is required.",
"url_placeholder": "Enter URL",
"logo": "Logo",
"logo_url": "Logo URL",
"logo_file": "Upload Logo File",
"logo_url_label": "Logo URL",
"logo_url_placeholder": "Enter logo URL",
"logo_upload_label": "Upload Logo",
"logo_upload_button": "Upload",
"save": "Save",
"edit_description": "Edit custom mini app configuration here. Each app should include id, name, url, and logo fields.",
"placeholder": "Enter custom mini app configuration (JSON format)",
"duplicate_ids": "Duplicate IDs found: {{ids}}",
"conflicting_ids": "Conflicting IDs with default apps: {{ids}}"
},
"disabled": "Hidden Mini Apps",
"empty": "Drag mini apps from the left to hide them",
"visible": "Visible Mini Apps",
@ -1586,4 +1632,4 @@
"visualization": "Visualization"
}
}
}
}

View File

@ -629,11 +629,26 @@
"open_link_external_on": "現在:ブラウザで開く",
"open_link_external_off": "現在:デフォルトのウィンドウで開く"
},
"sidebar.add.title": "サイドバーに追加",
"sidebar.remove.title": "サイドバーから削除",
"sidebar.close.title": "閉じる",
"sidebar.closeall.title": "すべて閉じる",
"sidebar.hide.title": "ミニアプリを隠す",
"sidebar": {
"add": {
"title": "サイドバーに追加"
},
"remove": {
"title": "サイドバーから削除"
},
"remove_custom": {
"title": "カスタムアプリを削除"
},
"hide": {
"title": "非表示"
},
"close": {
"title": "閉じる"
},
"closeall": {
"title": "すべて閉じる"
}
},
"title": "ミニアプリ"
},
"miniwindow": {
@ -1124,7 +1139,38 @@
"display_title": "ミニアプリ表示設定",
"sidebar_title": "サイドバーのアクティブなミニアプリ表示",
"sidebar_description": "サイドバーにアクティブなミニアプリを表示するかどうかを設定します",
"cache_change_notice": "設定値に達するまでミニアプリの開閉が行われた後に変更が適用されます"
"cache_change_notice": "設定値に達するまでミニアプリの開閉が行われた後に変更が適用されます",
"custom": {
"title": "カスタムミニアプリ",
"edit_title": "カスタムミニアプリの編集",
"save_success": "カスタムミニアプリの保存に成功しました。",
"save_error": "カスタムミニアプリの保存に失敗しました。",
"remove_success": "カスタムミニアプリの削除に成功しました。",
"remove_error": "カスタムミニアプリの削除に失敗しました。",
"logo_upload_success": "ロゴのアップロードに成功しました。",
"logo_upload_error": "ロゴのアップロードに失敗しました。",
"id": "ID",
"id_error": "IDは必須項目です。",
"id_placeholder": "IDを入力してください",
"name": "名前",
"name_error": "名前は必須項目です。",
"name_placeholder": "名前を入力してください",
"url": "URL",
"url_error": "URLは必須項目です。",
"url_placeholder": "URLを入力してください",
"logo": "ロゴ",
"logo_url": "ロゴURL",
"logo_file": "ロゴファイルをアップロード",
"logo_url_label": "ロゴURL",
"logo_url_placeholder": "ロゴURLを入力してください",
"logo_upload_label": "ロゴをアップロード",
"logo_upload_button": "アップロード",
"save": "保存",
"edit_description": "ここでカスタムミニアプリの設定を編集します。各アプリにはid、name、url、logoフィールドが必要です。",
"placeholder": "カスタムミニアプリの設定を入力してくださいJSON形式",
"duplicate_ids": "重複するIDが見つかりました: {{ids}}",
"conflicting_ids": "デフォルトアプリとIDが競合しています: {{ids}}"
}
},
"font_size.title": "メッセージのフォントサイズ",
"general": "一般設定",

View File

@ -629,11 +629,26 @@
"open_link_external_on": "Текущий: Открыть ссылки в браузере",
"open_link_external_off": "Текущий: Открыть ссылки в окне по умолчанию"
},
"sidebar.add.title": "Добавить в боковую панель",
"sidebar.remove.title": "Удалить из боковой панели",
"sidebar.close.title": "Закрыть",
"sidebar.closeall.title": "Закрыть все",
"sidebar.hide.title": "Скрыть приложение",
"sidebar": {
"add": {
"title": "Добавить в боковую панель"
},
"remove": {
"title": "Удалить из боковой панели"
},
"remove_custom": {
"title": "Удалить пользовательское приложение"
},
"hide": {
"title": "Скрыть"
},
"close": {
"title": "Закрыть"
},
"closeall": {
"title": "Закрыть все"
}
},
"title": "Встроенные приложения"
},
"miniwindow": {
@ -1124,7 +1139,38 @@
"display_title": "Настройки отображения мини-приложений",
"sidebar_title": "Отображение активных мини-приложений в боковой панели",
"sidebar_description": "Настройка отображения активных мини-приложений в боковой панели",
"cache_change_notice": "Изменения вступят в силу, когда количество открытых мини-приложений достигнет установленного значения"
"cache_change_notice": "Изменения вступят в силу, когда количество открытых мини-приложений достигнет установленного значения",
"custom": {
"save_success": "Пользовательское мини-приложение успешно сохранено.",
"save_error": "Не удалось сохранить пользовательское мини-приложение.",
"logo_upload_success": "Логотип успешно загружен.",
"logo_upload_error": "Не удалось загрузить логотип.",
"title": "Пользовательские мини-приложения",
"edit_title": "Редактировать пользовательское мини-приложение",
"id": "ID",
"remove_success": "Мини-приложение успешно удалено.",
"remove_error": "Не удалось удалить мини-приложение.",
"id_error": "ID обязателен.",
"id_placeholder": "Введите ID",
"name": "Имя",
"name_error": "Имя обязательно.",
"name_placeholder": "Введите имя",
"url": "URL",
"url_error": "URL обязателен.",
"url_placeholder": "Введите URL",
"logo": "Логотип",
"logo_url": "URL логотипа",
"logo_file": "Загрузить файл логотипа",
"logo_url_label": "URL логотипа",
"logo_url_placeholder": "Введите URL логотипа",
"logo_upload_label": "Загрузить логотип",
"logo_upload_button": "Загрузить",
"save": "Сохранить",
"edit_description": "Здесь вы можете редактировать конфигурации пользовательских мини-приложений. Каждое приложение должно содержать поля id, name, url и logo.",
"placeholder": "Введите конфигурацию мини-приложения (формат JSON)",
"duplicate_ids": "Найдены повторяющиеся ID: {{ids}}",
"conflicting_ids": "Конфликт ID с приложениями по умолчанию: {{ids}}"
}
},
"font_size.title": "Размер шрифта сообщений",
"general": "Общие настройки",

View File

@ -629,11 +629,26 @@
"open_link_external_on": "当前:在浏览器中打开链接",
"open_link_external_off": "当前:使用默认窗口打开链接"
},
"sidebar.add.title": "添加到侧边栏",
"sidebar.remove.title": "从侧边栏移除",
"sidebar.close.title": "关闭",
"sidebar.closeall.title": "全部关闭",
"sidebar.hide.title": "隐藏小程序",
"sidebar": {
"add": {
"title": "添加到侧边栏"
},
"remove": {
"title": "从侧边栏移除"
},
"remove_custom": {
"title": "删除自定义应用"
},
"hide": {
"title": "隐藏"
},
"close": {
"title": "关闭"
},
"closeall": {
"title": "关闭所有"
}
},
"title": "小程序"
},
"miniwindow": {
@ -1117,6 +1132,37 @@
"open_link_external": {
"title": "在浏览器中打开新窗口链接"
},
"custom": {
"title": "自定义小程序",
"edit_title": "编辑自定义小程序",
"save_success": "自定义小程序保存成功。",
"save_error": "自定义小程序保存失败。",
"remove_success": "自定义小程序删除成功。",
"remove_error": "自定义小程序删除失败。",
"logo_upload_success": "Logo 上传成功。",
"logo_upload_error": "Logo 上传失败。",
"id": "ID",
"id_error": "ID 是必填项。",
"id_placeholder": "请输入 ID",
"name": "名称",
"name_error": "名称是必填项。",
"name_placeholder": "请输入名称",
"url": "URL",
"url_error": "URL 是必填项。",
"url_placeholder": "请输入 URL",
"logo": "Logo",
"logo_url": "Logo URL",
"logo_file": "上传 Logo 文件",
"logo_url_label": "Logo URL",
"logo_url_placeholder": "请输入 Logo URL",
"logo_upload_label": "上传 Logo",
"logo_upload_button": "上传",
"save": "保存",
"edit_description": "在这里编辑自定义小应用的配置。每个应用需要包含 id、name、url 和 logo 字段。",
"placeholder": "请输入自定义小程序配置JSON格式",
"duplicate_ids": "发现重复的ID: {{ids}}",
"conflicting_ids": "与默认应用ID冲突: {{ids}}"
},
"cache_settings": "缓存设置",
"cache_title": "小程序缓存数量",
"cache_description": "设置同时保持活跃状态的小程序最大数量",

View File

@ -629,11 +629,26 @@
"open_link_external_on": "当前:在瀏覽器中開啟連結",
"open_link_external_off": "当前:使用預設視窗開啟連結"
},
"sidebar.add.title": "新增到側邊欄",
"sidebar.remove.title": "從側邊欄移除",
"sidebar.close.title": "關閉",
"sidebar.closeall.title": "全部關閉",
"sidebar.hide.title": "隱藏小工具",
"sidebar": {
"add": {
"title": "添加到側邊欄"
},
"remove": {
"title": "從側邊欄移除"
},
"remove_custom": {
"title": "刪除自定義應用"
},
"hide": {
"title": "隱藏"
},
"close": {
"title": "關閉"
},
"closeall": {
"title": "關閉所有"
}
},
"title": "小工具"
},
"miniwindow": {
@ -1117,6 +1132,37 @@
"open_link_external": {
"title": "在瀏覽器中打開新視窗連結"
},
"custom": {
"duplicate_ids": "發現重複的ID: {{ids}}",
"conflicting_ids": "與預設應用ID衝突: {{ids}}",
"title": "自定義小程序",
"edit_title": "編輯自定義小程序",
"save_success": "自定義小程序保存成功。",
"save_error": "自定義小程序保存失敗。",
"remove_success": "自定義小程序刪除成功。",
"remove_error": "自定義小程序刪除失敗。",
"logo_upload_success": "Logo 上傳成功。",
"logo_upload_error": "Logo 上傳失敗。",
"id": "ID",
"id_error": "ID 是必填項。",
"id_placeholder": "請輸入 ID",
"name": "名稱",
"name_error": "名稱是必填項。",
"name_placeholder": "請輸入名稱",
"url": "URL",
"url_error": "URL 是必填項。",
"url_placeholder": "請輸入 URL",
"logo": "Logo",
"logo_url": "Logo URL",
"logo_file": "上傳 Logo 文件",
"logo_url_label": "Logo URL",
"logo_url_placeholder": "請輸入 Logo URL",
"logo_upload_label": "上傳 Logo",
"logo_upload_button": "上傳",
"save": "保存",
"placeholder": "請輸入自定義小程序配置JSON格式",
"edit_description": "編輯自定義小程序配置"
},
"cache_settings": "緩存設置",
"cache_title": "小程式緩存數量",
"cache_description": "設置同時保持活躍狀態的小程式最大數量",

View File

@ -1,10 +1,13 @@
import { PlusOutlined, UploadOutlined } from '@ant-design/icons'
import MinAppIcon from '@renderer/components/Icons/MinAppIcon'
import { loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateDefaultMinApps } from '@renderer/config/minapps'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { MinAppType } from '@renderer/types'
import type { MenuProps } from 'antd'
import { Dropdown } from 'antd'
import { FC } from 'react'
import { Button, Dropdown, Form, Input, message, Modal, Radio, Upload } from 'antd'
import type { UploadFile } from 'antd/es/upload/interface'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -12,52 +15,220 @@ interface Props {
app: MinAppType
onClick?: () => void
size?: number
isLast?: boolean
}
const App: FC<Props> = ({ app, onClick, size = 60 }) => {
const App: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
const { openMinappKeepAlive } = useMinappPopup()
const { t } = useTranslation()
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
const isPinned = pinned.some((p) => p.id === app.id)
const isVisible = minapps.some((m) => m.id === app.id)
const [isModalVisible, setIsModalVisible] = useState(false)
const [form] = Form.useForm()
const [logoType, setLogoType] = useState<'url' | 'file'>('url')
const [fileList, setFileList] = useState<UploadFile[]>([])
const handleClick = () => {
if (isLast) {
setIsModalVisible(true)
return
}
openMinappKeepAlive(app)
onClick?.()
}
const menuItems: MenuProps['items'] = [
{
key: 'togglePin',
label: isPinned ? t('minapp.sidebar.remove.title') : t('minapp.sidebar.add.title'),
onClick: () => {
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...(pinned || []), app]
updatePinnedMinapps(newPinned)
const handleAddCustomApp = async (values: any) => {
try {
const content = await window.api.file.read('customMiniAPP')
const customApps = JSON.parse(content)
// Check for duplicate ID
if (customApps.some((app: MinAppType) => app.id === values.id)) {
message.error(t('settings.miniapps.custom.duplicate_ids', { ids: values.id }))
return
}
},
{
key: 'hide',
label: t('minapp.sidebar.hide.title'),
onClick: () => {
const newMinapps = minapps.filter((item) => item.id !== app.id)
updateMinapps(newMinapps)
const newDisabled = [...(disabled || []), app]
updateDisabledMinapps(newDisabled)
const newPinned = pinned.filter((item) => item.id !== app.id)
updatePinnedMinapps(newPinned)
if (ORIGIN_DEFAULT_MIN_APPS.some((app: MinAppType) => app.id === values.id)) {
message.error(t('settings.miniapps.custom.conflicting_ids', { ids: values.id }))
return
}
const newApp = {
id: values.id,
name: values.name,
url: values.url,
logo: form.getFieldValue('logo') || '',
type: 'Custom',
addTime: new Date().toISOString()
}
customApps.push(newApp)
await window.api.file.writeWithId('customMiniAPP', JSON.stringify(customApps, null, 2))
message.success(t('settings.miniapps.custom.save_success'))
setIsModalVisible(false)
form.resetFields()
setFileList([])
// 重新加载应用列表
const reloadedApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())]
updateDefaultMinApps(reloadedApps)
updateMinapps(reloadedApps)
} catch (error) {
message.error(t('settings.miniapps.custom.save_error'))
console.error('Failed to save custom mini app:', error)
}
}
const handleLogoTypeChange = (e: any) => {
setLogoType(e.target.value)
form.setFieldValue('logo', '')
setFileList([])
}
const handleFileChange = async (info: any) => {
console.log(info)
const file = info.fileList[info.fileList.length - 1]?.originFileObj
console.log(file)
setFileList(info.fileList.slice(-1))
if (file) {
try {
const reader = new FileReader()
reader.onload = (event) => {
const base64Data = event.target?.result
if (typeof base64Data === 'string') {
message.success(t('settings.miniapps.custom.logo_upload_success'))
form.setFieldValue('logo', base64Data)
}
}
reader.readAsDataURL(file)
} catch (error) {
console.error('Failed to read file:', error)
message.error(t('settings.miniapps.custom.logo_upload_error'))
}
}
]
}
if (!isVisible) return null
const menuItems: MenuProps['items'] = isLast
? []
: [
{
key: 'togglePin',
label: isPinned ? t('minapp.sidebar.remove.title') : t('minapp.sidebar.add.title'),
onClick: () => {
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...(pinned || []), app]
updatePinnedMinapps(newPinned)
}
},
{
key: 'hide',
label: t('minapp.sidebar.hide.title'),
onClick: () => {
const newMinapps = minapps.filter((item) => item.id !== app.id)
updateMinapps(newMinapps)
const newDisabled = [...(disabled || []), app]
updateDisabledMinapps(newDisabled)
const newPinned = pinned.filter((item) => item.id !== app.id)
updatePinnedMinapps(newPinned)
}
},
...(app.type === 'Custom'
? [
{
key: 'removeCustom',
label: t('minapp.sidebar.remove_custom.title'),
danger: true,
onClick: async () => {
try {
const content = await window.api.file.read('customMiniAPP')
const customApps = JSON.parse(content)
const updatedApps = customApps.filter((customApp: MinAppType) => customApp.id !== app.id)
await window.api.file.writeWithId('customMiniAPP', JSON.stringify(updatedApps, null, 2))
message.success(t('settings.miniapps.custom.remove_success'))
const reloadedApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())]
updateDefaultMinApps(reloadedApps)
updateMinapps(reloadedApps)
} catch (error) {
message.error(t('settings.miniapps.custom.remove_error'))
console.error('Failed to remove custom mini app:', error)
}
}
}
]
: [])
]
if (!isVisible && !isLast) return null
return (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<Container onClick={handleClick}>
<MinAppIcon size={size} app={app} />
<AppTitle>{app.name}</AppTitle>
</Container>
</Dropdown>
<>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<Container onClick={handleClick}>
{isLast ? (
<AddButton>
<PlusOutlined />
</AddButton>
) : (
<MinAppIcon size={size} app={app} />
)}
<AppTitle>{isLast ? t('settings.miniapps.custom.title') : app.name}</AppTitle>
</Container>
</Dropdown>
<Modal
title={t('settings.miniapps.custom.edit_title')}
open={isModalVisible}
onCancel={() => {
setIsModalVisible(false)
setFileList([])
}}
footer={null}>
<Form form={form} onFinish={handleAddCustomApp} layout="vertical">
<Form.Item
name="id"
label={t('settings.miniapps.custom.id')}
rules={[{ required: true, message: t('settings.miniapps.custom.id_error') }]}>
<Input placeholder={t('settings.miniapps.custom.id_placeholder')} />
</Form.Item>
<Form.Item
name="name"
label={t('settings.miniapps.custom.name')}
rules={[{ required: true, message: t('settings.miniapps.custom.name_error') }]}>
<Input placeholder={t('settings.miniapps.custom.name_placeholder')} />
</Form.Item>
<Form.Item
name="url"
label={t('settings.miniapps.custom.url')}
rules={[{ required: true, message: t('settings.miniapps.custom.url_error') }]}>
<Input placeholder={t('settings.miniapps.custom.url_placeholder')} />
</Form.Item>
<Form.Item label={t('settings.miniapps.custom.logo')}>
<Radio.Group value={logoType} onChange={handleLogoTypeChange}>
<Radio value="url">{t('settings.miniapps.custom.logo_url')}</Radio>
<Radio value="file">{t('settings.miniapps.custom.logo_file')}</Radio>
</Radio.Group>
</Form.Item>
{logoType === 'url' ? (
<Form.Item name="logo" label={t('settings.miniapps.custom.logo_url_label')}>
<Input placeholder={t('settings.miniapps.custom.logo_url_placeholder')} />
</Form.Item>
) : (
<Form.Item label={t('settings.miniapps.custom.logo_upload_label')}>
<Upload
accept="image/*"
maxCount={1}
fileList={fileList}
onChange={handleFileChange}
beforeUpload={() => false}>
<Button icon={<UploadOutlined />}>{t('settings.miniapps.custom.logo_upload_button')}</Button>
</Upload>
</Form.Item>
)}
<Form.Item>
<Button type="primary" htmlType="submit">
{t('settings.miniapps.custom.save')}
</Button>
</Form.Item>
</Form>
</Modal>
</>
)
}
@ -79,4 +250,25 @@ const AppTitle = styled.div`
white-space: nowrap;
`
const AddButton = styled.div`
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-background-soft);
border: 1px dashed var(--color-border);
color: var(--color-text-soft);
font-size: 24px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--color-background);
border-color: var(--color-primary);
color: var(--color-primary);
}
`
export default App

View File

@ -23,7 +23,7 @@ const AppsPage: FC = () => {
// Calculate the required number of lines
const itemsPerRow = Math.floor(930 / 115) // Maximum width divided by the width of each item (including spacing)
const rowCount = Math.ceil(filteredApps.length / itemsPerRow)
const rowCount = Math.ceil((filteredApps.length + 1) / itemsPerRow) // +1 for the add button
// Each line height is 85px (60px icon + 5px margin + 12px text + spacing)
const containerHeight = rowCount * 85 + (rowCount - 1) * 25 // 25px is the line spacing.
@ -60,6 +60,7 @@ const AppsPage: FC = () => {
{filteredApps.map((app) => (
<App key={app.id} app={app} />
))}
<App isLast app={filteredApps[0]} />
</AppsContainer>
)}
</ContentContainer>

View File

@ -1,5 +1,10 @@
import { UndoOutlined } from '@ant-design/icons' // 导入重置图标
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import {
DEFAULT_MIN_APPS,
loadCustomMiniApp,
ORIGIN_DEFAULT_MIN_APPS,
updateDefaultMinApps
} from '@renderer/config/minapps'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useSettings } from '@renderer/hooks/useSettings'
@ -9,7 +14,7 @@ import {
setMinappsOpenLinkExternal,
setShowOpenedMinappsInSidebar
} from '@renderer/store/settings'
import { Button, message, Slider, Switch, Tooltip } from 'antd'
import { Button, Input, message, Slider, Switch, Tooltip } from 'antd'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -31,6 +36,92 @@ const MiniAppSettings: FC = () => {
const [disabledMiniApps, setDisabledMiniApps] = useState(disabled || [])
const [messageApi, contextHolder] = message.useMessage()
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
const [customMiniAppContent, setCustomMiniAppContent] = useState('[]')
// 加载自定义小应用配置
useEffect(() => {
const loadCustomMiniApp = async () => {
try {
const content = await window.api.file.read('customMiniAPP')
let validContent = '[]'
try {
const parsed = JSON.parse(content)
validContent = JSON.stringify(parsed)
} catch (e) {
console.error('Invalid JSON format in custom mini app config:', e)
}
setCustomMiniAppContent(validContent)
} catch (error) {
console.error('Failed to load custom mini app config:', error)
setCustomMiniAppContent('[]')
}
}
loadCustomMiniApp()
}, [])
// 保存自定义小应用配置
const handleSaveCustomMiniApp = useCallback(async () => {
try {
// 验证 JSON 格式
if (customMiniAppContent === '') {
setCustomMiniAppContent('[]')
}
const parsedContent = JSON.parse(customMiniAppContent)
// 确保是数组
if (!Array.isArray(parsedContent)) {
throw new Error('Content must be an array')
}
// 检查自定义应用中的重复ID
const customIds = new Set<string>()
const duplicateIds = new Set<string>()
parsedContent.forEach((app: any) => {
if (app.id) {
if (customIds.has(app.id)) {
duplicateIds.add(app.id)
}
customIds.add(app.id)
}
})
// 检查与默认应用的ID重复
const defaultIds = new Set(ORIGIN_DEFAULT_MIN_APPS.map((app) => app.id))
const conflictingIds = new Set<string>()
customIds.forEach((id) => {
if (defaultIds.has(id)) {
conflictingIds.add(id)
}
})
// 如果有重复ID显示错误信息
if (duplicateIds.size > 0 || conflictingIds.size > 0) {
let errorMessage = ''
if (duplicateIds.size > 0) {
errorMessage += t('settings.miniapps.custom.duplicate_ids', { ids: Array.from(duplicateIds).join(', ') })
}
if (conflictingIds.size > 0) {
console.log('conflictingIds', Array.from(conflictingIds))
if (errorMessage) errorMessage += '\n'
errorMessage += t('settings.miniapps.custom.conflicting_ids', { ids: Array.from(conflictingIds).join(', ') })
}
messageApi.error(errorMessage)
return
}
// 保存文件
await window.api.file.writeWithId('customMiniAPP', customMiniAppContent)
messageApi.success(t('settings.miniapps.custom.save_success'))
// 重新加载应用列表
console.log('Reloading mini app list...')
const reloadedApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())]
updateDefaultMinApps(reloadedApps)
console.log('Reloaded mini app list:', reloadedApps)
updateMinapps(reloadedApps)
} catch (error) {
messageApi.error(t('settings.miniapps.custom.save_error'))
console.error('Failed to save custom mini app config:', error)
}
}, [customMiniAppContent, messageApi, t, updateMinapps])
const handleResetMinApps = useCallback(() => {
setVisibleMiniApps(DEFAULT_MIN_APPS)
@ -77,6 +168,7 @@ const MiniAppSettings: FC = () => {
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.miniapps.title')}</SettingTitle>
<SettingDivider />
<SettingTitle
style={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{t('settings.miniapps.display_title')}</span>
@ -143,6 +235,30 @@ const MiniAppSettings: FC = () => {
onChange={(checked) => dispatch(setShowOpenedMinappsInSidebar(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingLabelGroup>
<SettingRowTitle>{t('settings.miniapps.custom.edit_title')}</SettingRowTitle>
<SettingDescription>{t('settings.miniapps.custom.edit_description')}</SettingDescription>
</SettingLabelGroup>
</SettingRow>
<CustomEditorContainer>
<Input.TextArea
value={customMiniAppContent}
onChange={(e) => setCustomMiniAppContent(e.target.value)}
placeholder={t('settings.miniapps.custom.placeholder')}
style={{
minHeight: 200,
fontFamily: 'monospace',
backgroundColor: 'var(--color-bg-2)',
color: 'var(--color-text)',
borderColor: 'var(--color-border)'
}}
/>
<Button type="primary" onClick={handleSaveCustomMiniApp} style={{ marginTop: 8 }}>
{t('settings.miniapps.custom.save')}
</Button>
</CustomEditorContainer>
</SettingGroup>
</SettingContainer>
)
@ -229,4 +345,17 @@ const BorderedContainer = styled.div`
background-color: var(--color-bg-1);
`
// 新增自定义编辑器容器样式
const CustomEditorContainer = styled.div`
margin: 8px 0;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: 8px;
background-color: var(--color-bg-1);
.ant-input {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
}
`
export default MiniAppSettings

View File

@ -258,6 +258,8 @@ export type MinAppType = {
bodered?: boolean
background?: string
style?: React.CSSProperties
addTime?: string
type?: 'Custom' | 'Default' // Added the 'type' property
}
export interface FileType {