Compare commits

...

2 Commits

Author SHA1 Message Date
f5947e037e add SharedClipboard.vue 2026-01-15 13:46:51 +08:00
6fb017fd2a update dependencies 2026-01-15 13:43:32 +08:00
7 changed files with 847 additions and 397 deletions

View File

@ -1,2 +1,2 @@
# 接口
VITE_API=https://api.imyeyu.dev
VITE_API=https://api.imyeyu.dev

16
.gitignore vendored
View File

@ -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

View File

@ -9,36 +9,36 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.24",
"less": "^4.3.0",
"vue": "^3.5.26",
"less": "^4.5.1",
"pinia": "^3.0.4",
"vue-router": "^4.5.0",
"timi-web": "link:..\\timi-web",
"timi-tdesign-pc": "link:..\\timi-tdesign-pc",
"tdesign-vue-next": "1.17.7"
"tdesign-vue-next": "1.18.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/node": "^25.0.8",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4",
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"vite": "^7.3.1",
"vue-tsc": "^3.2.2",
"@typescript-eslint/eslint-plugin": "^8.53.0",
"@typescript-eslint/parser": "^8.53.0",
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/compiler-sfc": "^3.5.13",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.4.0",
"eslint": "^8.56.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-vue": "^9.32.0",
"less": "^4.3.0",
"prettier": "^3.5.2",
"unplugin-auto-import": "^19.2.0",
"unplugin-vue-components": "^28.5.0",
"eslint-plugin-vue": "^10.6.2",
"less": "^4.5.1",
"prettier": "^3.8.0",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0",
"vite-plugin-dts": "^4.5.3",
"vite-plugin-vue-setup-extend": "^0.4.0"
}

858
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

68
src/api/ClipboardAPI.ts Normal file
View 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
};

View 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>

View File

@ -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 {