Skip to content

Commit eb552d1

Browse files
committed
feat: export sources
1 parent 79f9997 commit eb552d1

2 files changed

Lines changed: 117 additions & 2 deletions

File tree

src/components/TopAppBar.vue

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<div class="app-bar-left">
55
<div class="app-bar-logo">
66
<router-link to="/" class="logo-link">
7-
<span class="logo-text">蛇王争霸</span>
7+
<span class="logo-text" @click.stop.prevent="onLogoTap">蛇王争霸</span>
88
</router-link>
99
</div>
1010

@@ -28,6 +28,15 @@
2828
</div>
2929

3030
<div class="app-bar-user">
31+
<button
32+
v-if="showExport"
33+
class="export-zip-button"
34+
:disabled="exporting"
35+
@click="downloadLatestSources"
36+
title="导出最新源码Zip"
37+
>
38+
{{ exporting ? '导出中…' : '导出源码' }}
39+
</button>
3140
<template v-if="isAuthenticated">
3241
<div class="user-info">
3342
<router-link to="/profile" class="user-profile-link" title="编辑个人资料">
@@ -55,7 +64,8 @@
5564
import { computed, ref, onMounted } from 'vue';
5665
import { useRoute, useRouter } from 'vue-router';
5766
import { useAuth } from '../stores/auth';
58-
import { avatarService } from '../services/api';
67+
import { avatarService, sandboxService } from '../services/api';
68+
import { RateLimitError } from '../types/RateLimitError';
5969
6070
const route = useRoute();
6171
const router = useRouter();
@@ -105,6 +115,12 @@ const getAvatarUrl = (avatarId: number) => {
105115
// 在组件挂载时获取默认头像ID
106116
onMounted(() => {
107117
fetchDefaultAvatarId();
118+
// 读取隐藏入口解锁状态
119+
try {
120+
if (localStorage.getItem('exportUnlocked') === '1') {
121+
showExport.value = true;
122+
}
123+
} catch {}
108124
});
109125
110126
// 生成头像颜色(用于没有头像时的占位符)
@@ -130,6 +146,53 @@ const handleLogout = async () => {
130146
await logout();
131147
router.push('/login');
132148
};
149+
150+
// 隐藏入口:连续点击 Logo 若干次显示导出按钮
151+
const tapCount = ref(0);
152+
const showExport = ref(false);
153+
const exporting = ref(false);
154+
let tapResetTimer: number | undefined;
155+
156+
const onLogoTap = () => {
157+
tapCount.value += 1;
158+
// 3 秒不再点击则重置计数,避免误触
159+
if (tapResetTimer) window.clearTimeout(tapResetTimer);
160+
tapResetTimer = window.setTimeout(() => (tapCount.value = 0), 3000);
161+
162+
if (tapCount.value >= 7) {
163+
showExport.value = true;
164+
try { localStorage.setItem('exportUnlocked', '1'); } catch {}
165+
}
166+
};
167+
168+
const downloadLatestSources = async () => {
169+
if (exporting.value) return;
170+
exporting.value = true;
171+
try {
172+
const { blob, filename } = await sandboxService.downloadLatestSourcesZip();
173+
const url = URL.createObjectURL(blob);
174+
const a = document.createElement('a');
175+
a.href = url;
176+
a.download = filename || 'latest-sources.zip';
177+
document.body.appendChild(a);
178+
a.click();
179+
a.remove();
180+
URL.revokeObjectURL(url);
181+
} catch (err: any) {
182+
if (err instanceof RateLimitError) {
183+
const wait = err.retryAfterSeconds ? ` 请在 ${err.retryAfterSeconds}s 后重试。` : '';
184+
alert('导出频率过高。' + wait);
185+
} else if (err?.response?.status === 401) {
186+
alert('未授权或登录已过期,请重新登录。');
187+
router.push('/login');
188+
} else {
189+
console.error('导出失败:', err);
190+
alert('导出失败,请稍后重试。');
191+
}
192+
} finally {
193+
exporting.value = false;
194+
}
195+
};
133196
</script>
134197

135198
<style scoped>
@@ -210,6 +273,30 @@ const handleLogout = async () => {
210273
align-items: center;
211274
}
212275
276+
.export-zip-button {
277+
margin-right: 8px;
278+
background-color: var(--accent-color);
279+
color: #000;
280+
border: none;
281+
padding: 6px 10px;
282+
border-radius: 4px;
283+
font-size: 12px;
284+
font-family: 'Press Start 2P', monospace;
285+
cursor: pointer;
286+
transition: all 0.2s;
287+
}
288+
289+
.export-zip-button:hover:not(:disabled) {
290+
background-color: var(--button-hover);
291+
transform: translateY(-1px);
292+
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.3);
293+
}
294+
295+
.export-zip-button:disabled {
296+
opacity: 0.6;
297+
cursor: not-allowed;
298+
}
299+
213300
.user-info {
214301
display: flex;
215302
align-items: center;

src/services/sandboxService.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,34 @@ export const sandboxService = {
292292

293293
return 2; // 默认向右移动
294294
},
295+
296+
/**
297+
* Download latest sources as a ZIP from sandbox backend.
298+
* Returns blob and suggested filename parsed from Content-Disposition.
299+
*/
300+
downloadLatestSourcesZip: async (): Promise<{ blob: Blob; filename: string }> => {
301+
const response = await sandboxClient.get(
302+
"/compile/export/latest-sources.zip",
303+
{ responseType: "blob" }
304+
);
305+
306+
// Try to extract filename from Content-Disposition
307+
const disposition = (response.headers as any)["content-disposition"] || (response.headers as any)["Content-Disposition"];
308+
let filename = "latest-sources.zip";
309+
if (typeof disposition === "string") {
310+
const match = disposition.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i);
311+
const raw = match?.[1] || match?.[2];
312+
if (raw) {
313+
try {
314+
filename = decodeURIComponent(raw);
315+
} catch {
316+
filename = raw;
317+
}
318+
}
319+
}
320+
321+
return { blob: response.data as Blob, filename };
322+
},
295323
};
296324

297325
/**

0 commit comments

Comments
 (0)