Skip to content

Commit 2f7df79

Browse files
committed
refactor: 插件类型改为名称后缀 _ui 推断,新增市场数据缓存
- 插件类型判断从配置 type 字段改为按名称后缀 _ui 推断(web/server) - 创建前端插件时自动追加 _ui 后缀并提示用户 - 插件市场数据新增本地缓存,网络失败时自动回退 - 移除未使用的 searchPlugins/filterByTag 导出 - 修复 create 流程中 config.database 潜在的 undefined 崩溃 Made-with: Cursor
1 parent d6255b0 commit 2f7df79

6 files changed

Lines changed: 124 additions & 74 deletions

File tree

src/commands/plugin/add.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
// plugin/add.ts — 插件添加
22
import * as clack from '@clack/prompts'
33
import chalk from 'chalk'
4+
import { basename } from 'path'
45
import { t } from '../../lib/i18n.js'
56
import { requireProjectDir, fatal } from '../../lib/errors.js'
6-
import { fetchPluginMarketData, searchPlugins, filterByType } from '../../lib/plugin-market.js'
7+
import { fetchPluginMarketData, filterByType, getMarketPluginType } from '../../lib/plugin-market.js'
78
import { installFromMarket, installFrontendPlugin, installBackendPlugin, runPnpmInstall } from '../../lib/plugin-install.js'
89
import type { PluginData } from '../../types/plugin.js'
10+
import { inferPluginType } from '../../types/plugin.js'
911

