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
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
@ -22,10 +31,3 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.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,6 +15,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</t-alert>
|
</t-alert>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
<shared-clipboard />
|
||||||
|
<div class="file">
|
||||||
<div class="panel left">
|
<div class="panel left">
|
||||||
<file-upload />
|
<file-upload />
|
||||||
</div>
|
</div>
|
||||||
@ -24,12 +26,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import FileUpload from "@/components/FileUpload.vue";
|
import FileUpload from "@/components/FileUpload.vue";
|
||||||
import FileDownload from "@/components/FileDownload.vue";
|
import FileDownload from "@/components/FileDownload.vue";
|
||||||
import FileHistoryList from "@/components/FileHistoryList.vue";
|
import FileHistoryList from "@/components/FileHistoryList.vue";
|
||||||
|
import SharedClipboard from "@/components/SharedClipboard.vue";
|
||||||
import { useFileHistoryStore } from "@/store/fileHistory.ts";
|
import { useFileHistoryStore } from "@/store/fileHistory.ts";
|
||||||
import { SettingMapper, Time } from "../../../timi-web";
|
import { SettingMapper, Time } from "../../../timi-web";
|
||||||
|
|
||||||
@ -63,6 +67,11 @@ onMounted(() => setInterval(fileHistoryStore.cleanExpiredFiles, Time.S));
|
|||||||
display: flex;
|
display: flex;
|
||||||
border-top: var(--tui-border);
|
border-top: var(--tui-border);
|
||||||
border-bottom: var(--tui-border);
|
border-bottom: var(--tui-border);
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.file {
|
||||||
|
display: flex;
|
||||||
|
border-top: var(--tui-border);
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -72,10 +81,11 @@ onMounted(() => setInterval(fileHistoryStore.cleanExpiredFiles, Time.S));
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.home .content {
|
.home .content .file {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.panel:first-child {
|
.panel:first-child {
|
||||||
|
|||||||
Reference in New Issue
Block a user