add SharedClipboard.vue
This commit is contained in:
16
.gitignore
vendored
16
.gitignore
vendored
@ -1,3 +1,12 @@
|
||||
/.env.production
|
||||
/.eslintrc-auto-import.json
|
||||
/.claude
|
||||
/CLAUDE.md
|
||||
/AGENTS.md
|
||||
/components.d.ts
|
||||
/src/auto-imports.d.ts
|
||||
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@ -22,10 +31,3 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
/.env.production
|
||||
/.eslintrc-auto-import.json
|
||||
/.claude
|
||||
/CLAUDE.md
|
||||
/components.d.ts
|
||||
/src/auto-imports.d.ts
|
||||
|
||||
68
src/api/ClipboardAPI.ts
Normal file
68
src/api/ClipboardAPI.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { axios } from "timi-web";
|
||||
|
||||
/** 共享剪切板请求 */
|
||||
interface ClipboardRequest {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** 构建共享剪切板 SSE 地址 */
|
||||
function buildStreamUrl(id: string): string {
|
||||
const baseUrl = axios.defaults.baseURL || "";
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
||||
return `${normalizedBaseUrl}/clipboard/stream/${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取共享剪切板内容
|
||||
*
|
||||
* @param id 会话 ID
|
||||
*/
|
||||
async function getContent(id: string): Promise<string> {
|
||||
// timi-web 响应拦截器已自动解包,直接返回内容
|
||||
return axios.get(`/clipboard/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置共享剪切板内容
|
||||
*
|
||||
* @param id 会话 ID
|
||||
* @param content 剪切板内容
|
||||
*/
|
||||
async function setContent(id: string, content: string): Promise<void> {
|
||||
const payload: ClipboardRequest = { content };
|
||||
await axios.post(`/clipboard/${id}`, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅共享剪切板实时更新
|
||||
*
|
||||
* @param id 会话 ID
|
||||
* @param onMessage 收到内容回调
|
||||
* @param onError 连接错误回调
|
||||
*/
|
||||
function openStream(id: string, onMessage: (content: string) => void, onError?: () => void): EventSource {
|
||||
const url = buildStreamUrl(id);
|
||||
// 跨域 SSE 需要携带凭证
|
||||
const source = new EventSource(url, { withCredentials: true });
|
||||
source.addEventListener("open", () => {
|
||||
console.debug("[Clipboard] SSE 连接已建立");
|
||||
});
|
||||
source.addEventListener("clipboard", (event) => {
|
||||
const message = event as MessageEvent;
|
||||
onMessage(String(message.data ?? ""));
|
||||
});
|
||||
source.addEventListener("error", (event) => {
|
||||
console.debug("[Clipboard] SSE 连接错误", event);
|
||||
source.close();
|
||||
if (onError) {
|
||||
onError();
|
||||
}
|
||||
});
|
||||
return source;
|
||||
}
|
||||
|
||||
export default {
|
||||
getContent,
|
||||
setContent,
|
||||
openStream
|
||||
};
|
||||
238
src/components/SharedClipboard.vue
Normal file
238
src/components/SharedClipboard.vue
Normal file
@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="clipboard">
|
||||
<t-collapse class="collapse" v-model="panels" expand-icon-placement="right">
|
||||
<t-collapse-panel value="clipboard">
|
||||
<template #header>
|
||||
<div class="header">
|
||||
<span class="title">共享剪切板</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="panel">
|
||||
<div class="row">
|
||||
<span class="label">频道 ID</span>
|
||||
<t-input
|
||||
v-model="id"
|
||||
placeholder="请输入频道 ID"
|
||||
clearable
|
||||
/>
|
||||
<t-button
|
||||
theme="default"
|
||||
variant="outline"
|
||||
:disabled="!isReady"
|
||||
@click="refresh"
|
||||
>
|
||||
刷新
|
||||
</t-button>
|
||||
</div>
|
||||
<t-textarea
|
||||
class="textarea"
|
||||
v-model="content"
|
||||
:disabled="!isReady"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
</div>
|
||||
</t-collapse-panel>
|
||||
</t-collapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { MessagePlugin } from "tdesign-vue-next";
|
||||
import ClipboardAPI from "@/api/ClipboardAPI.ts";
|
||||
|
||||
const panels = ref<string[]>([]);
|
||||
const id = ref("");
|
||||
const content = ref("");
|
||||
const loading = ref(false);
|
||||
const syncing = ref(false);
|
||||
const stream = ref<EventSource | null>(null);
|
||||
const syncTimer = ref<number | null>(null);
|
||||
const reconnectTimer = ref<number | null>(null);
|
||||
const remoteUpdating = ref(false);
|
||||
|
||||
const isOpen = computed(() => panels.value.includes("clipboard"));
|
||||
const isReady = computed(() => 0 < id.value.trim().length);
|
||||
const placeholder = computed(() => isReady.value ? "输入内容实时同步" : "请输入频道 ID");
|
||||
|
||||
/** 关闭 SSE 连接 */
|
||||
function closeStream() {
|
||||
if (stream.value) {
|
||||
stream.value.close();
|
||||
stream.value = null;
|
||||
}
|
||||
if (reconnectTimer.value) {
|
||||
clearTimeout(reconnectTimer.value);
|
||||
reconnectTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新内容(标记为远程更新,避免触发同步) */
|
||||
function updateContent(value: string) {
|
||||
remoteUpdating.value = true;
|
||||
content.value = value;
|
||||
window.setTimeout(() => {
|
||||
remoteUpdating.value = false;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** 加载剪切板内容 */
|
||||
async function loadContent() {
|
||||
const trimmedId = id.value.trim();
|
||||
if (!(0 < trimmedId.length)) {
|
||||
updateContent("");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await ClipboardAPI.getContent(trimmedId);
|
||||
updateContent(data || "");
|
||||
} catch (error) {
|
||||
await MessagePlugin.error("获取共享剪切板失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开 SSE 连接 */
|
||||
function openStream() {
|
||||
const trimmedId = id.value.trim();
|
||||
if (!(0 < trimmedId.length)) {
|
||||
return;
|
||||
}
|
||||
closeStream();
|
||||
stream.value = ClipboardAPI.openStream(trimmedId, (data) => {
|
||||
updateContent(data || "");
|
||||
}, async () => {
|
||||
closeStream();
|
||||
if (!loading.value) {
|
||||
await MessagePlugin.error("共享剪切板连接中断");
|
||||
}
|
||||
if (isOpen.value && isReady.value) {
|
||||
reconnectTimer.value = window.setTimeout(() => {
|
||||
openStream();
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 刷新剪切板 */
|
||||
async function refresh() {
|
||||
if (!isReady.value) {
|
||||
await MessagePlugin.warning("请先输入频道 ID");
|
||||
return;
|
||||
}
|
||||
await loadContent();
|
||||
openStream();
|
||||
}
|
||||
|
||||
/** 同步内容到服务器 */
|
||||
async function syncContent() {
|
||||
if (!isReady.value || syncing.value) {
|
||||
return;
|
||||
}
|
||||
const trimmedId = id.value.trim();
|
||||
syncing.value = true;
|
||||
try {
|
||||
await ClipboardAPI.setContent(trimmedId, content.value);
|
||||
} catch (error) {
|
||||
await MessagePlugin.error("同步共享剪切板失败");
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 延迟同步 */
|
||||
function scheduleSync() {
|
||||
if (!isReady.value) {
|
||||
return;
|
||||
}
|
||||
if (syncTimer.value) {
|
||||
clearTimeout(syncTimer.value);
|
||||
}
|
||||
syncTimer.value = window.setTimeout(async () => {
|
||||
await syncContent();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
watch([() => id.value, () => isOpen.value], async ([newId, open]) => {
|
||||
closeStream();
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const trimmedId = newId.trim();
|
||||
if (!(0 < trimmedId.length)) {
|
||||
updateContent("");
|
||||
return;
|
||||
}
|
||||
await loadContent();
|
||||
openStream();
|
||||
});
|
||||
|
||||
watch(content, () => {
|
||||
if (!isOpen.value || !isReady.value || remoteUpdating.value) {
|
||||
return;
|
||||
}
|
||||
scheduleSync();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
closeStream();
|
||||
if (syncTimer.value) {
|
||||
clearTimeout(syncTimer.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.clipboard {
|
||||
--td-comp-margin-xxl: 0;
|
||||
|
||||
:deep(.t-collapse-panel__body),
|
||||
:deep(.t-collapse-panel__header) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.collapse {
|
||||
border: none;
|
||||
|
||||
.header {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: .875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
border-top: var(--tui-border);
|
||||
flex-direction: column;
|
||||
|
||||
.row {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.label {
|
||||
font-size: .875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 6em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -15,12 +15,15 @@
|
||||
</template>
|
||||
</t-alert>
|
||||
<div class="content">
|
||||
<div class="panel left">
|
||||
<file-upload />
|
||||
</div>
|
||||
<div class="panel right">
|
||||
<file-download />
|
||||
<file-history-list />
|
||||
<shared-clipboard />
|
||||
<div class="file">
|
||||
<div class="panel left">
|
||||
<file-upload />
|
||||
</div>
|
||||
<div class="panel right">
|
||||
<file-download />
|
||||
<file-history-list />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -30,6 +33,7 @@
|
||||
import FileUpload from "@/components/FileUpload.vue";
|
||||
import FileDownload from "@/components/FileDownload.vue";
|
||||
import FileHistoryList from "@/components/FileHistoryList.vue";
|
||||
import SharedClipboard from "@/components/SharedClipboard.vue";
|
||||
import { useFileHistoryStore } from "@/store/fileHistory.ts";
|
||||
import { SettingMapper, Time } from "../../../timi-web";
|
||||
|
||||
@ -63,19 +67,25 @@ onMounted(() => setInterval(fileHistoryStore.cleanExpiredFiles, Time.S));
|
||||
display: flex;
|
||||
border-top: var(--tui-border);
|
||||
border-bottom: var(--tui-border);
|
||||
flex-direction: column;
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
.file {
|
||||
display: flex;
|
||||
border-top: var(--tui-border);
|
||||
|
||||
&:first-child {
|
||||
border-right: var(--tui-border);
|
||||
.panel {
|
||||
flex: 1;
|
||||
|
||||
&:first-child {
|
||||
border-right: var(--tui-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.home .content {
|
||||
.home .content .file {
|
||||
flex-direction: column;
|
||||
|
||||
.panel:first-child {
|
||||
|
||||
Reference in New Issue
Block a user