1012
/**
1113
* fba plugin add — 添加插件
@@ -73,12 +75,18 @@ export async function pluginMarketFlow(projectDir: string) {
7375

7476
let plugins: PluginData[]
7577
try {
76-
plugins = await fetchPluginMarketData()
78+
const result = await fetchPluginMarketData()
79+
plugins = result.data
80+
if (result.fromCache) {
81+
loadSpinner.stop(`${t('pluginMarketTitle')} (${plugins.length} ${t('pluginCount')})`)
82+
clack.log.warn(chalk.yellow(t('pluginMarketCacheUsed')))
83+
} else {
84+
loadSpinner.stop(`${t('pluginMarketTitle')} (${plugins.length} ${t('pluginCount')})`)
85+
}
7786
} catch (e: any) {
7887
loadSpinner.stop(chalk.red(`${t('pluginLoadFailed')}: ${e.message}`))
7988
return
8089
}
81-
loadSpinner.stop(`${t('pluginMarketTitle')} (${plugins.length} ${t('pluginCount')})`)
8290

8391
// 类型过滤
8492
const typeFilter = await clack.select({
@@ -99,7 +107,7 @@ export async function pluginMarketFlow(projectDir: string) {
99107
options: filtered.map((p, i) => ({
100108
value: i,
101109
label: `${p.plugin.summary}`,
102-
hint: `v${p.plugin.version} | ${p.plugin.type} | @${p.plugin.author}`,
110+
hint: `v${p.plugin.version} | ${getMarketPluginType(p)} | @${p.plugin.author}`,
103111
})),
104112
required: false,
105113
})
@@ -111,18 +119,18 @@ export async function pluginMarketFlow(projectDir: string) {
111119

112120
// 安装
113121
clack.log.step(t('pluginInstalling'))
114-
const result = await installFromMarket(projectDir, selectedPlugins)
122+
const installResult = await installFromMarket(projectDir, selectedPlugins)
115123

116-
for (const name of result.success) {
124+
for (const name of installResult.success) {
117125
clack.log.success(chalk.green(`✓ ${name}`))
118126
}
119-
for (const name of result.failed) {
127+
for (const name of installResult.failed) {
120128
clack.log.error(chalk.red(`✗ ${name}`))
121129
}
122130

123131
// 如果安装了前端插件,询问 pnpm install
124-
const hasWebPlugins = selectedPlugins.some(p => p?.plugin.type === 'web')
125-
if (hasWebPlugins && result.success.length > 0) {
132+
const hasWebPlugins = selectedPlugins.some(p => getMarketPluginType(p) === 'web')
133+
if (hasWebPlugins && installResult.success.length > 0) {
126134
const doPnpm = await clack.confirm({ message: t('pluginPnpmInstall') })
127135
if (!clack.isCancel(doPnpm) && doPnpm) {
128136
await runPnpmInstall(projectDir)

src/commands/plugin/create.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { requireProjectDir } from '../../lib/errors.js'
88
import { getFrontendPluginDir, getBackendPluginDir } from '../../lib/config.js'
99
import { renderTemplate, type TemplateVars } from '../../lib/template.js'
1010
import { gitInit } from '../../lib/git.js'
11-
import { VALID_TAGS, VALID_DATABASES, type ServerPluginLevel } from '../../types/plugin.js'
11+
import { VALID_TAGS, VALID_DATABASES, type ServerPluginLevel, ensureWebPluginName } from '../../types/plugin.js'
1212
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
1313
import { readFileSync, writeFileSync } from 'fs'
1414

@@ -61,30 +61,40 @@ export async function pluginCreateAction(options: { project?: string }) {
6161
}),
6262
}, { onCancel: () => process.exit(0) })
6363

64-
const vars: TemplateVars = {
65-
name: config.name,
66-
summary: config.summary,
67-
version: config.version,
68-
description: config.description,
69-
author: config.author,
70-
}
71-
7264
const typesToCreate: Array<'web' | 'server'> = config.type === 'all'
7365
? ['web', 'server']
7466
: [config.type as 'web' | 'server']
7567

7668
for (const pluginType of typesToCreate) {
69+
// 前端插件必须以 _ui 结尾
70+
let pluginName = config.name
71+
if (pluginType === 'web') {
72+
const result = ensureWebPluginName(pluginName)
73+
pluginName = result.name
74+
if (result.appended) {
75+
clack.log.info(chalk.cyan(`${t('pluginNameAutoSuffix')}: ${pluginName}`))
76+
}
77+
}
78+
79+
const vars: TemplateVars = {
80+
name: pluginName,
81+
summary: config.summary,
82+
version: config.version,
83+
description: config.description,
84+
author: config.author,
85+
}
86+
7787
const targetDir = pluginType === 'web'
78-
? join(getFrontendPluginDir(projectDir), config.name)
79-
: join(getBackendPluginDir(projectDir), config.name)
88+
? join(getFrontendPluginDir(projectDir), pluginName)
89+
: join(getBackendPluginDir(projectDir), pluginName)
8090

8191
if (existsSync(targetDir)) {
8292
clack.log.warn(chalk.yellow(`${t('pluginDirExists')}: ${targetDir}`))
8393
continue
8494
}
8595

8696
const createSpinner = clack.spinner()
87-
createSpinner.start(`${t('pluginCreating')} ${pluginType}: ${config.name}`)
97+
createSpinner.start(`${t('pluginCreating')} ${pluginType}: ${pluginName}`)
8898

8999
try {
90100
// 1. 渲染模板
@@ -106,8 +116,9 @@ export async function pluginCreateAction(options: { project?: string }) {
106116
parsed.plugin.description = config.description
107117
parsed.plugin.author = config.author
108118
parsed.plugin.tags = config.tags
109-
if (config.database.length > 0) {
110-
parsed.plugin.database = config.database
119+
const database = config.database as string[] | undefined
120+
if (database && database.length > 0) {
121+
parsed.plugin.database = database
111122
}
112123
}
113124
writeFileSync(tomlPath, stringifyToml(parsed), 'utf-8')

src/lib/i18n.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ const messages = {
150150
pluginTableVersion: "版本",
151151
pluginTableAuthor: "作者",
152152
pluginTotal: "共计",
153+
pluginNameAutoSuffix: "前端插件名称已自动添加 _ui 后缀",
154+
pluginMarketCacheUsed: "网络不可用,使用本地缓存数据",
153155

154156
// Completion
155157
createSuccess: "项目创建成功!",
@@ -398,6 +400,8 @@ const messages = {
398400
pluginTableVersion: "Version",
399401
pluginTableAuthor: "Author",
400402
pluginTotal: "Total",
403+
pluginNameAutoSuffix: "Frontend plugin name auto-appended with _ui suffix",
404+
pluginMarketCacheUsed: "Network unavailable, using local cache",
401405

402406
createSuccess: "Project created successfully!",
403407
nextSteps: "Next steps",

src/lib/plugin-install.ts

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import { run } from './process.js'
66
import { gitClone, removeGitDir } from './git.js'
77
import { getFrontendPluginDir, getBackendPluginDir, getBackendDir, getFrontendDir } from './config.js'
88
import type { InstalledPlugin, PluginInfo, PluginData } from '../types/plugin.js'
9+
import { inferPluginType } from '../types/plugin.js'
910

1011
/**
11-
* 扫描已安装的插件
12+
* 扫描已安装的插件(通过名称后缀 _ui 判断类型)
1213
*/
1314
export function scanInstalledPlugins(projectDir: string): InstalledPlugin[] {
1415
const plugins: InstalledPlugin[] = []
@@ -20,7 +21,7 @@ export function scanInstalledPlugins(projectDir: string): InstalledPlugin[] {
2021
const tomlPath = join(frontendPluginDir, dir, 'plugin.toml')
2122
const info = readPluginToml(tomlPath)
2223
if (info) {
23-
plugins.push({ name: dir, dir: join(frontendPluginDir, dir), type: 'web', info })
24+
plugins.push({ name: dir, dir: join(frontendPluginDir, dir), type: inferPluginType(dir), info })
2425
}
2526
}
2627
}
@@ -32,7 +33,7 @@ export function scanInstalledPlugins(projectDir: string): InstalledPlugin[] {
3233
const tomlPath = join(backendPluginDir, dir, 'plugin.toml')
3334
const info = readPluginToml(tomlPath)
3435
if (info) {
35-
plugins.push({ name: dir, dir: join(backendPluginDir, dir), type: 'server', info })
36+
plugins.push({ name: dir, dir: join(backendPluginDir, dir), type: inferPluginType(dir), info })
3637
}
3738
}
3839
}
@@ -114,7 +115,7 @@ export async function installBackendPlugin(
114115
}
115116

