Skip to content

Commit f5d66ba

Browse files
authored
Merge pull request #1 from kongye-su/ImportAndExport
导入导出功能修改
2 parents 980b557 + 2e55d73 commit f5d66ba

2 files changed

Lines changed: 160 additions & 41 deletions

File tree

app.js

Lines changed: 153 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ const i18n = {
4545
btn_test: '测试连接',
4646
btn_ping: '连通检测',
4747
btn_save: '保存服务商',
48+
btn_testall: '测试全部',
49+
btn_export_all: '导出全部',
50+
btn_import: '导入',
51+
btn_delete_all: '删除全部',
4852
btn_copy: '复制',
4953
btn_download: '下载',
5054
saved_title: '服务商',
@@ -68,6 +72,7 @@ const i18n = {
6872
onboarding_next: '下一步',
6973
toast_saved: '✓ 已保存(密钥已加密)',
7074
toast_deleted: '✓ 已删除',
75+
toast_deleted_all: '✓ 已清空全部服务商',
7176
toast_loaded: '✓ 已加载',
7277
toast_copied: '✓ 已复制',
7378
toast_imported: '✓ 导入并保存成功',
@@ -81,6 +86,7 @@ const i18n = {
8186
prompt_name: '为该服务商命名:',
8287
prompt_rename: '输入新名称:',
8388
confirm_delete: '确认删除该服务商及其所有配置?',
89+
confirm_delete_all: '确认删除全部已保存服务商?',
8490
status_ok: '正常',
8591
status_fail: '失败',
8692
ping_dns: 'DNS 解析',
@@ -151,6 +157,10 @@ const i18n = {
151157
btn_test: 'Test',
152158
btn_ping: 'Ping',
153159
btn_save: 'Save Provider',
160+
btn_testall: 'Test All',
161+
btn_export_all: 'Export All',
162+
btn_import: 'Import',
163+
btn_delete_all: 'Delete All',
154164
btn_copy: 'Copy',
155165
btn_download: 'Download',
156166
saved_title: 'PROVIDERS',
@@ -174,6 +184,7 @@ const i18n = {
174184
onboarding_next: 'Next',
175185
toast_saved: '✓ Saved (key encrypted)',
176186
toast_deleted: '✓ Deleted',
187+
toast_deleted_all: '✓ All providers cleared',
177188
toast_loaded: '✓ Loaded',
178189
toast_copied: '✓ Copied',
179190
toast_imported: '✓ Imported & saved',
@@ -187,6 +198,7 @@ const i18n = {
187198
prompt_name: 'Name this provider:',
188199
prompt_rename: 'Enter new name:',
189200
confirm_delete: 'Delete this provider and all its config?',
201+
confirm_delete_all: 'Delete all saved providers?',
190202
status_ok: 'OK',
191203
status_fail: 'FAIL',
192204
ping_dns: 'DNS Resolution',
@@ -236,6 +248,10 @@ function applyLang() {
236248
const key = el.getAttribute('data-i18n');
237249
if (i18n[currentLang][key]) el.textContent = i18n[currentLang][key];
238250
});
251+
document.querySelectorAll('[data-i18n-title]').forEach(el => {
252+
const key = el.getAttribute('data-i18n-title');
253+
if (i18n[currentLang][key]) el.title = i18n[currentLang][key];
254+
});
239255
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
240256
const key = el.getAttribute('data-i18n-placeholder');
241257
if (i18n[currentLang][key]) el.placeholder = i18n[currentLang][key];
@@ -596,6 +612,9 @@ async function renderProviders() {
596612
</div>
597613
</div>
598614
<div class="provider-actions" onclick="event.stopPropagation()">
615+
<button class="btn-icon btn-icon-sm" onclick="testProviderAllModels(${p.id})" title="${t('btn_testall')}">
616+
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 8h12"/><path d="M9 3l5 5-5 5"/></svg>
617+
</button>
599618
<button class="btn-icon btn-icon-sm" onclick="refreshProviderModels(${p.id})" title="${t('provider_refresh')}">
600619
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 8a6 6 0 0111.5-2.3"/><path d="M14 8a6 6 0 01-11.5 2.3"/><path d="M13 2v4h-4"/><path d="M3 14v-4h4"/></svg>
601620
</button>
@@ -663,10 +682,23 @@ async function deleteProvider(id) {
663682
expandedProviders.delete(id);
664683
setProviders(raw);
665684
renderProviders();
685+
syncProbeMatchesAfterProviderChange();
666686
showToast(t('toast_deleted'));
667687
});
668688
}
669689

690+
function deleteAllProviders() {
691+
const raw = getRawProviders();
692+
if (!raw.length) return;
693+
showConfirmModal(t('confirm_delete_all'), () => {
694+
expandedProviders.clear();
695+
setProviders([]);
696+
renderProviders();
697+
syncProbeMatchesAfterProviderChange();
698+
showToast(t('toast_deleted_all'));
699+
});
700+
}
701+
670702
async function refreshProviderModels(id) {
671703
const providers = await getProviders();
672704
const p = providers.find(x => x.id === id);
@@ -919,13 +951,39 @@ async function testAllProviders() {
919951
const providers = await getProviders();
920952
if (!providers.length) { showToast(t('testall_empty')); return; }
921953

954+
await runProviderModelTests(providers.map(p => ({
955+
id: p.id,
956+
name: p.name,
957+
baseUrl: p.baseUrl,
958+
apiKey: p._key,
959+
models: (p.models || []).slice()
960+
})));
961+
}
962+
963+
async function testProviderAllModels(id) {
964+
const providers = await getProviders();
965+
const p = providers.find(x => x.id === id);
966+
if (!p) return;
967+
968+
await runProviderModelTests([{
969+
id: p.id,
970+
name: p.name,
971+
baseUrl: p.baseUrl,
972+
apiKey: p._key,
973+
models: (p.models || []).slice()
974+
}]);
975+
}
976+
977+
async function runProviderModelTests(providerList) {
978+
if (!providerList.length) { showToast(t('testall_empty')); return; }
979+
922980
testAllAbort = false;
923981
document.getElementById('testall-modal').classList.remove('hidden');
924982
const container = document.getElementById('testall-results');
925983
const summaryEl = document.getElementById('testall-summary');
926984
let totalOk = 0, totalFail = 0, totalSkip = 0;
927985

928-
const pData = providers.map(p => ({
986+
const pData = providerList.map(p => ({
929987
id: p.id, name: p.name, baseUrl: p.baseUrl, apiKey: p._key,
930988
models: (p.models || []).slice(), // ALL models, no limit
931989
status: 'run', results: []
@@ -1032,22 +1090,64 @@ function importConfigs() { document.getElementById('import-file').click(); }
10321090
function handleImportFile(e) {
10331091
const file = e.target.files[0];
10341092
if (!file) return;
1093+
e.target.value = '';
1094+
1095+
if (file.name.toLowerCase().endsWith('.zip')) {
1096+
importZipFile(file).catch(() => showToast(t('toast_import_fail')));
1097+
return;
1098+
}
1099+
10351100
const reader = new FileReader();
10361101
reader.onload = ev => {
10371102
try { parseAndAutoSave(ev.target.result, file.name); }
10381103
catch { showToast(t('toast_import_fail')); }
10391104
};
10401105
reader.readAsText(file);
1041-
e.target.value = '';
1106+
}
1107+
1108+
async function importZipFile(file) {
1109+
if (typeof JSZip === 'undefined') {
1110+
showToast(currentLang === 'zh' ? 'JSZip 未加载,请刷新页面' : 'JSZip not loaded');
1111+
return;
1112+
}
1113+
1114+
const zip = await JSZip.loadAsync(file);
1115+
const entries = Object.values(zip.files).filter(entry => !entry.dir && entry.name.toLowerCase().endsWith('.json'));
1116+
if (entries.length === 0) {
1117+
showToast(t('toast_import_fail'));
1118+
return;
1119+
}
1120+
1121+
let importedCount = 0;
1122+
for (const entry of entries) {
1123+
const text = await entry.async('string');
1124+
try {
1125+
await parseAndAutoSave(text, entry.name);
1126+
importedCount++;
1127+
} catch {
1128+
// Skip invalid entry and continue importing remaining JSON files
1129+
}
1130+
}
1131+
1132+
if (importedCount === 0) {
1133+
showToast(t('toast_import_fail'));
1134+
return;
1135+
}
1136+
1137+
showToast(currentLang === 'zh'
1138+
? `✓ 已导入 ZIP(${importedCount} 个配置)`
1139+
: `✓ ZIP imported (${importedCount} configs)`);
10421140
}
10431141

10441142
async function parseAndAutoSave(text, fname) {
10451143
let cfg = {};
10461144
if (fname.endsWith('.json')) {
10471145
const j = JSON.parse(text);
1146+
cfg.name = j.name;
10481147
cfg.baseUrl = j.base_url || j.baseUrl || j.OPENAI_BASE_URL || j.api_base || '';
10491148
cfg.apiKey = j.api_key || j.apiKey || j.OPENAI_API_KEY || '';
1050-
cfg.model = j.model || j.MODEL || j.default_model || '';
1149+
cfg.model = j.model || '';
1150+
cfg.models = Array.isArray(j.models) ? j.models : [];
10511151
} else if (fname.endsWith('.env')) {
10521152
text.split('\n').forEach(line => {
10531153
const m = line.match(/^([^#=]+)=(.*)$/);
@@ -1082,16 +1182,24 @@ async function parseAndAutoSave(text, fname) {
10821182
// Fill form
10831183
document.getElementById('base-url').value = cfg.baseUrl;
10841184
document.getElementById('api-key').value = cfg.apiKey;
1085-
if (cfg.model) document.getElementById('model-name').value = cfg.model;
1185+
const modelNameEl = document.getElementById('model-name');
1186+
// Auto-save: build model list from imported fields first
1187+
let modelList = [];
1188+
if (cfg.models) {
1189+
modelList = cfg.models.filter(m => typeof m === 'string' && m.trim());
1190+
} else if (cfg.model) {
1191+
modelList = [cfg.model];
1192+
} else {
1193+
showToast(t('toast_fetching_models'));
1194+
modelList = await fetchModelsRaw(cfg.baseUrl, cfg.apiKey);
1195+
}
10861196

1087-
// Auto-save: fetch models then save
1088-
showToast(t('toast_fetching_models'));
1089-
const models = await fetchModelsRaw(cfg.baseUrl, cfg.apiKey);
1090-
const modelList = models.length > 0 ? models : (cfg.model ? [cfg.model] : []);
1197+
modelNameEl.value = (cfg.model && modelList.includes(cfg.model)) ? cfg.model : modelList ? modelList[0] : '';
10911198

10921199
const encKey = await encryptText(cfg.apiKey);
10931200
const raw = getRawProviders();
1094-
const name = extractHost(cfg.baseUrl);
1201+
const importedName = (cfg.name || '').trim();
1202+
const name = importedName || extractHost(cfg.baseUrl);
10951203
raw.push({
10961204
id: Date.now(),
10971205
name,
@@ -1102,6 +1210,7 @@ async function parseAndAutoSave(text, fname) {
11021210
});
11031211
setProviders(raw);
11041212
renderProviders();
1213+
await syncProbeMatchesAfterProviderChange();
11051214
showToast(t('toast_imported') + ` — ${name} (${modelList.length} ${currentLang === 'zh' ? '个模型' : 'models'})`);
11061215
}
11071216

@@ -1125,7 +1234,7 @@ function generateFormatContent(format, cfg) {
11251234
case 'curl': return { title: 'cURL', filename: 'test_api.sh', content: `curl "${base}/chat/completions" \\\n -H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey}" \\\n -d '{\n "model": "${model}",\n "messages": [{"role":"user","content":"${prompt.replace(/"/g, '\\"')}"}],\n "max_tokens": 256\n }'` };
11261235
case 'python': return { title: 'Python', filename: 'test_api.py', content: `from openai import OpenAI\n\nclient = OpenAI(\n api_key="${apiKey}",\n base_url="${base}"\n)\n\nres = client.chat.completions.create(\n model="${model}",\n messages=[{"role":"user","content":"${prompt.replace(/"/g, '\\"')}"}],\n max_tokens=256\n)\nprint(res.choices[0].message.content)` };
11271236
case 'json': return { title: 'JSON', filename: `${name.replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g,'_')}_config.json`, content: JSON.stringify({ name, base_url: base, api_key: apiKey, model, models: cfg.models || [model], created_at: new Date().toISOString() }, null, 2) };
1128-
case 'provider-full': return { title: name, filename: `${name.replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g,'_')}.json`, content: JSON.stringify({ name, base_url: base, api_key: apiKey, models: cfg.models || [], created_at: cfg.ts || new Date().toISOString() }, null, 2) };
1237+
case 'provider-full': return { title: name, filename: `${name.replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g,'_')}.json`, content: JSON.stringify({ name, base_url: base, api_key: apiKey, model, models: cfg.models || [], created_at: cfg.ts || new Date().toISOString() }, null, 2) };
11291238
default: return { title: '', filename: '', content: '' };
11301239
}
11311240
}
@@ -1243,6 +1352,39 @@ function downloadExport() {
12431352
let probeData = [];
12441353
let probeAbort = false;
12451354

1355+
async function syncProbeMatchesAfterProviderChange() {
1356+
const model = document.getElementById('probe-select').value;
1357+
const btn = document.getElementById('btn-probe-all');
1358+
1359+
if (!model) {
1360+
btn.disabled = true;
1361+
const container = document.getElementById('probe-results').innerHTML = '';
1362+
probeData = [];
1363+
return;
1364+
}
1365+
1366+
const providers = await getProviders();
1367+
const previousStatus = new Map(probeData.map(item => [item.providerId, { status: item.status, latency: item.latency }]));
1368+
1369+
probeData = providers
1370+
.filter(p => (p.models || []).includes(model))
1371+
.map(p => {
1372+
const previous = previousStatus.get(p.id);
1373+
return {
1374+
providerId: p.id,
1375+
providerName: p.name,
1376+
baseUrl: p.baseUrl,
1377+
apiKey: p._key,
1378+
model: model,
1379+
status: previous ? previous.status : 'wait',
1380+
latency: previous ? previous.latency : null
1381+
};
1382+
});
1383+
1384+
btn.disabled = probeData.length === 0;
1385+
renderProbeResults();
1386+
}
1387+
12461388
// Populate the dropdown with all unique models from all providers
12471389
async function refreshProbeDropdown() {
12481390
const providers = await getProviders();
@@ -1265,33 +1407,7 @@ async function refreshProbeDropdown() {
12651407

12661408
// When user selects a model from dropdown
12671409
async function onProbeSelect() {
1268-
const model = document.getElementById('probe-select').value;
1269-
const btn = document.getElementById('btn-probe-all');
1270-
if (!model) {
1271-
btn.disabled = true;
1272-
document.getElementById('probe-results').innerHTML = '';
1273-
probeData = [];
1274-
return;
1275-
}
1276-
1277-
const providers = await getProviders();
1278-
probeData = [];
1279-
for (const p of providers) {
1280-
if ((p.models || []).includes(model)) {
1281-
probeData.push({
1282-
providerId: p.id,
1283-
providerName: p.name,
1284-
baseUrl: p.baseUrl,
1285-
apiKey: p._key,
1286-
model,
1287-
status: 'wait',
1288-
latency: null
1289-
});
1290-
}
1291-
}
1292-
1293-
btn.disabled = probeData.length === 0;
1294-
renderProbeResults();
1410+
await syncProbeMatchesAfterProviderChange();
12951411
}
12961412

12971413
function renderProbeResults() {

index.html

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,17 +230,20 @@ <h2 class="card-title" data-i18n="result_title">测试结果</h2>
230230
<div class="card-dot"></div>
231231
<h2 class="card-title" data-i18n="saved_title">已保存</h2>
232232
<div class="card-header-actions">
233-
<button class="btn-icon" onclick="testAllProviders()" title="Test All" id="btn-test-all">
233+
<button class="btn-icon" onclick="testAllProviders()" title="测试全部" data-i18n-title="btn_testall" id="btn-test-all">
234234
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 7h12M9 3l4 4-4 4"/></svg>
235235
</button>
236-
<button class="btn-icon" onclick="exportAllProviders()" title="Export All" id="btn-export-all">
236+
<button class="btn-icon" onclick="exportAllProviders()" title="导出全部" data-i18n-title="btn_export_all" id="btn-export-all">
237237
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 2h10M2 6h10M2 10h10"/><path d="M12 5l-2 2 2 2"/></svg>
238238
</button>
239-
<button class="btn-icon" onclick="importConfigs()" title="Import">
239+
<button class="btn-icon" onclick="importConfigs()" title="导入" data-i18n-title="btn_import" id="btn-import-all">
240240
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M7 1v9M3 6l4 4 4-4"/><path d="M1 11v1a1 1 0 001 1h10a1 1 0 001-1v-1"/></svg>
241241
</button>
242+
<button class="btn-icon" onclick="deleteAllProviders()" title="删除全部" data-i18n-title="btn_delete_all" id="btn-delete-all">
243+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M2 4h10"/><path d="M5 4V2.5h4V4"/><path d="M4 4v7.5A1.5 1.5 0 005.5 13h3A1.5 1.5 0 0010 11.5V4"/><path d="M6 6.5v4M8 6.5v4"/></svg>
244+
</button>
242245
</div>
243-
<input type="file" id="import-file" accept=".json,.toml,.env,.yaml,.yml" class="sr-only">
246+
<input type="file" id="import-file" accept=".json,.toml,.env,.yaml,.yml,.zip" class="sr-only">
244247
</div>
245248
<div class="card-body">
246249
<div class="saved-list" id="saved-list">

0 commit comments

Comments
 (0)