Skip to content

Commit e78a532

Browse files
committed
feat: 市场分享增强-分享时先在本地market目录生成{type}/{id}.json并更新index.json(同名条目复用原id即重新分享更新),随后GitHub授权生成PR时同时写入条目文件与合并官方最新index.json(基于上游raw拉取合并本条目索引),前端分享弹窗增加分步进度提示(本地生成→令牌校验→fork→写条目文件→更新索引→创建PR)
1 parent c7202c9 commit e78a532

2 files changed

Lines changed: 236 additions & 34 deletions

File tree

‎Juggle.Api/Controllers/Api/MarketController.cs‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System.Text.Encodings.Web;
12
using System.Text.Json;
3+
using System.Text.Unicode;
24
using Juggle.Application.Models.Response;
35
using Juggle.Application.Services;
46
using Juggle.Domain.Entities;
@@ -216,6 +218,143 @@ public async Task<ApiResult> List([FromQuery] string? type, [FromQuery] int favo
216218
}));
217219
}
218220

221+
/// <summary>市场文件 JSON 序列化选项(中文不转义)。</summary>
222+
private static readonly JsonSerializerOptions MarketFileJsonOpts = new()
223+
{
224+
WriteIndented = true,
225+
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
226+
};
227+
228+
/// <summary>生成分享文件:写入本地 market/{type}/{id}.json 并更新 market/index.json(同名条目复用原 id),返回条目 JSON 与索引条目(供 GitHub PR 使用)。</summary>
229+
[HttpPost("generate-share-file")]
230+
public async Task<ApiResult> GenerateShareFile([FromBody] MarketShareRequest req)
231+
{
232+
if (string.IsNullOrWhiteSpace(req.ItemType) || string.IsNullOrWhiteSpace(req.Name))
233+
return ApiResult.Fail("条目类型与名称不能为空");
234+
if (string.IsNullOrWhiteSpace(req.Author))
235+
return ApiResult.Fail("请填写作者");
236+
237+
// 同名条目已分享过则复用原 id(重新分享即更新),否则生成新 id
238+
var id = FindExistingShareId(req.ItemType, req.Name)
239+
?? (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
240+
var updatedAt = DateTime.Now.ToString("yyyy-MM-dd");
241+
242+
object? content;
243+
try { content = JsonSerializer.Deserialize<JsonElement>(string.IsNullOrWhiteSpace(req.ContentJson) ? "{}" : req.ContentJson); }
244+
catch { return ApiResult.Fail("内容 JSON 格式非法"); }
245+
246+
var itemJson = JsonSerializer.Serialize(new Dictionary<string, object?>
247+
{
248+
["id"] = id,
249+
["type"] = req.ItemType,
250+
["name"] = req.Name,
251+
["description"] = req.Description ?? "",
252+
["icon"] = string.IsNullOrWhiteSpace(req.Icon) ? "📦" : req.Icon,
253+
["author"] = req.Author,
254+
["version"] = string.IsNullOrWhiteSpace(req.Version) ? "1.0.0" : req.Version,
255+
["updatedAt"] = updatedAt,
256+
["content"] = content
257+
}, MarketFileJsonOpts);
258+
259+
var indexEntry = new Dictionary<string, object?>
260+
{
261+
["id"] = id,
262+
["type"] = req.ItemType,
263+
["name"] = req.Name,
264+
["description"] = req.Description ?? "",
265+
["icon"] = string.IsNullOrWhiteSpace(req.Icon) ? "📦" : req.Icon,
266+
["author"] = req.Author,
267+
["version"] = string.IsNullOrWhiteSpace(req.Version) ? "1.0.0" : req.Version,
268+
["updatedAt"] = updatedAt,
269+
["file"] = $"{req.ItemType}/{id}.json"
270+
};
271+
272+
try
273+
{
274+
var typeDir = Path.Combine(MarketDir, req.ItemType);
275+
Directory.CreateDirectory(typeDir);
276+
await System.IO.File.WriteAllTextAsync(Path.Combine(typeDir, $"{id}.json"), itemJson);
277+
278+
var indexPath = Path.Combine(MarketDir, "index.json");
279+
var indexJson = await UpsertIndexEntryAsync(indexPath, id, req.ItemType, indexEntry);
280+
return ApiResult.Success(new { id, itemJson, indexEntry, indexJson, file = $"{req.ItemType}/{id}.json" });
281+
}
282+
catch (Exception ex)
283+
{
284+
return ApiResult.Fail($"生成本地分享文件失败: {ex.Message}");
285+
}
286+
}
287+
288+
/// <summary>查找同类型同名条目的已分享 id(重新分享时更新原文件)。</summary>
289+
private int? FindExistingShareId(string type, string name)
290+
{
291+
var typeDir = Path.Combine(MarketDir, type);
292+
if (!Directory.Exists(typeDir)) return null;
293+
foreach (var file in Directory.GetFiles(typeDir, "*.json"))
294+
{
295+
try
296+
{
297+
using var doc = JsonDocument.Parse(System.IO.File.ReadAllText(file));
298+
var root = doc.RootElement;
299+
var fName = root.TryGetProperty("name", out var n) ? n.GetString() : null;
300+
if (string.Equals(fName, name, StringComparison.OrdinalIgnoreCase)
301+
&& root.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.Number)
302+
return idEl.GetInt32();
303+
}
304+
catch { /* 跳过损坏文件 */ }
305+
}
306+
return null;
307+
}
308+
309+
/// <summary>更新本地 market/index.json(同 id+type 更新,否则追加),返回最新索引 JSON。</summary>
310+
private static async Task<string> UpsertIndexEntryAsync(string indexPath, int id, string type, Dictionary<string, object?> entry)
311+
{
312+
var entries = new List<Dictionary<string, object?>>();
313+
if (System.IO.File.Exists(indexPath))
314+
{
315+
try
316+
{
317+
using var doc = JsonDocument.Parse(await System.IO.File.ReadAllTextAsync(indexPath));
318+
var root = doc.RootElement;
319+
var items = root.ValueKind == JsonValueKind.Array ? root
320+
: root.TryGetProperty("items", out var it) ? it : default;
321+
if (items.ValueKind == JsonValueKind.Array)
322+
{
323+
foreach (var e in items.EnumerateArray())
324+
{
325+
var d = new Dictionary<string, object?>();
326+
foreach (var p in e.EnumerateObject()) d[p.Name] = p.Value.Clone();
327+
entries.Add(d);
328+
}
329+
}
330+
}
331+
catch { /* 损坏则重建 */ }
332+
}
333+
334+
var idx = entries.FindIndex(e =>
335+
AsLong(e.GetValueOrDefault("id")) == id && string.Equals(AsString(e.GetValueOrDefault("type")), type, StringComparison.OrdinalIgnoreCase));
336+
if (idx >= 0) entries[idx] = entry; else entries.Add(entry);
337+
338+
var indexJson = JsonSerializer.Serialize(new Dictionary<string, object?> { ["items"] = entries }, MarketFileJsonOpts);
339+
await System.IO.File.WriteAllTextAsync(indexPath, indexJson);
340+
return indexJson;
341+
}
342+
343+
private static long? AsLong(object? o) => o switch
344+
{
345+
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt64(),
346+
long l => l,
347+
int i => i,
348+
_ => null
349+
};
350+
351+
private static string? AsString(object? o) => o switch
352+
{
353+
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
354+
string s => s,
355+
_ => null
356+
};
357+
219358
/// <summary>收藏市场条目(幂等)。</summary>
220359
[HttpPost("favorite/{id}")]
221360
public async Task<ApiResult> Favorite(long id)
@@ -478,3 +617,14 @@ public class MarketImportFileRequest
478617
public string Type { get; set; } = "";
479618
public int MarketId { get; set; }
480619
}
620+
621+
public class MarketShareRequest
622+
{
623+
public string ItemType { get; set; } = "";
624+
public string Name { get; set; } = "";
625+
public string Description { get; set; } = "";
626+
public string Icon { get; set; } = "";
627+
public string Author { get; set; } = "";
628+
public string Version { get; set; } = "1.0.0";
629+
public string ContentJson { get; set; } = "{}";
630+
}