116117
/**
117-
* 从插件市场数据安装多个插件
118+
* 从插件市场数据安装多个插件(通过名称后缀 _ui 判断类型)
118119
*/
119120
export async function installFromMarket(
120121
projectDir: string,
@@ -123,21 +124,17 @@ export async function installFromMarket(
123124
const success: string[] = []
124125
const failed: string[] = []
125126

126-
const webPlugins = plugins.filter(p => p.plugin.type === 'web')
127-
const serverPlugins = plugins.filter(p => p.plugin.type === 'server')
128-
129-
// 安装前端插件
130-
for (const p of webPlugins) {
127+
for (const p of plugins) {
131128
const name = basename(p.git.path) || p.plugin.summary
132-
const ok = await installFrontendPlugin(projectDir, p.git.url, name, p.git.branch)
133-
if (ok) success.push(name)
134-
else failed.push(name)
135-
}
129+
const type = inferPluginType(name)
130+
131+
let ok: boolean
132+
if (type === 'web') {
133+
ok = await installFrontendPlugin(projectDir, p.git.url, name, p.git.branch)
134+
} else {
135+
ok = await installBackendPlugin(projectDir, { repoUrl: p.git.url })
136+
}
136137

137-
// 安装后端插件
138-
for (const p of serverPlugins) {
139-
const ok = await installBackendPlugin(projectDir, { repoUrl: p.git.url })
140-
const name = basename(p.git.path) || p.plugin.summary
141138
if (ok) success.push(name)
142139
else failed.push(name)
143140
}

src/lib/plugin-market.ts

Lines changed: 51 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,76 @@
11
// plugin-market.ts — 插件市场数据获取 & 解析
2+
import { existsSync, readFileSync, writeFileSync } from 'fs'
3+
import { join } from 'path'
4+
import { homedir } from 'os'
25
import { ofetch } from 'ofetch'
6+
import { basename } from 'path'
37
import type { PluginData } from '../types/plugin.js'
8+
import { inferPluginType } from '../types/plugin.js'
49

510
const PLUGIN_DATA_URL =
611
'https://raw.githubusercontent.com/fastapi-practices/plugins/refs/heads/master/plugins-data.ts'
712

8-
/**
9-
* 从远程获取并解析插件市场数据
10-
*/
11-
export async function fetchPluginMarketData(): Promise<PluginData[]> {
12-
const content = await ofetch(PLUGIN_DATA_URL, { responseType: 'text' })
13+
const CACHE_PATH = join(homedir(), '.fba-plugins-cache.json')
14+
15+
// ─── 缓存 ───
1316

14-
// 提取 pluginDataList 数组 JSON
15-
const match = content.match(/pluginDataList[^=]*=\s*(\[[\s\S]*\])/)
16-
if (!match?.[1]) {
17-
throw new Error('Failed to parse plugin market data: pluginDataList not found')
17+
function readCache(): PluginData[] | null {
18+
if (!existsSync(CACHE_PATH)) return null
19+
try {
20+
return JSON.parse(readFileSync(CACHE_PATH, 'utf-8')) as PluginData[]
21+
} catch {
22+
return null
1823
}
24+
}
1925

26+
function writeCache(data: PluginData[]): void {
2027
try {
21-
// TS 文件中的值本身就是合法 JSON
22-
return JSON.parse(match[1]) as PluginData[]
23-
} catch (e) {
24-
throw new Error(`Failed to parse plugin data JSON: ${e}`)
28+
writeFileSync(CACHE_PATH, JSON.stringify(data), 'utf-8')
29+
} catch {
30+
// 缓存写入失败不影响主流程
2531
}
2632
}
2733

34+
// ─── 数据获取 ───
35+
2836
/**
29-
* 按关键词搜索插件
37+
* 从远程获取并解析插件市场数据,失败时回退到本地缓存
38+
*
39+
* @returns `{ data, fromCache }` — fromCache 为 true 时说明使用了缓存
3040
*/
31-
export function searchPlugins(plugins: PluginData[], query: string): PluginData[] {
32-
if (!query.trim()) return plugins
33-
const q = query.toLowerCase()
34-
return plugins.filter(p => {
35-
const plugin = p.plugin
36-
return (
37-
plugin.summary.toLowerCase().includes(q) ||
38-
plugin.description.toLowerCase().includes(q) ||
39-
plugin.author.toLowerCase().includes(q) ||
40-
plugin.tags?.some(t => t.toLowerCase().includes(q))
41-
)
42-
})
41+
export async function fetchPluginMarketData(): Promise<{ data: PluginData[]; fromCache: boolean }> {
42+
try {
43+
const content = await ofetch(PLUGIN_DATA_URL, { responseType: 'text' })
44+
45+
const match = content.match(/pluginDataList[^=]*=\s*(\[[\s\S]*\])/)
46+
if (!match?.[1]) {
47+
throw new Error('pluginDataList not found')
48+
}
49+
50+
const data = JSON.parse(match[1]) as PluginData[]
51+
writeCache(data)
52+
return { data, fromCache: false }
53+
} catch {
54+
const cached = readCache()
55+
if (cached) return { data: cached, fromCache: true }
56+
throw new Error('Failed to fetch plugin market data and no local cache available')
57+
}
4358
}
4459

4560
/**
46-
* 按 tag 过滤插件
61+
* 按 type 过滤插件(基于 git.path 名称推断类型)
4762
*/
48-
export function filterByTag(plugins: PluginData[], tag: string): PluginData[] {
49-
if (!tag || tag === 'all') return plugins
50-
return plugins.filter(p => p.plugin.tags?.includes(tag as any))
63+
export function filterByType(plugins: PluginData[], type: string): PluginData[] {
64+
if (!type || type === 'all') return plugins
65+
return plugins.filter(p => {
66+
const name = basename(p.git.path)
67+
return inferPluginType(name) === type
68+
})
5169
}
5270

5371
/**
54-
* 按 type 过滤插件
72+
* 根据市场插件的 git.path 推断插件类型
5573
*/
56-
export function filterByType(plugins: PluginData[], type: string): PluginData[] {
57-
if (!type || type === 'all') return plugins
58-
return plugins.filter(p => p.plugin.type === type)
74+
export function getMarketPluginType(p: PluginData): 'web' | 'server' {
75+
return inferPluginType(basename(p.git.path))
5976
}

src/types/plugin.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,22 @@ export type PluginTag = 'ai' | 'mcp' | 'agent' | 'auth' | 'storage' | 'notificat
6060
export type DatabaseType = 'mysql' | 'postgresql'
6161
export type ServerPluginLevel = 'app' | 'ext'
6262

63+
export const PLUGIN_WEB_SUFFIX = '_ui'
64+
6365
export const VALID_TAGS: PluginTag[] = ['ai', 'mcp', 'agent', 'auth', 'storage', 'notification', 'task', 'payment', 'other']
6466
export const VALID_DATABASES: DatabaseType[] = ['mysql', 'postgresql']
6567

68+
/** 根据名称后缀推断插件类型:以 _ui 结尾为 web 插件,否则为 server 插件 */
69+
export function inferPluginType(name: string): PluginType {
70+
return name.endsWith(PLUGIN_WEB_SUFFIX) ? 'web' : 'server'
71+
}
72+
73+
/** 确保 web 插件名称以 _ui 结尾,返回是否进行了自动追加 */
74+
export function ensureWebPluginName(name: string): { name: string; appended: boolean } {
75+
if (name.endsWith(PLUGIN_WEB_SUFFIX)) return { name, appended: false }
76+
return { name: `${name}${PLUGIN_WEB_SUFFIX}`, appended: true }
77+
}
78+
6679
/** 已安装的本地插件信息 */
6780
export interface InstalledPlugin {
6881
name: string

0 commit comments

Comments
 (0)