Compare commits

...
6 Commits
Author SHA1 Message Date
shenmo7192 f585be0dcd feat(submitter): add form validation tips and improve submission checks
1. add real-time form validation prompt component for submission page
2. add URL validation for official website and screenshot format checks
3. refactor form validity calculation and user input status judgment
4. replace mirror switch with configurable base URL, support dev proxy path handling
5. add processing logic for base64 format screenshots
6. optimize history search and form reset logic
2026-07-16 15:56:13 +08:00
shenmo7192 a8ea7bec34 refactor(submitter): 优化分类加载逻辑,支持按架构获取CDN分类
1. 提取公共的CDN分类获取函数,增加 fallback 兜底逻辑
2. 新增支持amd64、arm64等更多架构类型
3. 为每个架构单独加载对应的分类列表,适配不同架构的分类差异
4. 更新日志打印参数,更清晰展示各架构的分类数量
2026-07-16 14:27:26 +08:00
shenmo7192 b06966c0a7 chore: 移除systemd定时器相关的安装和文件
删除了spark-update-notifier.timer文件,同时移除Makefile中对应systemd目录的安装拷贝步骤
2026-07-16 14:18:24 +08:00
shenmo7192 e496449c31 refactor(app): adjust loading sequence of app data
调整了初始化时的数据加载顺序,将侧边栏预加载移至全部应用加载完成后执行,避免网络连接被抢占,同时简化了Promise调用逻辑
2026-07-16 14:04:27 +08:00
shenmo7192 0a908a31f6 chore: release v5.2.1 and improve submitter UI/UX
1. update version to 5.2.1 in changelog and package.json
2. add real-time upload progress display in submission window
3. optimize icon filename display and form reset logic
4. add success modal after successful submission
5. improve logging formatting in submitter backend
2026-07-16 14:03:40 +08:00
shenmo7192 68195a94f6 refactor(submitter): 重构分类处理逻辑,完善提交校验与安装包流程
1.  重构提交器的分类处理,移除硬编码的分类映射,改用后端返回的type字段
2.  添加categoryId同步逻辑,完善表单校验和提交数据
3.  优化图标处理逻辑,支持base64格式图标
4.  完善deb包元数据处理,自动生成规范的文件名
5.  新增更全面的提交字段校验和API业务错误处理
6.  补充安装脚本的目录复制规则
2026-07-16 13:57:39 +08:00
7 changed files with 613 additions and 161 deletions
+9
View File
@@ -29,6 +29,9 @@ install:
mkdir -p $(DESTDIR)/usr/share/icons/ mkdir -p $(DESTDIR)/usr/share/icons/
mkdir -p $(DESTDIR)/usr/lib/ mkdir -p $(DESTDIR)/usr/lib/
mkdir -p $(DESTDIR)/usr/bin/ mkdir -p $(DESTDIR)/usr/bin/
mkdir -p $(DESTDIR)/etc/apt/
mkdir -p $(DESTDIR)/lib/systemd/
mkdir -p $(DESTDIR)/tmp/
cp -rv release/*/linux*-unpacked/* $(DESTDIR)/opt/spark-store/bin/ cp -rv release/*/linux*-unpacked/* $(DESTDIR)/opt/spark-store/bin/
cp -rv release/*/linux*-unpacked/extras/* $(DESTDIR)/opt/spark-store/extras/ cp -rv release/*/linux*-unpacked/extras/* $(DESTDIR)/opt/spark-store/extras/
cp -rv tool/* $(DESTDIR)/opt/durapps/spark-store/bin/ cp -rv tool/* $(DESTDIR)/opt/durapps/spark-store/bin/
@@ -39,5 +42,11 @@ install:
cp -rv pkg/usr/share/applications/ $(DESTDIR)/usr/share/ cp -rv pkg/usr/share/applications/ $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/polkit-1 $(DESTDIR)/usr/share/ cp -rv pkg/usr/share/polkit-1 $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/aptss $(DESTDIR)/usr/share/ cp -rv pkg/usr/share/aptss $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/ssinstall/ $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/ssinstall-local/ $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/dsg/ $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/bash-completion/ $(DESTDIR)/usr/share/
cp -rv pkg/etc/apt/ $(DESTDIR)/etc/
cp -rv pkg/tmp/spark-store-install/ $(DESTDIR)/tmp/
cp -rv tool/spark-store.asc $(DESTDIR)/opt/durapps/spark-store/bin/ cp -rv tool/spark-store.asc $(DESTDIR)/opt/durapps/spark-store/bin/
ln -s ../../../spark-store/extras/spark-store $(DESTDIR)/opt/durapps/spark-store/bin/spark-store ln -s ../../../spark-store/extras/spark-store $(DESTDIR)/opt/durapps/spark-store/bin/spark-store
+1 -1
View File
@@ -1,4 +1,4 @@
spark-store (5.2.0) UNRELEASED; urgency=medium spark-store (5.2.1) UNRELEASED; urgency=medium
* Initial release. (Closes: #nnnn) <nnnn is the bug number of your ITP> * Initial release. (Closes: #nnnn) <nnnn is the bug number of your ITP>
+234 -80
View File
@@ -45,25 +45,6 @@ interface OssUploadMetadata {
}; };
} }
const categoryNameToIdMap: Record<string, number> = {
network: 3,
chat: 9,
music: 2,
video: 12,
image_graphics: 6,
games: 1,
office: 4,
reading: 8,
development: 7,
tools: 11,
themes: 10,
others: 5,
};
function getCategoryIdByName(categoryName: string): number {
return categoryNameToIdMap[categoryName];
}
function generateUUID(): string { function generateUUID(): string {
const hexChars = "0123456789abcdef"; const hexChars = "0123456789abcdef";
let uuid = ""; let uuid = "";
@@ -660,15 +641,7 @@ export function registerSubmitterHandlers(
} }
}); });
ipcMain.handle( const FALLBACK_CATEGORIES = [
"search-history-app",
async (_event, pkgname: string, useMirror = false) => {
try {
const baseUrl = useMirror
? "https://mirrors.sdu.edu.cn/spark-store"
: "https://spk-json.spark-app.store";
const storeArchs = ["store", "aarch64-store", "loong64-store"];
const categories = [
"chat", "chat",
"development", "development",
"games", "games",
@@ -682,21 +655,99 @@ export function registerSubmitterHandlers(
"tools", "tools",
"video", "video",
]; ];
async function fetchCategoriesFromCdn(
baseUrl: string,
arch: string,
): Promise<string[]> {
const url = `${baseUrl}/${arch}/categories.json`;
logger.info({ url }, "[Submitter] Fetching categories from CDN");
try {
const response = await fetch(url, {
headers: { "User-Agent": getUserAgent() },
});
if (!response.ok) {
logger.warn(
{ status: response.status, arch },
"[Submitter] Failed to fetch categories.json, using fallback",
);
return FALLBACK_CATEGORIES;
}
const json = await response.json();
if (json && typeof json === "object" && !Array.isArray(json)) {
const keys = Object.keys(json);
logger.info(
{ arch, categories: keys, count: keys.length },
"[Submitter] Categories loaded from CDN",
);
return keys.length > 0 ? keys : FALLBACK_CATEGORIES;
}
logger.warn(
{ arch },
"[Submitter] Unexpected categories.json format, using fallback",
);
return FALLBACK_CATEGORIES;
} catch (err) {
logger.warn(
{ err, arch },
"[Submitter] Error fetching categories.json, using fallback",
);
return FALLBACK_CATEGORIES;
}
}
ipcMain.handle(
"search-history-app",
async (_event, pkgname: string, baseUrl?: string) => {
try {
let resolvedBaseUrl = baseUrl || "https://erotica.spark-app.store";
// 开发模式下来自 Vite 代理的路径(如 /local_amd64-store),
// 主进程的 fetch() 无法解析相对 URL,需要转为实际地址
if (resolvedBaseUrl.startsWith("/")) {
logger.info(
{ devPath: resolvedBaseUrl },
"[Submitter] Dev proxy path detected, resolving to production URL",
);
resolvedBaseUrl = "https://erotica.spark-app.store";
}
const storeArchs = [
"amd64-store",
"arm64-store",
"loong64-store",
"amd64-apm",
"arm64-apm",
"loong64-apm",
];
const results: HistoryAppInfo[] = []; const results: HistoryAppInfo[] = [];
// 为每个架构目录获取各自的分类列表(不同目录可能有不同分类,如 apm-extensions 只在 *-apm 下有)
const archCategoriesMap = new Map<string, string[]>();
for (const arch of storeArchs) {
const cats = await fetchCategoriesFromCdn(resolvedBaseUrl, arch);
archCategoriesMap.set(arch, cats);
}
logger.info( logger.info(
"[Submitter] ============== SEARCH HISTORY APP START ==============", "[Submitter] ============== SEARCH HISTORY APP START ==============",
); );
logger.info( logger.info(
{ pkgname, useMirror, baseUrl, storeArchs, categories }, {
pkgname,
baseUrl: resolvedBaseUrl,
storeArchs,
archCategoryCounts: Array.from(archCategoriesMap.entries()).map(
([a, c]) => ({ arch: a, categoryCount: c.length }),
),
},
"[Submitter] Search parameters", "[Submitter] Search parameters",
); );
const allPromises: Promise<void>[] = []; const allPromises: Promise<void>[] = [];
for (const arch of storeArchs) { for (const arch of storeArchs) {
const categories = archCategoriesMap.get(arch) || FALLBACK_CATEGORIES;
for (const category of categories) { for (const category of categories) {
const url = `${baseUrl}/${arch}/${category}/${pkgname}/app.json`; const url = `${resolvedBaseUrl}/${arch}/${category}/${pkgname}/app.json`;
logger.info( logger.info(
{ arch, category, url }, { arch, category, url },
"[Submitter] Starting search request", "[Submitter] Starting search request",
@@ -735,7 +786,7 @@ export function registerSubmitterHandlers(
"[Submitter] Found matching item", "[Submitter] Found matching item",
); );
let iconUrl = json.icons || json.icon || ""; const iconUrl = json.icons || json.icon || "";
let imgs = json.imgUrls || json.imgs || json.img_urls || []; let imgs = json.imgUrls || json.imgs || json.img_urls || [];
if (typeof imgs === "string") { if (typeof imgs === "string") {
@@ -760,27 +811,6 @@ export function registerSubmitterHandlers(
} }
} }
if (iconUrl && typeof iconUrl === "string") {
if (useMirror) {
iconUrl = iconUrl.replace(
"spk-json.spark-app.store",
"mirrors.sdu.edu.cn/spark-store",
);
}
}
if (Array.isArray(imgs)) {
imgs = imgs.map((img: string) => {
if (useMirror && typeof img === "string") {
return img.replace(
"spk-json.spark-app.store",
"mirrors.sdu.edu.cn/spark-store",
);
}
return img;
});
}
results.push({ results.push({
id: json.id || json.Id || 0, id: json.id || json.Id || 0,
name: json.name || json.Name || "", name: json.name || json.Name || "",
@@ -802,11 +832,24 @@ export function registerSubmitterHandlers(
"[Submitter] Added to results", "[Submitter] Added to results",
); );
} }
} else {
logger.warn(
{
arch,
category,
url,
status: response.status,
statusText: response.statusText,
},
"[Submitter] Non-ok response",
);
} }
}) })
.catch((error) => { .catch((error) => {
const errMsg =
error instanceof Error ? error.message : String(error);
logger.warn( logger.warn(
{ arch, category, error }, { arch, category, url, error: errMsg },
"[Submitter] Request failed or exception caught", "[Submitter] Request failed or exception caught",
); );
}); });
@@ -1083,7 +1126,33 @@ export function registerSubmitterHandlers(
); );
let iconFilePath = iconPath; let iconFilePath = iconPath;
if (iconPath.startsWith("http://") || iconPath.startsWith("https://")) { if (iconPath.startsWith("data:")) {
logger.info("[Submitter] Icon is a Base64 data URL, decoding");
const base64Data = iconPath.split(",")[1];
if (!base64Data) {
return { success: false, message: "图标数据格式错误" };
}
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), "spark-store-submitter-"),
);
iconFilePath = path.join(tempDir, "icon.png");
try {
fs.writeFileSync(iconFilePath, Buffer.from(base64Data, "base64"));
logger.info(
{ iconFilePath },
"[Submitter] Icon decoded from data URL",
);
} catch (err) {
logger.error({ err }, "[Submitter] Failed to decode icon data URL");
return {
success: false,
message: `图标解码失败: ${(err as Error).message}`,
};
}
} else if (
iconPath.startsWith("http://") ||
iconPath.startsWith("https://")
) {
logger.info( logger.info(
{ iconPath }, { iconPath },
"[Submitter] Icon is a remote URL, downloading first", "[Submitter] Icon is a remote URL, downloading first",
@@ -1140,7 +1209,8 @@ export function registerSubmitterHandlers(
if ( if (
iconPath.startsWith("http://") || iconPath.startsWith("http://") ||
iconPath.startsWith("https://") iconPath.startsWith("https://") ||
iconPath.startsWith("data:")
) { ) {
fs.unlinkSync(iconFilePath); fs.unlinkSync(iconFilePath);
fs.rmdirSync(path.dirname(iconFilePath)); fs.rmdirSync(path.dirname(iconFilePath));
@@ -1203,6 +1273,35 @@ export function registerSubmitterHandlers(
); );
continue; continue;
} }
} else if (screenshot.startsWith("data:")) {
logger.info(
"[Submitter] Screenshot is a Base64 data URL, decoding",
);
const base64Data = screenshot.split(",")[1];
if (!base64Data) {
logger.warn("[Submitter] Invalid screenshot data URL, skipping");
continue;
}
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), "spark-store-submitter-"),
);
screenshotFilePath = path.join(tempDir, `screen_${i + 1}.png`);
try {
fs.writeFileSync(
screenshotFilePath,
Buffer.from(base64Data, "base64"),
);
logger.info(
{ screenshotFilePath },
"[Submitter] Screenshot decoded from data URL",
);
} catch (err) {
logger.error(
{ err },
"[Submitter] Failed to decode screenshot data URL",
);
continue;
}
} }
if (fs.existsSync(screenshotFilePath)) { if (fs.existsSync(screenshotFilePath)) {
@@ -1241,7 +1340,8 @@ export function registerSubmitterHandlers(
if ( if (
screenshot.startsWith("http://") || screenshot.startsWith("http://") ||
screenshot.startsWith("https://") screenshot.startsWith("https://") ||
screenshot.startsWith("data:")
) { ) {
fs.unlinkSync(screenshotFilePath); fs.unlinkSync(screenshotFilePath);
fs.rmdirSync(path.dirname(screenshotFilePath)); fs.rmdirSync(path.dirname(screenshotFilePath));
@@ -1294,11 +1394,23 @@ export function registerSubmitterHandlers(
logger.info( logger.info(
"[Submitter] ============== STEP 4: SUBMIT APPLICATION ==============", "[Submitter] ============== STEP 4: SUBMIT APPLICATION ==============",
); );
sendUploadProgress("submit", 0, "正在提交投稿信息...");
const debFileStat = fs.statSync(debFilePath); const debFileStat = fs.statSync(debFilePath);
// 参考老 Qt 投稿器:根据 deb 元数据构造 file_name: {pkgname}_{version}_{arch}.deb
// arch 由前端解析 deb 时获取并传入,无需再次调用 dpkg-deb
const debArch = String(dataObj.arch || "amd64");
const pkgVersion = String(dataObj.version || "0.0.0");
const debPkgName = String(dataObj.pkgname || "unknown");
const formFileName = `${debPkgName}_${pkgVersion}_${debArch}.deb`;
logger.info(
{ formFileName, debArch },
"[Submitter] Constructed file_name",
);
const categoryName = String(dataObj.category || ""); const categoryName = String(dataObj.category || "");
const categoryId = getCategoryIdByName(categoryName); const categoryId = Number(dataObj.categoryId) || 0;
logger.info( logger.info(
{ categoryName, categoryId }, { categoryName, categoryId },
"[Submitter] Category name and ID", "[Submitter] Category name and ID",
@@ -1313,55 +1425,80 @@ export function registerSubmitterHandlers(
: []; : [];
logger.info({ tagsString, tagsArray }, "[Submitter] Tags conversion"); logger.info({ tagsString, tagsArray }, "[Submitter] Tags conversion");
const submitData = { const remark =
application_name: String(dataObj.pkgname || ""), ((dataObj.remark as string) || "") +
" - (来自于投稿器_v" +
getAppVersion() +
")";
const submitData: Record<string, unknown> = {
application_name: debPkgName,
application_name_zh: String(dataObj.name || ""), application_name_zh: String(dataObj.name || ""),
contributor: String(dataObj.contributor || ""), contributor: String(dataObj.contributor || ""),
icons: iconUrl, icons: iconUrl,
size: debFileStat.size, size: debFileStat.size,
file_name: path.basename(debFilePath).replace(/\s+/g, "_plus_"), file_name: formFileName,
website: String(dataObj.website || ""), website: String(dataObj.website || ""),
version: String(dataObj.version || ""), version: pkgVersion,
more: String(dataObj.description || ""), more: String(dataObj.description || ""),
type_id: categoryId, type_id: categoryId,
author: String(dataObj.author || ""), author: String(dataObj.author || ""),
remark: remark,
((dataObj.remark as string) || "") +
" - (来自于投稿器_v" +
getAppVersion() +
")",
img_urls: screenshotUrls, img_urls: screenshotUrls,
deb_url: debUrl, deb_url: debUrl,
mail: String(dataObj.mail || dataObj.contributor || ""), mail: String(dataObj.mail || dataObj.contributor || ""),
tags: tagsArray, tags: tagsArray,
architecture: debArch,
}; };
logger.info( logger.info(
"[Submitter] ============== VALIDATING SUBMISSION DATA ==============", "[Submitter] ============== VALIDATING SUBMISSION DATA ==============",
); );
const requiredFields = [ // 对齐老 Qt 投稿器 isReadySubmit() 的完整校验
"application_name", const checks: Array<{ field: keyof typeof submitData; label: string }> = [
"application_name_zh", { field: "application_name", label: "包名" },
"contributor", { field: "application_name_zh", label: "应用名称" },
"icons", { field: "contributor", label: "贡献者" },
"size", { field: "icons", label: "图标URL" },
"file_name", { field: "size", label: "文件大小" },
"version", { field: "file_name", label: "文件名" },
"type_id", { field: "website", label: "官网地址" },
"author", { field: "version", label: "版本号" },
"deb_url", { field: "more", label: "应用描述" },
{ field: "type_id", label: "分类ID" },
{ field: "author", label: "作者" },
{ field: "remark", label: "测试情况" },
{ field: "deb_url", label: "安装包URL" },
{ field: "mail", label: "联系邮箱" },
]; ];
const missingFields = requiredFields.filter( const missingFields: string[] = [];
(field) => !submitData[field as keyof typeof submitData], for (const { field, label } of checks) {
); const val = submitData[field];
if (val === undefined || val === null || val === "" || val === 0) {
missingFields.push(`${label}(${field})`);
}
}
if (screenshotUrls.length === 0) {
missingFields.push("截图(img_urls)");
}
if (tagsArray.length === 0) {
missingFields.push("标签(tags)");
}
if (missingFields.length > 0) { if (missingFields.length > 0) {
logger.error({ missingFields }, "[Submitter] Missing required fields"); logger.error({ missingFields }, "[Submitter] Missing required fields");
return {
success: false,
message: `缺少必填字段: ${missingFields.join(", ")}`,
};
} }
logger.info( logger.info(
"[Submitter] ============== PREPARING SUBMISSION REQUEST ==============", "[Submitter] ============== PREPARING SUBMISSION REQUEST ==============",
); );
logger.info({ submitData }, "[Submitter] Final submission data"); logger.info({ submitData }, "[Submitter] Final submission data");
logger.info(
`[Submitter] ============== FULL SUBMIT JSON ==============\n${JSON.stringify(submitData, null, 2)}`,
);
const submitterApiUrl = const submitterApiUrl =
"https://upload.deepinos.org.cn/api/index/upload_application"; "https://upload.deepinos.org.cn/api/index/upload_application";
@@ -1471,6 +1608,22 @@ export function registerSubmitterHandlers(
result = responseText; result = responseText;
} }
// 检查响应体中是否有业务错误码(部分 API HTTP 200 但业务失败)
if (result && typeof result === "object" && !Array.isArray(result)) {
const respObj = result as Record<string, unknown>;
if (respObj.code !== undefined && respObj.code !== 0) {
logger.error(
{ respCode: respObj.code, respMsg: respObj.msg },
"[Submitter] API returned business error",
);
return {
success: false,
message: String(respObj.msg || respObj.message || "提交失败"),
apiResponse: result,
};
}
}
logger.info( logger.info(
"[Submitter] ============== SUBMISSION SUCCESSFUL ==============", "[Submitter] ============== SUBMISSION SUCCESSFUL ==============",
); );
@@ -1479,6 +1632,7 @@ export function registerSubmitterHandlers(
{ duration: requestDuration }, { duration: requestDuration },
"[Submitter] Total duration (ms)", "[Submitter] Total duration (ms)",
); );
sendUploadProgress("submit", 100, "投稿信息提交完成");
return { success: true, data: result }; return { success: true, data: result };
} catch (err) { } catch (err) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "spark-store", "name": "spark-store",
"version": "5.2.0", "version": "5.2.1",
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
"description": "Client for Spark App Store", "description": "Client for Spark App Store",
"author": "elysia-best <elysia-best@simplelinux.cn.eu.org>", "author": "elysia-best <elysia-best@simplelinux.cn.eu.org>",
@@ -1,11 +0,0 @@
[Unit]
Description=Timer for Spark Update Notifier
[Timer]
# 开机后第一次执行
OnBootSec=1min
# 每天执行一次
OnUnitActiveSec=1d
[Install]
WantedBy=timers.target
+10 -11
View File
@@ -2838,16 +2838,11 @@ onMounted(async () => {
await loadTabCategories(); await loadTabCategories();
// 先启动侧边栏入口预加载,让其请求优先进入网络队列
// 避免被「全部应用」的大量并发请求抢占连接池
const sidebarPreloadPromise = preloadSidebarTabApps();
loading.value = true; loading.value = true;
homeLoading.value = true; homeLoading.value = true;
// 侧边栏入口预加载先启动,全部应用和主页数据随后并行加载 // 先加载全部应用,再预加载侧边栏入口
Promise.all([ await Promise.all([
sidebarPreloadPromise,
loadHome(), loadHome(),
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
loadApps(() => { loadApps(() => {
@@ -2855,12 +2850,16 @@ onMounted(async () => {
resolve(); resolve();
}); });
}), }),
]).then(() => { ]);
// 所有数据加载完成后的回调(可选)
logger.info("所有应用数据加载完成"); // 全部应用加载完成后再预加载侧边栏入口
void maybePromptInstalledSync(); preloadSidebarTabApps().then(() => {
logger.info("侧边栏入口预加载完成");
}); });
void maybePromptInstalledSync();
logger.info("所有应用数据加载完成");
// 设置键盘导航 // 设置键盘导航
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (showPreview.value) { if (showPreview.value) {
+348 -47
View File
@@ -203,8 +203,8 @@
<p class="text-slate-600 dark:text-slate-400"> <p class="text-slate-600 dark:text-slate-400">
{{ iconPreview ? "点击浏览更换图标" : "点击浏览" }} {{ iconPreview ? "点击浏览更换图标" : "点击浏览" }}
</p> </p>
<p v-if="formData.iconPath" class="mt-2 text-sm text-blue-500"> <p v-if="iconFileName" class="mt-2 text-sm text-blue-500">
{{ formData.iconPath.split("/").pop() }} {{ iconFileName }}
</p> </p>
</div> </div>
</div> </div>
@@ -384,15 +384,156 @@
</button> </button>
</div> </div>
<!-- 表单校验提示 -->
<div <div
v-if="submitSuccess" v-if="!isFormValid && hasUserStartedFilling"
class="p-4 bg-green-50 border border-green-200 rounded-lg dark:bg-green-900/20 dark:border-green-800" class="mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg dark:bg-amber-900/20 dark:border-amber-800"
>
<p
class="text-sm font-medium text-amber-700 dark:text-amber-400 mb-1.5"
>
请完善以下信息
</p>
<ul class="text-xs text-amber-600 dark:text-amber-500 space-y-0.5">
<li v-if="!formData.name.trim()">
<i class="fas fa-times-circle mr-1"></i> 应用名称
</li>
<li v-if="!formData.pkgname.trim()">
<i class="fas fa-times-circle mr-1"></i> 包名
</li>
<li v-if="!formData.version.trim()">
<i class="fas fa-times-circle mr-1"></i> 版本号
</li>
<li v-if="!formData.category.trim()">
<i class="fas fa-times-circle mr-1"></i> 分类
</li>
<li v-if="!formData.debFilePath">
<i class="fas fa-times-circle mr-1"></i> 安装包
</li>
<li v-if="!formData.remark.trim()">
<i class="fas fa-times-circle mr-1"></i> 测试情况
</li>
<li v-if="!formData.website.trim()">
<i class="fas fa-times-circle mr-1"></i> 官网地址
</li>
<li
v-if="
formData.website.trim() && !isValidUrl(formData.website.trim())
"
>
<i class="fas fa-times-circle mr-1"></i> 官网地址格式不正确(需以
http:// 或 https:// 开头)
</li>
<li v-if="!formData.tags.trim()">
<i class="fas fa-times-circle mr-1"></i> 标签
</li>
<li v-if="formData.screenshots.length === 0">
<i class="fas fa-times-circle mr-1"></i> 至少上传一张截图
</li>
</ul>
</div>
<div
v-if="isSubmitting || isPackaging"
class="p-4 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-900/20 dark:border-blue-800"
>
<div class="flex items-center gap-3 mb-3">
<i
class="fas fa-spinner fa-spin text-blue-500 dark:text-blue-400"
></i>
<span class="text-blue-700 dark:text-blue-400 font-medium text-sm">
{{ isPackaging ? "正在打包..." : uploadStage || "正在提交..." }}
</span>
</div>
<div class="space-y-2">
<div
class="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-2.5"
> >
<div <div
class="flex items-center gap-2 text-green-700 dark:text-green-400" class="h-2.5 rounded-full transition-all duration-300"
:class="isPackaging ? 'bg-emerald-500' : 'bg-blue-500'"
:style="{ width: uploadProgress + '%' }"
></div>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400">
{{ uploadStageMessage || "准备中..." }}
</p>
<div
v-if="uploadStages.length > 0 && !isPackaging"
class="mt-3 space-y-1.5"
> >
<i class="fas fa-check-circle"></i> <div
<span>投稿提交成功我们会尽快审核你的应用</span> v-for="stage in uploadStages"
:key="stage.stage"
class="flex items-center gap-2 text-xs"
>
<i
v-if="stage.progress >= 100"
class="fas fa-check-circle text-green-500"
></i>
<i
v-else-if="stage.progress > 0"
class="fas fa-spinner fa-spin text-blue-500"
></i>
<i
v-else
class="far fa-circle text-slate-300 dark:text-slate-600"
></i>
<span
:class="
stage.progress >= 100
? 'text-green-600 dark:text-green-400'
: stage.progress > 0
? 'text-blue-600 dark:text-blue-400'
: 'text-slate-400 dark:text-slate-500'
"
>{{ stage.label }}</span
>
<span
v-if="stage.progress > 0 && stage.progress < 100"
class="text-slate-400"
>{{ Math.floor(stage.progress) }}%</span
>
</div>
</div>
</div>
</div>
<div v-if="showSubmitSuccessModal" class="relative">
<div
class="p-6 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 shadow-lg"
>
<div class="flex items-center gap-3 mb-4">
<div
class="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center"
>
<i class="fas fa-check-circle text-green-500 text-xl"></i>
</div>
<div>
<h3 class="font-semibold text-slate-900 dark:text-slate-100">
投稿提交成功!
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
我们会尽快审核你的应用
</p>
</div>
</div>
<div class="flex gap-3">
<button
type="button"
class="flex-1 px-4 py-2 rounded-lg bg-blue-500 text-white font-medium hover:bg-blue-600 transition-colors"
@click="closeSubmitSuccessModal"
>
关闭
</button>
<button
type="button"
class="flex-1 px-4 py-2 rounded-lg border border-slate-200 bg-white text-slate-700 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
@click="resetFormAndClose"
>
继续投递
</button>
</div>
</div> </div>
</div> </div>
@@ -559,7 +700,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed } from "vue"; import { ref, reactive, computed, watch } from "vue";
import { APM_STORE_BASE_URL } from "@/global/storeConfig";
interface HistoryArchInfo { interface HistoryArchInfo {
id: number; id: number;
@@ -595,12 +737,23 @@ const formData = reactive({
description: "", description: "",
tags: "", tags: "",
category: "", category: "",
categoryId: 0,
remark: "", remark: "",
}); });
// 当 category 变化时,从 categoriesList 中同步 categoryId
watch(
() => formData.category,
(val) => {
const found = categoriesList.value.find((c) => c.value === val);
formData.categoryId = found?.id ?? 0;
},
);
const isSubmitting = ref(false); const isSubmitting = ref(false);
const submitSuccess = ref(false); const submitSuccess = ref(false);
const submitError = ref(""); const submitError = ref("");
const showSubmitSuccessModal = ref(false);
const isParsingDeb = ref(false); const isParsingDeb = ref(false);
const debParseError = ref(""); const debParseError = ref("");
const isSearchingHistory = ref(false); const isSearchingHistory = ref(false);
@@ -608,6 +761,14 @@ const showArchDialog = ref(false);
const availableArchs = ref<HistoryArchInfo[]>([]); const availableArchs = ref<HistoryArchInfo[]>([]);
const currentDebArch = ref(""); const currentDebArch = ref("");
const iconPreview = ref(""); const iconPreview = ref("");
const iconFileName = ref("");
const uploadProgress = ref(0);
const uploadStage = ref("");
const uploadStageMessage = ref("");
const uploadStages = ref<{ stage: string; label: string; progress: number }[]>(
[],
);
const isLoadingCategories = ref(false); const isLoadingCategories = ref(false);
const categoriesLoadError = ref(""); const categoriesLoadError = ref("");
@@ -634,23 +795,6 @@ interface Category {
value: string; value: string;
} }
// 服务器分类 id → 英文标识符映射(与后端 submitter.ts 的 categoryNameToIdMap 反向)
// 历史记录中 category 字段存储的就是这些英文标识符
const categoryIdToValueMap: Record<number, string> = {
1: "games",
2: "music",
3: "network",
4: "office",
5: "others",
6: "image_graphics",
7: "development",
8: "reading",
9: "chat",
10: "themes",
11: "tools",
12: "video",
};
interface Tag { interface Tag {
name: string; name: string;
value: string; value: string;
@@ -661,13 +805,55 @@ const tagsList = ref<Tag[]>([]);
const selectedTags = ref<Tag[]>([]); const selectedTags = ref<Tag[]>([]);
const selectedTagValue = ref(""); const selectedTagValue = ref("");
const isValidUrl = (url: string): boolean => {
if (!url.trim()) return false;
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
};
const isFormValid = computed(() => { const isFormValid = computed(() => {
return ( const hasRequiredFields =
formData.name.trim() && formData.name.trim() &&
formData.pkgname.trim() && formData.pkgname.trim() &&
formData.version.trim() && formData.version.trim() &&
formData.category.trim() && formData.category.trim() &&
formData.debFilePath formData.debFilePath &&
formData.remark.trim() &&
formData.website.trim() &&
formData.tags.trim();
if (!hasRequiredFields) return false;
// 官网:必须为有效的 http/https URL
if (!isValidUrl(formData.website.trim())) {
return false;
}
// 截图:至少一张,且每张都是有效的 data URL 或 http/https URL
if (formData.screenshots.length === 0) return false;
for (const screenshot of formData.screenshots) {
if (
!screenshot.startsWith("data:") &&
!screenshot.startsWith("http://") &&
!screenshot.startsWith("https://")
) {
return false;
}
}
return true;
});
const hasUserStartedFilling = computed(() => {
return (
formData.name.trim() ||
formData.pkgname.trim() ||
formData.debFilePath ||
formData.screenshots.length > 0
); );
}); });
@@ -711,12 +897,13 @@ const loadCategoriesList = async (): Promise<void> => {
data.data.length > 0 data.data.length > 0
) { ) {
categoriesList.value = data.data.map( categoriesList.value = data.data.map(
(item: { id: number; name: string }, index: number) => ({ (
item: { id: number; name: string; type?: string },
index: number,
) => ({
id: typeof item.id === "number" ? item.id : index + 1, id: typeof item.id === "number" ? item.id : index + 1,
// API 只返回中文 namevalue 通过 id 反向映射为英文标识符
// 英文标识符与历史记录中的 category 字段一致,用于匹配
name: item.name || "", name: item.name || "",
value: categoryIdToValueMap[item.id] || item.name || "", value: item.type || String(item.id),
}), }),
); );
categoriesLoadError.value = ""; categoriesLoadError.value = "";
@@ -726,10 +913,13 @@ const loadCategoriesList = async (): Promise<void> => {
); );
} else if (data.code === 0 && Array.isArray(data)) { } else if (data.code === 0 && Array.isArray(data)) {
categoriesList.value = data.map( categoriesList.value = data.map(
(item: { id: number; name: string }, index: number) => ({ (
item: { id: number; name: string; type?: string },
index: number,
) => ({
id: typeof item.id === "number" ? item.id : index + 1, id: typeof item.id === "number" ? item.id : index + 1,
name: item.name || "", name: item.name || "",
value: categoryIdToValueMap[item.id] || item.name || "", value: item.type || String(item.id),
}), }),
); );
categoriesLoadError.value = ""; categoriesLoadError.value = "";
@@ -853,8 +1043,6 @@ const selectDebFile = async () => {
} }
}; };
const useMirror = ref(true);
const searchHistoryApp = async () => { const searchHistoryApp = async () => {
console.log( console.log(
"[Submitter] ============== SEARCHING HISTORY INFO ==============", "[Submitter] ============== SEARCHING HISTORY INFO ==============",
@@ -863,7 +1051,6 @@ const searchHistoryApp = async () => {
"[Submitter] pkgname is not empty, searching history with:", "[Submitter] pkgname is not empty, searching history with:",
formData.pkgname, formData.pkgname,
); );
console.log("[Submitter] Using mirror:", useMirror.value);
console.log( console.log(
"[Submitter] Calling IPC: search-history-app with pkgname:", "[Submitter] Calling IPC: search-history-app with pkgname:",
@@ -872,7 +1059,7 @@ const searchHistoryApp = async () => {
const historyResult = await window.ipcRenderer.invoke( const historyResult = await window.ipcRenderer.invoke(
"search-history-app", "search-history-app",
formData.pkgname, formData.pkgname,
useMirror.value, APM_STORE_BASE_URL,
); );
console.log( console.log(
"[Submitter] Received history search response:", "[Submitter] Received history search response:",
@@ -980,6 +1167,9 @@ const searchHistoryApp = async () => {
historyResult?.message, historyResult?.message,
); );
} }
// 未找到历史记录时清空旧数据,避免上一个应用的残留
availableArchs.value = [];
showArchDialog.value = false;
} }
}; };
@@ -1162,9 +1352,12 @@ const selectArch = async (arch: HistoryArchInfo) => {
console.log("[Submitter] Tags loaded from history:", selectedTags.value); console.log("[Submitter] Tags loaded from history:", selectedTags.value);
} }
const baseUrl = useMirror.value // 开发模式下来自 Vite 代理的路径(如 /local_amd64-store),
? `https://mirrors.sdu.edu.cn/spark-store/${arch.store}/${arch.category}/${arch.pkgname}` // 后端 fetch/fs 无法处理,需要解析为实际 CDN 地址
: `https://spk-json.spark-app.store/${arch.store}/${arch.category}/${arch.pkgname}`; const resolvedBaseUrl = APM_STORE_BASE_URL.startsWith("/")
? "https://erotica.spark-app.store"
: APM_STORE_BASE_URL;
const baseUrl = `${resolvedBaseUrl}/${arch.store}/${arch.category}/${arch.pkgname}`;
console.log( console.log(
"[Submitter] Building icon and screenshot URLs with baseUrl:", "[Submitter] Building icon and screenshot URLs with baseUrl:",
@@ -1174,6 +1367,7 @@ const selectArch = async (arch: HistoryArchInfo) => {
if (arch.icon) { if (arch.icon) {
formData.iconPath = `${baseUrl}/icon.png`; formData.iconPath = `${baseUrl}/icon.png`;
iconPreview.value = formData.iconPath; iconPreview.value = formData.iconPath;
iconFileName.value = "icon.png";
console.log("[Submitter] Icon URL:", formData.iconPath); console.log("[Submitter] Icon URL:", formData.iconPath);
console.log("[Submitter] Icon preview set:", iconPreview.value); console.log("[Submitter] Icon preview set:", iconPreview.value);
} }
@@ -1198,12 +1392,14 @@ const selectIconFile = () => {
const handleIconFileSelect = (event: Event) => { const handleIconFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
const file = target.files?.[0] as File & { path?: string }; const file = target.files?.[0];
if (file) { if (file) {
formData.iconPath = file.path || file.name; iconFileName.value = file.name;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
iconPreview.value = e.target?.result as string; const dataUrl = e.target?.result as string;
formData.iconPath = dataUrl;
iconPreview.value = dataUrl;
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
@@ -1211,17 +1407,19 @@ const handleIconFileSelect = (event: Event) => {
const handleIconDrop = (event: DragEvent) => { const handleIconDrop = (event: DragEvent) => {
event.preventDefault(); event.preventDefault();
const file = event.dataTransfer?.files?.[0] as File & { path?: string }; const file = event.dataTransfer?.files?.[0];
if ( if (
file && file &&
(file.name.endsWith(".png") || (file.name.endsWith(".png") ||
file.name.endsWith(".jpg") || file.name.endsWith(".jpg") ||
file.name.endsWith(".jpeg")) file.name.endsWith(".jpeg"))
) { ) {
formData.iconPath = file.path || file.name; iconFileName.value = file.name;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
iconPreview.value = e.target?.result as string; const dataUrl = e.target?.result as string;
formData.iconPath = dataUrl;
iconPreview.value = dataUrl;
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
@@ -1273,6 +1471,7 @@ const resetForm = () => {
formData.description = ""; formData.description = "";
formData.tags = ""; formData.tags = "";
formData.category = ""; formData.category = "";
formData.categoryId = 0;
formData.remark = ""; formData.remark = "";
submitSuccess.value = false; submitSuccess.value = false;
submitError.value = ""; submitError.value = "";
@@ -1280,6 +1479,28 @@ const resetForm = () => {
packageSuccess.value = false; packageSuccess.value = false;
packageError.value = ""; packageError.value = "";
packageResult.value = null; packageResult.value = null;
iconFileName.value = "";
iconPreview.value = "";
uploadProgress.value = 0;
uploadStage.value = "";
uploadStageMessage.value = "";
uploadStages.value = [];
selectedTags.value = [];
selectedTagValue.value = "";
availableArchs.value = [];
showArchDialog.value = false;
currentDebArch.value = "";
isSearchingHistory.value = false;
};
const closeSubmitSuccessModal = () => {
showSubmitSuccessModal.value = false;
resetForm();
};
const resetFormAndClose = () => {
showSubmitSuccessModal.value = false;
resetForm();
}; };
const submitForm = async () => { const submitForm = async () => {
@@ -1289,6 +1510,45 @@ const submitForm = async () => {
submitSuccess.value = false; submitSuccess.value = false;
submitError.value = ""; submitError.value = "";
uploadProgress.value = 0;
uploadStage.value = "准备提交...";
uploadStageMessage.value = "正在准备...";
uploadStages.value = [
{ stage: "icon", label: "上传图标", progress: 0 },
{ stage: "screenshots", label: "上传截图", progress: 0 },
{ stage: "deb", label: "上传安装包", progress: 0 },
{ stage: "submit", label: "提交信息", progress: 0 },
];
const progressListener = (
_event: unknown,
data: { step: string; progress: number; message: string },
) => {
let stageIndex: number;
if (data.step.startsWith("screenshot")) {
stageIndex = uploadStages.value.findIndex(
(s) => s.stage === "screenshots",
);
} else {
stageIndex = uploadStages.value.findIndex((s) => s.stage === data.step);
}
if (stageIndex !== -1) {
uploadStages.value[stageIndex].progress = Math.max(
uploadStages.value[stageIndex].progress,
data.progress,
);
}
if (data.progress >= 100 && !data.step.startsWith("screenshot")) {
uploadProgress.value = data.progress;
} else {
uploadProgress.value = Math.max(uploadProgress.value, data.progress);
}
uploadStage.value = data.message;
uploadStageMessage.value = data.message;
};
window.ipcRenderer.on("submit-upload-progress", progressListener);
try { try {
const submitData = { const submitData = {
name: formData.name, name: formData.name,
@@ -1304,7 +1564,9 @@ const submitForm = async () => {
description: formData.description, description: formData.description,
tags: formData.tags, tags: formData.tags,
category: formData.category, category: formData.category,
categoryId: formData.categoryId,
remark: formData.remark, remark: formData.remark,
arch: currentDebArch.value,
}; };
console.log("[Submitter] ============== SUBMIT FORM =============="); console.log("[Submitter] ============== SUBMIT FORM ==============");
@@ -1321,14 +1583,19 @@ const submitForm = async () => {
const result = await window.ipcRenderer.invoke("submit-app", submitData); const result = await window.ipcRenderer.invoke("submit-app", submitData);
if (result?.success) { if (result?.success) {
uploadStages.value.forEach((s) => (s.progress = 100));
uploadProgress.value = 100;
uploadStage.value = "提交完成";
uploadStageMessage.value = "投稿提交成功!";
submitSuccess.value = true; submitSuccess.value = true;
resetForm(); showSubmitSuccessModal.value = true;
} else { } else {
submitError.value = result?.message || "提交失败"; submitError.value = result?.message || "提交失败";
} }
} catch (error) { } catch (error) {
submitError.value = (error as Error)?.message || "提交失败"; submitError.value = (error as Error)?.message || "提交失败";
} finally { } finally {
window.ipcRenderer.off("submit-upload-progress", progressListener);
isSubmitting.value = false; isSubmitting.value = false;
} }
}; };
@@ -1346,6 +1613,35 @@ const packageApp = async (storeArch: string) => {
packageError.value = ""; packageError.value = "";
packageResult.value = null; packageResult.value = null;
uploadProgress.value = 0;
uploadStage.value = "准备打包...";
uploadStageMessage.value = "正在准备...";
uploadStages.value = [
{ stage: "init", label: "创建临时目录", progress: 0 },
{ stage: "icon", label: "处理图标", progress: 0 },
{ stage: "screenshots", label: "处理截图", progress: 0 },
{ stage: "deb", label: "复制安装包", progress: 0 },
{ stage: "json", label: "生成配置文件", progress: 0 },
{ stage: "tar", label: "打包压缩", progress: 0 },
];
const progressListener = (
_event: unknown,
data: { step: string; progress: number; message: string },
) => {
const stageIndex = uploadStages.value.findIndex(
(s) => s.stage === data.step,
);
if (stageIndex !== -1) {
uploadStages.value[stageIndex].progress = data.progress;
}
uploadProgress.value = data.progress;
uploadStage.value = data.step;
uploadStageMessage.value = data.message;
};
window.ipcRenderer.on("package-progress", progressListener);
try { try {
const packageData = { const packageData = {
name: formData.name, name: formData.name,
@@ -1361,6 +1657,7 @@ const packageApp = async (storeArch: string) => {
description: formData.description, description: formData.description,
tags: formData.tags, tags: formData.tags,
category: formData.category, category: formData.category,
categoryId: formData.categoryId,
remark: formData.remark, remark: formData.remark,
storeArch, storeArch,
}; };
@@ -1374,6 +1671,9 @@ const packageApp = async (storeArch: string) => {
const result = await window.ipcRenderer.invoke("package-app", packageData); const result = await window.ipcRenderer.invoke("package-app", packageData);
if (result?.success) { if (result?.success) {
uploadStages.value.forEach((s) => (s.progress = 100));
uploadProgress.value = 100;
uploadStageMessage.value = "打包完成!";
packageSuccess.value = true; packageSuccess.value = true;
packageResult.value = result.data; packageResult.value = result.data;
} else { } else {
@@ -1382,6 +1682,7 @@ const packageApp = async (storeArch: string) => {
} catch (error) { } catch (error) {
packageError.value = (error as Error)?.message || "打包失败"; packageError.value = (error as Error)?.message || "打包失败";
} finally { } finally {
window.ipcRenderer.off("package-progress", progressListener);
isPackaging.value = false; isPackaging.value = false;
} }
}; };