‎JuggleNet6.Frontend/src/components/MarketShareDialog.vue‎

Lines changed: 86 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,31 @@
11
<template>
2-
<el-dialog :model-value="visible" @update:model-value="onVisibleChange" title="🚀 分享到官方市场(GitHub)" width="560px" append-to-body>
2+
<el-dialog :model-value="visible" @update:model-value="onVisibleChange" title="🚀 分享到官方市场(GitHub)" width="580px" append-to-body>
33
<div style="margin-bottom:10px;color:#606266;font-size:13px;line-height:1.7">
4-
将当前条目以 <b>JSON 文件</b> 形式生成 <b>Pull Request</b> 到官方市场仓库
5-
<b>pythonHuang/APIWorkflowCanvasDesignNet</b> 的 <b>market/{type}/{id}.json</b>,
6-
管理员定期审核合并后进入官方市场,其他用户可通过「发现」拉取。
4+
分享流程:① 填写作者 → ② 在本机生成 <b>market/{type}/{id}.json</b> 并更新 <b>market/index.json</b>
5+
→ ③ 登录 GitHub 授权(Token)→ ④ 自动生成 <b>Pull Request</b> 到官方市场仓库
6+
<b>pythonHuang/APIWorkflowCanvasDesignNet</b>,管理员定期审核合并后进入官方市场(其他用户可通过「发现」拉取)。
77
</div>
88
<el-form label-width="90px" size="small">
9+
<el-form-item label="作者"><el-input v-model="form.author" placeholder="您的署名(如 pythonHuang)" /></el-form-item>
10+
<el-form-item label="版本号"><el-input v-model="form.version" placeholder="如 1.0.0" /></el-form-item>
911
<el-form-item label="GitHub Token">
1012
<el-input v-model="form.token" type="password" show-password placeholder="ghp_...(仅保存在本机浏览器)" />
1113
<div style="font-size:11px;color:#909399;line-height:1.6">
12-
需要 Personal Access Token(classic)并勾选 <b>repo</b> 权限:GitHub → Settings → Developer settings → Tokens。仅用于本次分享操作,保存在 localStorage。
14+
需要 Personal Access Token(classic)并勾选 <b>repo</b> 权限:GitHub → Settings → Developer settings → Tokens。仅用于本次分享操作。
1315
</div>
1416
</el-form-item>
15-
<el-form-item label="作者"><el-input v-model="form.author" placeholder="您的署名(如 pythonHuang)" /></el-form-item>
16-
<el-form-item label="版本号"><el-input v-model="form.version" placeholder="如 1.0.0" /></el-form-item>
1717
<el-form-item label="条目">
1818
<div style="font-size:13px;color:#303133">
1919
<span style="margin-right:4px">{{ props.icon || '📦' }}</span>{{ props.itemName }}
2020
<el-tag size="small" style="margin-left:6px">{{ typeLabel }}</el-tag>
2121
</div>
2222
</el-form-item>
2323
</el-form>
24+
<div v-if="shareSteps.length > 0" style="margin-bottom:8px;font-size:12px;color:#606266;line-height:1.8">
25+
<div v-for="(s, i) in shareSteps" :key="i" :style="`color:${s.ok === undefined ? '#909399' : s.ok ? '#67c23a' : '#f56c6c'}`">
26+
{{ s.ok === undefined ? '⏳' : s.ok ? '✅' : '❌' }} {{ s.text }}
27+
</div>
28+
</div>
2429
<el-alert v-if="shareResult" :type="shareResult.ok ? 'success' : 'error'" :closable="false" style="margin-bottom:8px">
2530
<template #title>{{ shareResult.msg }}</template>
2631
</el-alert>
@@ -34,6 +39,7 @@
3439
<script setup lang="ts">
3540
import { ref, computed } from 'vue'
3641
import { ElMessage } from 'element-plus'
42+
import request from '../utils/request'
3743
3844
const props = defineProps<{
3945
visible: boolean
@@ -58,6 +64,7 @@ const typeLabel = computed(() => typeLabels[props.itemType] || props.itemType)
5864
const form = ref({ token: localStorage.getItem(TOKEN_KEY) || '', author: '', version: '1.0.0' })
5965
const sharing = ref(false)
6066
const shareResult = ref<{ ok: boolean; msg: string } | null>(null)
67+
const shareSteps = ref<{ text: string; ok?: boolean }[]>([])
6168
6269
async function gh(path: string, token: string, opts?: { method?: string; body?: any }) {
6370
const res = await fetch(`${GITHUB_API}${path}`, {
@@ -76,20 +83,44 @@ async function gh(path: string, token: string, opts?: { method?: string; body?:
7683
7784
function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)) }
7885
86+
/** base64 编码(支持中文) */
87+
function b64(s: string) { return btoa(unescape(encodeURIComponent(s))) }
88+
7989
async function doShare() {
80-
const token = form.value.token.trim()
81-
if (!token) { ElMessage.warning('请填写 GitHub 访问令牌'); return }
8290
if (!form.value.author.trim()) { ElMessage.warning('请填写作者'); return }
83-
localStorage.setItem(TOKEN_KEY, token)
91+
if (!form.value.token.trim()) { ElMessage.warning('请填写 GitHub 访问令牌'); return }
92+
localStorage.setItem(TOKEN_KEY, form.value.token.trim())
8493
8594
sharing.value = true
8695
shareResult.value = null
96+
shareSteps.value = []
97+
const step = (text: string, ok?: boolean) => shareSteps.value.push({ text, ok })
98+
const token = form.value.token.trim()
99+
87100
try {
88-
// 1. 校验令牌并获取登录用户
101+
// ① 本地生成 market/{type}/{id}.json 并更新 market/index.json(后端落盘)
102+
step('生成本地 market/{type}/{id}.json 并更新 index.json...')
103+
const genRes: any = await request.post('/market/generate-share-file', {
104+
itemType: props.itemType,
105+
name: props.itemName,
106+
description: props.description || '',
107+
icon: props.icon || '📦',
108+
author: form.value.author.trim(),
109+
version: form.value.version.trim() || '1.0.0',
110+
contentJson: props.contentJson
111+
})
112+
const { id, itemJson, indexEntry } = genRes.data || {}
113+
if (!id || !itemJson) throw new Error('生成分享文件失败')
114+
step(`已生成本地文件 market/${props.itemType}/${id}.json 并更新 index.json`, true)
115+
116+
// ② 校验令牌并获取登录用户
117+
step('校验 GitHub Token...')
89118
const user = await gh('/user', token)
90119
const username = user.login
120+
step(`GitHub 登录成功(${username})`, true)
91121
92-
// 2. 确保 fork 存在(不存在则创建并轮询等待)
122+
// ③ 确保 fork 存在(不存在则创建并轮询等待)
123+
step('确保仓库 fork 存在...')
93124
let fork = await gh(`/repos/${username}/${REPO_NAME}`, token).catch(() => null)
94125
if (!fork) {
95126
await gh(`/repos/${REPO_OWNER}/${REPO_NAME}/forks`, token, { method: 'POST' })
@@ -100,44 +131,54 @@ async function doShare() {
100131
}
101132
if (!fork) throw new Error('Fork 创建超时,请稍后在 GitHub 页面确认后重试')
102133
}
134+
step('fork 就绪', true)
103135
104-
// 3. 基于 fork 默认分支创建分享分支
136+
// ④ 基于 fork 默认分支创建分享分支
105137
const baseBranch = fork.default_branch || 'main'
106138
const baseRef = await gh(`/repos/${username}/${REPO_NAME}/git/ref/heads/${baseBranch}`, token)
107-
const shareId = Math.floor(Date.now() / 1000)
108-
const branch = `market-share-${props.itemType}-${shareId}`
139+
const branch = `market-share-${props.itemType}-${id}`
109140
try {
110141
await gh(`/repos/${username}/${REPO_NAME}/git/refs`, token, {
111142
method: 'POST',
112143
body: { ref: `refs/heads/${branch}`, sha: baseRef.object.sha }
113144
})
114145
} catch { /* 分支已存在则直接复用 */ }
115146
116-
// 4. 组装条目 JSON 并写入 market/{type}/{id}.json
117-
let content: any = {}
118-
try { content = JSON.parse(props.contentJson || '{}') } catch { content = {} }
119-
const itemJson = JSON.stringify({
120-
id: shareId,
121-
type: props.itemType,
122-
name: props.itemName,
123-
description: props.description || '',
124-
icon: props.icon || '📦',
125-
author: form.value.author.trim(),
126-
version: form.value.version.trim() || '1.0.0',
127-
updatedAt: new Date().toISOString().slice(0, 10),
128-
content
129-
}, null, 2)
130-
const filePath = `market/${props.itemType}/${shareId}.json`
147+
// ⑤ 写入条目文件 market/{type}/{id}.json
148+
step(`写入 market/${props.itemType}/${id}.json...`)
149+
const filePath = `market/${props.itemType}/${id}.json`
131150
await gh(`/repos/${username}/${REPO_NAME}/contents/${encodeURIComponent(filePath)}`, token, {
132151
method: 'PUT',
133152
body: {
134153
message: `market: add ${typeLabel.value}「${props.itemName}」 by ${form.value.author.trim()}`,
135-
content: btoa(unescape(encodeURIComponent(itemJson))),
154+
content: b64(itemJson),
136155
branch
137156
}
138157
})
158+
step(`已写入 ${filePath}`, true)
139159
140-
// 5. 创建 Pull Request
160+
// ⑥ 基于官方仓库最新 index.json 合并本条索引并写入
161+
step('更新 market/index.json...')
162+
const upstreamIndex = await fetchUpstreamIndex()
163+
const idx = upstreamIndex.findIndex((e: any) =>
164+
Number(e?.id) === Number(indexEntry.id) && e?.type === indexEntry.type)
165+
if (idx >= 0) upstreamIndex[idx] = indexEntry
166+
else upstreamIndex.push(indexEntry)
167+
const indexJson = JSON.stringify({ items: upstreamIndex }, null, 2)
168+
const idxFile = await gh(`/repos/${username}/${REPO_NAME}/contents/market/index.json?ref=${encodeURIComponent(baseBranch)}`, token).catch(() => null)
169+
await gh(`/repos/${username}/${REPO_NAME}/contents/market/index.json`, token, {
170+
method: 'PUT',
171+
body: {
172+
message: `market: update index.json (${typeLabel.value}「${props.itemName}」)`,
173+
content: b64(indexJson),
174+
branch,
175+
...(idxFile?.sha ? { sha: idxFile.sha } : {})
176+
}
177+
})
178+
step('已更新 market/index.json', true)
179+
180+
// ⑦ 创建 Pull Request
181+
step('创建 Pull Request...')
141182
const pr = await gh(`/repos/${REPO_OWNER}/${REPO_NAME}/pulls`, token, {
142183
method: 'POST',
143184
body: {
@@ -152,19 +193,30 @@ async function doShare() {
152193
`描述:${props.description || ''}`,
153194
'',
154195
`文件:${filePath}`,
196+
`索引:market/index.json(已同步本条目)`,
155197
'',
156198
'管理员审核合并后进入官方市场(market 目录),其他用户可通过「市场 → 发现」拉取。'
157199
].join('\n')
158200
}
159201
})
160-
202+
step(`PR 创建成功:${pr.html_url}`, true)
161203
shareResult.value = { ok: true, msg: `PR 创建成功:${pr.html_url}` }
162204
emit('shared', pr.html_url)
163205
window.open(pr.html_url, '_blank')
164206
} catch (e: any) {
165-
shareResult.value = { ok: false, msg: `分享失败:${e?.message || '未知错误'}` }
207+
const msg = e?.message || '未知错误'
208+
shareSteps.value[shareSteps.value.length - 1] && (shareSteps.value[shareSteps.value.length - 1].ok = false)
209+
shareResult.value = { ok: false, msg: `分享失败:${msg}` }
166210
} finally {
167211
sharing.value = false
168212
}
169213
}
214+
215+
/** 拉取官方仓库最新 market/index.json 条目列表(不存在则返回空数组) */
216+
async function fetchUpstreamIndex(): Promise<any[]> {
217+
const res = await fetch(`https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/main/market/index.json`)
218+
if (!res.ok) return []
219+
const data = await res.json().catch(() => ({}))
220+
return Array.isArray(data) ? data : (data?.items || [])
221+
}
170222
</script>

0 commit comments

Comments
 (0)