migrate from github
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.log
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
.git
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
src/gen/
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
src/gen/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# dev
|
||||||
|
FROM node:20-alpine AS dev
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "npm install && npm run dev -- --host --port 80"]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# release
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
RUN apk add --no-cache protobuf
|
||||||
|
|
||||||
|
COPY frontend ./
|
||||||
|
COPY proto /app/proto
|
||||||
|
|
||||||
|
RUN mkdir -p /app/frontend/src/gen
|
||||||
|
|
||||||
|
RUN protoc -I /app/proto \
|
||||||
|
--plugin=protoc-gen-es=/app/frontend/node_modules/.bin/protoc-gen-es \
|
||||||
|
--plugin=protoc-gen-connect-es=/app/frontend/node_modules/.bin/protoc-gen-connect-es \
|
||||||
|
--es_out=/app/frontend/src/gen --es_opt=target=js,import_extension=.js \
|
||||||
|
--connect-es_out=/app/frontend/src/gen --connect-es_opt=target=js,import_extension=.js \
|
||||||
|
/app/proto/yoresee_doc/v1/yoresee_doc.proto
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine AS runner
|
||||||
|
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||||
|
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Yoresee</title>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var savedTheme = localStorage.getItem('theme');
|
||||||
|
var savedDarkMode = localStorage.getItem('darkMode');
|
||||||
|
var shouldUseDark = false;
|
||||||
|
if (savedTheme === 'light' || savedDarkMode === 'false') {
|
||||||
|
shouldUseDark = false;
|
||||||
|
} else if (savedTheme === 'dark' || savedDarkMode === 'true') {
|
||||||
|
shouldUseDark = true;
|
||||||
|
} else {
|
||||||
|
shouldUseDark = true;
|
||||||
|
}
|
||||||
|
if (shouldUseDark) {
|
||||||
|
document.documentElement.classList.add('dark-mode');
|
||||||
|
document.documentElement.style.backgroundColor = '#121212';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore read errors
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
html.dark-mode,
|
||||||
|
html.dark-mode body,
|
||||||
|
html.dark-mode #app {
|
||||||
|
background-color: #121212;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark-mode .top-nav,
|
||||||
|
html.dark-mode .sidebar {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark-mode .top-nav,
|
||||||
|
html.dark-mode .sidebar,
|
||||||
|
html.dark-mode .sidebar-header,
|
||||||
|
html.dark-mode .sidebar-title {
|
||||||
|
border-color: #333333;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+6388
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "^1.10.0",
|
||||||
|
"@connectrpc/connect": "^1.7.0",
|
||||||
|
"@connectrpc/connect-web": "^1.7.0",
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"@tiptap/extension-code-block-lowlight": "^3.22.2",
|
||||||
|
"@tiptap/extension-collaboration": "^3.22.3",
|
||||||
|
"@tiptap/extension-color": "^3.22.2",
|
||||||
|
"@tiptap/extension-highlight": "^3.22.2",
|
||||||
|
"@tiptap/extension-link": "^3.22.2",
|
||||||
|
"@tiptap/extension-mention": "^3.22.3",
|
||||||
|
"@tiptap/extension-placeholder": "^3.22.2",
|
||||||
|
"@tiptap/extension-text-style": "^3.22.2",
|
||||||
|
"@tiptap/extension-underline": "^3.22.2",
|
||||||
|
"@tiptap/starter-kit": "^3.22.2",
|
||||||
|
"@tiptap/vue-3": "^3.22.2",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"codemirror": "^5.65.21",
|
||||||
|
"diff": "^8.0.4",
|
||||||
|
"diff2html": "^3.4.56",
|
||||||
|
"easymde": "^2.20.0",
|
||||||
|
"element-plus": "^2.13.2",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
|
"marked": "^17.0.5",
|
||||||
|
"markmap-lib": "^0.18.12",
|
||||||
|
"markmap-view": "^0.18.12",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"swiper": "^11.2.10",
|
||||||
|
"turndown": "^7.2.4",
|
||||||
|
"vditor": "^3.11.2",
|
||||||
|
"vue": "^3.5.25",
|
||||||
|
"vue-i18n": "^9.10.2",
|
||||||
|
"vue-router": "^5.0.3",
|
||||||
|
"x-data-spreadsheet": "^1.1.9",
|
||||||
|
"y-websocket": "^1.5.4",
|
||||||
|
"yjs": "^13.6.16"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@bufbuild/protoc-gen-es": "^1.10.0",
|
||||||
|
"@connectrpc/protoc-gen-connect-es": "^1.7.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
|
"esbuild": "^0.24.0",
|
||||||
|
"less": "^4.6.4",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
+220
@@ -0,0 +1,220 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<div class="main-container">
|
||||||
|
<el-config-provider :locale="elementLocale">
|
||||||
|
<router-view />
|
||||||
|
</el-config-provider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
|
||||||
|
import { useUserStore } from './store/user';
|
||||||
|
import { ElConfigProvider } from 'element-plus';
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
||||||
|
import en from 'element-plus/es/locale/lang/en';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const { locale } = useI18n();
|
||||||
|
|
||||||
|
let darkModeObserver = null;
|
||||||
|
|
||||||
|
const applyDarkModeClass = () => {
|
||||||
|
const enabled = Boolean(userStore.darkMode);
|
||||||
|
document.documentElement.classList.toggle('dark-mode', enabled);
|
||||||
|
document.body.classList.toggle('dark-mode', enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => userStore.darkMode,
|
||||||
|
() => {
|
||||||
|
applyDarkModeClass();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
applyDarkModeClass();
|
||||||
|
darkModeObserver = new MutationObserver(() => {
|
||||||
|
const enabled = Boolean(userStore.darkMode);
|
||||||
|
const rootMatches = document.documentElement.classList.contains('dark-mode') === enabled;
|
||||||
|
const bodyMatches = document.body.classList.contains('dark-mode') === enabled;
|
||||||
|
if (rootMatches && bodyMatches) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyDarkModeClass();
|
||||||
|
});
|
||||||
|
darkModeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
darkModeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (!darkModeObserver) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
darkModeObserver.disconnect();
|
||||||
|
darkModeObserver = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const elementLocale = computed(() => (locale.value === 'zh' ? zhCn : en));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Inter, PingFang SC, Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-medium);
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-medium);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用容器 */
|
||||||
|
.app-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主内容区 */
|
||||||
|
.main-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全局返回按钮样式 */
|
||||||
|
.back-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通用 Tabs 样式 */
|
||||||
|
.common-tabs .el-tabs__header {
|
||||||
|
margin: 0 0 var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.common-tabs .el-tabs__nav-wrap {
|
||||||
|
padding: 0 var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.common-tabs .el-tabs__item {
|
||||||
|
color: var(--text-medium);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.common-tabs .el-tabs__item.is-active {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.common-tabs .el-tabs__active-bar {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深色模式下的Element Plus组件样式 */
|
||||||
|
.dark-mode {
|
||||||
|
|
||||||
|
/* 输入框文字颜色(EP 变量覆盖不到原生 input) */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="email"] {
|
||||||
|
color: var(--el-input-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* message box 背景统一 */
|
||||||
|
.el-message-box,
|
||||||
|
.el-message-box__header,
|
||||||
|
.el-message-box__content,
|
||||||
|
.el-message-box__btns {
|
||||||
|
background-color: var(--el-fill-color-blank);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message-box__header { border-bottom: none; }
|
||||||
|
.el-message-box__btns { border-top: none; }
|
||||||
|
|
||||||
|
/* select dropdown 项 */
|
||||||
|
.el-select-dropdown__item.selected {
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* date picker panel */
|
||||||
|
.el-picker-panel__body,
|
||||||
|
.el-picker-panel__content {
|
||||||
|
background-color: var(--el-fill-color-blank);
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-date-table td.in-range div,
|
||||||
|
.el-date-table td.available:hover div {
|
||||||
|
background-color: var(--bg-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 数字输入按钮 */
|
||||||
|
.el-input-number__decrease,
|
||||||
|
.el-input-number__increase {
|
||||||
|
background-color: var(--bg-medium);
|
||||||
|
color: var(--text-dark);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input-number__decrease:hover,
|
||||||
|
.el-input-number__increase:hover {
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 对话框边框 */
|
||||||
|
.el-dialog__header {
|
||||||
|
border-bottom: 1px solid var(--el-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__footer {
|
||||||
|
border-top: 1px solid var(--el-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载动画 */
|
||||||
|
.el-loading-spinner .path {
|
||||||
|
stroke: var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<img
|
||||||
|
v-if="src"
|
||||||
|
:src="resolvedSrc"
|
||||||
|
class="app-avatar"
|
||||||
|
:style="sizeStyle"
|
||||||
|
:alt="name"
|
||||||
|
@error="onImgError"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="app-avatar app-avatar--fallback"
|
||||||
|
:style="sizeStyle"
|
||||||
|
:aria-label="name"
|
||||||
|
>
|
||||||
|
{{ initial }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: Number,
|
||||||
|
default: 28
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the image errors out, fall back to the badge
|
||||||
|
const imgFailed = ref(false);
|
||||||
|
watch(() => props.src, () => { imgFailed.value = false; });
|
||||||
|
|
||||||
|
const onImgError = () => { imgFailed.value = true; };
|
||||||
|
|
||||||
|
const resolvedSrc = computed(() => (!imgFailed.value && props.src) ? props.src : '');
|
||||||
|
|
||||||
|
const initial = computed(() => {
|
||||||
|
const n = (props.name || '').trim();
|
||||||
|
return n ? n.charAt(0).toUpperCase() : '?';
|
||||||
|
});
|
||||||
|
|
||||||
|
const sizeStyle = computed(() => ({
|
||||||
|
width: `${props.size}px`,
|
||||||
|
height: `${props.size}px`,
|
||||||
|
fontSize: `${Math.round(props.size * 0.43)}px`
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-avatar--fallback {
|
||||||
|
background: var(--primary-color, #165dff);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<el-dropdown
|
||||||
|
:trigger="trigger"
|
||||||
|
:placement="resolvedPlacement"
|
||||||
|
:disabled="disabled"
|
||||||
|
:hide-on-click="hideOnClick"
|
||||||
|
:teleported="teleported"
|
||||||
|
:popper-class="resolvedPopperClass"
|
||||||
|
@command="(command) => emit('command', command)"
|
||||||
|
@visible-change="handleVisibleChange"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<template #dropdown>
|
||||||
|
<slot name="dropdown" />
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
trigger: {
|
||||||
|
type: String,
|
||||||
|
default: 'click'
|
||||||
|
},
|
||||||
|
placement: {
|
||||||
|
type: String,
|
||||||
|
default: undefined
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
hideOnClick: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
teleported: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
popperClass: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['command', 'visible-change']);
|
||||||
|
|
||||||
|
const validPlacements = new Set([
|
||||||
|
'top',
|
||||||
|
'top-start',
|
||||||
|
'top-end',
|
||||||
|
'bottom',
|
||||||
|
'bottom-start',
|
||||||
|
'bottom-end',
|
||||||
|
'left',
|
||||||
|
'left-start',
|
||||||
|
'left-end',
|
||||||
|
'right',
|
||||||
|
'right-start',
|
||||||
|
'right-end'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resolvedPlacement = computed(() => {
|
||||||
|
const placement = (props.placement || '').trim();
|
||||||
|
if (!placement) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return validPlacements.has(placement) ? placement : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedPopperClass = computed(() =>
|
||||||
|
['app-dropdown-popper', props.popperClass].filter(Boolean).join(' ')
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleVisibleChange = (visible) => {
|
||||||
|
emit('visible-change', visible);
|
||||||
|
if (visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (!active || !(active instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!active.classList.contains('el-dropdown-menu__item')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
active.blur();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:global(.dark-mode .app-dropdown-popper.el-popper) {
|
||||||
|
background-color: var(--el-bg-color-overlay);
|
||||||
|
border-color: var(--el-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode .app-dropdown-popper .el-popper__arrow::before) {
|
||||||
|
background-color: var(--el-bg-color-overlay);
|
||||||
|
border-color: var(--el-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode .app-dropdown-popper .el-dropdown-menu) {
|
||||||
|
background-color: var(--el-bg-color-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode .app-dropdown-popper .el-dropdown-menu__item) {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode .app-dropdown-popper .el-dropdown-menu__item:not(.is-disabled):hover),
|
||||||
|
:global(.dark-mode .app-dropdown-popper .el-dropdown-menu__item:not(.is-disabled):focus),
|
||||||
|
:global(.dark-mode .app-dropdown-popper .el-dropdown-menu__item.is-hovering),
|
||||||
|
:global(.dark-mode .app-dropdown-popper .el-dropdown-menu__item.hover) {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode .app-dropdown-popper .el-dropdown-menu__item.hover:not(:hover):not(:focus):not(.is-hovering)) {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
ref="menuRef"
|
||||||
|
class="app-menu"
|
||||||
|
:class="menuClass"
|
||||||
|
:style="menuStyle"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
minWidth: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 150
|
||||||
|
},
|
||||||
|
zIndex: {
|
||||||
|
type: Number,
|
||||||
|
default: 3000
|
||||||
|
},
|
||||||
|
menuClass: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
closeOnOutside: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
closeOnScroll: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
closeOnResize: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
closeOnEscape: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
closeOnContextOutside: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
ignoreElements: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const menuRef = ref(null);
|
||||||
|
const getMenuElement = () => menuRef.value;
|
||||||
|
|
||||||
|
const resolveElement = (entry) => {
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (entry instanceof HTMLElement) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
if (entry?.value instanceof HTMLElement) {
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
if (entry?.$el instanceof HTMLElement) {
|
||||||
|
return entry.$el;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isInsideIgnoredTargets = (target) => {
|
||||||
|
if (!target || !props.ignoreElements.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const entry of props.ignoreElements) {
|
||||||
|
const el = resolveElement(entry);
|
||||||
|
if (el?.contains(target)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestClose = () => {
|
||||||
|
if (!props.visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (event) => {
|
||||||
|
if (!props.visible || !props.closeOnOutside) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = event.target;
|
||||||
|
if (menuRef.value?.contains(target) || isInsideIgnoredTargets(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = (event) => {
|
||||||
|
if (!props.visible || !props.closeOnContextOutside) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = event.target;
|
||||||
|
if (menuRef.value?.contains(target) || isInsideIgnoredTargets(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowScroll = () => {
|
||||||
|
if (props.closeOnScroll) {
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowResize = () => {
|
||||||
|
if (props.closeOnResize) {
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowKeydown = (event) => {
|
||||||
|
if (props.closeOnEscape && event.key === 'Escape') {
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuStyle = computed(() => {
|
||||||
|
const minWidth = typeof props.minWidth === 'number' ? `${props.minWidth}px` : props.minWidth;
|
||||||
|
return {
|
||||||
|
left: `${props.x}px`,
|
||||||
|
top: `${props.y}px`,
|
||||||
|
minWidth,
|
||||||
|
zIndex: props.zIndex
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('mousedown', handlePointerDown, true);
|
||||||
|
window.addEventListener('contextmenu', handleContextMenu, true);
|
||||||
|
window.addEventListener('scroll', handleWindowScroll, true);
|
||||||
|
window.addEventListener('resize', handleWindowResize);
|
||||||
|
window.addEventListener('keydown', handleWindowKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('mousedown', handlePointerDown, true);
|
||||||
|
window.removeEventListener('contextmenu', handleContextMenu, true);
|
||||||
|
window.removeEventListener('scroll', handleWindowScroll, true);
|
||||||
|
window.removeEventListener('resize', handleWindowResize);
|
||||||
|
window.removeEventListener('keydown', handleWindowKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getMenuElement
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-menu {
|
||||||
|
position: fixed;
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: var(--spacing-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .app-menu {
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="app-menu-item"
|
||||||
|
:class="{
|
||||||
|
'is-danger': danger,
|
||||||
|
'is-disabled': disabled
|
||||||
|
}"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<slot name="icon" />
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
danger: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['click']);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('click');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-md);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
color: var(--text-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu-item:hover {
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .app-menu-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu-item.is-danger:hover {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu-item.is-disabled {
|
||||||
|
color: var(--text-light);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu-item.is-disabled:hover {
|
||||||
|
color: var(--text-light);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu-item :deep(.el-icon) {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu-item:hover :deep(.el-icon) {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu-item.is-danger:hover :deep(.el-icon) {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<span :class="['app-tag', `app-tag--${resolvedType}`, `app-tag--${size}`]">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'info'
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'small'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedType = computed(() => {
|
||||||
|
const allowed = ['success', 'warning', 'danger', 'info', 'primary'];
|
||||||
|
return allowed.includes(props.type) ? props.type : 'info';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tag--small {
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tag--success {
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
color: #10b981;
|
||||||
|
border-color: rgba(16, 185, 129, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tag--warning {
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
color: #d97706;
|
||||||
|
border-color: rgba(245, 158, 11, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tag--danger {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: #ef4444;
|
||||||
|
border-color: rgba(239, 68, 68, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tag--info {
|
||||||
|
background: rgba(100, 116, 139, 0.12);
|
||||||
|
color: #475569;
|
||||||
|
border-color: rgba(100, 116, 139, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tag--primary {
|
||||||
|
background: rgba(59, 130, 246, 0.12);
|
||||||
|
color: #2563eb;
|
||||||
|
border-color: rgba(59, 130, 246, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .app-tag--success {
|
||||||
|
background: rgba(16, 185, 129, 0.2);
|
||||||
|
color: #34d399;
|
||||||
|
border-color: rgba(16, 185, 129, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .app-tag--warning {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
color: #fbbf24;
|
||||||
|
border-color: rgba(245, 158, 11, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .app-tag--danger {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #f87171;
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .app-tag--info {
|
||||||
|
background: rgba(148, 163, 184, 0.16);
|
||||||
|
color: #cbd5e1;
|
||||||
|
border-color: rgba(148, 163, 184, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .app-tag--primary {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: #93c5fd;
|
||||||
|
border-color: rgba(59, 130, 246, 0.35);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
<template>
|
||||||
|
<div class="comment-item" :class="rootClass">
|
||||||
|
<AppAvatar v-if="showAvatar" :src="avatar" :name="author" :size="28" />
|
||||||
|
<div class="comment-item-main">
|
||||||
|
<div class="comment-meta">
|
||||||
|
<span class="comment-author">{{ author }}</span>
|
||||||
|
<div class="comment-meta-actions">
|
||||||
|
<span class="comment-time">{{ time }}</span>
|
||||||
|
<AppDropdown v-if="hasActions" trigger="click" @command="emitAction">
|
||||||
|
<el-button text class="comment-more">
|
||||||
|
<el-icon><MoreFilled /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item
|
||||||
|
v-for="action in actions"
|
||||||
|
:key="action.key"
|
||||||
|
:command="action.key"
|
||||||
|
:divided="action.divided"
|
||||||
|
:disabled="action.disabled"
|
||||||
|
:class="{ 'comment-action-danger': action.danger }"
|
||||||
|
>
|
||||||
|
{{ action.label }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</AppDropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="replyText" class="comment-reply">{{ replyText }}</div>
|
||||||
|
<slot v-if="editing" name="editor" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="comment-text"
|
||||||
|
:class="{ 'comment-text--clickable': contentClickable }"
|
||||||
|
v-html="renderMentions(content)"
|
||||||
|
@click="handleContentClick"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="!editing && replyable"
|
||||||
|
type="button"
|
||||||
|
class="comment-reply-action"
|
||||||
|
@click="$emit('reply')"
|
||||||
|
>
|
||||||
|
{{ resolvedReplyLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { MoreFilled } from '@element-plus/icons-vue';
|
||||||
|
import AppDropdown from '@/components/base/AppDropdown.vue';
|
||||||
|
import AppAvatar from '@/components/base/AppAvatar.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
avatar: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
replyText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
editing: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'normal'
|
||||||
|
},
|
||||||
|
showAvatar: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
contentClickable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
replyable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
replyLabel: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['content-click', 'action', 'reply']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const rootClass = computed(() => '');
|
||||||
|
const hasActions = computed(() => Array.isArray(props.actions) && props.actions.length > 0);
|
||||||
|
const resolvedReplyLabel = computed(() => props.replyLabel || t('common.reply'));
|
||||||
|
|
||||||
|
const emitAction = (key) => {
|
||||||
|
emit('action', key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContentClick = () => {
|
||||||
|
if (!props.contentClickable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('content-click');
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMentions(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
return escapeHtml(text).replace(/@([\w\u4e00-\u9fa5]+)/g, '<span class="mention-chip">@$1</span>');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.comment-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item :deep(.app-avatar) {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item--reply {
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
background: var(--bg-light);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item--reply::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: 2px;
|
||||||
|
background-color: var(--border-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item--reply .comment-item-main {
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-more {
|
||||||
|
padding: 2px 4px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-more:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-action-danger {
|
||||||
|
color: #e53935;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-medium);
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply-action {
|
||||||
|
align-self: flex-start;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-light);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: color 0.2s ease, background-color 0.2s ease;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item:hover .comment-reply-action {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply-action:hover {
|
||||||
|
color: var(--text-medium);
|
||||||
|
background-color: rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-light);
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .comment-author {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .comment-text {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .comment-meta {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<div class="comment-list-panel">
|
||||||
|
<div v-if="showHeader" class="comment-list-header">
|
||||||
|
<div class="comment-list-title">{{ title }}</div>
|
||||||
|
<div class="comment-list-actions">
|
||||||
|
<slot name="header-actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comment-list-body">
|
||||||
|
<div v-if="!items || items.length === 0" class="comment-list-empty">
|
||||||
|
{{ emptyText }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="comment-list-items">
|
||||||
|
<template v-for="item in items" :key="resolveKey(item)">
|
||||||
|
<slot name="item" :item="item" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, useSlots } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
keyField: {
|
||||||
|
type: String,
|
||||||
|
default: 'external_id'
|
||||||
|
},
|
||||||
|
showTitle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const slots = useSlots();
|
||||||
|
const showHeader = computed(() => props.showTitle || !!slots['header-actions']);
|
||||||
|
|
||||||
|
const resolveKey = (item) => {
|
||||||
|
if (!item) return Math.random().toString(36);
|
||||||
|
if (props.keyField && item[props.keyField] !== undefined) {
|
||||||
|
return item[props.keyField];
|
||||||
|
}
|
||||||
|
return item.external_id || item.id || item.anchor_id || Math.random().toString(36);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.comment-list-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-list-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-list-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-list-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-list-empty {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-list-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<template>
|
||||||
|
<PanelSidebarShell
|
||||||
|
:collapsed="collapsed"
|
||||||
|
:resizing="resizing"
|
||||||
|
resize-edge="left"
|
||||||
|
:container-style="containerStyle"
|
||||||
|
:panel-style="sidebarStyle"
|
||||||
|
@resize-start="startResize"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="comment-header">
|
||||||
|
<div class="comment-title">{{ displayTitle }}</div>
|
||||||
|
<el-button text class="collapse-button" @click="$emit('toggle')" :title="collapseTitle">
|
||||||
|
<el-icon>
|
||||||
|
<ArrowRight />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="comment-body">
|
||||||
|
<CommentList
|
||||||
|
:show-title="false"
|
||||||
|
:items="displayComments"
|
||||||
|
:empty-text="inlineEmptyText"
|
||||||
|
key-field="local_id"
|
||||||
|
>
|
||||||
|
<template #item="{ item }">
|
||||||
|
<CommentItem
|
||||||
|
:class="{ 'comment-item--reply': item.level > 0 }"
|
||||||
|
:style="getIndentStyle(item)"
|
||||||
|
:avatar="item.creator_avatar"
|
||||||
|
:author="item.creator_name"
|
||||||
|
:time="formatCommentTime(item.created_at)"
|
||||||
|
:content="item.content"
|
||||||
|
:reply-text="item.parent_external_id ? replyLabel(item) : ''"
|
||||||
|
:actions="getActions(item)"
|
||||||
|
:editing="item.editing"
|
||||||
|
:content-clickable="true"
|
||||||
|
:replyable="true"
|
||||||
|
:reply-label="t('common.reply')"
|
||||||
|
@action="(action) => handleAction(item, action)"
|
||||||
|
@reply="() => handleReply(item)"
|
||||||
|
@content-click="() => handleContentClick(item)"
|
||||||
|
@mouseenter="() => handleHover(item, true)"
|
||||||
|
@mouseleave="() => handleHover(item, false)"
|
||||||
|
>
|
||||||
|
<template #editor>
|
||||||
|
<div class="inline-comment-editor">
|
||||||
|
<MentionTextarea
|
||||||
|
v-model="item.draft"
|
||||||
|
:placeholder="t('document.inlineCommentInputPlaceholder')"
|
||||||
|
@mention-users-change="(ids) => { item.mentionedUserExternalIds = ids }"
|
||||||
|
/>
|
||||||
|
<div class="inline-comment-editor-actions">
|
||||||
|
<el-button size="small" type="primary" :loading="item.saving" @click="saveEdit(item)">
|
||||||
|
{{ t('common.save') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" text @click="cancelEdit(item)">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</CommentItem>
|
||||||
|
</template>
|
||||||
|
</CommentList>
|
||||||
|
</div>
|
||||||
|
</PanelSidebarShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { ArrowRight } from '@element-plus/icons-vue';
|
||||||
|
import CommentList from '@/components/comment/CommentList.vue';
|
||||||
|
import CommentItem from '@/components/comment/CommentItem.vue';
|
||||||
|
import MentionTextarea from '@/components/comment/MentionTextarea.vue';
|
||||||
|
import PanelSidebarShell from '@/components/layout/PanelSidebarShell.vue';
|
||||||
|
import { usePanelSidebar } from '@/composables/layout/usePanelSidebar';
|
||||||
|
import { useInlineComments } from '@/composables/document/comments/useInlineComments';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
collapseTitle: { type: String, default: '' },
|
||||||
|
collapsed: { type: Boolean, default: false },
|
||||||
|
docId: { type: String, default: '' },
|
||||||
|
inlineEnabled: { type: Boolean, default: false },
|
||||||
|
userInfo: { type: Object, default: null },
|
||||||
|
onAnchorClick: { type: Function, default: null },
|
||||||
|
onAnchorHover: { type: Function, default: null },
|
||||||
|
onAnchorRemove: { type: Function, default: null },
|
||||||
|
onCommentMutated: { type: Function, default: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['toggle', 'width-change']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const {
|
||||||
|
width: commentWidth,
|
||||||
|
resizing,
|
||||||
|
startResize
|
||||||
|
} = usePanelSidebar({
|
||||||
|
defaultWidth: 320,
|
||||||
|
minWidth: 280,
|
||||||
|
maxWidth: 560,
|
||||||
|
resizeEdge: 'left',
|
||||||
|
widthStorageKey: 'commentSidebarWidth',
|
||||||
|
externalCollapsed: computed(() => props.collapsed),
|
||||||
|
getMaxWidth: () => Math.min(560, Math.max(280, window.innerWidth - 360))
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerStyle = computed(() => {
|
||||||
|
if (props.collapsed) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return { width: `${commentWidth.value}px` };
|
||||||
|
});
|
||||||
|
const sidebarStyle = computed(() => ({ width: `${commentWidth.value}px` }));
|
||||||
|
const {
|
||||||
|
inlineEmptyText,
|
||||||
|
titleWithCount,
|
||||||
|
displayComments,
|
||||||
|
getIndentStyle,
|
||||||
|
getActions,
|
||||||
|
replyLabel,
|
||||||
|
handleAction,
|
||||||
|
handleReply,
|
||||||
|
handleContentClick,
|
||||||
|
handleHover,
|
||||||
|
saveEdit,
|
||||||
|
cancelEdit,
|
||||||
|
formatCommentTime,
|
||||||
|
handleInlineAnchorAdd,
|
||||||
|
handleInlineAnchorRemove,
|
||||||
|
reload
|
||||||
|
} = useInlineComments({
|
||||||
|
t,
|
||||||
|
getDocId: () => props.docId,
|
||||||
|
getInlineEnabled: () => props.inlineEnabled,
|
||||||
|
getUserInfo: () => props.userInfo,
|
||||||
|
onAnchorClick: (id) => props.onAnchorClick?.(id),
|
||||||
|
onAnchorHover: (id, hovering) => props.onAnchorHover?.(id, hovering),
|
||||||
|
onAnchorRemove: (ids) => props.onAnchorRemove?.(ids),
|
||||||
|
onCommentMutated: (payload) => props.onCommentMutated?.(payload)
|
||||||
|
});
|
||||||
|
const displayTitle = computed(() => {
|
||||||
|
return titleWithCount.value ? `${props.title} (${titleWithCount.value})` : props.title;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => commentWidth.value,
|
||||||
|
(value) => {
|
||||||
|
emit('width-change', value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getCurrentWidth: () => commentWidth.value,
|
||||||
|
handleInlineAnchorAdd,
|
||||||
|
handleInlineAnchorRemove,
|
||||||
|
reload
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-button {
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-button:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-comment-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-comment-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .comment-title {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapperRef" class="mention-textarea-wrapper">
|
||||||
|
<el-input
|
||||||
|
ref="inputRef"
|
||||||
|
:model-value="modelValue"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="autosize"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@update:model-value="onModelValueUpdate"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
<!-- Mention dropdown anchored to wrapper -->
|
||||||
|
<div
|
||||||
|
v-if="dropdownVisible && users.length > 0"
|
||||||
|
class="mention-dropdown"
|
||||||
|
:style="dropdownStyle"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(user, index) in users"
|
||||||
|
:key="user.external_id"
|
||||||
|
class="mention-dropdown-item"
|
||||||
|
:class="{ 'is-active': index === activeIndex }"
|
||||||
|
@mousedown.prevent="selectUser(user)"
|
||||||
|
>
|
||||||
|
<AppAvatar
|
||||||
|
:src="user.avatar"
|
||||||
|
:name="user.nickname || user.username"
|
||||||
|
:size="24"
|
||||||
|
/>
|
||||||
|
<span class="mention-item-name">{{ user.nickname || user.username }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, nextTick } from 'vue';
|
||||||
|
import { listUsers } from '@/services/api/membership.js';
|
||||||
|
import AppAvatar from '@/components/base/AppAvatar.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: '' },
|
||||||
|
placeholder: { type: String, default: '' },
|
||||||
|
autosize: { type: [Boolean, Object], default: () => ({ minRows: 2, maxRows: 4 }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'mention-users-change']);
|
||||||
|
|
||||||
|
const inputRef = ref(null);
|
||||||
|
const wrapperRef = ref(null);
|
||||||
|
|
||||||
|
const dropdownVisible = ref(false);
|
||||||
|
const dropdownStyle = ref({});
|
||||||
|
const users = ref([]);
|
||||||
|
const activeIndex = ref(0);
|
||||||
|
const keyword = ref('');
|
||||||
|
const mentionedUsers = ref([]);
|
||||||
|
|
||||||
|
let mentionStart = -1;
|
||||||
|
let fetchTimer = null;
|
||||||
|
|
||||||
|
function getTextareaEl() {
|
||||||
|
return inputRef.value?.textarea ?? inputRef.value?.$el?.querySelector('textarea') ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onModelValueUpdate(val) {
|
||||||
|
emit('update:modelValue', val);
|
||||||
|
detectMention(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectMention(value) {
|
||||||
|
const el = getTextareaEl();
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const cursor = el.selectionStart ?? value.length;
|
||||||
|
|
||||||
|
// Scan backwards from cursor for @ on same line
|
||||||
|
let atPos = -1;
|
||||||
|
for (let i = cursor - 1; i >= 0; i--) {
|
||||||
|
const ch = value[i];
|
||||||
|
if (ch === '@') { atPos = i; break; }
|
||||||
|
if (ch === '\n' || ch === ' ') break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (atPos === -1) {
|
||||||
|
closeDrop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mentionStart = atPos;
|
||||||
|
keyword.value = value.slice(atPos + 1, cursor);
|
||||||
|
activeIndex.value = 0;
|
||||||
|
|
||||||
|
// Position dropdown below the @ character in the textarea
|
||||||
|
positionDropdown(el, atPos);
|
||||||
|
dropdownVisible.value = true;
|
||||||
|
|
||||||
|
// Debounce the API fetch
|
||||||
|
clearTimeout(fetchTimer);
|
||||||
|
fetchTimer = setTimeout(() => fetchUsers(keyword.value), 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUsers(kw) {
|
||||||
|
try {
|
||||||
|
const result = await listUsers({ keyword: kw, page: 1, page_size: 8 });
|
||||||
|
users.value = result.users || [];
|
||||||
|
if (users.value.length === 0) closeDrop();
|
||||||
|
} catch {
|
||||||
|
users.value = [];
|
||||||
|
closeDrop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionDropdown(el, atCharPos) {
|
||||||
|
// Use a mirror div to find approximate caret Y position
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
const mirror = document.createElement('div');
|
||||||
|
const props_to_copy = [
|
||||||
|
'fontSize', 'fontFamily', 'fontWeight', 'letterSpacing',
|
||||||
|
'lineHeight', 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight',
|
||||||
|
'borderTopWidth', 'borderLeftWidth', 'boxSizing', 'wordWrap',
|
||||||
|
];
|
||||||
|
for (const p of props_to_copy) mirror.style[p] = style[p];
|
||||||
|
mirror.style.position = 'absolute';
|
||||||
|
mirror.style.visibility = 'hidden';
|
||||||
|
mirror.style.top = '-9999px';
|
||||||
|
mirror.style.left = '-9999px';
|
||||||
|
mirror.style.width = el.offsetWidth + 'px';
|
||||||
|
mirror.style.whiteSpace = 'pre-wrap';
|
||||||
|
mirror.style.overflowWrap = 'break-word';
|
||||||
|
|
||||||
|
const before = el.value.slice(0, atCharPos);
|
||||||
|
mirror.textContent = before;
|
||||||
|
const caret = document.createElement('span');
|
||||||
|
caret.textContent = '@';
|
||||||
|
mirror.appendChild(caret);
|
||||||
|
document.body.appendChild(mirror);
|
||||||
|
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const caretTop = caret.offsetTop - el.scrollTop;
|
||||||
|
const caretLeft = caret.offsetLeft;
|
||||||
|
const lineH = parseInt(style.lineHeight) || parseInt(style.fontSize) * 1.4 || 20;
|
||||||
|
document.body.removeChild(mirror);
|
||||||
|
|
||||||
|
// Position relative to wrapper
|
||||||
|
const wRect = wrapperRef.value?.getBoundingClientRect() ?? elRect;
|
||||||
|
const relTop = elRect.top - wRect.top + caretTop + lineH + 2;
|
||||||
|
const relLeft = elRect.left - wRect.left + caretLeft;
|
||||||
|
|
||||||
|
dropdownStyle.value = {
|
||||||
|
top: relTop + 'px',
|
||||||
|
left: Math.max(0, relLeft) + 'px',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event) {
|
||||||
|
if (!dropdownVisible.value || users.value.length === 0) return;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex.value = (activeIndex.value + 1) % users.value.length;
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex.value = (activeIndex.value - 1 + users.value.length) % users.value.length;
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
const user = users.value[activeIndex.value];
|
||||||
|
if (user) {
|
||||||
|
event.preventDefault();
|
||||||
|
selectUser(user);
|
||||||
|
}
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
closeDrop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectUser(user) {
|
||||||
|
const el = getTextareaEl();
|
||||||
|
if (!el || mentionStart === -1) { closeDrop(); return; }
|
||||||
|
|
||||||
|
const cursor = el.selectionStart ?? el.value.length;
|
||||||
|
const displayName = user.nickname || user.username;
|
||||||
|
const before = el.value.slice(0, mentionStart);
|
||||||
|
const after = el.value.slice(cursor);
|
||||||
|
const inserted = `@${displayName} `;
|
||||||
|
const newValue = before + inserted + after;
|
||||||
|
|
||||||
|
emit('update:modelValue', newValue);
|
||||||
|
nextTick(() => {
|
||||||
|
const newCursor = mentionStart + inserted.length;
|
||||||
|
el.value = newValue;
|
||||||
|
el.setSelectionRange(newCursor, newCursor);
|
||||||
|
el.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mentionedUsers.value.find(u => u.external_id === user.external_id)) {
|
||||||
|
mentionedUsers.value.push({ external_id: user.external_id, nickname: displayName });
|
||||||
|
emit('mention-users-change', mentionedUsers.value.map(u => u.external_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
mentionStart = -1;
|
||||||
|
closeDrop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDrop() {
|
||||||
|
dropdownVisible.value = false;
|
||||||
|
users.value = [];
|
||||||
|
keyword.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMentionedUsers() {
|
||||||
|
return mentionedUsers.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
mentionedUsers.value = [];
|
||||||
|
closeDrop();
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ getMentionedUsers, reset });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mention-textarea-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 9999;
|
||||||
|
background: var(--bg-white, #fff);
|
||||||
|
border: 1px solid var(--border-color, #c9cdd4);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 260px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary, #1d2129);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-dropdown-item:hover,
|
||||||
|
.mention-dropdown-item.is-active {
|
||||||
|
background: var(--primary-light, #e8f0ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-item-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<PanelSidebarShell
|
||||||
|
:collapsed="collapsed"
|
||||||
|
:resizing="resizing"
|
||||||
|
resize-edge="right"
|
||||||
|
:panel-style="panelStyle"
|
||||||
|
@resize-start="$emit('resize-start', $event)"
|
||||||
|
>
|
||||||
|
<div class="directory-sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<el-button text class="back-button" @click="$emit('back')">
|
||||||
|
<el-icon>
|
||||||
|
<ArrowLeft />
|
||||||
|
</el-icon>
|
||||||
|
{{ backLabel }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-title">
|
||||||
|
{{ title }}
|
||||||
|
<el-button text class="collapse-button" @click="$emit('toggle')" :title="collapseTitle">
|
||||||
|
<el-icon>
|
||||||
|
<ArrowLeft />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<DocumentTree
|
||||||
|
ref="treeComponentRef"
|
||||||
|
class="directory-tree-panel"
|
||||||
|
:nodes="nodes"
|
||||||
|
:loading="loading"
|
||||||
|
:current-id="currentId"
|
||||||
|
:expand-all="expandAll"
|
||||||
|
:disable-delete="disableDelete"
|
||||||
|
@toggle-expand="$emit('toggle-expand')"
|
||||||
|
@node-click="(data) => $emit('node-click', data)"
|
||||||
|
@create="(target) => $emit('create', target)"
|
||||||
|
@delete="(target) => $emit('delete', target)"
|
||||||
|
@rename="(target) => $emit('rename', target)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PanelSidebarShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { ArrowLeft } from '@element-plus/icons-vue';
|
||||||
|
import DocumentTree from '@/components/document/DocumentTree.vue';
|
||||||
|
import PanelSidebarShell from '@/components/layout/PanelSidebarShell.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
collapsed: { type: Boolean, default: false },
|
||||||
|
resizing: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
collapseTitle: { type: String, default: '' },
|
||||||
|
backLabel: { type: String, default: '' },
|
||||||
|
nodes: { type: Array, default: () => [] },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
currentId: { type: [String, Number], default: '' },
|
||||||
|
expandAll: { type: Boolean, default: false },
|
||||||
|
disableDelete: { type: Boolean, default: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['back', 'toggle', 'toggle-expand', 'node-click', 'create', 'delete', 'rename', 'resize-start']);
|
||||||
|
|
||||||
|
const treeComponentRef = ref(null);
|
||||||
|
const panelStyle = {
|
||||||
|
width: 'var(--sidebar-width)',
|
||||||
|
maxWidth: '520px'
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getTreeRef: () => treeComponentRef.value.treeRef,
|
||||||
|
closeContextMenu: () => treeComponentRef.value?.closeContextMenu?.()
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.directory-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-tree-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .sidebar-header {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .sidebar-title {
|
||||||
|
color: var(--text-dark);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-button {
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-button:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="t('knowledgeBase.createDocument')"
|
||||||
|
:width="dialogWidth"
|
||||||
|
class="document-create-dialog"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@keydown.esc.prevent="handleCancel"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
:model="formState"
|
||||||
|
label-position="top"
|
||||||
|
class="create-form"
|
||||||
|
@submit.prevent
|
||||||
|
@keydown.enter.prevent="handleCreate"
|
||||||
|
>
|
||||||
|
<el-form-item v-if="showTitleInput" :label="t('knowledgeBase.documentTitle')" required>
|
||||||
|
<el-input
|
||||||
|
v-model="formState.title"
|
||||||
|
:placeholder="t('knowledgeBase.enterDocumentTitle')"
|
||||||
|
maxlength="100"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="showPublicSwitch" :label="t('document.settings.publicLabel')">
|
||||||
|
<el-switch v-model="formState.isPublic" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="showLocationSelector" :label="t('document.createLocationLabel')" required>
|
||||||
|
<el-radio-group v-model="formState.containerType" class="location-segment">
|
||||||
|
<el-radio-button
|
||||||
|
v-for="item in locationOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-select
|
||||||
|
v-if="formState.containerType === 'knowledge_base'"
|
||||||
|
v-model="formState.targetKnowledgeBaseId"
|
||||||
|
class="location-kb-select"
|
||||||
|
:placeholder="t('document.createLocationKnowledgeBase')"
|
||||||
|
:loading="loadingKnowledgeBases"
|
||||||
|
filterable
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in knowledgeBaseOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="showTemplatePicker" :label="t('knowledgeBase.template')" class="template-picker-field">
|
||||||
|
<DocumentTemplatePicker
|
||||||
|
:visible="visible"
|
||||||
|
:preferred-scope="formState.templateMeta?.scope || ''"
|
||||||
|
:selected-template-id="formState.template"
|
||||||
|
:document-type="formState.documentType"
|
||||||
|
:knowledge-base-id="knowledgeBaseId"
|
||||||
|
@select="selectTemplate"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="handleCancel">{{ t('button.cancel') }}</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" @click="handleCreate">
|
||||||
|
{{ t('button.create') }}
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import DocumentTemplatePicker from '@/components/document/DocumentTemplatePicker.vue';
|
||||||
|
import { getKnowledgeBases } from '@/services/api';
|
||||||
|
import { DEFAULT_DOCUMENT_TYPE, normalizeDocumentType } from '@/utils/documentType';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showTemplatePicker: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showTitleInput: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showPublicSwitch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showLocationSelector: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
initialTitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
initialIsPublic: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
initialContainerType: {
|
||||||
|
type: String,
|
||||||
|
default: 'own'
|
||||||
|
},
|
||||||
|
initialTargetKnowledgeBaseId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
parentExternalId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
knowledgeBaseId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
initialTemplateId: {
|
||||||
|
type: [String, Number, BigInt],
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
initialDocumentType: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: DEFAULT_DOCUMENT_TYPE
|
||||||
|
},
|
||||||
|
initialTemplateMeta: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'submit', 'cancel']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const dialogWidth = computed(() => {
|
||||||
|
if (props.showTemplatePicker) {
|
||||||
|
return '980px';
|
||||||
|
}
|
||||||
|
return '620px';
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadingKnowledgeBases = ref(false);
|
||||||
|
const knowledgeBaseOptions = ref([]);
|
||||||
|
|
||||||
|
const locationOptions = computed(() => [
|
||||||
|
{ label: t('document.createLocationOwn'), value: 'own' },
|
||||||
|
{ label: t('document.createLocationKnowledgeBase'), value: 'knowledge_base' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const formState = reactive({
|
||||||
|
title: '',
|
||||||
|
isPublic: false,
|
||||||
|
containerType: 'own',
|
||||||
|
targetKnowledgeBaseId: '',
|
||||||
|
template: '',
|
||||||
|
parentExternalId: '',
|
||||||
|
templateMeta: null,
|
||||||
|
documentType: DEFAULT_DOCUMENT_TYPE
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadKnowledgeBaseOptions = async () => {
|
||||||
|
if (!props.showLocationSelector) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadingKnowledgeBases.value = true;
|
||||||
|
try {
|
||||||
|
const resp = await getKnowledgeBases({
|
||||||
|
only_mine: true,
|
||||||
|
page: 1,
|
||||||
|
page_size: 200,
|
||||||
|
order_by: 'updated_at',
|
||||||
|
order_desc: true
|
||||||
|
});
|
||||||
|
knowledgeBaseOptions.value = (resp.knowledge_bases || []).map((item) => ({
|
||||||
|
value: item.external_id,
|
||||||
|
label: item.name || item.external_id
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DocumentCreateDialog] load knowledge base options failed', error);
|
||||||
|
knowledgeBaseOptions.value = [];
|
||||||
|
} finally {
|
||||||
|
loadingKnowledgeBases.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
formState.title = props.initialTitle || t('document.untitledDefaultTitle');
|
||||||
|
formState.isPublic = Boolean(props.initialIsPublic);
|
||||||
|
formState.containerType = props.initialContainerType === 'knowledge_base' ? 'knowledge_base' : 'own';
|
||||||
|
formState.targetKnowledgeBaseId =
|
||||||
|
props.initialTargetKnowledgeBaseId || props.knowledgeBaseId || '';
|
||||||
|
const hasInitialTemplateId =
|
||||||
|
props.initialTemplateId !== '' &&
|
||||||
|
props.initialTemplateId !== null &&
|
||||||
|
props.initialTemplateId !== undefined;
|
||||||
|
formState.template = hasInitialTemplateId ? String(props.initialTemplateId) : '';
|
||||||
|
formState.templateMeta = props.initialTemplateMeta || null;
|
||||||
|
const inferredType = props.initialTemplateMeta?.type;
|
||||||
|
formState.documentType = normalizeDocumentType(
|
||||||
|
inferredType || props.initialDocumentType || DEFAULT_DOCUMENT_TYPE
|
||||||
|
);
|
||||||
|
formState.parentExternalId = props.parentExternalId || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('cancel');
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (props.showTitleInput && !formState.title.trim()) {
|
||||||
|
ElMessage.error(t('knowledgeBase.titleRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
props.showLocationSelector &&
|
||||||
|
formState.containerType === 'knowledge_base' &&
|
||||||
|
!formState.targetKnowledgeBaseId
|
||||||
|
) {
|
||||||
|
ElMessage.error(t('knowledgeBase.selectKnowledgeBase'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = formState.title.trim() || t('document.untitledDefaultTitle');
|
||||||
|
|
||||||
|
emit('submit', {
|
||||||
|
title,
|
||||||
|
type: formState.documentType,
|
||||||
|
template: formState.template,
|
||||||
|
template_meta: formState.templateMeta,
|
||||||
|
parent_external_id: formState.parentExternalId || undefined,
|
||||||
|
is_public: props.showPublicSwitch ? Boolean(formState.isPublic) : false,
|
||||||
|
container_type: props.showLocationSelector ? formState.containerType : undefined,
|
||||||
|
knowledge_base_external_id:
|
||||||
|
props.showLocationSelector && formState.containerType === 'knowledge_base'
|
||||||
|
? formState.targetKnowledgeBaseId || undefined
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectTemplate = (tpl) => {
|
||||||
|
if (tpl?.is_blank) {
|
||||||
|
formState.template = '';
|
||||||
|
formState.templateMeta = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const templateId = String(tpl.id);
|
||||||
|
if (formState.template === templateId) {
|
||||||
|
formState.template = '';
|
||||||
|
formState.templateMeta = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formState.template = templateId;
|
||||||
|
formState.templateMeta = tpl;
|
||||||
|
if (tpl?.type !== undefined && tpl?.type !== null && tpl?.type !== '') {
|
||||||
|
formState.documentType = normalizeDocumentType(tpl.type, formState.documentType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
async (nextVisible) => {
|
||||||
|
if (nextVisible) {
|
||||||
|
resetForm();
|
||||||
|
await loadKnowledgeBaseOptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.document-create-dialog :deep(.el-dialog__body) {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-picker-field {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-picker-field :deep(.el-form-item__content) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-segment {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-segment :deep(.el-radio-button) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-segment :deep(.el-radio-button__inner) {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-kb-select {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<TableDiffViewer
|
||||||
|
v-if="previewKind === 'table'"
|
||||||
|
:left-rows="tableDiffRows.leftRows"
|
||||||
|
:right-rows="tableDiffRows.rightRows"
|
||||||
|
:left-title="leftTitle"
|
||||||
|
:right-title="rightTitle"
|
||||||
|
/>
|
||||||
|
<TextDiffViewer
|
||||||
|
v-else
|
||||||
|
:left-text="resolvedTextDiff.leftText"
|
||||||
|
:right-text="resolvedTextDiff.rightText"
|
||||||
|
:left-title="leftTitle"
|
||||||
|
:right-title="rightTitle"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import TextDiffViewer from '@/components/document/TextDiffViewer.vue';
|
||||||
|
import TableDiffViewer from '@/components/document/render/TableDiffViewer.vue';
|
||||||
|
import {
|
||||||
|
resolveDocumentDiffContentPair,
|
||||||
|
resolveDocumentPreviewKind,
|
||||||
|
resolveTableDiffRowsPair
|
||||||
|
} from '@/composables/document/render/useDocumentRenderBridge';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
leftContent: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
rightContent: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
documentType: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: '1'
|
||||||
|
},
|
||||||
|
leftTitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
rightTitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewKind = computed(() => resolveDocumentPreviewKind(props.documentType));
|
||||||
|
const resolvedTextDiff = computed(() => resolveDocumentDiffContentPair({
|
||||||
|
leftContent: props.leftContent,
|
||||||
|
rightContent: props.rightContent,
|
||||||
|
documentType: props.documentType
|
||||||
|
}));
|
||||||
|
const tableDiffRows = computed(() => resolveTableDiffRowsPair({
|
||||||
|
leftContent: props.leftContent,
|
||||||
|
rightContent: props.rightContent
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div class="editor-header">
|
||||||
|
<div class="editor-title">
|
||||||
|
<div
|
||||||
|
v-if="!isEditingTitle"
|
||||||
|
class="doc-title"
|
||||||
|
:title="titlePlaceholder"
|
||||||
|
@click="$emit('start-edit-title')"
|
||||||
|
>
|
||||||
|
{{ currentDocTitle || titlePlaceholder }}
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-else
|
||||||
|
ref="titleInputRef"
|
||||||
|
:model-value="pendingTitle"
|
||||||
|
class="doc-title-input"
|
||||||
|
maxlength="200"
|
||||||
|
@update:model-value="$emit('update:pending-title', $event)"
|
||||||
|
@blur="$emit('commit-title')"
|
||||||
|
@keyup.enter="$emit('commit-title')"
|
||||||
|
@keyup.esc="$emit('cancel-edit-title')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<slot name="title-action" />
|
||||||
|
<el-button
|
||||||
|
class="editor-action-button"
|
||||||
|
text
|
||||||
|
:title="isSidebarCollapsed ? expandTitle : collapseTitle"
|
||||||
|
@click="$emit('toggle-sidebar')"
|
||||||
|
>
|
||||||
|
<el-icon>
|
||||||
|
<component :is="isSidebarCollapsed ? Expand : Fold" />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
class="editor-action-button"
|
||||||
|
text
|
||||||
|
:title="commentsTitle"
|
||||||
|
@click="$emit('toggle-comment-sidebar')"
|
||||||
|
>
|
||||||
|
<el-icon><ChatLineRound /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<AppDropdown
|
||||||
|
trigger="click"
|
||||||
|
:teleported="!isFullscreen"
|
||||||
|
@command="$emit('header-command', $event)"
|
||||||
|
>
|
||||||
|
<el-button class="editor-action-button" text>
|
||||||
|
<el-icon><MoreFilled /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="show_history" :disabled="!canManageAttachments">
|
||||||
|
{{ historyLabel }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="manage_attachments" :disabled="!canManageAttachments">
|
||||||
|
{{ attachmentsLabel }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="document_settings" :disabled="!canManageSettings">
|
||||||
|
{{ settingsLabel }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="create_template">
|
||||||
|
{{ saveAsLabel }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</AppDropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { nextTick, ref, watch } from 'vue';
|
||||||
|
import { Expand, Fold, MoreFilled, ChatLineRound } from '@element-plus/icons-vue';
|
||||||
|
import AppDropdown from '@/components/base/AppDropdown.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isEditingTitle: { type: Boolean, default: false },
|
||||||
|
currentDocTitle: { type: String, default: '' },
|
||||||
|
pendingTitle: { type: String, default: '' },
|
||||||
|
isSidebarCollapsed: { type: Boolean, default: false },
|
||||||
|
titlePlaceholder: { type: String, default: '' },
|
||||||
|
collapseTitle: { type: String, default: '' },
|
||||||
|
expandTitle: { type: String, default: '' },
|
||||||
|
commentsTitle: { type: String, default: '' },
|
||||||
|
historyLabel: { type: String, default: '' },
|
||||||
|
saveAsLabel: { type: String, default: '' },
|
||||||
|
attachmentsLabel: { type: String, default: '' },
|
||||||
|
settingsLabel: { type: String, default: '' },
|
||||||
|
isFullscreen: { type: Boolean, default: false },
|
||||||
|
canManageAttachments: { type: Boolean, default: false },
|
||||||
|
canManageSettings: { type: Boolean, default: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits([
|
||||||
|
'update:pending-title',
|
||||||
|
'start-edit-title',
|
||||||
|
'commit-title',
|
||||||
|
'cancel-edit-title',
|
||||||
|
'toggle-sidebar',
|
||||||
|
'toggle-comment-sidebar',
|
||||||
|
'header-command'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const titleInputRef = ref(null);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.isEditingTitle,
|
||||||
|
async (editing) => {
|
||||||
|
if (!editing) return;
|
||||||
|
await nextTick();
|
||||||
|
titleInputRef.value?.focus?.();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-action-button {
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-action-button:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
cursor: text;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-title-input :deep(.el-input__wrapper) {
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-title-input {
|
||||||
|
width: min(100%, 560px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-title-input :deep(.el-input__inner) {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
padding: 0;
|
||||||
|
height: 28px;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .editor-header {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .doc-title {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .doc-title-input :deep(.el-input__inner) {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<template>
|
||||||
|
<div class="template-picker-shell">
|
||||||
|
<aside class="template-scope-nav">
|
||||||
|
<button
|
||||||
|
v-for="scope in scopeOptions"
|
||||||
|
:key="scope.name"
|
||||||
|
type="button"
|
||||||
|
class="scope-item"
|
||||||
|
:class="{ 'is-active': activeScope === scope.name }"
|
||||||
|
@click="activeScope = scope.name"
|
||||||
|
>
|
||||||
|
{{ scope.label }}
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="template-main-panel">
|
||||||
|
<div class="template-toolbar">
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
clearable
|
||||||
|
class="template-search-input"
|
||||||
|
:placeholder="t('templates.searchPlaceholder')"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="template-content">
|
||||||
|
<TemplatePickerPane
|
||||||
|
:loading="currentLoading"
|
||||||
|
:items="filteredTemplates"
|
||||||
|
:selected-template-id="selectedTemplateIdForPane"
|
||||||
|
:empty-text="currentEmptyText"
|
||||||
|
:fallback-description="t('templates.noDescription')"
|
||||||
|
layout="grid"
|
||||||
|
@select="(tpl) => emit('select', tpl)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Search } from '@element-plus/icons-vue';
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import TemplatePickerPane from '@/components/template/TemplatePickerPane.vue';
|
||||||
|
import { useTemplateCatalog } from '@/composables/template/useTemplateCatalog';
|
||||||
|
import { matchDocumentType, normalizeDocumentType } from '@/utils/documentType';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
preferredScope: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
selectedTemplateId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
documentType: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
knowledgeBaseId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const activeScope = ref('recent');
|
||||||
|
const keyword = ref('');
|
||||||
|
const showKnowledgeBaseTemplates = computed(() => Boolean(props.knowledgeBaseId));
|
||||||
|
const selectedDocumentType = computed(() => normalizeDocumentType(props.documentType, ''));
|
||||||
|
|
||||||
|
const normalizeScopeToTab = (scope) => {
|
||||||
|
if (scope === 'system') {
|
||||||
|
return 'public';
|
||||||
|
}
|
||||||
|
if (scope === 'private') {
|
||||||
|
return 'my';
|
||||||
|
}
|
||||||
|
if (scope === 'knowledge_base') {
|
||||||
|
return 'knowledge_base';
|
||||||
|
}
|
||||||
|
return 'recent';
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
recentTemplates,
|
||||||
|
myTemplates,
|
||||||
|
publicTemplates,
|
||||||
|
kbTemplates,
|
||||||
|
loadingRecent,
|
||||||
|
loadingMy,
|
||||||
|
loadingPublic,
|
||||||
|
loadingKb,
|
||||||
|
ensureLoaded: ensureTemplateLoaded,
|
||||||
|
invalidateScope: invalidateTemplateScope
|
||||||
|
} = useTemplateCatalog({
|
||||||
|
includeKnowledgeBase: true,
|
||||||
|
knowledgeBaseId: computed(() => props.knowledgeBaseId || ''),
|
||||||
|
documentType: selectedDocumentType,
|
||||||
|
onError: (error, scope) => {
|
||||||
|
console.error(`[DocumentTemplatePicker] load ${scope} templates failed`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const scopeOptions = computed(() => {
|
||||||
|
const base = [
|
||||||
|
{ name: 'recent', label: t('templates.recent') },
|
||||||
|
{ name: 'my', label: t('templates.my') },
|
||||||
|
{ name: 'public', label: t('templates.public') }
|
||||||
|
];
|
||||||
|
if (showKnowledgeBaseTemplates.value) {
|
||||||
|
base.push({ name: 'knowledge_base', label: t('templates.knowledgeBaseTab') });
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
|
||||||
|
const blankTemplateOption = computed(() => ({
|
||||||
|
id: '__blank__',
|
||||||
|
name: t('templates.blankDocument'),
|
||||||
|
description: t('templates.blankDocumentDesc'),
|
||||||
|
tags: [],
|
||||||
|
is_blank: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
const currentTemplates = computed(() => {
|
||||||
|
if (activeScope.value === 'my') {
|
||||||
|
return myTemplates.value;
|
||||||
|
}
|
||||||
|
if (activeScope.value === 'public') {
|
||||||
|
return publicTemplates.value;
|
||||||
|
}
|
||||||
|
if (activeScope.value === 'knowledge_base') {
|
||||||
|
return kbTemplates.value;
|
||||||
|
}
|
||||||
|
return recentTemplates.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentLoading = computed(() => {
|
||||||
|
if (activeScope.value === 'my') {
|
||||||
|
return loadingMy.value;
|
||||||
|
}
|
||||||
|
if (activeScope.value === 'public') {
|
||||||
|
return loadingPublic.value;
|
||||||
|
}
|
||||||
|
if (activeScope.value === 'knowledge_base') {
|
||||||
|
return loadingKb.value;
|
||||||
|
}
|
||||||
|
return loadingRecent.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentEmptyText = computed(() => {
|
||||||
|
if (activeScope.value === 'my') {
|
||||||
|
return t('templates.noMy');
|
||||||
|
}
|
||||||
|
if (activeScope.value === 'public') {
|
||||||
|
return t('templates.noPublic');
|
||||||
|
}
|
||||||
|
if (activeScope.value === 'knowledge_base') {
|
||||||
|
return t('templates.noKb');
|
||||||
|
}
|
||||||
|
return t('templates.noRecent');
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredTemplates = computed(() => {
|
||||||
|
const text = keyword.value.trim().toLowerCase();
|
||||||
|
const blank = blankTemplateOption.value;
|
||||||
|
const typedTemplates = currentTemplates.value.filter((item) =>
|
||||||
|
matchDocumentType(item?.type, selectedDocumentType.value)
|
||||||
|
);
|
||||||
|
if (!text) {
|
||||||
|
return [blank, ...typedTemplates];
|
||||||
|
}
|
||||||
|
const list = typedTemplates.filter((item) => {
|
||||||
|
const name = String(item?.name || '').toLowerCase();
|
||||||
|
const description = String(item?.description || '').toLowerCase();
|
||||||
|
const tags = Array.isArray(item?.tags) ? item.tags.join(' ').toLowerCase() : '';
|
||||||
|
return name.includes(text) || description.includes(text) || tags.includes(text);
|
||||||
|
});
|
||||||
|
const blankMatched = [blank.name, blank.description]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(text);
|
||||||
|
if (blankMatched) {
|
||||||
|
return [blank, ...list];
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedTemplateIdForPane = computed(() => props.selectedTemplateId || '__blank__');
|
||||||
|
|
||||||
|
const initialScope = computed(() => {
|
||||||
|
const mapped = normalizeScopeToTab(props.preferredScope);
|
||||||
|
if (mapped === 'knowledge_base' && !showKnowledgeBaseTemplates.value) {
|
||||||
|
return 'recent';
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchTemplates = async (scope) => {
|
||||||
|
if (!props.visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scope === 'knowledge_base' && !showKnowledgeBaseTemplates.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ensureTemplateLoaded(scope);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
async (nextVisible) => {
|
||||||
|
if (!nextVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Wait one tick so parent dialog can finish resetForm and propagate preferredScope/template id.
|
||||||
|
await nextTick();
|
||||||
|
activeScope.value = initialScope.value;
|
||||||
|
keyword.value = '';
|
||||||
|
if (activeScope.value === 'recent') {
|
||||||
|
fetchTemplates('recent');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchTemplates('recent');
|
||||||
|
fetchTemplates(activeScope.value);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.visible, props.preferredScope, props.selectedTemplateId],
|
||||||
|
([visible]) => {
|
||||||
|
if (!visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!props.selectedTemplateId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const preferred = initialScope.value;
|
||||||
|
if (preferred === 'recent') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If scope arrives late, auto-correct only when user still stays on default tab.
|
||||||
|
if (activeScope.value === 'recent') {
|
||||||
|
activeScope.value = preferred;
|
||||||
|
fetchTemplates(preferred);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.knowledgeBaseId,
|
||||||
|
(next, prev) => {
|
||||||
|
if (next === prev) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
invalidateTemplateScope('knowledge_base');
|
||||||
|
if (activeScope.value === 'knowledge_base' && props.visible) {
|
||||||
|
fetchTemplates('knowledge_base');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(showKnowledgeBaseTemplates, (show) => {
|
||||||
|
if (!show && activeScope.value === 'knowledge_base') {
|
||||||
|
activeScope.value = 'recent';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(activeScope, (scope) => {
|
||||||
|
fetchTemplates(scope);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.documentType,
|
||||||
|
() => {
|
||||||
|
if (!props.visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchTemplates(activeScope.value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.template-picker-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 460px;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-scope-nav {
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
padding: 12px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
background: color-mix(in srgb, var(--bg-white) 92%, #f3f5f9 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-item {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 36px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-item:hover {
|
||||||
|
background: color-mix(in srgb, #3370ff 10%, transparent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-item.is-active {
|
||||||
|
background: color-mix(in srgb, #3370ff 16%, transparent);
|
||||||
|
color: #2f65e2;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-main-panel {
|
||||||
|
padding: 12px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-toolbar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-search-input {
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-picker-shell {
|
||||||
|
background: #0f1218;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-scope-nav {
|
||||||
|
background: #0b0f14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .scope-item {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .scope-item:hover {
|
||||||
|
background: rgba(76, 141, 255, 0.14);
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .scope-item.is-active {
|
||||||
|
background: rgba(76, 141, 255, 0.2);
|
||||||
|
color: #88b0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.template-picker-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-scope-nav {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-search-input {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
<template>
|
||||||
|
<div class="document-tree">
|
||||||
|
<div v-if="showToolbar" class="tree-toolbar">
|
||||||
|
<div class="tree-toolbar-left">
|
||||||
|
<el-button text @click="emit('toggle-expand')"
|
||||||
|
:title="expandAll ? t('common.collapseAll') : t('common.expandAll')" class="tree-expand-btn">
|
||||||
|
<el-icon :size="14">
|
||||||
|
<FolderOpened v-if="expandAll" />
|
||||||
|
<Folder v-else />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="tree-toolbar-actions">
|
||||||
|
<DocumentTypeMenu
|
||||||
|
v-if="showCreate"
|
||||||
|
@open="closeContextMenu"
|
||||||
|
@select="(type) => emitCreate(type, null)"
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
class="tree-action-btn"
|
||||||
|
:title="t('knowledgeBase.createDocument')"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<el-icon :size="16">
|
||||||
|
<Plus />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</DocumentTypeMenu>
|
||||||
|
<el-button v-if="showDelete" text class="tree-action-btn tree-action-btn--danger" :disabled="disableDelete"
|
||||||
|
:title="t('document.deleteDocument')" @click="emit('delete', null)">
|
||||||
|
<el-icon :size="16">
|
||||||
|
<Delete />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="directory-tree" v-loading="loading" @click="closeContextMenu">
|
||||||
|
<el-tree ref="treeRef" :data="nodes" :props="treeProps" node-key="id" :default-expand-all="false"
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
@node-click="handleNodeClick" class="editor-tree">
|
||||||
|
<template #default="{ node, data }">
|
||||||
|
<div class="tree-node-content" :class="{ 'is-selected': isSelected(data) }" @click="closeContextMenu"
|
||||||
|
@contextmenu.prevent="(event) => handleNodeContextMenu(event, data)">
|
||||||
|
<div class="node-icon">
|
||||||
|
<el-icon v-if="data.isFolder">
|
||||||
|
<FolderOpened v-if="node.expanded" />
|
||||||
|
<Folder v-else />
|
||||||
|
</el-icon>
|
||||||
|
<el-icon
|
||||||
|
v-else
|
||||||
|
:class="{ 'node-icon--table': isTableDocument(data), 'node-icon--slide': isSlideDocument(data) }"
|
||||||
|
>
|
||||||
|
<component :is="resolveDocumentIcon(data)" />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="node-info">
|
||||||
|
<el-input
|
||||||
|
v-if="data.isRenaming"
|
||||||
|
:ref="(el) => setRenamingInputRef(el, data)"
|
||||||
|
v-model="data.renameValue"
|
||||||
|
size="small"
|
||||||
|
class="inline-rename-input"
|
||||||
|
@keyup.enter="confirmInlineRename(data)"
|
||||||
|
@keydown.esc.prevent="cancelInlineRename(data)"
|
||||||
|
@blur="confirmInlineRename(data)"
|
||||||
|
/>
|
||||||
|
<span v-else class="node-label">{{ node.label }}</span>
|
||||||
|
<slot name="node-extra" :node="node" :data="data" />
|
||||||
|
</div>
|
||||||
|
<div class="node-actions">
|
||||||
|
<DocumentTypeMenu
|
||||||
|
class="node-action-dropdown"
|
||||||
|
@open="closeContextMenu"
|
||||||
|
@select="(type) => emitCreate(type, data)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="node-action-icon node-action-icon--hover"
|
||||||
|
:title="t('knowledgeBase.createDocument')"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<el-icon :size="14">
|
||||||
|
<Plus />
|
||||||
|
</el-icon>
|
||||||
|
</button>
|
||||||
|
</DocumentTypeMenu>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="node-action-icon node-action-icon--hover"
|
||||||
|
title="..."
|
||||||
|
@click.stop="openContextMenuFromAction($event, data)"
|
||||||
|
>
|
||||||
|
<el-icon :size="14">
|
||||||
|
<MoreFilled />
|
||||||
|
</el-icon>
|
||||||
|
</button>
|
||||||
|
<slot name="node-actions" :node="node" :data="data" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppMenu
|
||||||
|
v-if="contextMenuEnabled"
|
||||||
|
:visible="contextMenu.visible"
|
||||||
|
:x="contextMenu.x"
|
||||||
|
:y="contextMenu.y"
|
||||||
|
@close="closeContextMenu"
|
||||||
|
>
|
||||||
|
<AppMenuItem
|
||||||
|
v-if="showRename"
|
||||||
|
@click="handleContextCommand('rename')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<el-icon :size="14">
|
||||||
|
<Edit />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
{{ t('document.renameDocument') }}
|
||||||
|
</AppMenuItem>
|
||||||
|
<AppMenuItem
|
||||||
|
v-if="showDelete"
|
||||||
|
danger
|
||||||
|
@click="handleContextCommand('delete')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<el-icon :size="14">
|
||||||
|
<Delete />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
{{ t('document.deleteDocument') }}
|
||||||
|
</AppMenuItem>
|
||||||
|
</AppMenu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Folder, FolderOpened, Document, Grid, Postcard, EditPen, Plus, Delete, Edit, MoreFilled } from '@element-plus/icons-vue';
|
||||||
|
import AppMenu from '@/components/base/AppMenu.vue';
|
||||||
|
import AppMenuItem from '@/components/base/AppMenuItem.vue';
|
||||||
|
import DocumentTypeMenu from '@/components/document/DocumentTypeMenu.vue';
|
||||||
|
import { useDocumentTreeContextMenu } from '@/composables/document/tree/useDocumentTreeContextMenu';
|
||||||
|
import { useInlineRename } from '@/composables/document/tree/useInlineRename';
|
||||||
|
import { normalizeDocumentType } from '@/utils/documentType';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
nodes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
currentId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
expandAll: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showToolbar: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showCreate: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showDelete: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
disableDelete: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
contextMenuEnabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showRename: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
revertRename: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['node-click', 'toggle-expand', 'create', 'delete', 'rename']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const treeRef = ref(null);
|
||||||
|
const contextMenuEnabled = computed(() => props.contextMenuEnabled);
|
||||||
|
const {
|
||||||
|
contextMenu,
|
||||||
|
openContextMenu,
|
||||||
|
closeContextMenu
|
||||||
|
} = useDocumentTreeContextMenu({
|
||||||
|
enabled: contextMenuEnabled
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
setRenamingInputRef,
|
||||||
|
startInlineRename,
|
||||||
|
cancelInlineRename,
|
||||||
|
confirmInlineRename
|
||||||
|
} = useInlineRename({
|
||||||
|
revertRename: computed(() => props.revertRename),
|
||||||
|
onConfirm: ({ node, title }) => {
|
||||||
|
emit('rename', { data: node, title });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const treeProps = {
|
||||||
|
children: 'children',
|
||||||
|
label: 'label',
|
||||||
|
isLeaf: 'isLeaf'
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelected = (data) => String(data?.id) === String(props.currentId);
|
||||||
|
const isTableDocument = (data) => normalizeDocumentType(data?.type, '1') === '2';
|
||||||
|
const isSlideDocument = (data) => normalizeDocumentType(data?.type, '1') === '3';
|
||||||
|
const isRichTextDocument = (data) => normalizeDocumentType(data?.type, '1') === '4';
|
||||||
|
const resolveDocumentIcon = (data) => {
|
||||||
|
if (isTableDocument(data)) {
|
||||||
|
return Grid;
|
||||||
|
}
|
||||||
|
if (isSlideDocument(data)) {
|
||||||
|
return Postcard;
|
||||||
|
}
|
||||||
|
if (isRichTextDocument(data)) {
|
||||||
|
return EditPen;
|
||||||
|
}
|
||||||
|
return Document;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNodeClick = (data) => {
|
||||||
|
if (data?.isRenaming) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('node-click', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNodeContextMenu = (event, data) => {
|
||||||
|
openContextMenu(event, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitCreate = (type, target) => {
|
||||||
|
emit('create', {
|
||||||
|
target: target || null,
|
||||||
|
type
|
||||||
|
});
|
||||||
|
closeContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openContextMenuFromAction = (event, data) => {
|
||||||
|
openContextMenu(event, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextCommand = (command) => {
|
||||||
|
const target = contextMenu.value.data;
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contextMenu.value.visible = false;
|
||||||
|
if (command === 'delete') {
|
||||||
|
emit('delete', target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === 'rename') {
|
||||||
|
startInlineRename(target);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ treeRef, closeContextMenu });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.document-tree {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .tree-toolbar {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-action-btn {
|
||||||
|
padding: 2px 4px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-action-btn:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-action-btn--danger:hover {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-tree {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tree {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tree :deep(.el-tree-node__label) {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tree :deep(.el-tree-node__content) {
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tree :deep(.el-tree-node__children) {
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tree :deep(.el-tree-node__content:hover) {
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .editor-tree :deep(.el-tree-node__content:hover) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tree :deep(.el-tree-node.is-current > .el-tree-node__content),
|
||||||
|
.editor-tree :deep(.el-tree-node.is-focus > .el-tree-node__content),
|
||||||
|
.editor-tree :deep(.el-tree-node:focus-within > .el-tree-node__content),
|
||||||
|
.editor-tree :deep(.el-tree-node__content:focus),
|
||||||
|
.editor-tree :deep(.el-tree-node__content:focus-visible) {
|
||||||
|
background-color: transparent;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .editor-tree :deep(.el-tree-node.is-current > .el-tree-node__content),
|
||||||
|
.dark-mode .editor-tree :deep(.el-tree-node.is-focus > .el-tree-node__content),
|
||||||
|
.dark-mode .editor-tree :deep(.el-tree-node:focus-within > .el-tree-node__content),
|
||||||
|
.dark-mode .editor-tree :deep(.el-tree-node__content:focus),
|
||||||
|
.dark-mode .editor-tree :deep(.el-tree-node__content:focus-visible) {
|
||||||
|
background-color: transparent;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-content.is-selected {
|
||||||
|
background-color: rgba(22, 93, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .tree-node-content.is-selected {
|
||||||
|
background-color: rgba(64, 128, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-icon--table {
|
||||||
|
color: #2f80ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .node-icon--table {
|
||||||
|
color: #8fb2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-icon--slide {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .node-icon--slide {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-dropdown {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-content:hover .node-action-dropdown,
|
||||||
|
.tree-node-content.is-selected .node-action-dropdown {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-icon--hover {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-content:hover .node-action-icon--hover,
|
||||||
|
.tree-node-content.is-selected .node-action-icon--hover {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-light);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-icon:hover {
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 14%, transparent);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .node-action-icon {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .node-action-icon:hover {
|
||||||
|
color: #8fb2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-rename-input {
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<span
|
||||||
|
ref="triggerRef"
|
||||||
|
class="document-type-menu-trigger"
|
||||||
|
:class="{ 'is-disabled': disabled }"
|
||||||
|
@click.capture="handleTriggerClick"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<AppMenu
|
||||||
|
ref="menuRef"
|
||||||
|
:visible="visible"
|
||||||
|
:x="position.x"
|
||||||
|
:y="position.y"
|
||||||
|
:ignore-elements="[triggerRef]"
|
||||||
|
@close="closeMenu"
|
||||||
|
>
|
||||||
|
<AppMenuItem
|
||||||
|
v-for="option in resolvedOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:disabled="Boolean(option.disabled)"
|
||||||
|
@click="handleSelect(option)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</AppMenuItem>
|
||||||
|
</AppMenu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import AppMenu from '@/components/base/AppMenu.vue';
|
||||||
|
import AppMenuItem from '@/components/base/AppMenuItem.vue';
|
||||||
|
import { buildDocumentTypeOptions, normalizeDocumentType } from '@/utils/documentType';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
trigger: {
|
||||||
|
type: String,
|
||||||
|
default: 'click'
|
||||||
|
},
|
||||||
|
placement: {
|
||||||
|
type: String,
|
||||||
|
default: 'bottom-start'
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select', 'open', 'close', 'visible-change']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const triggerRef = ref(null);
|
||||||
|
const menuRef = ref(null);
|
||||||
|
const position = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const defaultOptions = computed(() => buildDocumentTypeOptions(t));
|
||||||
|
const resolvedOptions = computed(() => (props.options.length ? props.options : defaultOptions.value));
|
||||||
|
const updatePosition = async () => {
|
||||||
|
const triggerEl = triggerRef.value;
|
||||||
|
if (!triggerEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = triggerEl.getBoundingClientRect();
|
||||||
|
const gap = 6;
|
||||||
|
let x = rect.left;
|
||||||
|
let y = rect.bottom + gap;
|
||||||
|
|
||||||
|
if (props.placement === 'right-start') {
|
||||||
|
x = rect.right + gap;
|
||||||
|
y = rect.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
position.value = { x, y };
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const menuEl = menuRef.value?.getMenuElement?.();
|
||||||
|
if (!menuEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuWidth = menuEl.offsetWidth || 150;
|
||||||
|
const menuHeight = menuEl.offsetHeight || 120;
|
||||||
|
const maxX = Math.max(8, window.innerWidth - menuWidth - 8);
|
||||||
|
const maxY = Math.max(8, window.innerHeight - menuHeight - 8);
|
||||||
|
|
||||||
|
position.value = {
|
||||||
|
x: Math.min(Math.max(8, x), maxX),
|
||||||
|
y: Math.min(Math.max(8, y), maxY)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVisible = (nextVisible) => {
|
||||||
|
if (visible.value === nextVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visible.value = nextVisible;
|
||||||
|
emit('visible-change', nextVisible);
|
||||||
|
emit(nextVisible ? 'open' : 'close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
setVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTriggerClick = async (event) => {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
const nextVisible = !visible.value;
|
||||||
|
setVisible(nextVisible);
|
||||||
|
if (nextVisible) {
|
||||||
|
await updatePosition();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (option) => {
|
||||||
|
if (!option || option.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('select', normalizeDocumentType(option.value));
|
||||||
|
closeMenu();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.document-type-menu-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-type-menu-trigger.is-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
<template>
|
||||||
|
<div class="markdown-editor">
|
||||||
|
<div ref="editorRef" class="vditor-container"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||||
|
import 'vditor/dist/index.css';
|
||||||
|
import { useVditorCore } from '@/composables/document/editor/markdown-editor/useVditorCore';
|
||||||
|
import { useYjsCollaboration } from '@/composables/document/editor/markdown-editor/useYjsCollaboration';
|
||||||
|
import { useCommentBridge } from '@/composables/document/editor/markdown-editor/useCommentBridge';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: '100%'
|
||||||
|
},
|
||||||
|
collabEnabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
collabRoom: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
collabUrl: {
|
||||||
|
type: String,
|
||||||
|
default: '/collab'
|
||||||
|
},
|
||||||
|
collabToken: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
commentEnabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:modelValue',
|
||||||
|
'collab-sync',
|
||||||
|
'ready',
|
||||||
|
'comment-add',
|
||||||
|
'comment-remove',
|
||||||
|
'comment-scroll',
|
||||||
|
'comment-adjust',
|
||||||
|
'comment-changed'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const editorRef = ref(null);
|
||||||
|
const vditorRef = ref(null);
|
||||||
|
const isVditorReady = ref(false);
|
||||||
|
const suppressInput = ref(false);
|
||||||
|
|
||||||
|
let getEditableElement = () => editorRef.value;
|
||||||
|
let getValueSafely = (fallback = '') => fallback;
|
||||||
|
let setValueSafely = () => {};
|
||||||
|
|
||||||
|
const collaboration = useYjsCollaboration({
|
||||||
|
props,
|
||||||
|
emit,
|
||||||
|
editorRef,
|
||||||
|
vditorRef,
|
||||||
|
isVditorReady,
|
||||||
|
suppressInput,
|
||||||
|
getEditableElement: (...args) => getEditableElement(...args),
|
||||||
|
getValueSafely: (...args) => getValueSafely(...args),
|
||||||
|
setValueSafely: (...args) => setValueSafely(...args)
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncEditorToYjs = (fallbackValue = '') => {
|
||||||
|
const nextValue = getValueSafely(fallbackValue);
|
||||||
|
collaboration.syncContentToYjs(nextValue);
|
||||||
|
emit('update:modelValue', nextValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getCommentOptions, removeCommentIds, broadcastCommentChange } = useCommentBridge({
|
||||||
|
props,
|
||||||
|
emit,
|
||||||
|
vditorRef,
|
||||||
|
ydocRef: collaboration.ydocRef,
|
||||||
|
syncEditorToYjs
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeIds = (ids = []) => {
|
||||||
|
if (Array.isArray(ids)) {
|
||||||
|
return ids.filter(Boolean);
|
||||||
|
}
|
||||||
|
return ids ? [ids] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCommentIds = () => {
|
||||||
|
const vditor = vditorRef.value;
|
||||||
|
if (!vditor || typeof vditor.getCommentIds !== 'function') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const entries = vditor.getCommentIds();
|
||||||
|
return Array.isArray(entries) ? entries : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const hlCommentIds = (ids = []) => {
|
||||||
|
const vditor = vditorRef.value;
|
||||||
|
if (!vditor || typeof vditor.hlCommentIds !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetIds = normalizeIds(ids);
|
||||||
|
if (targetIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vditor.hlCommentIds(targetIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unHlCommentIds = (ids = []) => {
|
||||||
|
const vditor = vditorRef.value;
|
||||||
|
if (!vditor || typeof vditor.unHlCommentIds !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetIds = normalizeIds(ids);
|
||||||
|
if (targetIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vditor.unHlCommentIds(targetIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToCommentId = (id) => {
|
||||||
|
const targetId = String(id || '').trim();
|
||||||
|
if (!targetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entries = getCommentIds();
|
||||||
|
const target = entries.find((entry) => entry.id === targetId);
|
||||||
|
const vditor = vditorRef.value;
|
||||||
|
const container = vditor?.vditor?.wysiwyg?.element || vditor?.vditor?.ir?.element;
|
||||||
|
if (!target || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const top = Math.max((target.top || 0) - 24, 0);
|
||||||
|
if (typeof container.scrollTo === 'function') {
|
||||||
|
container.scrollTo({ top, behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
container.scrollTop = top;
|
||||||
|
}
|
||||||
|
hlCommentIds([targetId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const vditorCore = useVditorCore({
|
||||||
|
props,
|
||||||
|
emit,
|
||||||
|
editorRef,
|
||||||
|
vditorRef,
|
||||||
|
isVditorReady,
|
||||||
|
suppressInput,
|
||||||
|
getCommentOptions,
|
||||||
|
onEditorInput: (value) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
syncEditorToYjs(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
getEditableElement = vditorCore.getEditableElement;
|
||||||
|
getValueSafely = vditorCore.getValueSafely;
|
||||||
|
setValueSafely = vditorCore.setValueSafely;
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getVditor: () => vditorRef.value,
|
||||||
|
getCommentIds,
|
||||||
|
hlCommentIds,
|
||||||
|
unHlCommentIds,
|
||||||
|
removeCommentIds,
|
||||||
|
scrollToCommentId,
|
||||||
|
broadcastCommentChange
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
vditorCore.initVditor();
|
||||||
|
collaboration.setupCollaboration();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
vditorCore.destroyVditor();
|
||||||
|
collaboration.teardownCollaboration();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newValue) => {
|
||||||
|
collaboration.handleModelValueChange(newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.collabRoom, () => {
|
||||||
|
collaboration.handleRoomChange();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.markdown-editor {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vditor-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor :deep(.vditor) {
|
||||||
|
border: none;
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .markdown-editor :deep(.vditor) {
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor :deep(.vditor-toolbar) {
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .markdown-editor :deep(.vditor-toolbar) {
|
||||||
|
background-color: var(--bg-medium);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor :deep(.vditor-toolbar__item) {
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .markdown-editor :deep(.vditor-toolbar__item) {
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor :deep(.vditor-toolbar__item:hover) {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor :deep(.vditor-toolbar__item--current) {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor :deep(.vditor-content) {
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .markdown-editor :deep(.vditor-content) {
|
||||||
|
background-color: var(--bg-medium);
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor :deep(.vditor-ir) {
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .markdown-editor :deep(.vditor-ir) {
|
||||||
|
background-color: var(--bg-medium);
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor :deep(.vditor-ir__node) {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .markdown-editor :deep(.vditor-ir__node) {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor :deep(.vditor-ir__link) {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor :deep(.vditor-ir__link:hover) {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor :deep(.vditor-ir__marker) {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor :deep(.vditor-ir__marker--heading) {
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .markdown-editor :deep(.vditor-ir__marker) {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<template>
|
||||||
|
<div class="slide-editor">
|
||||||
|
<section class="slide-panel slide-panel--source">
|
||||||
|
<header class="slide-panel-header">
|
||||||
|
<span>{{ t('document.slide.sourceTitle') }}</span>
|
||||||
|
<span class="slide-panel-subtitle">{{ t('document.slide.sourceTip') }}</span>
|
||||||
|
</header>
|
||||||
|
<el-input
|
||||||
|
:model-value="draft"
|
||||||
|
type="textarea"
|
||||||
|
class="slide-source-input"
|
||||||
|
resize="none"
|
||||||
|
:autosize="false"
|
||||||
|
:placeholder="placeholder || t('document.slide.placeholder')"
|
||||||
|
@update:model-value="handleInput"
|
||||||
|
@blur="emit('commit')"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="slide-panel slide-panel--preview">
|
||||||
|
<header class="slide-panel-header">
|
||||||
|
<span>{{ t('document.slide.previewTitle') }}</span>
|
||||||
|
<span class="slide-panel-subtitle">{{ t('document.slide.count', { count: renderedSlides.length }) }}</span>
|
||||||
|
</header>
|
||||||
|
<div class="slide-preview-body">
|
||||||
|
<swiper
|
||||||
|
ref="swiperRef"
|
||||||
|
class="slide-swiper"
|
||||||
|
:modules="swiperModules"
|
||||||
|
:slides-per-view="1"
|
||||||
|
:space-between="12"
|
||||||
|
:navigation="true"
|
||||||
|
:pagination="{ clickable: true }"
|
||||||
|
:keyboard="{ enabled: true }"
|
||||||
|
:observer="true"
|
||||||
|
:observe-parents="true"
|
||||||
|
@slideChange="handleSlideChange"
|
||||||
|
>
|
||||||
|
<swiper-slide
|
||||||
|
v-for="slide in renderedSlides"
|
||||||
|
:key="slide.id"
|
||||||
|
class="slide-swiper-item"
|
||||||
|
>
|
||||||
|
<article class="slide-card" v-html="slide.html"></article>
|
||||||
|
</swiper-slide>
|
||||||
|
</swiper>
|
||||||
|
</div>
|
||||||
|
<div v-if="activeIndex >= 0" class="slide-index">
|
||||||
|
{{ `${activeIndex + 1} / ${renderedSlides.length}` }}
|
||||||
|
</div>
|
||||||
|
<div class="slide-toolbar-hint">
|
||||||
|
{{ t('document.slide.previewHint') }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import { Swiper, SwiperSlide } from 'swiper/vue';
|
||||||
|
import { Keyboard, Navigation, Pagination } from 'swiper/modules';
|
||||||
|
import 'swiper/css';
|
||||||
|
import 'swiper/css/navigation';
|
||||||
|
import 'swiper/css/pagination';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'commit']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const swiperRef = ref(null);
|
||||||
|
const draft = ref(props.modelValue || '');
|
||||||
|
const activeIndex = ref(0);
|
||||||
|
const swiperModules = [Navigation, Pagination, Keyboard];
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
gfm: true,
|
||||||
|
breaks: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const splitSlides = (content) => {
|
||||||
|
const normalized = String(content || '').replace(/\r\n/g, '\n');
|
||||||
|
const chunks = normalized
|
||||||
|
.split(/\n-{3,}\n/g)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (chunks.length > 0) {
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
return [t('document.slide.emptySlide')];
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderedSlides = computed(() =>
|
||||||
|
splitSlides(draft.value).map((slide, index) => ({
|
||||||
|
id: `${index}-${slide.length}`,
|
||||||
|
html: marked.parse(slide)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSwiperLayout = async () => {
|
||||||
|
await nextTick();
|
||||||
|
const instance = swiperRef.value?.swiper;
|
||||||
|
if (!instance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance.update?.();
|
||||||
|
if (instance.activeIndex >= renderedSlides.value.length) {
|
||||||
|
const targetIndex = Math.max(0, renderedSlides.value.length - 1);
|
||||||
|
instance.slideTo(targetIndex, 0);
|
||||||
|
}
|
||||||
|
activeIndex.value = instance.activeIndex || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInput = (value) => {
|
||||||
|
draft.value = value;
|
||||||
|
emit('update:modelValue', value);
|
||||||
|
updateSwiperLayout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlideChange = (swiper) => {
|
||||||
|
if (!swiper) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeIndex.value = Number(swiper.activeIndex) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
if (value === draft.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draft.value = value || '';
|
||||||
|
updateSwiperLayout();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
renderedSlides,
|
||||||
|
() => {
|
||||||
|
updateSwiperLayout();
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const reRender = () => {
|
||||||
|
updateSwiperLayout();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
reRender
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.slide-editor {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-panel--source {
|
||||||
|
width: min(40%, 460px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-panel--preview {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-panel-subtitle {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-source-input {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-source-input :deep(.el-textarea__inner) {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
resize: none;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-preview-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: color-mix(in srgb, var(--bg-light) 75%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-swiper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-swiper-item {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-card {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
background: var(--bg-white);
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-card :deep(h1),
|
||||||
|
.slide-card :deep(h2),
|
||||||
|
.slide-card :deep(h3) {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-card :deep(p),
|
||||||
|
.slide-card :deep(li) {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-index {
|
||||||
|
padding: 6px 12px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-light);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-toolbar-hint {
|
||||||
|
padding: 4px 12px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .slide-panel {
|
||||||
|
background: var(--bg-white);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .slide-panel-header {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .slide-source-input :deep(.el-textarea__inner) {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .slide-card {
|
||||||
|
background: color-mix(in srgb, var(--bg-white) 95%, #05070a 5%);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .slide-card :deep(p),
|
||||||
|
:global(.dark-mode) .slide-card :deep(li) {
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-panel--preview {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-swiper :deep(.swiper-button-next),
|
||||||
|
.slide-swiper :deep(.swiper-button-prev) {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-swiper :deep(.swiper-pagination-bullet) {
|
||||||
|
background: var(--text-light);
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-swiper :deep(.swiper-pagination-bullet-active) {
|
||||||
|
background: var(--primary-color);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
<template>
|
||||||
|
<div class="table-editor">
|
||||||
|
<div ref="editorRef" class="sheet-container"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import Spreadsheet from 'x-data-spreadsheet';
|
||||||
|
import 'x-data-spreadsheet/dist/xspreadsheet.css';
|
||||||
|
|
||||||
|
const DEFAULT_ROW_COUNT = 8;
|
||||||
|
const DEFAULT_COLUMN_COUNT = 4;
|
||||||
|
const MIN_COLUMN_LEN = 26;
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'commit']);
|
||||||
|
|
||||||
|
const editorRef = ref(null);
|
||||||
|
const sheetRef = ref(null);
|
||||||
|
const lastSerialized = ref('');
|
||||||
|
const applyingData = ref(false);
|
||||||
|
const wheelBlocker = ref(null);
|
||||||
|
const resizeHandler = ref(null);
|
||||||
|
const rafIdRef = ref(0);
|
||||||
|
const transitionContainerRef = ref(null);
|
||||||
|
const transitionEndHandlerRef = ref(null);
|
||||||
|
const themeObserverRef = ref(null);
|
||||||
|
const setBodyTableEditorOpen = (enabled) => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.body.classList.toggle('table-editor-open', Boolean(enabled));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSheetStyle = () => ({
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
color: '#0a0a0a',
|
||||||
|
align: 'left',
|
||||||
|
valign: 'middle',
|
||||||
|
textwrap: false,
|
||||||
|
strike: false,
|
||||||
|
underline: false,
|
||||||
|
font: {
|
||||||
|
name: 'Arial',
|
||||||
|
size: 10,
|
||||||
|
bold: false,
|
||||||
|
italic: false
|
||||||
|
},
|
||||||
|
format: 'normal'
|
||||||
|
});
|
||||||
|
|
||||||
|
const createEmptyRows = (rowCount = DEFAULT_ROW_COUNT, colCount = DEFAULT_COLUMN_COUNT) =>
|
||||||
|
Array.from({ length: rowCount }, () => Array.from({ length: colCount }, () => ''));
|
||||||
|
|
||||||
|
const normalizeRows = (sourceRows) => {
|
||||||
|
if (!Array.isArray(sourceRows) || sourceRows.length === 0) {
|
||||||
|
return createEmptyRows();
|
||||||
|
}
|
||||||
|
const width = Math.max(
|
||||||
|
DEFAULT_COLUMN_COUNT,
|
||||||
|
...sourceRows.map((row) => (Array.isArray(row) ? row.length : 0))
|
||||||
|
);
|
||||||
|
return sourceRows.map((row) => {
|
||||||
|
const values = Array.isArray(row) ? row : [];
|
||||||
|
return Array.from({ length: width }, (_, index) => {
|
||||||
|
const value = values[index];
|
||||||
|
return value == null ? '' : String(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseRows = (rawContent) => {
|
||||||
|
if (!rawContent || !String(rawContent).trim()) {
|
||||||
|
return createEmptyRows();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawContent);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return normalizeRows(parsed);
|
||||||
|
}
|
||||||
|
if (Array.isArray(parsed?.rows)) {
|
||||||
|
return normalizeRows(parsed.rows);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// ignore parse error and fallback
|
||||||
|
}
|
||||||
|
return createEmptyRows();
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeRows = (rows) =>
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'table',
|
||||||
|
version: 1,
|
||||||
|
rows
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowsToSheetData = (rows) => {
|
||||||
|
const rowCount = Math.max(DEFAULT_ROW_COUNT, rows.length);
|
||||||
|
const colCount = Math.max(
|
||||||
|
MIN_COLUMN_LEN,
|
||||||
|
...rows.map((row) => (Array.isArray(row) ? row.length : 0))
|
||||||
|
);
|
||||||
|
const sheetRows = { len: rowCount };
|
||||||
|
rows.forEach((row, rowIndex) => {
|
||||||
|
const cells = {};
|
||||||
|
row.forEach((value, colIndex) => {
|
||||||
|
cells[colIndex] = { text: value == null ? '' : String(value) };
|
||||||
|
});
|
||||||
|
sheetRows[rowIndex] = { cells };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
name: 'Sheet1',
|
||||||
|
freeze: 'A1',
|
||||||
|
cols: { len: colCount },
|
||||||
|
rows: sheetRows
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstSheet = (data) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data[0] || {};
|
||||||
|
}
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
if (data.rows) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
const candidates = Object.values(data);
|
||||||
|
const found = candidates.find((item) => item && typeof item === 'object' && item.rows);
|
||||||
|
return found || {};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const sheetDataToRows = (data) => {
|
||||||
|
const sheet = firstSheet(data);
|
||||||
|
const rowMap = sheet?.rows && typeof sheet.rows === 'object' ? sheet.rows : {};
|
||||||
|
const rowIndexes = Object.keys(rowMap)
|
||||||
|
.filter((key) => /^\d+$/.test(key))
|
||||||
|
.map((key) => Number(key));
|
||||||
|
|
||||||
|
let maxRow = DEFAULT_ROW_COUNT - 1;
|
||||||
|
let maxCol = DEFAULT_COLUMN_COUNT - 1;
|
||||||
|
|
||||||
|
rowIndexes.forEach((rowIndex) => {
|
||||||
|
if (rowIndex > maxRow) {
|
||||||
|
maxRow = rowIndex;
|
||||||
|
}
|
||||||
|
const cellMap = rowMap[rowIndex]?.cells || {};
|
||||||
|
Object.keys(cellMap)
|
||||||
|
.filter((key) => /^\d+$/.test(key))
|
||||||
|
.forEach((key) => {
|
||||||
|
const colIndex = Number(key);
|
||||||
|
if (colIndex > maxCol) {
|
||||||
|
maxCol = colIndex;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (let r = 0; r <= maxRow; r += 1) {
|
||||||
|
const current = [];
|
||||||
|
const cellMap = rowMap[r]?.cells || {};
|
||||||
|
for (let c = 0; c <= maxCol; c += 1) {
|
||||||
|
const value = cellMap[c]?.text;
|
||||||
|
current.push(value == null ? '' : String(value));
|
||||||
|
}
|
||||||
|
rows.push(current);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitModelValueFromSheet = (sheetData) => {
|
||||||
|
const rows = sheetDataToRows(sheetData);
|
||||||
|
const serialized = serializeRows(rows);
|
||||||
|
if (serialized === lastSerialized.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSerialized.value = serialized;
|
||||||
|
emit('update:modelValue', serialized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rerenderSheet = () => {
|
||||||
|
const instance = sheetRef.value;
|
||||||
|
if (!instance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance.sheet?.reload?.();
|
||||||
|
instance.reRender?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRerender = () => {
|
||||||
|
if (rafIdRef.value) {
|
||||||
|
cancelAnimationFrame(rafIdRef.value);
|
||||||
|
}
|
||||||
|
rafIdRef.value = requestAnimationFrame(() => {
|
||||||
|
rafIdRef.value = 0;
|
||||||
|
rerenderSheet();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyModelValue = async (value) => {
|
||||||
|
const rows = parseRows(value);
|
||||||
|
lastSerialized.value = serializeRows(rows);
|
||||||
|
if (!sheetRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyingData.value = true;
|
||||||
|
sheetRef.value.loadData(rowsToSheetData(rows));
|
||||||
|
await nextTick();
|
||||||
|
applyingData.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyThemeToSheet = () => {
|
||||||
|
const instance = sheetRef.value;
|
||||||
|
if (!instance?.data?.settings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextStyle = buildSheetStyle();
|
||||||
|
const currentStyle = instance.data.settings.style || {};
|
||||||
|
instance.data.settings.style = {
|
||||||
|
...currentStyle,
|
||||||
|
...nextStyle,
|
||||||
|
font: {
|
||||||
|
...(currentStyle.font || {}),
|
||||||
|
...nextStyle.font
|
||||||
|
}
|
||||||
|
};
|
||||||
|
scheduleRerender();
|
||||||
|
};
|
||||||
|
|
||||||
|
const initSpreadsheet = () => {
|
||||||
|
if (!editorRef.value || sheetRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const instance = new Spreadsheet(editorRef.value, {
|
||||||
|
mode: 'edit',
|
||||||
|
showToolbar: true,
|
||||||
|
showGrid: true,
|
||||||
|
showContextmenu: true,
|
||||||
|
showBottomBar: false,
|
||||||
|
row: {
|
||||||
|
len: 100,
|
||||||
|
height: 28
|
||||||
|
},
|
||||||
|
col: {
|
||||||
|
len: 26,
|
||||||
|
width: 120,
|
||||||
|
indexWidth: 52,
|
||||||
|
minWidth: 72
|
||||||
|
},
|
||||||
|
view: {
|
||||||
|
height: () => editorRef.value?.clientHeight || 640,
|
||||||
|
width: () => editorRef.value?.clientWidth || 960
|
||||||
|
},
|
||||||
|
style: buildSheetStyle()
|
||||||
|
});
|
||||||
|
sheetRef.value = instance;
|
||||||
|
instance.change((data) => {
|
||||||
|
if (applyingData.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emitModelValueFromSheet(data);
|
||||||
|
});
|
||||||
|
instance.on('cell-edited', () => {
|
||||||
|
emit('commit');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
reRender: () => scheduleRerender()
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
setBodyTableEditorOpen(true);
|
||||||
|
initSpreadsheet();
|
||||||
|
const handler = (event) => {
|
||||||
|
const container = editorRef.value;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!container.contains(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
window.addEventListener('wheel', handler, { passive: false, capture: true });
|
||||||
|
window.addEventListener('mousewheel', handler, { passive: false, capture: true });
|
||||||
|
window.addEventListener('DOMMouseScroll', handler, { passive: false, capture: true });
|
||||||
|
wheelBlocker.value = handler;
|
||||||
|
const onResize = () => rerenderSheet();
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
resizeHandler.value = onResize;
|
||||||
|
const transitionContainer = editorRef.value?.closest('.editor-layout');
|
||||||
|
if (transitionContainer) {
|
||||||
|
const onTransitionEnd = () => {
|
||||||
|
scheduleRerender();
|
||||||
|
};
|
||||||
|
transitionContainer.addEventListener('transitionend', onTransitionEnd);
|
||||||
|
transitionContainerRef.value = transitionContainer;
|
||||||
|
transitionEndHandlerRef.value = onTransitionEnd;
|
||||||
|
}
|
||||||
|
if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
applyThemeToSheet();
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
themeObserverRef.value = observer;
|
||||||
|
}
|
||||||
|
applyThemeToSheet();
|
||||||
|
await applyModelValue(props.modelValue);
|
||||||
|
scheduleRerender();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
setBodyTableEditorOpen(false);
|
||||||
|
if (themeObserverRef.value) {
|
||||||
|
themeObserverRef.value.disconnect();
|
||||||
|
}
|
||||||
|
if (wheelBlocker.value) {
|
||||||
|
window.removeEventListener('wheel', wheelBlocker.value, true);
|
||||||
|
window.removeEventListener('mousewheel', wheelBlocker.value, true);
|
||||||
|
window.removeEventListener('DOMMouseScroll', wheelBlocker.value, true);
|
||||||
|
}
|
||||||
|
if (sheetRef.value && typeof sheetRef.value.destroy === 'function') {
|
||||||
|
sheetRef.value.destroy();
|
||||||
|
}
|
||||||
|
if (resizeHandler.value) {
|
||||||
|
window.removeEventListener('resize', resizeHandler.value);
|
||||||
|
}
|
||||||
|
if (transitionContainerRef.value && transitionEndHandlerRef.value) {
|
||||||
|
transitionContainerRef.value.removeEventListener('transitionend', transitionEndHandlerRef.value);
|
||||||
|
}
|
||||||
|
if (rafIdRef.value) {
|
||||||
|
cancelAnimationFrame(rafIdRef.value);
|
||||||
|
}
|
||||||
|
rafIdRef.value = 0;
|
||||||
|
themeObserverRef.value = null;
|
||||||
|
transitionContainerRef.value = null;
|
||||||
|
transitionEndHandlerRef.value = null;
|
||||||
|
resizeHandler.value = null;
|
||||||
|
wheelBlocker.value = null;
|
||||||
|
sheetRef.value = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
async (newValue) => {
|
||||||
|
if (typeof newValue !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newValue === lastSerialized.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await applyModelValue(newValue);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-editor {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--bg-white);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-container {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-editor :deep(.x-spreadsheet) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
box-shadow: none;
|
||||||
|
background: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-editor :deep(.x-spreadsheet-toolbar),
|
||||||
|
.table-editor :deep(.x-spreadsheet-bottombar) {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--bg-white);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body.table-editor-open > .x-spreadsheet-dimmer {
|
||||||
|
display: none !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-toolbar,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-bottombar {
|
||||||
|
background: #111827;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-toolbar .x-spreadsheet-toolbar-btn:hover,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-toolbar .x-spreadsheet-toolbar-btn.active {
|
||||||
|
background: rgba(148, 163, 184, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-toolbar .x-spreadsheet-icon .x-spreadsheet-icon-img,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-dropdown-header .x-spreadsheet-icon .x-spreadsheet-icon-img {
|
||||||
|
filter: invert(0.9);
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-toolbar-divider {
|
||||||
|
border-right-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-menu > li,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-item {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-menu > li.active,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-item:hover,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-item.active {
|
||||||
|
background: rgba(148, 163, 184, 0.18);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-sheet,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-table {
|
||||||
|
background: #0b1220;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-overlayer,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-overlayer-content {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-table {
|
||||||
|
filter: invert(1) hue-rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-editor .x-spreadsheet-editor-area,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-selector .x-spreadsheet-selector-area {
|
||||||
|
background: rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-editor .x-spreadsheet-editor-area textarea {
|
||||||
|
background: #111827;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-scrollbar {
|
||||||
|
background-color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-scrollbar.horizontal > div,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-scrollbar.vertical > div {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-dropdown .x-spreadsheet-dropdown-content,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-contextmenu,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-suggest,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-sort-filter,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-calendar,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-modal,
|
||||||
|
body.dark-mode .table-editor .x-spreadsheet-toast {
|
||||||
|
border-color: #374151;
|
||||||
|
background: #111827;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<template>
|
||||||
|
<div class="diff-viewer">
|
||||||
|
<div class="diff-head">
|
||||||
|
<div class="diff-head-cell">{{ leftTitle }}</div>
|
||||||
|
<div class="diff-head-cell">{{ rightTitle }}</div>
|
||||||
|
</div>
|
||||||
|
<div ref="diffRoot" class="diff-body"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { createTwoFilesPatch } from 'diff';
|
||||||
|
import { html as renderDiffHtml } from 'diff2html';
|
||||||
|
import 'diff2html/bundles/css/diff2html.min.css';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
leftText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
rightText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
leftTitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
rightTitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const diffRoot = ref(null);
|
||||||
|
|
||||||
|
const renderDiff = async () => {
|
||||||
|
await nextTick();
|
||||||
|
if (!diffRoot.value) return;
|
||||||
|
const patch = createTwoFilesPatch(
|
||||||
|
'document',
|
||||||
|
'document',
|
||||||
|
`${props.leftText || ''}`,
|
||||||
|
`${props.rightText || ''}`,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
{ context: 3 }
|
||||||
|
);
|
||||||
|
const html = renderDiffHtml(patch, {
|
||||||
|
outputFormat: 'side-by-side',
|
||||||
|
drawFileList: false,
|
||||||
|
matching: 'lines',
|
||||||
|
diffStyle: 'word',
|
||||||
|
renderNothingWhenEmpty: false
|
||||||
|
});
|
||||||
|
if (diffRoot.value) {
|
||||||
|
diffRoot.value.innerHTML = html;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await renderDiff();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.leftText, props.rightText, props.leftTitle, props.rightTitle],
|
||||||
|
async () => {
|
||||||
|
await renderDiff();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (diffRoot.value) {
|
||||||
|
diffRoot.value.innerHTML = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.diff-viewer {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-head-cell {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-medium);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-head-cell:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-body {
|
||||||
|
padding: 12px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.d2h-wrapper) {
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-white);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.d2h-file-wrapper) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.d2h-file-side-diff) {
|
||||||
|
max-height: 640px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .diff-head {
|
||||||
|
background: #1b2330;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) :deep(.d2h-wrapper),
|
||||||
|
:global(.dark-mode) :deep(.d2h-file-wrapper) {
|
||||||
|
background: #111827;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) :deep(.d2h-file-header),
|
||||||
|
:global(.dark-mode) :deep(.d2h-code-linenumber),
|
||||||
|
:global(.dark-mode) :deep(.d2h-code-side-linenumber),
|
||||||
|
:global(.dark-mode) :deep(.d2h-code-side-emptyplaceholder),
|
||||||
|
:global(.dark-mode) :deep(.d2h-file-side-diff) {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #d1d5db;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.diff-body {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.d2h-file-side-diff) {
|
||||||
|
max-height: 520px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rich-text-editor">
|
||||||
|
<div v-if="editor" ref="bodyScrollRef" class="rich-text-body">
|
||||||
|
<RichTextCodeLanguageFloating
|
||||||
|
v-model="codeLanguageDraft"
|
||||||
|
:visible="codeLanguageFloatingVisible"
|
||||||
|
:style-object="codeLanguageFloatingStyle"
|
||||||
|
:query-suggestions="queryCodeLanguageSuggestions"
|
||||||
|
@enter="codeLanguageInteracting = true"
|
||||||
|
@leave="codeLanguageInteracting = false"
|
||||||
|
@focus="codeLanguageInteracting = true"
|
||||||
|
@blur="handleCodeLanguageInputBlur"
|
||||||
|
@change="applyCodeLanguageFromDraft"
|
||||||
|
@select="handleCodeLanguageSelect"
|
||||||
|
/>
|
||||||
|
<RichTextSelectionActionBubble
|
||||||
|
:visible="selectionCommentVisible"
|
||||||
|
:style-object="selectionCommentStyle"
|
||||||
|
:comment-enabled="commentEnabled"
|
||||||
|
:comment-title="t('document.comments')"
|
||||||
|
:selection-bold-active="isActive('bold')"
|
||||||
|
:selection-underline-active="isActive('underline')"
|
||||||
|
:selection-italic-active="isActive('italic')"
|
||||||
|
:selection-strike-active="isActive('strike')"
|
||||||
|
:selection-inline-code-active="isActive('code')"
|
||||||
|
:selection-link-active="isActive('link')"
|
||||||
|
:selection-highlight-active="isActive('highlight')"
|
||||||
|
:selection-text-color="selectionTextColor"
|
||||||
|
:selection-background-color="selectionBackgroundColor"
|
||||||
|
@enter="selectionCommentHovering = true"
|
||||||
|
@leave="selectionCommentHovering = false"
|
||||||
|
@toggle-bold="toggleBold"
|
||||||
|
@toggle-underline="toggleUnderline"
|
||||||
|
@toggle-italic="toggleItalic"
|
||||||
|
@toggle-strike="toggleStrike"
|
||||||
|
@toggle-inline-code="toggleInlineCode"
|
||||||
|
@link-click="openSelectionLinkDialog"
|
||||||
|
@text-color-change="setTextColor"
|
||||||
|
@background-color-change="setBackgroundColor"
|
||||||
|
@comment-click="handleSelectionCommentClick"
|
||||||
|
/>
|
||||||
|
<RichTextLinkHoverCard
|
||||||
|
:visible="linkHoverVisible"
|
||||||
|
:style-object="linkHoverStyle"
|
||||||
|
:href="linkHoverHref"
|
||||||
|
@enter="handleLinkCardEnter"
|
||||||
|
@leave="handleLinkCardLeave"
|
||||||
|
@edit="openHoverLinkDialog"
|
||||||
|
@remove="removeHoverLink"
|
||||||
|
/>
|
||||||
|
<RichTextLinkDialog
|
||||||
|
v-model="linkDialogValue"
|
||||||
|
:visible="linkDialogVisible"
|
||||||
|
@cancel="closeLinkDialog"
|
||||||
|
@confirm="applyLinkDialog"
|
||||||
|
/>
|
||||||
|
<RichTextParagraphHandle
|
||||||
|
:visible="paragraphHandleVisible"
|
||||||
|
:style="paragraphHandleStyle"
|
||||||
|
:mode="paragraphHandleMode"
|
||||||
|
:block-type="paragraphType"
|
||||||
|
:actions="paragraphActions"
|
||||||
|
plus-title="新增段落"
|
||||||
|
more-title="更多操作"
|
||||||
|
:type-label="paragraphTypeLabel"
|
||||||
|
@action="runParagraphAction"
|
||||||
|
@mouseenter="handleHandleEnter"
|
||||||
|
@mouseleave="handleHandleLeave"
|
||||||
|
/>
|
||||||
|
<EditorContent :editor="editor" class="rich-text-content-host" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="rich-text-placeholder">{{ placeholderText }}</div>
|
||||||
|
|
||||||
|
<footer class="rich-text-hint">
|
||||||
|
{{ t('document.richTextShortcutHint') }}
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Mention popup — rendered inside the main app so it inherits theme/CSS vars -->
|
||||||
|
<MentionUserList
|
||||||
|
:visible="mentionPopupState.visible"
|
||||||
|
:keyword="mentionPopupState.keyword"
|
||||||
|
:position="mentionPopupState.position"
|
||||||
|
:active-index="mentionPopupState.activeIndex"
|
||||||
|
@select="onMentionSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch, onBeforeUnmount } from 'vue';
|
||||||
|
import { EditorContent } from '@tiptap/vue-3';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import RichTextParagraphHandle from '@/components/document/rich-text/RichTextParagraphHandle.vue';
|
||||||
|
import RichTextCodeLanguageFloating from '@/components/document/rich-text/overlay/RichTextCodeLanguageFloating.vue';
|
||||||
|
import RichTextSelectionActionBubble from '@/components/document/rich-text/overlay/RichTextSelectionActionBubble.vue';
|
||||||
|
import RichTextLinkHoverCard from '@/components/document/rich-text/overlay/RichTextLinkHoverCard.vue';
|
||||||
|
import RichTextLinkDialog from '@/components/document/rich-text/overlay/RichTextLinkDialog.vue';
|
||||||
|
import MentionUserList from '@/components/shared/MentionUserList.vue';
|
||||||
|
import { mentionPopupState, hideMentionPopup } from '@/components/document/rich-text/mentionPopupState.js';
|
||||||
|
import { useRichTextParagraphHandle } from '@/composables/document/editor/rich-text-editor/useRichTextParagraphHandle';
|
||||||
|
import { useRichTextValueBridge } from '@/composables/document/editor/rich-text-editor/useRichTextValueBridge';
|
||||||
|
import { useRichTextCodeLanguageOverlay } from '@/composables/document/editor/rich-text-editor/useRichTextCodeLanguageOverlay';
|
||||||
|
import { useRichTextCommentBridge } from '@/composables/document/editor/rich-text-editor/useRichTextCommentBridge';
|
||||||
|
import { useRichTextLinkOverlay } from '@/composables/document/editor/rich-text-editor/useRichTextLinkOverlay';
|
||||||
|
import { useRichTextToolbarActions } from '@/composables/document/editor/rich-text-editor/useRichTextToolbarActions';
|
||||||
|
import { useRichTextEditorRuntime } from '@/composables/document/editor/rich-text-editor/useRichTextEditorRuntime';
|
||||||
|
import { useRichTextSelectionColors } from '@/composables/document/editor/rich-text-editor/useRichTextSelectionColors';
|
||||||
|
import { useRichTextParagraphActions } from '@/composables/document/editor/rich-text-editor/useRichTextParagraphActions';
|
||||||
|
import { useRichTextYjsCollaboration } from '@/composables/document/editor/rich-text-editor/useRichTextYjsCollaboration';
|
||||||
|
|
||||||
|
function onMentionSelect(user) {
|
||||||
|
mentionPopupState.onSelect?.(user);
|
||||||
|
hideMentionPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
hideMentionPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: [String, Object],
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
valueFormat: {
|
||||||
|
type: String,
|
||||||
|
default: 'markdown',
|
||||||
|
validator: (value) => ['markdown', 'json'].includes(value)
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
externalExtensions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
enabledComponents: {
|
||||||
|
type: Array,
|
||||||
|
default: () => ['mindmap', 'drawio', 'table']
|
||||||
|
},
|
||||||
|
commentEnabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
collabEnabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
collabRoom: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
collabUrl: {
|
||||||
|
type: String,
|
||||||
|
default: '/ws/doc'
|
||||||
|
},
|
||||||
|
collabToken: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
reservedBridgeOptions: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:modelValue',
|
||||||
|
'collab-sync',
|
||||||
|
'commit',
|
||||||
|
'ready',
|
||||||
|
'comment-add',
|
||||||
|
'comment-remove',
|
||||||
|
'comment-changed'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const editor = ref(null);
|
||||||
|
const bodyScrollRef = ref(null);
|
||||||
|
const componentToolbarItems = ref([]);
|
||||||
|
|
||||||
|
const valueFormatRef = computed(() => props.valueFormat);
|
||||||
|
const modelValueRef = computed(() => props.modelValue);
|
||||||
|
const enabledComponentsRef = computed(() => props.enabledComponents);
|
||||||
|
const externalExtensionsRef = computed(() => props.externalExtensions);
|
||||||
|
const reservedBridgeOptionsRef = computed(() => props.reservedBridgeOptions);
|
||||||
|
const collabEnabledRef = computed(() => props.collabEnabled);
|
||||||
|
const collabRoomRef = computed(() => props.collabRoom);
|
||||||
|
const collabUrlRef = computed(() => props.collabUrl);
|
||||||
|
const collabTokenRef = computed(() => props.collabToken);
|
||||||
|
const {
|
||||||
|
applyingModelValue,
|
||||||
|
isJsonValueMode,
|
||||||
|
lastEmittedValue,
|
||||||
|
serializeModelValue,
|
||||||
|
modelValueFromEditor,
|
||||||
|
resolveInitialEditorContent,
|
||||||
|
applyModelValueToEditor,
|
||||||
|
syncLastEmittedValue
|
||||||
|
} = useRichTextValueBridge({
|
||||||
|
editorRef: editor,
|
||||||
|
valueFormatRef
|
||||||
|
});
|
||||||
|
|
||||||
|
const collaboration = useRichTextYjsCollaboration({
|
||||||
|
collabEnabledRef,
|
||||||
|
collabRoomRef,
|
||||||
|
collabUrlRef,
|
||||||
|
collabTokenRef,
|
||||||
|
modelValueRef,
|
||||||
|
editorRef: editor,
|
||||||
|
resolveInitialEditorContent,
|
||||||
|
onSync: (isSynced) => emit('collab-sync', isSynced)
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
lowlight,
|
||||||
|
codeLanguageDraft,
|
||||||
|
codeLanguageFloatingVisible,
|
||||||
|
codeLanguageFloatingStyle,
|
||||||
|
codeLanguageInteracting,
|
||||||
|
queryCodeLanguageSuggestions,
|
||||||
|
applyCodeLanguageFromDraft,
|
||||||
|
handleCodeLanguageSelect,
|
||||||
|
handleCodeLanguageInputBlur,
|
||||||
|
syncCodeLanguageDraft,
|
||||||
|
updateCodeLanguageFloating,
|
||||||
|
hideCodeLanguageFloating,
|
||||||
|
handleEditorBlur: handleCodeLanguageEditorBlur,
|
||||||
|
resolveCodeBlockLanguage
|
||||||
|
} = useRichTextCodeLanguageOverlay({
|
||||||
|
editorRef: editor,
|
||||||
|
scrollContainerRef: bodyScrollRef
|
||||||
|
});
|
||||||
|
|
||||||
|
const commentEnabledRef = computed(() => props.commentEnabled);
|
||||||
|
const {
|
||||||
|
selectionCommentVisible,
|
||||||
|
selectionCommentStyle,
|
||||||
|
selectionCommentHovering,
|
||||||
|
addInlineComment,
|
||||||
|
updateSelectionCommentTrigger,
|
||||||
|
requestSelectionCommentTriggerUpdate,
|
||||||
|
handleEditorBlur: handleSelectionCommentEditorBlur,
|
||||||
|
clearSelectionRaf,
|
||||||
|
getCommentIds,
|
||||||
|
hlCommentIds,
|
||||||
|
unHlCommentIds,
|
||||||
|
removeCommentIds,
|
||||||
|
scrollToCommentId
|
||||||
|
} = useRichTextCommentBridge({
|
||||||
|
editorRef: editor,
|
||||||
|
scrollContainerRef: bodyScrollRef,
|
||||||
|
commentEnabledRef,
|
||||||
|
onCommentAdd: (payload) => emit('comment-add', payload),
|
||||||
|
onCommentRemove: (ids) => emit('comment-remove', ids),
|
||||||
|
onCommentChanged: () => emit('comment-changed')
|
||||||
|
});
|
||||||
|
|
||||||
|
const placeholderText = computed(() => props.placeholder || t('document.editorPlaceholder'));
|
||||||
|
|
||||||
|
const {
|
||||||
|
isActive,
|
||||||
|
toggleBold,
|
||||||
|
toggleUnderline,
|
||||||
|
toggleItalic,
|
||||||
|
toggleStrike,
|
||||||
|
toggleInlineCode,
|
||||||
|
toggleBulletList,
|
||||||
|
toggleOrderedList,
|
||||||
|
toggleBlockquote,
|
||||||
|
toggleCodeBlock,
|
||||||
|
setLink,
|
||||||
|
unsetLink,
|
||||||
|
setTextColor,
|
||||||
|
setBackgroundColor,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
runComponentCommand
|
||||||
|
} = useRichTextToolbarActions({
|
||||||
|
editorRef: editor,
|
||||||
|
codeLanguageDraftRef: codeLanguageDraft,
|
||||||
|
resolveCodeBlockLanguage,
|
||||||
|
hideCodeLanguageFloating,
|
||||||
|
updateCodeLanguageFloating
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
linkDialogVisible,
|
||||||
|
linkDialogValue,
|
||||||
|
linkHoverVisible,
|
||||||
|
linkHoverStyle,
|
||||||
|
linkHoverHref,
|
||||||
|
openSelectionLinkDialog,
|
||||||
|
openHoverLinkDialog,
|
||||||
|
closeLinkDialog,
|
||||||
|
applyLinkDialog,
|
||||||
|
removeHoverLink,
|
||||||
|
handleLinkCardEnter,
|
||||||
|
handleLinkCardLeave
|
||||||
|
} = useRichTextLinkOverlay({
|
||||||
|
editorRef: editor,
|
||||||
|
scrollContainerRef: bodyScrollRef,
|
||||||
|
setLink,
|
||||||
|
unsetLink
|
||||||
|
});
|
||||||
|
|
||||||
|
const { emptyParagraphActions } = useRichTextParagraphActions({
|
||||||
|
editorRef: editor,
|
||||||
|
componentToolbarItemsRef: componentToolbarItems,
|
||||||
|
runComponentCommand,
|
||||||
|
toggleBulletList,
|
||||||
|
toggleOrderedList,
|
||||||
|
toggleBlockquote,
|
||||||
|
toggleCodeBlock,
|
||||||
|
undo,
|
||||||
|
redo
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
paragraphHandleVisible,
|
||||||
|
paragraphHandleStyle,
|
||||||
|
paragraphHandleMode,
|
||||||
|
paragraphType,
|
||||||
|
paragraphActions,
|
||||||
|
runParagraphAction,
|
||||||
|
handleHandleEnter,
|
||||||
|
handleHandleLeave
|
||||||
|
} = useRichTextParagraphHandle({
|
||||||
|
editorRef: editor,
|
||||||
|
scrollContainerRef: bodyScrollRef,
|
||||||
|
labels: {
|
||||||
|
insertEmptyParagraph: '插入空段落',
|
||||||
|
addAbove: '在上方',
|
||||||
|
addBelow: '在下方',
|
||||||
|
delete: t('document.delete')
|
||||||
|
},
|
||||||
|
resolveActions: ({ isEmpty, defaults }) => {
|
||||||
|
if (!isEmpty) {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
return [...emptyParagraphActions.value, ...defaults];
|
||||||
|
},
|
||||||
|
onMutated: () => {
|
||||||
|
emit('comment-changed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const paragraphTypeLabelMap = {
|
||||||
|
paragraph: '段落',
|
||||||
|
heading: '标题',
|
||||||
|
list: '列表',
|
||||||
|
quote: '引用',
|
||||||
|
code: '代码块',
|
||||||
|
table: '表格',
|
||||||
|
divider: '分割线',
|
||||||
|
mindmap: 'Mindmap',
|
||||||
|
drawio: 'Draw.io'
|
||||||
|
};
|
||||||
|
const paragraphTypeLabel = computed(() => paragraphTypeLabelMap[paragraphType.value] || '段落');
|
||||||
|
const { selectionTextColor, selectionBackgroundColor } = useRichTextSelectionColors({
|
||||||
|
editorRef: editor
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSelectionCommentClick = () => {
|
||||||
|
addInlineComment();
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtime = useRichTextEditorRuntime({
|
||||||
|
editorRef: editor,
|
||||||
|
bodyScrollRef,
|
||||||
|
modelValueRef,
|
||||||
|
valueFormatIsJsonRef: isJsonValueMode,
|
||||||
|
placeholderTextRef: placeholderText,
|
||||||
|
commentEnabledRef,
|
||||||
|
enabledComponentsRef,
|
||||||
|
externalExtensionsRef,
|
||||||
|
additionalExtensionsRef: collaboration.collaborationExtensionsRef,
|
||||||
|
reservedBridgeOptionsRef,
|
||||||
|
collaborationBridgeRef: collaboration.collaborationBridgeRef,
|
||||||
|
componentToolbarItemsRef: componentToolbarItems,
|
||||||
|
lowlight,
|
||||||
|
applyingModelValueRef: applyingModelValue,
|
||||||
|
lastEmittedValueRef: lastEmittedValue,
|
||||||
|
serializeModelValue,
|
||||||
|
modelValueFromEditor,
|
||||||
|
resolveInitialEditorContent,
|
||||||
|
applyModelValueToEditor,
|
||||||
|
syncLastEmittedValue,
|
||||||
|
syncCodeLanguageDraft,
|
||||||
|
updateCodeLanguageFloating,
|
||||||
|
updateSelectionCommentTrigger,
|
||||||
|
handleSelectionCommentEditorBlur,
|
||||||
|
handleCodeLanguageEditorBlur,
|
||||||
|
requestSelectionCommentTriggerUpdate,
|
||||||
|
clearSelectionRaf,
|
||||||
|
selectionCommentVisibleRef: selectionCommentVisible,
|
||||||
|
onUpdateModelValue: (value) => emit('update:modelValue', value),
|
||||||
|
onCommit: () => emit('commit'),
|
||||||
|
onReady: (payload) => emit('ready', payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.collabEnabled, props.collabRoom, props.collabUrl, props.collabToken],
|
||||||
|
() => {
|
||||||
|
collaboration.setupCollaboration();
|
||||||
|
runtime.recreateEditor();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
reRender: () => {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance?.view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance.view.updateState(instance.state);
|
||||||
|
},
|
||||||
|
getEditor: () => editor.value,
|
||||||
|
getCommentIds,
|
||||||
|
hlCommentIds,
|
||||||
|
unHlCommentIds,
|
||||||
|
removeCommentIds,
|
||||||
|
scrollToCommentId,
|
||||||
|
broadcastCommentChange: () => {
|
||||||
|
emit('comment-changed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped src="./rich-text/yoreseeRichTextEditor.css"></style>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<div class="document-preview-viewer">
|
||||||
|
<TablePreviewViewer
|
||||||
|
v-if="previewKind === 'table'"
|
||||||
|
:rows="tableRows"
|
||||||
|
/>
|
||||||
|
<SlidePreviewViewer
|
||||||
|
v-else-if="previewKind === 'slide'"
|
||||||
|
:slides="slideSections"
|
||||||
|
/>
|
||||||
|
<div v-else class="markdown-preview-shell">
|
||||||
|
<el-empty v-if="!previewMarkdown" />
|
||||||
|
<div v-else ref="markdownRef" class="markdown-preview-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import Vditor from 'vditor';
|
||||||
|
import 'vditor/dist/index.css';
|
||||||
|
import {
|
||||||
|
resolveDocumentPreviewContent,
|
||||||
|
resolveDocumentPreviewKind,
|
||||||
|
resolveSlidePreviewSections,
|
||||||
|
resolveTablePreviewRows
|
||||||
|
} from '@/composables/document/render/useDocumentRenderBridge';
|
||||||
|
import TablePreviewViewer from '@/components/document/render/TablePreviewViewer.vue';
|
||||||
|
import SlidePreviewViewer from '@/components/document/render/SlidePreviewViewer.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
documentType: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: '1'
|
||||||
|
},
|
||||||
|
isTemplate: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
isDarkMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const markdownRef = ref(null);
|
||||||
|
|
||||||
|
const previewKind = computed(() => resolveDocumentPreviewKind(props.documentType));
|
||||||
|
const previewMarkdown = computed(() => resolveDocumentPreviewContent({
|
||||||
|
content: props.content,
|
||||||
|
documentType: props.documentType,
|
||||||
|
isTemplate: props.isTemplate
|
||||||
|
}));
|
||||||
|
const tableRows = computed(() => resolveTablePreviewRows({
|
||||||
|
content: props.content,
|
||||||
|
isTemplate: props.isTemplate
|
||||||
|
}));
|
||||||
|
const slideSections = computed(() => resolveSlidePreviewSections({
|
||||||
|
content: props.content,
|
||||||
|
isTemplate: props.isTemplate
|
||||||
|
}));
|
||||||
|
|
||||||
|
const renderMarkdown = async () => {
|
||||||
|
if (previewKind.value !== 'markdown') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
if (!markdownRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!previewMarkdown.value) {
|
||||||
|
markdownRef.value.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Vditor.preview(markdownRef.value, previewMarkdown.value, {
|
||||||
|
mode: props.isDarkMode ? 'dark' : 'light',
|
||||||
|
theme: {
|
||||||
|
current: props.isDarkMode ? 'dark' : 'light'
|
||||||
|
},
|
||||||
|
hljs: {
|
||||||
|
style: props.isDarkMode ? 'monokai' : 'github'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [previewKind.value, previewMarkdown.value, props.isDarkMode],
|
||||||
|
() => {
|
||||||
|
renderMarkdown();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.document-preview-viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview-shell {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--bg-white);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview-body {
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .markdown-preview-body.vditor-reset {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<div class="slide-preview-viewer">
|
||||||
|
<el-empty v-if="renderedSlides.length === 0" />
|
||||||
|
<template v-else>
|
||||||
|
<swiper
|
||||||
|
ref="swiperRef"
|
||||||
|
class="slide-swiper"
|
||||||
|
:modules="swiperModules"
|
||||||
|
:slides-per-view="1"
|
||||||
|
:space-between="12"
|
||||||
|
:navigation="true"
|
||||||
|
:pagination="{ clickable: true }"
|
||||||
|
:keyboard="{ enabled: true }"
|
||||||
|
:observer="true"
|
||||||
|
:observe-parents="true"
|
||||||
|
@slideChange="handleSlideChange"
|
||||||
|
>
|
||||||
|
<swiper-slide
|
||||||
|
v-for="slide in renderedSlides"
|
||||||
|
:key="slide.id"
|
||||||
|
class="slide-item"
|
||||||
|
>
|
||||||
|
<article class="slide-card" v-html="slide.html"></article>
|
||||||
|
</swiper-slide>
|
||||||
|
</swiper>
|
||||||
|
<div class="slide-index">{{ `${activeIndex + 1} / ${renderedSlides.length}` }}</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import { Swiper, SwiperSlide } from 'swiper/vue';
|
||||||
|
import { Keyboard, Navigation, Pagination } from 'swiper/modules';
|
||||||
|
import 'swiper/css';
|
||||||
|
import 'swiper/css/navigation';
|
||||||
|
import 'swiper/css/pagination';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
slides: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const swiperRef = ref(null);
|
||||||
|
const activeIndex = ref(0);
|
||||||
|
const swiperModules = [Navigation, Pagination, Keyboard];
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
gfm: true,
|
||||||
|
breaks: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderedSlides = computed(() =>
|
||||||
|
(Array.isArray(props.slides) ? props.slides : [])
|
||||||
|
.map((slide) => String(slide || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((slide, index) => ({
|
||||||
|
id: `${index}-${slide.length}`,
|
||||||
|
html: marked.parse(slide)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSwiperLayout = async () => {
|
||||||
|
await nextTick();
|
||||||
|
const instance = swiperRef.value?.swiper;
|
||||||
|
if (!instance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance.update?.();
|
||||||
|
if (instance.activeIndex >= renderedSlides.value.length) {
|
||||||
|
const target = Math.max(0, renderedSlides.value.length - 1);
|
||||||
|
instance.slideTo(target, 0);
|
||||||
|
}
|
||||||
|
activeIndex.value = instance.activeIndex || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlideChange = (swiper) => {
|
||||||
|
if (!swiper) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeIndex.value = Number(swiper.activeIndex) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
renderedSlides,
|
||||||
|
() => {
|
||||||
|
updateSwiperLayout();
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.slide-preview-viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--bg-white);
|
||||||
|
padding: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-swiper {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-item {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-card {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg-white);
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-card :deep(h1),
|
||||||
|
.slide-card :deep(h2),
|
||||||
|
.slide-card :deep(h3) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-card :deep(p),
|
||||||
|
.slide-card :deep(li) {
|
||||||
|
color: var(--text-medium);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-index {
|
||||||
|
margin-top: 8px;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-swiper :deep(.swiper-button-next),
|
||||||
|
.slide-swiper :deep(.swiper-button-prev) {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-swiper :deep(.swiper-pagination-bullet) {
|
||||||
|
background: var(--text-light);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-swiper :deep(.swiper-pagination-bullet-active) {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<template>
|
||||||
|
<div class="table-diff-viewer">
|
||||||
|
<div class="table-diff-head">
|
||||||
|
<div class="head-cell">{{ leftTitle }}</div>
|
||||||
|
<div class="head-cell">{{ rightTitle }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-diff-body">
|
||||||
|
<div class="table-pane">
|
||||||
|
<table class="diff-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="index-head"></th>
|
||||||
|
<th v-for="label in columnLabels" :key="`l-${label}`" class="col-head">{{ label }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="rowIndex in rowIndexes" :key="`left-row-${rowIndex}`">
|
||||||
|
<th class="index-cell">{{ rowIndex + 1 }}</th>
|
||||||
|
<td
|
||||||
|
v-for="colIndex in colIndexes"
|
||||||
|
:key="`left-cell-${rowIndex}-${colIndex}`"
|
||||||
|
class="value-cell"
|
||||||
|
:class="leftCellClass(rowIndex, colIndex)"
|
||||||
|
>
|
||||||
|
{{ resolveCell(leftRowsNormalized, rowIndex, colIndex) || '\u00A0' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-pane">
|
||||||
|
<table class="diff-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="index-head"></th>
|
||||||
|
<th v-for="label in columnLabels" :key="`r-${label}`" class="col-head">{{ label }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="rowIndex in rowIndexes" :key="`right-row-${rowIndex}`">
|
||||||
|
<th class="index-cell">{{ rowIndex + 1 }}</th>
|
||||||
|
<td
|
||||||
|
v-for="colIndex in colIndexes"
|
||||||
|
:key="`right-cell-${rowIndex}-${colIndex}`"
|
||||||
|
class="value-cell"
|
||||||
|
:class="rightCellClass(rowIndex, colIndex)"
|
||||||
|
>
|
||||||
|
{{ resolveCell(rightRowsNormalized, rowIndex, colIndex) || '\u00A0' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-diff-legend">
|
||||||
|
<span class="legend-item is-added">Added</span>
|
||||||
|
<span class="legend-item is-removed">Removed</span>
|
||||||
|
<span class="legend-item is-changed">Changed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
leftRows: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
rightRows: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
leftTitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
rightTitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
import { normalizeRows, columnLabelAt } from '@/utils/tableUtils';
|
||||||
|
|
||||||
|
const rowCount = computed(() => Math.max(
|
||||||
|
Array.isArray(props.leftRows) ? props.leftRows.length : 0,
|
||||||
|
Array.isArray(props.rightRows) ? props.rightRows.length : 0
|
||||||
|
));
|
||||||
|
|
||||||
|
const columnCount = computed(() => {
|
||||||
|
const leftMax = Array.isArray(props.leftRows)
|
||||||
|
? Math.max(0, ...props.leftRows.map((row) => (Array.isArray(row) ? row.length : 0)))
|
||||||
|
: 0;
|
||||||
|
const rightMax = Array.isArray(props.rightRows)
|
||||||
|
? Math.max(0, ...props.rightRows.map((row) => (Array.isArray(row) ? row.length : 0)))
|
||||||
|
: 0;
|
||||||
|
return Math.max(1, leftMax, rightMax);
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftRowsNormalized = computed(() => normalizeRows(props.leftRows, columnCount.value));
|
||||||
|
const rightRowsNormalized = computed(() => normalizeRows(props.rightRows, columnCount.value));
|
||||||
|
const rowIndexes = computed(() => Array.from({ length: rowCount.value }, (_, index) => index));
|
||||||
|
const colIndexes = computed(() => Array.from({ length: columnCount.value }, (_, index) => index));
|
||||||
|
const columnLabels = computed(() => colIndexes.value.map((index) => columnLabelAt(index)));
|
||||||
|
|
||||||
|
const resolveCell = (rows, rowIndex, colIndex) =>
|
||||||
|
String(rows?.[rowIndex]?.[colIndex] ?? '');
|
||||||
|
|
||||||
|
const cellStatus = (rowIndex, colIndex) => {
|
||||||
|
const leftValue = resolveCell(leftRowsNormalized.value, rowIndex, colIndex);
|
||||||
|
const rightValue = resolveCell(rightRowsNormalized.value, rowIndex, colIndex);
|
||||||
|
if (leftValue === rightValue) {
|
||||||
|
return 'equal';
|
||||||
|
}
|
||||||
|
if (!leftValue && rightValue) {
|
||||||
|
return 'added';
|
||||||
|
}
|
||||||
|
if (leftValue && !rightValue) {
|
||||||
|
return 'removed';
|
||||||
|
}
|
||||||
|
return 'changed';
|
||||||
|
};
|
||||||
|
|
||||||
|
const leftCellClass = (rowIndex, colIndex) => {
|
||||||
|
const status = cellStatus(rowIndex, colIndex);
|
||||||
|
if (status === 'changed') {
|
||||||
|
return 'is-changed';
|
||||||
|
}
|
||||||
|
if (status === 'removed') {
|
||||||
|
return 'is-removed';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const rightCellClass = (rowIndex, colIndex) => {
|
||||||
|
const status = cellStatus(rowIndex, colIndex);
|
||||||
|
if (status === 'changed') {
|
||||||
|
return 'is-changed';
|
||||||
|
}
|
||||||
|
if (status === 'added') {
|
||||||
|
return 'is-added';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-diff-viewer {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--bg-white);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-diff-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-cell {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-medium);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-cell:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-diff-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-pane {
|
||||||
|
overflow: auto;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-pane:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-table {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-table th,
|
||||||
|
.diff-table td {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 220px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--bg-light);
|
||||||
|
color: var(--text-medium);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-head,
|
||||||
|
.index-cell {
|
||||||
|
min-width: 44px !important;
|
||||||
|
max-width: 44px !important;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-head {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
background: var(--bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-cell {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--bg-light);
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-cell {
|
||||||
|
background: var(--bg-white);
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-added {
|
||||||
|
background: rgba(34, 197, 94, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-removed {
|
||||||
|
background: rgba(239, 68, 68, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-changed {
|
||||||
|
background: rgba(245, 158, 11, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-diff-legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .table-diff-head,
|
||||||
|
.dark-mode .index-head,
|
||||||
|
.dark-mode .index-cell,
|
||||||
|
.dark-mode .col-head {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<div class="table-preview-viewer">
|
||||||
|
<el-empty v-if="!hasRows" />
|
||||||
|
<div v-else class="table-preview-scroll">
|
||||||
|
<table class="table-preview-grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="row-index-head"></th>
|
||||||
|
<th v-for="label in columnLabels" :key="label" class="col-head">
|
||||||
|
{{ label }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, rowIndex) in normalizedRows" :key="`row-${rowIndex}`">
|
||||||
|
<th class="row-index-cell">{{ rowIndex + 1 }}</th>
|
||||||
|
<td v-for="(cell, colIndex) in row" :key="`cell-${rowIndex}-${colIndex}`" class="value-cell">
|
||||||
|
{{ cell || '\u00A0' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
rows: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
import { normalizeRows, columnLabelAt } from '@/utils/tableUtils';
|
||||||
|
|
||||||
|
const normalizedRows = computed(() => normalizeRows(props.rows));
|
||||||
|
const hasRows = computed(() => normalizedRows.value.length > 0);
|
||||||
|
const columnLabels = computed(() => {
|
||||||
|
const width = normalizedRows.value[0]?.length || 0;
|
||||||
|
return Array.from({ length: width }, (_, index) => columnLabelAt(index));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-preview-viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--bg-white);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-preview-scroll {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-preview-grid {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-preview-grid th,
|
||||||
|
.table-preview-grid td {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 260px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-preview-grid thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--bg-light);
|
||||||
|
color: var(--text-medium);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-index-head,
|
||||||
|
.row-index-cell {
|
||||||
|
min-width: 52px !important;
|
||||||
|
max-width: 52px !important;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-index-cell {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--bg-light);
|
||||||
|
color: var(--text-light);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-index-head {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-cell {
|
||||||
|
color: var(--text-dark);
|
||||||
|
background: var(--bg-white);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { mentionPopupState, hideMentionPopup } from './mentionPopupState.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiptap suggestion configuration for @mention.
|
||||||
|
* UI is rendered by MentionUserList inside the main Vue app (via YoreseeRichTextEditor).
|
||||||
|
* This file only drives the reactive state — no createApp, no DOM manipulation.
|
||||||
|
*/
|
||||||
|
export const mentionSuggestion = {
|
||||||
|
char: '@',
|
||||||
|
allowSpaces: false,
|
||||||
|
startOfLine: false,
|
||||||
|
|
||||||
|
// items() is not used here — fetching is done inside MentionUserList via watch on keyword
|
||||||
|
items: () => [],
|
||||||
|
|
||||||
|
render: () => {
|
||||||
|
function getPosition(clientRect) {
|
||||||
|
const rect = typeof clientRect === 'function' ? clientRect() : clientRect;
|
||||||
|
if (!rect) return { x: 0, y: 0 };
|
||||||
|
return { x: rect.left, y: rect.bottom + 4 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart(props) {
|
||||||
|
mentionPopupState.visible = true;
|
||||||
|
mentionPopupState.keyword = props.query || '';
|
||||||
|
mentionPopupState.position = getPosition(props.clientRect);
|
||||||
|
mentionPopupState.activeIndex = 0;
|
||||||
|
mentionPopupState.onSelect = (user) =>
|
||||||
|
props.command({ id: user.external_id, label: user.nickname || user.username });
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdate(props) {
|
||||||
|
mentionPopupState.keyword = props.query || '';
|
||||||
|
mentionPopupState.position = getPosition(props.clientRect);
|
||||||
|
mentionPopupState.onSelect = (user) =>
|
||||||
|
props.command({ id: user.external_id, label: user.nickname || user.username });
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeyDown({ event }) {
|
||||||
|
if (!mentionPopupState.visible) return false;
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
hideMentionPopup();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
mentionPopupState.activeIndex = (mentionPopupState.activeIndex + 1) % 8;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
mentionPopupState.activeIndex = Math.max(0, mentionPopupState.activeIndex - 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
onExit() {
|
||||||
|
hideMentionPopup();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div class="handle-menu-branch" :class="{ 'is-submenu': level > 0 }">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in actions"
|
||||||
|
:key="buildPath(item, index)"
|
||||||
|
class="handle-menu-node"
|
||||||
|
@mouseenter="handleNodeEnter(item, index)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="handle-menu-row"
|
||||||
|
:class="{
|
||||||
|
'is-danger': Boolean(item?.danger),
|
||||||
|
'is-disabled': Boolean(item?.disabled),
|
||||||
|
'is-group': isGroupAction(item)
|
||||||
|
}"
|
||||||
|
:disabled="Boolean(item?.disabled)"
|
||||||
|
@click.stop.prevent="handleRowClick(item)"
|
||||||
|
>
|
||||||
|
<span class="handle-menu-row-icon">
|
||||||
|
<el-icon v-if="resolveIcon(item)">
|
||||||
|
<component :is="resolveIcon(item)" />
|
||||||
|
</el-icon>
|
||||||
|
</span>
|
||||||
|
<span class="handle-menu-row-label">{{ item?.label || '' }}</span>
|
||||||
|
<span v-if="isGroupAction(item)" class="handle-menu-row-arrow">
|
||||||
|
<el-icon><ArrowRight /></el-icon>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isGroupAction(item) && isGroupOpen(buildPath(item, index))"
|
||||||
|
class="handle-menu-subpanel"
|
||||||
|
>
|
||||||
|
<RichTextHandleMenuBranch
|
||||||
|
:actions="item.children"
|
||||||
|
:level="level + 1"
|
||||||
|
:path-prefix="buildPath(item, index)"
|
||||||
|
:open-paths="openPaths"
|
||||||
|
:resolve-icon="resolveIcon"
|
||||||
|
@select="$emit('select', $event)"
|
||||||
|
@group-enter="$emit('group-enter', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ArrowRight } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
actions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
pathPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
openPaths: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
resolveIcon: {
|
||||||
|
type: Function,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select', 'group-enter']);
|
||||||
|
|
||||||
|
const isGroupAction = (item) => Array.isArray(item?.children) && item.children.length > 0;
|
||||||
|
|
||||||
|
const buildPath = (item, index) => {
|
||||||
|
const token = item?.key || `idx-${index}`;
|
||||||
|
return props.pathPrefix ? `${props.pathPrefix}.${token}` : token;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isGroupOpen = (path) => props.openPaths[props.level] === path;
|
||||||
|
|
||||||
|
const handleNodeEnter = (item, index) => {
|
||||||
|
const path = isGroupAction(item) ? buildPath(item, index) : null;
|
||||||
|
emit('group-enter', {
|
||||||
|
level: props.level,
|
||||||
|
path
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowClick = (item) => {
|
||||||
|
if (!item || item.disabled || isGroupAction(item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('select', item.key);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.handle-menu-branch {
|
||||||
|
min-width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-branch.is-submenu {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--bg-white);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: var(--spacing-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-node {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-row {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-md);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
color: var(--text-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-row-icon {
|
||||||
|
width: 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-row-label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-row-arrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-row:hover {
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-row:hover .handle-menu-row-icon,
|
||||||
|
.handle-menu-row:hover .handle-menu-row-arrow {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-row.is-danger:hover {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-row.is-danger:hover .handle-menu-row-icon,
|
||||||
|
.handle-menu-row.is-danger:hover .handle-menu-row-arrow {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-row.is-disabled {
|
||||||
|
color: var(--text-light);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-row.is-disabled:hover {
|
||||||
|
color: var(--text-light);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-row.is-disabled:hover .handle-menu-row-icon,
|
||||||
|
.handle-menu-row.is-disabled:hover .handle-menu-row-arrow {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-row.is-group {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-subpanel {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: calc(100% - 3px);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
ref="rootRef"
|
||||||
|
class="rich-text-paragraph-handle"
|
||||||
|
:style="style"
|
||||||
|
@mouseenter="onHandleEnter"
|
||||||
|
@mouseleave="onHandleLeave"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="mode === 'empty'"
|
||||||
|
ref="triggerRef"
|
||||||
|
type="button"
|
||||||
|
class="handle-btn handle-btn--plus"
|
||||||
|
:title="plusTitle"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click.stop="toggleMenu"
|
||||||
|
>
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
ref="triggerRef"
|
||||||
|
type="button"
|
||||||
|
class="handle-btn handle-btn--combo"
|
||||||
|
:title="moreTitle || typeLabel"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click.stop="toggleMenu"
|
||||||
|
>
|
||||||
|
<span class="handle-btn-combo-main">
|
||||||
|
<el-icon><component :is="currentTypeIcon" /></el-icon>
|
||||||
|
</span>
|
||||||
|
<span class="handle-btn-combo-divider"></span>
|
||||||
|
<span class="handle-btn-combo-dots" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppMenu
|
||||||
|
ref="menuRef"
|
||||||
|
:visible="menuVisible"
|
||||||
|
:x="menuPosition.x"
|
||||||
|
:y="menuPosition.y"
|
||||||
|
:ignore-elements="[triggerRef, rootRef]"
|
||||||
|
@close="closeMenu"
|
||||||
|
>
|
||||||
|
<div class="handle-menu-root" @mouseleave="handleMenuMouseLeave">
|
||||||
|
<RichTextHandleMenuBranch
|
||||||
|
:actions="actions"
|
||||||
|
:open-paths="openPaths"
|
||||||
|
:resolve-icon="resolveActionIcon"
|
||||||
|
@group-enter="handleGroupEnter"
|
||||||
|
@select="selectAction"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AppMenu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
ArrowDownBold,
|
||||||
|
ArrowUpBold,
|
||||||
|
ChatDotRound,
|
||||||
|
ChatLineRound,
|
||||||
|
Connection,
|
||||||
|
Delete,
|
||||||
|
Document,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
Menu,
|
||||||
|
Minus,
|
||||||
|
Plus,
|
||||||
|
RefreshLeft,
|
||||||
|
RefreshRight,
|
||||||
|
Tickets
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
|
import AppMenu from '@/components/base/AppMenu.vue';
|
||||||
|
import RichTextHandleMenuBranch from '@/components/document/rich-text/RichTextHandleMenuBranch.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'normal'
|
||||||
|
},
|
||||||
|
blockType: {
|
||||||
|
type: String,
|
||||||
|
default: 'paragraph'
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
plusTitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
moreTitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
typeLabel: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['action', 'mouseenter', 'mouseleave']);
|
||||||
|
|
||||||
|
const triggerRef = ref(null);
|
||||||
|
const rootRef = ref(null);
|
||||||
|
const menuRef = ref(null);
|
||||||
|
const menuVisible = ref(false);
|
||||||
|
const menuPosition = ref({ x: 0, y: 0 });
|
||||||
|
const openPaths = ref([]);
|
||||||
|
|
||||||
|
const blockTypeIconMap = {
|
||||||
|
paragraph: Document,
|
||||||
|
heading: Menu,
|
||||||
|
list: List,
|
||||||
|
quote: ChatLineRound,
|
||||||
|
code: Tickets,
|
||||||
|
table: Grid,
|
||||||
|
divider: Minus,
|
||||||
|
mindmap: Connection,
|
||||||
|
drawio: Connection
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionIconMap = {
|
||||||
|
'add-above': ArrowUpBold,
|
||||||
|
'add-below': ArrowDownBold,
|
||||||
|
paragraph: Document,
|
||||||
|
'heading-1': Menu,
|
||||||
|
'heading-2': Menu,
|
||||||
|
'bullet-list': List,
|
||||||
|
'ordered-list': List,
|
||||||
|
blockquote: ChatLineRound,
|
||||||
|
'code-block': Tickets,
|
||||||
|
table: Grid,
|
||||||
|
mindmap: Connection,
|
||||||
|
drawio: Connection,
|
||||||
|
insert: Plus,
|
||||||
|
comment: ChatDotRound,
|
||||||
|
undo: RefreshLeft,
|
||||||
|
redo: RefreshRight,
|
||||||
|
delete: Delete
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentTypeIcon = computed(() => blockTypeIconMap[props.blockType] || Document);
|
||||||
|
|
||||||
|
const resolveActionIcon = (item) => {
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (item.icon && typeof item.icon === 'object') {
|
||||||
|
return item.icon;
|
||||||
|
}
|
||||||
|
return actionIconMap[item.iconKey || item.key] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMenuPosition = async () => {
|
||||||
|
const triggerEl = triggerRef.value;
|
||||||
|
if (!triggerEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = triggerEl.getBoundingClientRect();
|
||||||
|
const baseX = rect.right + 6;
|
||||||
|
const baseY = rect.top;
|
||||||
|
menuPosition.value = { x: baseX, y: baseY };
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const menuEl = menuRef.value?.getMenuElement?.();
|
||||||
|
if (!menuEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maxX = Math.max(8, window.innerWidth - menuEl.offsetWidth - 8);
|
||||||
|
const maxY = Math.max(8, window.innerHeight - menuEl.offsetHeight - 8);
|
||||||
|
menuPosition.value = {
|
||||||
|
x: Math.max(8, Math.min(baseX, maxX)),
|
||||||
|
y: Math.max(8, Math.min(baseY, maxY))
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMenu = async () => {
|
||||||
|
menuVisible.value = true;
|
||||||
|
openPaths.value = [];
|
||||||
|
emit('mouseenter');
|
||||||
|
await updateMenuPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
menuVisible.value = false;
|
||||||
|
openPaths.value = [];
|
||||||
|
emit('mouseleave');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
if (menuVisible.value) {
|
||||||
|
closeMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAction = (actionKey) => {
|
||||||
|
if (!actionKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('action', actionKey);
|
||||||
|
closeMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGroupEnter = ({ level, path }) => {
|
||||||
|
const next = openPaths.value.slice(0, Math.max(0, level + 1));
|
||||||
|
next[level] = path || null;
|
||||||
|
openPaths.value = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuMouseLeave = () => {
|
||||||
|
openPaths.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHandleEnter = () => {
|
||||||
|
emit('mouseenter');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHandleLeave = () => {
|
||||||
|
if (menuVisible.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('mouseleave');
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(next) => {
|
||||||
|
if (!next && menuVisible.value) {
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rich-text-paragraph-handle {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 26;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-btn {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-white);
|
||||||
|
color: var(--text-light);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-btn:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--primary-color) 55%, var(--border-color) 45%);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-btn--plus:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--primary-color) 65%, var(--border-color) 35%);
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 9%, var(--bg-white) 91%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-btn--combo {
|
||||||
|
width: auto;
|
||||||
|
min-width: 42px;
|
||||||
|
padding: 0 6px;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-btn-combo-main {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-btn-combo-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 11px;
|
||||||
|
background: color-mix(in srgb, var(--border-color) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-btn-combo-dots {
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow:
|
||||||
|
0 -5px 0 currentColor,
|
||||||
|
0 5px 0 currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-menu-root {
|
||||||
|
min-width: 170px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<template>
|
||||||
|
<NodeViewWrapper class="drawio-node" contenteditable="false">
|
||||||
|
<header class="drawio-header" data-drag-handle>
|
||||||
|
<span class="drawio-badge">Draw.io</span>
|
||||||
|
<div class="drawio-actions">
|
||||||
|
<button type="button" class="drawio-btn" @click="openEditor">
|
||||||
|
{{ hasDiagram ? 'Edit Diagram' : 'Create Diagram' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="hasDiagram"
|
||||||
|
type="button"
|
||||||
|
class="drawio-btn"
|
||||||
|
@click="clearDiagram"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="drawio-body">
|
||||||
|
<div v-if="hasDiagram" class="drawio-preview-wrap">
|
||||||
|
<iframe
|
||||||
|
ref="previewFrameRef"
|
||||||
|
class="drawio-preview-frame"
|
||||||
|
:src="DRAWIO_PREVIEW_URL"
|
||||||
|
tabindex="-1"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="editorVisible" class="drawio-overlay" @click.self="closeEditor">
|
||||||
|
<div class="drawio-modal">
|
||||||
|
<header class="drawio-modal-header">
|
||||||
|
<span>Draw.io Editor</span>
|
||||||
|
<button type="button" class="drawio-btn" @click="closeEditor">Close</button>
|
||||||
|
</header>
|
||||||
|
<iframe ref="iframeRef" class="drawio-frame" :src="DRAWIO_EMBED_URL"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
import { NodeViewWrapper } from '@tiptap/vue-3';
|
||||||
|
import { DEFAULT_DRAWIO_XML } from './drawioExtension';
|
||||||
|
|
||||||
|
const DRAWIO_ORIGIN = 'https://embed.diagrams.net';
|
||||||
|
const DRAWIO_EMBED_URL = `${DRAWIO_ORIGIN}/?embed=1&ui=min&spin=1&proto=json&saveAndExit=1&noSaveBtn=1`;
|
||||||
|
const DRAWIO_PREVIEW_URL = `${DRAWIO_ORIGIN}/?embed=1&ui=min&spin=1&proto=json&chrome=0&toolbar=0&pages=0&layers=0&status=0&noSaveBtn=1`;
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
node: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
updateAttributes: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const iframeRef = ref(null);
|
||||||
|
const previewFrameRef = ref(null);
|
||||||
|
const editorVisible = ref(false);
|
||||||
|
const previewReady = ref(false);
|
||||||
|
|
||||||
|
const currentDiagram = computed(() => String(props.node?.attrs?.diagram || ''));
|
||||||
|
const hasDiagram = computed(() => currentDiagram.value.trim().length > 0);
|
||||||
|
|
||||||
|
const postToFrame = (frameRef, message) => {
|
||||||
|
const targetWindow = frameRef.value?.contentWindow;
|
||||||
|
if (!targetWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targetWindow.postMessage(JSON.stringify(message), DRAWIO_ORIGIN);
|
||||||
|
};
|
||||||
|
|
||||||
|
const postToEditorFrame = (message) => {
|
||||||
|
postToFrame(iframeRef, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPreviewDiagram = () => {
|
||||||
|
if (!previewReady.value || !hasDiagram.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
postToFrame(previewFrameRef, {
|
||||||
|
action: 'load',
|
||||||
|
autosave: 0,
|
||||||
|
xml: currentDiagram.value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
editorVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditor = () => {
|
||||||
|
editorVisible.value = true;
|
||||||
|
postToEditorFrame({
|
||||||
|
action: 'load',
|
||||||
|
autosave: 1,
|
||||||
|
xml: currentDiagram.value || DEFAULT_DRAWIO_XML
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearDiagram = () => {
|
||||||
|
props.updateAttributes({ diagram: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMessage = (event) => {
|
||||||
|
if (event.origin !== DRAWIO_ORIGIN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isEditorFrame = event.source === iframeRef.value?.contentWindow;
|
||||||
|
const isPreviewFrame = event.source === previewFrameRef.value?.contentWindow;
|
||||||
|
if (!isEditorFrame && !isPreviewFrame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let payload = event.data;
|
||||||
|
if (typeof payload === 'string') {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(payload);
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventName = String(payload.event || '').trim();
|
||||||
|
if (eventName === 'init') {
|
||||||
|
if (isPreviewFrame) {
|
||||||
|
previewReady.value = true;
|
||||||
|
loadPreviewDiagram();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editorVisible.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
postToEditorFrame({
|
||||||
|
action: 'load',
|
||||||
|
autosave: 1,
|
||||||
|
xml: currentDiagram.value || DEFAULT_DRAWIO_XML
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditorFrame || !editorVisible.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventName === 'autosave' && typeof payload.xml === 'string') {
|
||||||
|
props.updateAttributes({ diagram: payload.xml });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventName === 'save') {
|
||||||
|
if (typeof payload.xml === 'string') {
|
||||||
|
props.updateAttributes({ diagram: payload.xml });
|
||||||
|
}
|
||||||
|
postToEditorFrame({ action: 'exit' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventName === 'exit') {
|
||||||
|
closeEditor();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
currentDiagram,
|
||||||
|
() => {
|
||||||
|
loadPreviewDiagram();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
hasDiagram,
|
||||||
|
(next) => {
|
||||||
|
if (!next) {
|
||||||
|
previewReady.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.drawio-node {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawio-header {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: color-mix(in srgb, var(--bg-light) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawio-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawio-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawio-btn {
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-white);
|
||||||
|
color: var(--text-medium);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawio-body {
|
||||||
|
min-height: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawio-preview-wrap {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 220px;
|
||||||
|
height: 280px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawio-preview-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawio-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in srgb, #000 45%, transparent);
|
||||||
|
z-index: 4000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawio-modal {
|
||||||
|
width: min(1200px, calc(100vw - 40px));
|
||||||
|
height: min(760px, calc(100vh - 40px));
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-white);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawio-modal-header {
|
||||||
|
height: 46px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 0 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawio-frame {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { mergeAttributes, Node } from '@tiptap/core';
|
||||||
|
import { VueNodeViewRenderer } from '@tiptap/vue-3';
|
||||||
|
import DrawioNodeView from './DrawioNodeView.vue';
|
||||||
|
|
||||||
|
export const DEFAULT_DRAWIO_XML = `<mxfile host="app.diagrams.net" modified="2026-01-01T00:00:00.000Z" agent="yoresee-rich-text" version="24.7.17">
|
||||||
|
<diagram id="page-1" name="Page-1">
|
||||||
|
<mxGraphModel dx="1142" dy="658" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1280" pageHeight="720" background="#ffffff" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
</mxfile>`;
|
||||||
|
|
||||||
|
const encodeDiagram = (value) => encodeURIComponent(String(value || ''));
|
||||||
|
const decodeDiagram = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return DEFAULT_DRAWIO_XML;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(String(value));
|
||||||
|
} catch (_) {
|
||||||
|
return String(value) || DEFAULT_DRAWIO_XML;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DrawioExtension = Node.create({
|
||||||
|
name: 'drawioBlock',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
diagram: {
|
||||||
|
default: DEFAULT_DRAWIO_XML,
|
||||||
|
parseHTML: (element) => decodeDiagram(
|
||||||
|
element.getAttribute('data-diagram') ||
|
||||||
|
element.getAttribute('diagram')
|
||||||
|
),
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
'data-diagram': encodeDiagram(attributes.diagram || '')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'yoresee-drawio' }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['yoresee-drawio', mergeAttributes(HTMLAttributes, { 'data-type': 'drawio' })];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return VueNodeViewRenderer(DrawioNodeView);
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertDrawioBlock:
|
||||||
|
(attrs = {}) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
attrs: {
|
||||||
|
diagram: attrs.diagram || DEFAULT_DRAWIO_XML
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const drawioCommandMeta = {
|
||||||
|
key: 'drawio',
|
||||||
|
label: 'Draw.io',
|
||||||
|
command: 'insertDrawioBlock'
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const hashText = (input) => {
|
||||||
|
const source = String(input || '');
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let index = 0; index < source.length; index += 1) {
|
||||||
|
hash ^= source.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return (hash >>> 0).toString(16);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDrawio = (node) => {
|
||||||
|
const xml = String(node?.attrs?.diagram || '');
|
||||||
|
if (!xml.trim()) {
|
||||||
|
return '[Draw.io] (empty)';
|
||||||
|
}
|
||||||
|
return `[Draw.io] bytes=${xml.length}, hash=${hashText(xml)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const drawioPreviewDiffAdapter = {
|
||||||
|
toPreview: formatDrawio,
|
||||||
|
toDiff: formatDrawio
|
||||||
|
};
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<template>
|
||||||
|
<NodeViewWrapper class="mindmap-node" contenteditable="false">
|
||||||
|
<header class="mindmap-node-header" data-drag-handle>
|
||||||
|
<span class="mindmap-badge">Mindmap</span>
|
||||||
|
<div class="mindmap-actions">
|
||||||
|
<button type="button" class="mindmap-btn" @click="toggleEditing">
|
||||||
|
{{ editing ? 'Done' : 'Edit Source' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mindmap-node-body" :class="{ 'is-editing': editing }">
|
||||||
|
<textarea
|
||||||
|
v-if="editing"
|
||||||
|
v-model="draftSource"
|
||||||
|
class="mindmap-source-input"
|
||||||
|
@input="applySource"
|
||||||
|
/>
|
||||||
|
<div ref="canvasWrapRef" class="mindmap-canvas-wrap">
|
||||||
|
<svg ref="svgRef" class="mindmap-canvas"></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { NodeViewWrapper } from '@tiptap/vue-3';
|
||||||
|
import { Markmap } from 'markmap-view';
|
||||||
|
import { Transformer } from 'markmap-lib';
|
||||||
|
import { DEFAULT_MINDMAP_SOURCE } from './mindmapExtension';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
node: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
updateAttributes: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const svgRef = ref(null);
|
||||||
|
const canvasWrapRef = ref(null);
|
||||||
|
const editing = ref(false);
|
||||||
|
const isDarkMode = ref(Boolean(document?.body?.classList?.contains('dark-mode')));
|
||||||
|
const draftSource = ref(props.node?.attrs?.source || DEFAULT_MINDMAP_SOURCE);
|
||||||
|
const markmapInstanceRef = ref(null);
|
||||||
|
const resizeObserverRef = ref(null);
|
||||||
|
const transformer = new Transformer();
|
||||||
|
const themeObserverRef = ref(null);
|
||||||
|
|
||||||
|
const resolveMindmapTextColor = () =>
|
||||||
|
isDarkMode.value ? '#e5e7eb' : '#1f2937';
|
||||||
|
|
||||||
|
const fitMindmapToViewport = async () => {
|
||||||
|
const instance = markmapInstanceRef.value;
|
||||||
|
if (!instance || editing.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
const fitPromise = instance.fit?.(Number.POSITIVE_INFINITY);
|
||||||
|
if (fitPromise && typeof fitPromise.catch === 'function') {
|
||||||
|
fitPromise.catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyMindmapThemeVariables = () => {
|
||||||
|
const svg = svgRef.value;
|
||||||
|
if (!svg) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const textColor = resolveMindmapTextColor();
|
||||||
|
svg.style.setProperty('--markmap-text-color', textColor);
|
||||||
|
svg.style.setProperty('--markmap-a-color', isDarkMode.value ? '#9ec5ff' : '#2563eb');
|
||||||
|
svg.style.setProperty('--markmap-a-hover-color', isDarkMode.value ? '#c7dcff' : '#1d4ed8');
|
||||||
|
svg.style.setProperty('--markmap-code-bg', isDarkMode.value ? '#111827' : '#f3f4f6');
|
||||||
|
svg.style.setProperty('--markmap-code-color', isDarkMode.value ? '#e5e7eb' : '#374151');
|
||||||
|
svg.style.setProperty('--markmap-circle-open-bg', isDarkMode.value ? '#1f2937' : '#ffffff');
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncSvgViewport = () => {
|
||||||
|
const svg = svgRef.value;
|
||||||
|
const wrap = canvasWrapRef.value;
|
||||||
|
if (!svg || !wrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = wrap.getBoundingClientRect();
|
||||||
|
const width = Math.max(320, Math.round(rect.width || wrap.clientWidth || 0));
|
||||||
|
const height = Math.max(220, Math.round(rect.height || wrap.clientHeight || 0));
|
||||||
|
svg.setAttribute('width', String(width));
|
||||||
|
svg.setAttribute('height', String(height));
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMindmap = async () => {
|
||||||
|
await nextTick();
|
||||||
|
if (!svgRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncSvgViewport();
|
||||||
|
applyMindmapThemeVariables();
|
||||||
|
const source = String(props.node?.attrs?.source || DEFAULT_MINDMAP_SOURCE).trim() || DEFAULT_MINDMAP_SOURCE;
|
||||||
|
const transformed = transformer.transform(source);
|
||||||
|
const root = transformed?.root;
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!markmapInstanceRef.value) {
|
||||||
|
markmapInstanceRef.value = Markmap.create(
|
||||||
|
svgRef.value,
|
||||||
|
{
|
||||||
|
autoFit: false,
|
||||||
|
duration: 120,
|
||||||
|
fitRatio: 0.98,
|
||||||
|
color: () => resolveMindmapTextColor()
|
||||||
|
},
|
||||||
|
root
|
||||||
|
);
|
||||||
|
await fitMindmapToViewport();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await markmapInstanceRef.value.setData(root);
|
||||||
|
await fitMindmapToViewport();
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySource = () => {
|
||||||
|
props.updateAttributes({
|
||||||
|
source: draftSource.value || DEFAULT_MINDMAP_SOURCE
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEditing = () => {
|
||||||
|
editing.value = !editing.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.node?.attrs?.source,
|
||||||
|
(next) => {
|
||||||
|
draftSource.value = next || DEFAULT_MINDMAP_SOURCE;
|
||||||
|
renderMindmap();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(editing, (next) => {
|
||||||
|
if (!next) {
|
||||||
|
renderMindmap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && canvasWrapRef.value) {
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
syncSvgViewport();
|
||||||
|
void fitMindmapToViewport();
|
||||||
|
});
|
||||||
|
observer.observe(canvasWrapRef.value);
|
||||||
|
resizeObserverRef.value = observer;
|
||||||
|
}
|
||||||
|
if (typeof MutationObserver !== 'undefined' && document?.body) {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
isDarkMode.value = Boolean(document?.body?.classList?.contains('dark-mode'));
|
||||||
|
renderMindmap();
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
themeObserverRef.value = observer;
|
||||||
|
}
|
||||||
|
renderMindmap();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (resizeObserverRef.value) {
|
||||||
|
resizeObserverRef.value.disconnect();
|
||||||
|
resizeObserverRef.value = null;
|
||||||
|
}
|
||||||
|
if (themeObserverRef.value) {
|
||||||
|
themeObserverRef.value.disconnect();
|
||||||
|
themeObserverRef.value = null;
|
||||||
|
}
|
||||||
|
markmapInstanceRef.value = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mindmap-node {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-white);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-node-header {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: color-mix(in srgb, var(--bg-light) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-btn {
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-white);
|
||||||
|
color: var(--text-medium);
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-node-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 0.45fr) minmax(0, 1fr);
|
||||||
|
height: 320px;
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-node-body:not(.is-editing) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-source-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
outline: none;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
resize: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-canvas-wrap {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 280px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .mindmap-node .mindmap-canvas :deep(.markmap-link) {
|
||||||
|
stroke: #475569 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mindmap-node-body.is-editing {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-source-input {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { mergeAttributes, Node } from '@tiptap/core';
|
||||||
|
import { VueNodeViewRenderer } from '@tiptap/vue-3';
|
||||||
|
import MindmapNodeView from './MindmapNodeView.vue';
|
||||||
|
|
||||||
|
export const DEFAULT_MINDMAP_SOURCE = `# Mindmap
|
||||||
|
## Topic A
|
||||||
|
### Detail A1
|
||||||
|
## Topic B`;
|
||||||
|
|
||||||
|
const encodeSource = (value) => encodeURIComponent(String(value || ''));
|
||||||
|
const decodeSource = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return DEFAULT_MINDMAP_SOURCE;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(String(value));
|
||||||
|
} catch (_) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MindmapExtension = Node.create({
|
||||||
|
name: 'mindmapBlock',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
source: {
|
||||||
|
default: DEFAULT_MINDMAP_SOURCE,
|
||||||
|
parseHTML: (element) => decodeSource(
|
||||||
|
element.getAttribute('data-source') || element.getAttribute('source')
|
||||||
|
),
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
'data-source': encodeSource(attributes.source || DEFAULT_MINDMAP_SOURCE)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'yoresee-mindmap' }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['yoresee-mindmap', mergeAttributes(HTMLAttributes)];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return VueNodeViewRenderer(MindmapNodeView);
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertMindmapBlock:
|
||||||
|
(attrs = {}) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
attrs: {
|
||||||
|
source: attrs.source || DEFAULT_MINDMAP_SOURCE
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mindmapCommandMeta = {
|
||||||
|
key: 'mindmap',
|
||||||
|
label: 'Mindmap',
|
||||||
|
command: 'insertMindmapBlock'
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const formatMindmap = (node) => {
|
||||||
|
const source = String(node?.attrs?.source || '').trim();
|
||||||
|
if (!source) {
|
||||||
|
return '[Mindmap]';
|
||||||
|
}
|
||||||
|
const lines = source.split('\n').slice(0, 8).join('\n');
|
||||||
|
return `[Mindmap]\n${lines}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mindmapPreviewDiffAdapter = {
|
||||||
|
toPreview: formatMindmap,
|
||||||
|
toDiff: formatMindmap
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { MindmapExtension, mindmapCommandMeta } from './mindmap/mindmapExtension';
|
||||||
|
import { DrawioExtension, drawioCommandMeta } from './drawio/drawioExtension';
|
||||||
|
import { RichTableExtension, richTableCommandMeta } from './table/richTableExtension';
|
||||||
|
import { mindmapPreviewDiffAdapter } from './mindmap/mindmapPreviewDiffAdapter';
|
||||||
|
import { drawioPreviewDiffAdapter } from './drawio/drawioPreviewDiffAdapter';
|
||||||
|
import { richTablePreviewDiffAdapter } from './table/richTablePreviewDiffAdapter';
|
||||||
|
|
||||||
|
const COMPONENT_REGISTRY = {
|
||||||
|
mindmap: {
|
||||||
|
extension: MindmapExtension,
|
||||||
|
toolbarItem: mindmapCommandMeta,
|
||||||
|
nodeType: 'mindmapBlock',
|
||||||
|
previewDiffAdapter: mindmapPreviewDiffAdapter
|
||||||
|
},
|
||||||
|
drawio: {
|
||||||
|
extension: DrawioExtension,
|
||||||
|
toolbarItem: drawioCommandMeta,
|
||||||
|
nodeType: 'drawioBlock',
|
||||||
|
previewDiffAdapter: drawioPreviewDiffAdapter
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
extension: RichTableExtension,
|
||||||
|
toolbarItem: richTableCommandMeta,
|
||||||
|
nodeType: 'tableBlock',
|
||||||
|
previewDiffAdapter: richTablePreviewDiffAdapter
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_RICH_TEXT_COMPONENTS = ['mindmap', 'drawio', 'table'];
|
||||||
|
|
||||||
|
export const resolveRichTextComponentSystem = (enabledComponents = DEFAULT_RICH_TEXT_COMPONENTS) => {
|
||||||
|
const keys = Array.isArray(enabledComponents) && enabledComponents.length > 0
|
||||||
|
? enabledComponents
|
||||||
|
: DEFAULT_RICH_TEXT_COMPONENTS;
|
||||||
|
|
||||||
|
const extensions = [];
|
||||||
|
const toolbarItems = [];
|
||||||
|
|
||||||
|
keys.forEach((key) => {
|
||||||
|
const component = COMPONENT_REGISTRY[key];
|
||||||
|
if (!component) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (component.extension) {
|
||||||
|
extensions.push(component.extension);
|
||||||
|
}
|
||||||
|
if (component.toolbarItem) {
|
||||||
|
toolbarItems.push(component.toolbarItem);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
extensions,
|
||||||
|
toolbarItems
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveRichTextPreviewDiffAdapterRegistry = (
|
||||||
|
enabledComponents = DEFAULT_RICH_TEXT_COMPONENTS
|
||||||
|
) => {
|
||||||
|
const keys = Array.isArray(enabledComponents) && enabledComponents.length > 0
|
||||||
|
? enabledComponents
|
||||||
|
: DEFAULT_RICH_TEXT_COMPONENTS;
|
||||||
|
|
||||||
|
return keys.reduce((result, key) => {
|
||||||
|
const component = COMPONENT_REGISTRY[key];
|
||||||
|
if (!component?.nodeType || !component?.previewDiffAdapter) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result[component.nodeType] = component.previewDiffAdapter;
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<NodeViewWrapper class="rich-table-node" contenteditable="false">
|
||||||
|
<header class="rich-table-header" data-drag-handle>
|
||||||
|
<span class="rich-table-badge">Table</span>
|
||||||
|
<div class="rich-table-actions">
|
||||||
|
<button type="button" class="rich-table-btn" @click="appendRow">
|
||||||
|
+ Row
|
||||||
|
</button>
|
||||||
|
<button type="button" class="rich-table-btn" @click="appendColumn">
|
||||||
|
+ Col
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="rich-table-body">
|
||||||
|
<table class="rich-table-grid">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, rowIndex) in tableModel.rows" :key="`row-${rowIndex}`">
|
||||||
|
<td v-for="(cell, colIndex) in row" :key="`cell-${rowIndex}-${colIndex}`">
|
||||||
|
<input
|
||||||
|
:value="cell"
|
||||||
|
class="rich-table-cell-input"
|
||||||
|
@mousedown.stop
|
||||||
|
@click.stop
|
||||||
|
@keydown.stop
|
||||||
|
@input="updateCell(rowIndex, colIndex, $event.target.value)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { NodeViewWrapper } from '@tiptap/vue-3';
|
||||||
|
import {
|
||||||
|
cloneRichTableModel,
|
||||||
|
createRichTableModel,
|
||||||
|
normalizeRichTableModel
|
||||||
|
} from './richTableModel';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
node: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
updateAttributes: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableModel = ref(createRichTableModel());
|
||||||
|
|
||||||
|
const serializeRows = (rows) => JSON.stringify(rows || []);
|
||||||
|
|
||||||
|
const applyModel = (next) => {
|
||||||
|
const normalized = normalizeRichTableModel(next);
|
||||||
|
tableModel.value = normalized;
|
||||||
|
props.updateAttributes({
|
||||||
|
table: cloneRichTableModel(normalized)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCell = (rowIndex, colIndex, value) => {
|
||||||
|
const next = cloneRichTableModel(tableModel.value);
|
||||||
|
if (!Array.isArray(next.rows[rowIndex])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next.rows[rowIndex][colIndex] = String(value ?? '');
|
||||||
|
applyModel(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendRow = () => {
|
||||||
|
const next = cloneRichTableModel(tableModel.value);
|
||||||
|
const columnCount = Math.max(1, Number(next.rows?.[0]?.length || 1));
|
||||||
|
next.rows.push(Array.from({ length: columnCount }, () => ''));
|
||||||
|
applyModel(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendColumn = () => {
|
||||||
|
const next = cloneRichTableModel(tableModel.value);
|
||||||
|
next.rows = next.rows.map((row) => [...row, '']);
|
||||||
|
applyModel(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.node?.attrs?.table,
|
||||||
|
(nextValue) => {
|
||||||
|
const normalized = normalizeRichTableModel(nextValue);
|
||||||
|
if (serializeRows(normalized.rows) === serializeRows(tableModel.value.rows)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tableModel.value = normalized;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rich-table-node {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-white);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-table-header {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: color-mix(in srgb, var(--bg-light) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-table-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-table-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-table-btn {
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-white);
|
||||||
|
color: var(--text-medium);
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-table-body {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-table-grid {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-table-grid td {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-table-cell-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 7px 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-table-cell-input:focus {
|
||||||
|
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--primary-color) 60%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { mergeAttributes, Node } from '@tiptap/core';
|
||||||
|
import { VueNodeViewRenderer } from '@tiptap/vue-3';
|
||||||
|
import RichTableNodeView from './RichTableNodeView.vue';
|
||||||
|
import {
|
||||||
|
createRichTableModel,
|
||||||
|
decodeRichTableModelFromAttr,
|
||||||
|
encodeRichTableModelForAttr,
|
||||||
|
normalizeRichTableModel
|
||||||
|
} from './richTableModel';
|
||||||
|
|
||||||
|
export const RichTableExtension = Node.create({
|
||||||
|
name: 'tableBlock',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
table: {
|
||||||
|
default: createRichTableModel(),
|
||||||
|
parseHTML: (element) =>
|
||||||
|
decodeRichTableModelFromAttr(
|
||||||
|
element.getAttribute('data-table') || element.getAttribute('table')
|
||||||
|
),
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
'data-table': encodeRichTableModelForAttr(attributes.table),
|
||||||
|
'data-type': 'table'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'yoresee-table' }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['yoresee-table', mergeAttributes(HTMLAttributes)];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return VueNodeViewRenderer(RichTableNodeView);
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertTableBlock:
|
||||||
|
(attrs = {}) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
attrs: {
|
||||||
|
table: normalizeRichTableModel(attrs.table || createRichTableModel(attrs))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const richTableCommandMeta = {
|
||||||
|
key: 'table',
|
||||||
|
label: '表格',
|
||||||
|
command: 'insertTableBlock'
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
const DEFAULT_ROWS = 3;
|
||||||
|
const DEFAULT_COLS = 3;
|
||||||
|
const MAX_SIDE = 30;
|
||||||
|
|
||||||
|
const clampSize = (value, fallback) => {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return Math.max(1, Math.min(MAX_SIDE, Math.floor(number)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toCellText = (value) => String(value ?? '');
|
||||||
|
|
||||||
|
const encodeUtf8Base64 = (value) => {
|
||||||
|
const source = String(value || '');
|
||||||
|
if (!source) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (typeof TextEncoder !== 'undefined') {
|
||||||
|
const bytes = new TextEncoder().encode(source);
|
||||||
|
let binary = '';
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(index, index + chunkSize);
|
||||||
|
binary += String.fromCharCode(...chunk);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
return btoa(unescape(encodeURIComponent(source)));
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeUtf8Base64 = (value) => {
|
||||||
|
const source = String(value || '').trim();
|
||||||
|
if (!source) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const binary = atob(source);
|
||||||
|
if (typeof TextDecoder !== 'undefined') {
|
||||||
|
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
return decodeURIComponent(escape(binary));
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRichTableModel = ({ rows = DEFAULT_ROWS, cols = DEFAULT_COLS } = {}) => {
|
||||||
|
const rowCount = clampSize(rows, DEFAULT_ROWS);
|
||||||
|
const colCount = clampSize(cols, DEFAULT_COLS);
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
rows: Array.from({ length: rowCount }, () =>
|
||||||
|
Array.from({ length: colCount }, () => '')
|
||||||
|
)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeRichTableModel = (value, fallback = {}) => {
|
||||||
|
const fallbackModel = createRichTableModel(fallback);
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return fallbackModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceRows = Array.isArray(value.rows) ? value.rows : [];
|
||||||
|
if (sourceRows.length === 0) {
|
||||||
|
return fallbackModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRowCount = clampSize(sourceRows.length, fallbackModel.rows.length);
|
||||||
|
const rawMaxCols = sourceRows.reduce((max, row) => {
|
||||||
|
const size = Array.isArray(row) ? row.length : 0;
|
||||||
|
return Math.max(max, size);
|
||||||
|
}, 0);
|
||||||
|
const normalizedColCount = clampSize(
|
||||||
|
rawMaxCols || fallbackModel.rows[0]?.length || DEFAULT_COLS,
|
||||||
|
fallbackModel.rows[0]?.length || DEFAULT_COLS
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = Array.from({ length: normalizedRowCount }, (_, rowIndex) => {
|
||||||
|
const sourceRow = Array.isArray(sourceRows[rowIndex]) ? sourceRows[rowIndex] : [];
|
||||||
|
return Array.from({ length: normalizedColCount }, (_, colIndex) =>
|
||||||
|
toCellText(sourceRow[colIndex] || '')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cloneRichTableModel = (value) => normalizeRichTableModel(value);
|
||||||
|
|
||||||
|
export const encodeRichTableModelForAttr = (value) =>
|
||||||
|
encodeURIComponent(JSON.stringify(normalizeRichTableModel(value)));
|
||||||
|
|
||||||
|
export const decodeRichTableModelFromAttr = (value) => {
|
||||||
|
const source = String(value || '').trim();
|
||||||
|
if (!source) {
|
||||||
|
return createRichTableModel();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return normalizeRichTableModel(JSON.parse(decodeURIComponent(source)));
|
||||||
|
} catch (_) {
|
||||||
|
return createRichTableModel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const encodeRichTableModelToMarkdownBlock = (value) => {
|
||||||
|
const payload = JSON.stringify(normalizeRichTableModel(value));
|
||||||
|
const encoded = encodeUtf8Base64(payload);
|
||||||
|
return encoded ? `base64:${encoded}` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decodeRichTableModelFromMarkdownBlock = (value) => {
|
||||||
|
const source = String(value || '').trim();
|
||||||
|
if (!source) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonSource = source.startsWith('base64:')
|
||||||
|
? decodeUtf8Base64(source.slice('base64:'.length))
|
||||||
|
: source;
|
||||||
|
|
||||||
|
if (!jsonSource) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return normalizeRichTableModel(JSON.parse(jsonSource));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { normalizeRichTableModel } from './richTableModel';
|
||||||
|
|
||||||
|
const renderTableSummary = (node) => {
|
||||||
|
const model = normalizeRichTableModel(node?.attrs?.table);
|
||||||
|
const rows = model.rows || [];
|
||||||
|
const rowCount = rows.length;
|
||||||
|
const colCount = rows[0]?.length || 0;
|
||||||
|
|
||||||
|
const previewRows = rows.slice(0, 4).map((row) => {
|
||||||
|
const cells = row.slice(0, 4).map((cell) => String(cell || '').trim() || ' ');
|
||||||
|
return `| ${cells.join(' | ')} |`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = [`[Table] ${rowCount}x${colCount}`];
|
||||||
|
if (previewRows.length > 0) {
|
||||||
|
lines.push(...previewRows);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const richTablePreviewDiffAdapter = {
|
||||||
|
toPreview: renderTableSummary,
|
||||||
|
toDiff: renderTableSummary
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { resolveRichTextPreviewDiffAdapterRegistry } from '@/components/document/rich-text/components/registry';
|
||||||
|
|
||||||
|
const defaultAdapterRegistry = resolveRichTextPreviewDiffAdapterRegistry();
|
||||||
|
|
||||||
|
const collectText = (node) => {
|
||||||
|
if (!node || typeof node !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (node.type === 'text') {
|
||||||
|
return String(node.text || '');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(node.content)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return node.content.map((child) => collectText(child)).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeNode = (node, output, mode, adapterRegistry, orderedIndexRef = { value: 1 }) => {
|
||||||
|
if (!node || typeof node !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = adapterRegistry[node.type];
|
||||||
|
const adapterSerializer = mode === 'preview' ? adapter?.toPreview : adapter?.toDiff;
|
||||||
|
if (typeof adapterSerializer === 'function') {
|
||||||
|
const result = adapterSerializer(node);
|
||||||
|
if (result) {
|
||||||
|
output.push(result);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'heading') {
|
||||||
|
const level = Number(node?.attrs?.level || 1);
|
||||||
|
output.push(`${'#'.repeat(Math.max(1, Math.min(6, level)))} ${collectText(node)}`.trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'paragraph') {
|
||||||
|
const text = collectText(node).trim();
|
||||||
|
if (text) {
|
||||||
|
output.push(text);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'blockquote') {
|
||||||
|
const text = collectText(node).trim();
|
||||||
|
if (text) {
|
||||||
|
output.push(`> ${text}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'codeBlock') {
|
||||||
|
const lang = String(node?.attrs?.language || '').trim();
|
||||||
|
const text = collectText(node);
|
||||||
|
output.push(`\`\`\`${lang}\n${text}\n\`\`\``.trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'bulletList' && Array.isArray(node.content)) {
|
||||||
|
node.content.forEach((item) => {
|
||||||
|
const text = collectText(item).trim();
|
||||||
|
if (text) {
|
||||||
|
output.push(`- ${text}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'orderedList' && Array.isArray(node.content)) {
|
||||||
|
const start = Number(node?.attrs?.start || 1);
|
||||||
|
orderedIndexRef.value = Number.isFinite(start) ? start : 1;
|
||||||
|
node.content.forEach((item) => {
|
||||||
|
const text = collectText(item).trim();
|
||||||
|
if (text) {
|
||||||
|
output.push(`${orderedIndexRef.value}. ${text}`);
|
||||||
|
orderedIndexRef.value += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
node.content.forEach((child) => serializeNode(child, output, mode, adapterRegistry, orderedIndexRef));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeRichTextJson = (doc, mode = 'diff', adapterRegistry = defaultAdapterRegistry) => {
|
||||||
|
const output = [];
|
||||||
|
serializeNode(doc, output, mode, adapterRegistry);
|
||||||
|
return output.join('\n\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeRichTextJsonToDiffText = (
|
||||||
|
doc,
|
||||||
|
adapterRegistry = defaultAdapterRegistry
|
||||||
|
) => serializeRichTextJson(doc, 'diff', adapterRegistry);
|
||||||
|
|
||||||
|
export const serializeRichTextJsonToPreviewText = (
|
||||||
|
doc,
|
||||||
|
adapterRegistry = defaultAdapterRegistry
|
||||||
|
) => serializeRichTextJson(doc, 'preview', adapterRegistry);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Mark, mergeAttributes } from '@tiptap/core';
|
||||||
|
|
||||||
|
export const COMMENT_ANCHOR_ATTR = 'data-comment-anchor-id';
|
||||||
|
|
||||||
|
export const CommentAnchorExtension = Mark.create({
|
||||||
|
name: 'commentAnchor',
|
||||||
|
inclusive: false,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
anchorId: {
|
||||||
|
default: '',
|
||||||
|
parseHTML: (element) => String(element.getAttribute(COMMENT_ANCHOR_ATTR) || '').trim(),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
const anchorId = String(attributes.anchorId || '').trim();
|
||||||
|
if (!anchorId) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
[COMMENT_ANCHOR_ATTR]: anchorId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `span[${COMMENT_ANCHOR_ATTR}]`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'span',
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
class: 'yoresee-comment-anchor'
|
||||||
|
}),
|
||||||
|
0
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setCommentAnchor:
|
||||||
|
(anchorId) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.setMark(this.name, { anchorId }),
|
||||||
|
unsetCommentAnchor:
|
||||||
|
() =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.unsetMark(this.name)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Mention from '@tiptap/extension-mention';
|
||||||
|
import { mentionSuggestion } from '../MentionSuggestion.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiptap Mention extension configured with:
|
||||||
|
* - .mention-chip CSS class for unified styling
|
||||||
|
* - data-mention-id attribute for extraction
|
||||||
|
* - Plain-text renderText for search indexing
|
||||||
|
*/
|
||||||
|
export const MentionExtension = Mention.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'mention-chip',
|
||||||
|
},
|
||||||
|
renderLabel({ options, node }) {
|
||||||
|
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||||
|
},
|
||||||
|
suggestion: mentionSuggestion,
|
||||||
|
}).extend({
|
||||||
|
// Override renderHTML to include data-mention-id
|
||||||
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
...HTMLAttributes,
|
||||||
|
'data-mention-id': node.attrs.id,
|
||||||
|
'data-mention-label': node.attrs.label,
|
||||||
|
},
|
||||||
|
`@${node.attrs.label ?? node.attrs.id}`,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared reactive state for the mention suggestion popup.
|
||||||
|
* Updated by MentionSuggestion.js, read by MentionUserList (rendered inside the main app).
|
||||||
|
*/
|
||||||
|
export const mentionPopupState = reactive({
|
||||||
|
visible: false,
|
||||||
|
keyword: '',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
activeIndex: 0,
|
||||||
|
onSelect: null, // (user: {external_id, nickname, username}) => void
|
||||||
|
});
|
||||||
|
|
||||||
|
export function hideMentionPopup() {
|
||||||
|
mentionPopupState.visible = false;
|
||||||
|
mentionPopupState.onSelect = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
class="code-language-floating"
|
||||||
|
:style="styleObject"
|
||||||
|
@mouseenter="$emit('enter')"
|
||||||
|
@mouseleave="$emit('leave')"
|
||||||
|
>
|
||||||
|
<div class="code-language-panel">
|
||||||
|
<el-input
|
||||||
|
v-model="draftProxy"
|
||||||
|
size="small"
|
||||||
|
class="code-language-input"
|
||||||
|
placeholder="语言"
|
||||||
|
@focus="handleFocus"
|
||||||
|
@blur="handleBlur"
|
||||||
|
@input="handleInput"
|
||||||
|
@keydown.stop
|
||||||
|
@keydown.down.prevent="handleArrowDown"
|
||||||
|
@keydown.up.prevent="handleArrowUp"
|
||||||
|
@keydown.enter.prevent="handleEnter"
|
||||||
|
@keydown.esc.prevent="closeMenu"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-show="menuVisible && options.length > 0"
|
||||||
|
class="code-language-menu"
|
||||||
|
@mousedown.prevent
|
||||||
|
@mouseenter="handleMenuEnter"
|
||||||
|
@mouseleave="handleMenuLeave"
|
||||||
|
>
|
||||||
|
<AppMenuItem
|
||||||
|
v-for="(item, index) in options"
|
||||||
|
:key="item.value"
|
||||||
|
class="code-language-option-row"
|
||||||
|
:class="{ 'is-highlighted': highlightedIndex === index }"
|
||||||
|
@mouseenter="highlightedIndex = index"
|
||||||
|
@click="selectOption(item)"
|
||||||
|
>
|
||||||
|
<span class="code-language-option-label">{{ item.label }}</span>
|
||||||
|
<span class="code-language-option-value">{{ item.value }}</span>
|
||||||
|
</AppMenuItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
import AppMenuItem from '@/components/base/AppMenuItem.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
styleObject: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: 'plaintext'
|
||||||
|
},
|
||||||
|
querySuggestions: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:modelValue',
|
||||||
|
'enter',
|
||||||
|
'leave',
|
||||||
|
'focus',
|
||||||
|
'blur',
|
||||||
|
'change',
|
||||||
|
'select'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const draftProxy = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = ref([]);
|
||||||
|
const menuVisible = ref(false);
|
||||||
|
const highlightedIndex = ref(-1);
|
||||||
|
const inputFocused = ref(false);
|
||||||
|
const menuHovering = ref(false);
|
||||||
|
let blurTimer = 0;
|
||||||
|
|
||||||
|
const clearBlurTimer = () => {
|
||||||
|
if (blurTimer) {
|
||||||
|
window.clearTimeout(blurTimer);
|
||||||
|
blurTimer = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOptions = (keyword = '') => {
|
||||||
|
props.querySuggestions?.(keyword, (items) => {
|
||||||
|
options.value = Array.isArray(items) ? items : [];
|
||||||
|
highlightedIndex.value = options.value.length > 0 ? 0 : -1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
clearBlurTimer();
|
||||||
|
inputFocused.value = true;
|
||||||
|
emit('focus');
|
||||||
|
updateOptions(draftProxy.value || '');
|
||||||
|
menuVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInput = (value) => {
|
||||||
|
updateOptions(value);
|
||||||
|
menuVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
menuVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalizeBlur = () => {
|
||||||
|
if (inputFocused.value || menuHovering.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
menuVisible.value = false;
|
||||||
|
emit('change');
|
||||||
|
emit('blur');
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectOption = (item) => {
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('update:modelValue', item.value || '');
|
||||||
|
emit('select', item);
|
||||||
|
menuVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
inputFocused.value = false;
|
||||||
|
clearBlurTimer();
|
||||||
|
blurTimer = window.setTimeout(() => {
|
||||||
|
blurTimer = 0;
|
||||||
|
finalizeBlur();
|
||||||
|
}, 120);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuEnter = () => {
|
||||||
|
menuHovering.value = true;
|
||||||
|
clearBlurTimer();
|
||||||
|
emit('enter');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuLeave = () => {
|
||||||
|
menuHovering.value = false;
|
||||||
|
finalizeBlur();
|
||||||
|
emit('leave');
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveHighlight = (delta) => {
|
||||||
|
const length = options.value.length;
|
||||||
|
if (length <= 0) {
|
||||||
|
highlightedIndex.value = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = highlightedIndex.value < 0 ? 0 : highlightedIndex.value;
|
||||||
|
highlightedIndex.value = (current + delta + length) % length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArrowDown = () => {
|
||||||
|
if (!menuVisible.value) {
|
||||||
|
menuVisible.value = true;
|
||||||
|
updateOptions(draftProxy.value || '');
|
||||||
|
}
|
||||||
|
moveHighlight(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArrowUp = () => {
|
||||||
|
if (!menuVisible.value) {
|
||||||
|
menuVisible.value = true;
|
||||||
|
updateOptions(draftProxy.value || '');
|
||||||
|
}
|
||||||
|
moveHighlight(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnter = () => {
|
||||||
|
if (menuVisible.value && highlightedIndex.value >= 0) {
|
||||||
|
const next = options.value[highlightedIndex.value];
|
||||||
|
if (next) {
|
||||||
|
selectOption(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit('change');
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(nextVisible) => {
|
||||||
|
if (nextVisible) {
|
||||||
|
updateOptions(draftProxy.value || '');
|
||||||
|
menuVisible.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearBlurTimer();
|
||||||
|
menuVisible.value = false;
|
||||||
|
inputFocused.value = false;
|
||||||
|
menuHovering.value = false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearBlurTimer();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.code-language-floating {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 34;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-language-panel {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-language-input {
|
||||||
|
width: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.code-language-input .el-input__wrapper) {
|
||||||
|
min-height: 26px;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.code-language-input .el-input__inner) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-language-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
z-index: 1;
|
||||||
|
min-width: 176px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-white);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-language-menu::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-language-menu::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, var(--border-color) 85%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-language-option-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-language-option-label {
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-language-option-value {
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-language-menu :deep(.app-menu-item.is-highlighted) {
|
||||||
|
background: var(--select-option-hover);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="link-dialog-mask"
|
||||||
|
@mousedown.self="$emit('cancel')"
|
||||||
|
>
|
||||||
|
<div class="link-dialog-card" @mousedown.stop>
|
||||||
|
<div class="link-dialog-title">{{ title || '编辑超链接' }}</div>
|
||||||
|
<input
|
||||||
|
v-model="valueProxy"
|
||||||
|
class="link-dialog-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://yoresee.cc"
|
||||||
|
@keydown.enter.prevent="$emit('confirm')"
|
||||||
|
>
|
||||||
|
<div class="link-dialog-actions">
|
||||||
|
<button type="button" class="link-dialog-btn" @click="$emit('cancel')">取消</button>
|
||||||
|
<button type="button" class="link-dialog-btn link-dialog-btn--primary" @click="$emit('confirm')">
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'cancel', 'confirm']);
|
||||||
|
|
||||||
|
const valueProxy = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.link-dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 90;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-card {
|
||||||
|
width: min(460px, calc(100vw - 40px));
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-white);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-input {
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-white);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 10px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-input:focus {
|
||||||
|
border-color: color-mix(in srgb, var(--primary-color) 52%, var(--border-color) 48%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-btn {
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-white);
|
||||||
|
color: var(--text-medium);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-btn--primary {
|
||||||
|
border-color: color-mix(in srgb, var(--primary-color) 52%, var(--border-color) 48%);
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
class="link-hover-card"
|
||||||
|
:style="styleObject"
|
||||||
|
@mouseenter="$emit('enter')"
|
||||||
|
@mouseleave="$emit('leave')"
|
||||||
|
>
|
||||||
|
<div class="link-hover-title">超链接</div>
|
||||||
|
<div class="link-hover-url">{{ href }}</div>
|
||||||
|
<div class="link-hover-actions">
|
||||||
|
<button type="button" class="link-hover-btn" @mousedown.prevent @click="$emit('edit')">
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="link-hover-btn link-hover-btn--danger"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="$emit('remove')"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
styleObject: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
href: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['enter', 'leave', 'edit', 'remove']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.link-hover-card {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 36;
|
||||||
|
width: 280px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-white);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-hover-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-hover-url {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-hover-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-hover-btn {
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-white);
|
||||||
|
color: var(--text-medium);
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-hover-btn:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--primary-color) 48%, var(--border-color) 52%);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-hover-btn--danger:hover {
|
||||||
|
border-color: color-mix(in srgb, #ef4444 48%, var(--border-color) 52%);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
.selection-action-bubble {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 35;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-white);
|
||||||
|
color: var(--text-light);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-action-btn {
|
||||||
|
min-width: 26px;
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-medium);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-action-btn:hover,
|
||||||
|
.selection-action-btn.is-active {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-color: color-mix(in srgb, var(--primary-color) 48%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 11%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-action-btn--icon :deep(.el-icon) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-group {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-trigger {
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--bg-light) 58%, var(--bg-white) 42%);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 2px 0 4px;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-trigger:hover,
|
||||||
|
.selection-color-trigger.is-active {
|
||||||
|
border-color: color-mix(in srgb, var(--primary-color) 48%, var(--border-color) 52%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-trigger-a {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 64%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-trigger-arrow {
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-panel {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
width: 264px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-white);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 10px;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-panel-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-text-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-text-item {
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-white);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-text-item.is-active {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary-color) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-bg-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-bg-item {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-bg-item.is-none::before,
|
||||||
|
.selection-color-bg-item.is-none::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 50% auto auto 50%;
|
||||||
|
width: 18px;
|
||||||
|
height: 2px;
|
||||||
|
background: #5b8def;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-bg-item.is-none::before {
|
||||||
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-bg-item.is-none::after {
|
||||||
|
transform: translate(-50%, -50%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-bg-item.is-active {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-reset-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-white);
|
||||||
|
color: var(--text-medium);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-color-reset-btn:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--primary-color) 48%, var(--border-color) 52%);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
class="selection-action-bubble"
|
||||||
|
:style="styleObject"
|
||||||
|
@mouseenter="$emit('enter')"
|
||||||
|
@mouseleave="$emit('leave')"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selection-action-btn"
|
||||||
|
:class="{ 'is-active': selectionBoldActive }"
|
||||||
|
title="Bold"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="$emit('toggle-bold')"
|
||||||
|
>
|
||||||
|
B
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selection-action-btn"
|
||||||
|
:class="{ 'is-active': selectionUnderlineActive }"
|
||||||
|
title="Underline"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="$emit('toggle-underline')"
|
||||||
|
>
|
||||||
|
U
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selection-action-btn"
|
||||||
|
:class="{ 'is-active': selectionItalicActive }"
|
||||||
|
title="Italic"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="$emit('toggle-italic')"
|
||||||
|
>
|
||||||
|
I
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selection-action-btn"
|
||||||
|
:class="{ 'is-active': selectionStrikeActive }"
|
||||||
|
title="Strike"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="$emit('toggle-strike')"
|
||||||
|
>
|
||||||
|
S
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selection-action-btn"
|
||||||
|
:class="{ 'is-active': selectionInlineCodeActive }"
|
||||||
|
title="Inline Code"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="$emit('toggle-inline-code')"
|
||||||
|
>
|
||||||
|
</>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selection-action-btn selection-action-btn--icon"
|
||||||
|
:class="{ 'is-active': selectionLinkActive }"
|
||||||
|
title="Link"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="$emit('link-click')"
|
||||||
|
>
|
||||||
|
<el-icon>
|
||||||
|
<Link />
|
||||||
|
</el-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="selection-color-group">
|
||||||
|
<button
|
||||||
|
ref="colorTriggerRef"
|
||||||
|
type="button"
|
||||||
|
class="selection-color-trigger"
|
||||||
|
:class="{ 'is-active': selectionHighlightActive }"
|
||||||
|
title="字体与背景颜色"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="toggleColorPanel"
|
||||||
|
>
|
||||||
|
<span class="selection-color-trigger-a" :style="colorTriggerAStyle">A</span>
|
||||||
|
<el-icon class="selection-color-trigger-arrow">
|
||||||
|
<component :is="colorPanelVisible ? ArrowUp : ArrowDown" />
|
||||||
|
</el-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="colorPanelVisible"
|
||||||
|
ref="colorPanelRef"
|
||||||
|
class="selection-color-panel"
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<div class="selection-color-panel-title">字体颜色</div>
|
||||||
|
<div class="selection-color-text-row">
|
||||||
|
<button
|
||||||
|
v-for="item in textColorItems"
|
||||||
|
:key="item"
|
||||||
|
type="button"
|
||||||
|
class="selection-color-text-item"
|
||||||
|
:class="{ 'is-active': isTextColorActive(item) }"
|
||||||
|
:style="{ color: item }"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="pickTextColor(item)"
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="selection-color-panel-title">背景颜色</div>
|
||||||
|
<div class="selection-color-bg-grid">
|
||||||
|
<button
|
||||||
|
v-for="item in backgroundColorItems"
|
||||||
|
:key="item || 'none'"
|
||||||
|
type="button"
|
||||||
|
class="selection-color-bg-item"
|
||||||
|
:class="{
|
||||||
|
'is-active': isBackgroundColorActive(item),
|
||||||
|
'is-none': !item
|
||||||
|
}"
|
||||||
|
:style="item ? { backgroundColor: item } : {}"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="pickBackgroundColor(item)"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selection-color-reset-btn"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="resetColorToDefault"
|
||||||
|
>
|
||||||
|
恢复默认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="commentEnabled"
|
||||||
|
type="button"
|
||||||
|
class="selection-action-btn selection-action-btn--icon"
|
||||||
|
:title="commentTitle"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="$emit('comment-click')"
|
||||||
|
>
|
||||||
|
<el-icon>
|
||||||
|
<ChatDotRound />
|
||||||
|
</el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { ArrowDown, ArrowUp, ChatDotRound, Link } from '@element-plus/icons-vue';
|
||||||
|
import { useRichTextSelectionColorPanel } from './useRichTextSelectionColorPanel';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
styleObject: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
commentEnabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
commentTitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
selectionBoldActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
selectionUnderlineActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
selectionItalicActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
selectionStrikeActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
selectionInlineCodeActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
selectionLinkActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
selectionHighlightActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
selectionTextColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#1f2937'
|
||||||
|
},
|
||||||
|
selectionBackgroundColor: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'enter',
|
||||||
|
'leave',
|
||||||
|
'toggle-bold',
|
||||||
|
'toggle-underline',
|
||||||
|
'toggle-italic',
|
||||||
|
'toggle-strike',
|
||||||
|
'toggle-inline-code',
|
||||||
|
'link-click',
|
||||||
|
'text-color-change',
|
||||||
|
'background-color-change',
|
||||||
|
'comment-click'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const visibleRef = computed(() => props.visible);
|
||||||
|
const selectionTextColorRef = computed(() => props.selectionTextColor);
|
||||||
|
const selectionBackgroundColorRef = computed(() => props.selectionBackgroundColor);
|
||||||
|
|
||||||
|
const {
|
||||||
|
textColorItems,
|
||||||
|
backgroundColorItems,
|
||||||
|
colorPanelVisible,
|
||||||
|
colorPanelRef,
|
||||||
|
colorTriggerRef,
|
||||||
|
colorTriggerAStyle,
|
||||||
|
pickTextColor,
|
||||||
|
pickBackgroundColor,
|
||||||
|
isTextColorActive,
|
||||||
|
isBackgroundColorActive,
|
||||||
|
resetColorToDefault,
|
||||||
|
toggleColorPanel
|
||||||
|
} = useRichTextSelectionColorPanel({
|
||||||
|
visibleRef,
|
||||||
|
selectionTextColorRef,
|
||||||
|
selectionBackgroundColorRef,
|
||||||
|
onTextColorChange: (value) => emit('text-color-change', value),
|
||||||
|
onBackgroundColorChange: (value) => emit('background-color-change', value)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style scoped src="./RichTextSelectionActionBubble.css"></style>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const DEFAULT_TEXT_COLOR = '#1f2937';
|
||||||
|
const DEFAULT_BACKGROUND_COLOR = '';
|
||||||
|
|
||||||
|
const textColorItems = [
|
||||||
|
'#1f2937',
|
||||||
|
'#9ca3af',
|
||||||
|
'#ef4444',
|
||||||
|
'#f97316',
|
||||||
|
'#eab308',
|
||||||
|
'#22c55e',
|
||||||
|
'#2563eb',
|
||||||
|
'#7c3aed'
|
||||||
|
];
|
||||||
|
|
||||||
|
const backgroundColorItems = [
|
||||||
|
'',
|
||||||
|
'#d1d5db',
|
||||||
|
'#f0b4b4',
|
||||||
|
'#efcfaa',
|
||||||
|
'#efe687',
|
||||||
|
'#b7dfb3',
|
||||||
|
'#b3c2e3',
|
||||||
|
'#c7b5e8',
|
||||||
|
'#d1d5db',
|
||||||
|
'#aeb3bc',
|
||||||
|
'#f26767',
|
||||||
|
'#f8a231',
|
||||||
|
'#f7df1e',
|
||||||
|
'#59c24a',
|
||||||
|
'#8ea7e1',
|
||||||
|
'#ad90e0'
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalizeHexColor = (value) => {
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(normalized) ? normalized : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useRichTextSelectionColorPanel({
|
||||||
|
visibleRef,
|
||||||
|
selectionTextColorRef,
|
||||||
|
selectionBackgroundColorRef,
|
||||||
|
onTextColorChange,
|
||||||
|
onBackgroundColorChange
|
||||||
|
}) {
|
||||||
|
const colorPanelVisible = ref(false);
|
||||||
|
const colorPanelRef = ref(null);
|
||||||
|
const colorTriggerRef = ref(null);
|
||||||
|
|
||||||
|
const normalizedTextColor = computed(
|
||||||
|
() => normalizeHexColor(selectionTextColorRef.value) || DEFAULT_TEXT_COLOR
|
||||||
|
);
|
||||||
|
const normalizedBackgroundColor = computed(
|
||||||
|
() => normalizeHexColor(selectionBackgroundColorRef.value) || DEFAULT_BACKGROUND_COLOR
|
||||||
|
);
|
||||||
|
|
||||||
|
const colorTriggerAStyle = computed(() => ({
|
||||||
|
color: normalizedTextColor.value,
|
||||||
|
backgroundColor: normalizedBackgroundColor.value || 'transparent'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pickTextColor = (color) => {
|
||||||
|
onTextColorChange(color);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickBackgroundColor = (color) => {
|
||||||
|
onBackgroundColorChange(color || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTextColorActive = (color) => normalizedTextColor.value === normalizeHexColor(color);
|
||||||
|
const isBackgroundColorActive = (color) => normalizedBackgroundColor.value === normalizeHexColor(color);
|
||||||
|
|
||||||
|
const resetColorToDefault = () => {
|
||||||
|
onTextColorChange(DEFAULT_TEXT_COLOR);
|
||||||
|
onBackgroundColorChange(DEFAULT_BACKGROUND_COLOR);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleColorPanel = () => {
|
||||||
|
colorPanelVisible.value = !colorPanelVisible.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeColorPanel = () => {
|
||||||
|
colorPanelVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentPointerDown = (event) => {
|
||||||
|
if (!colorPanelVisible.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = event.target;
|
||||||
|
if (colorPanelRef.value?.contains(target) || colorTriggerRef.value?.contains(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeColorPanel();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('mousedown', handleDocumentPointerDown, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('mousedown', handleDocumentPointerDown, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(visibleRef, (next) => {
|
||||||
|
if (!next) {
|
||||||
|
closeColorPanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
textColorItems,
|
||||||
|
backgroundColorItems,
|
||||||
|
colorPanelVisible,
|
||||||
|
colorPanelRef,
|
||||||
|
colorTriggerRef,
|
||||||
|
colorTriggerAStyle,
|
||||||
|
pickTextColor,
|
||||||
|
pickBackgroundColor,
|
||||||
|
isTextColorActive,
|
||||||
|
isBackgroundColorActive,
|
||||||
|
resetColorToDefault,
|
||||||
|
toggleColorPanel
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
.rich-text-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-placeholder {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-hint {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content) {
|
||||||
|
min-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
padding: 14px 18px 14px 56px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.7;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content h1),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content h2),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content h3) {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content p) {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
min-height: 1.45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content ul),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content ol) {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content blockquote) {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 3px solid color-mix(in srgb, var(--primary-color) 38%, var(--border-color) 62%);
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content a) {
|
||||||
|
color: color-mix(in srgb, var(--primary-color) 88%, #1d4ed8 12%);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
text-decoration-thickness: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content a:hover) {
|
||||||
|
color: color-mix(in srgb, var(--primary-color) 72%, #1e40af 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content code) {
|
||||||
|
font-family: 'JetBrains Mono', 'SFMono-Regular', Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
background: color-mix(in srgb, var(--bg-light) 72%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content .mindmap-node),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content .drawio-node) {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content .rich-table-node) {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content .yoresee-comment-anchor) {
|
||||||
|
background: color-mix(in srgb, #f59e0b 20%, transparent);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content .yoresee-comment-anchor.comment-anchor-highlight) {
|
||||||
|
background: color-mix(in srgb, #f59e0b 38%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre) {
|
||||||
|
background: color-mix(in srgb, var(--bg-light) 75%, transparent);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code) {
|
||||||
|
display: block;
|
||||||
|
font-family: 'JetBrains Mono', 'SFMono-Regular', Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code.hljs) {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-comment),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-quote) {
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-keyword),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-selector-tag),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-literal),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-section),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-link) {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-string),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-title),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-name),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-type),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-attribute),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-symbol),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-bullet),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-addition),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-template-tag),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-template-variable) {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-number),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-meta),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-meta-keyword),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-built_in),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-builtin-name) {
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-variable),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-params),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-attr),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-selector-id),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-selector-class),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-selector-attr),
|
||||||
|
.rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-selector-pseudo) {
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .rich-text-editor {
|
||||||
|
background: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content) {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre) {
|
||||||
|
background: color-mix(in srgb, var(--bg-light) 20%, #05070a 80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content a) {
|
||||||
|
color: #8fb6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content a:hover) {
|
||||||
|
color: #b7d0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content code) {
|
||||||
|
background: color-mix(in srgb, #0f172a 72%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #334155 66%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-comment),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-quote) {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-keyword),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-selector-tag),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-literal),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-section),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-link) {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-string),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-title),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-name),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-type),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-attribute),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-symbol),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-bullet),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-addition),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-template-tag),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-template-variable) {
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-number),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-meta),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-meta-keyword),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-built_in),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-builtin-name) {
|
||||||
|
color: #fdba74;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-variable),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-params),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-attr),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-selector-id),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-selector-class),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-selector-attr),
|
||||||
|
:global(.dark-mode) .rich-text-body :deep(.yoresee-rich-text-content pre code .hljs-selector-pseudo) {
|
||||||
|
color: #c4b5fd;
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<section class="kb-tree-panel">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3 class="section-title">{{ title }}</h3>
|
||||||
|
|
||||||
|
<div class="tree-controls">
|
||||||
|
<el-input
|
||||||
|
v-model="searchValue"
|
||||||
|
:placeholder="searchPlaceholder"
|
||||||
|
prefix-icon="Search"
|
||||||
|
clearable
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-select v-model="sortValue" :placeholder="sortPlaceholder" class="sort-select">
|
||||||
|
<el-option
|
||||||
|
v-for="option in sortOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tree-content" v-loading="loading">
|
||||||
|
<DocumentTree
|
||||||
|
v-if="nodes.length > 0"
|
||||||
|
:nodes="nodes"
|
||||||
|
:loading="loading"
|
||||||
|
:show-toolbar="false"
|
||||||
|
:show-create="false"
|
||||||
|
:show-delete="false"
|
||||||
|
:context-menu-enabled="false"
|
||||||
|
@node-click="(data) => emit('node-click', data)"
|
||||||
|
>
|
||||||
|
<template #node-extra="{ data }">
|
||||||
|
<AppTag v-if="data.tags && data.tags.length > 0" size="small" type="info" class="node-tag">
|
||||||
|
{{ data.tags[0] }}
|
||||||
|
</AppTag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #node-actions="{ data }">
|
||||||
|
<el-button size="small" type="primary" text @click.stop="emit('open', data)">
|
||||||
|
{{ openLabel }}
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<AppDropdown trigger="click" @command="(command) => emit('node-action', command, data)">
|
||||||
|
<el-button size="small" text @click.stop>
|
||||||
|
<el-icon>
|
||||||
|
<MoreFilled />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="rename">
|
||||||
|
{{ renameLabel }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="share" divided>
|
||||||
|
{{ shareLabel }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="delete" divided>
|
||||||
|
{{ deleteLabel }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</AppDropdown>
|
||||||
|
</template>
|
||||||
|
</DocumentTree>
|
||||||
|
|
||||||
|
<div v-else-if="!loading" class="empty-tree-state">
|
||||||
|
<el-empty :description="emptyText" :image-size="64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination-container" v-if="total > pageSize">
|
||||||
|
<el-pagination
|
||||||
|
:current-page="currentPage"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="(value) => emit('size-change', value)"
|
||||||
|
@current-change="(value) => emit('current-change', value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { MoreFilled } from '@element-plus/icons-vue';
|
||||||
|
import DocumentTree from '@/components/document/DocumentTree.vue';
|
||||||
|
import AppTag from '@/components/base/AppTag.vue';
|
||||||
|
import AppDropdown from '@/components/base/AppDropdown.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
searchKeyword: { type: String, default: '' },
|
||||||
|
searchPlaceholder: { type: String, default: '' },
|
||||||
|
sortBy: { type: String, default: '' },
|
||||||
|
sortPlaceholder: { type: String, default: '' },
|
||||||
|
sortOptions: { type: Array, default: () => [] },
|
||||||
|
nodes: { type: Array, default: () => [] },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
emptyText: { type: String, default: '' },
|
||||||
|
total: { type: Number, default: 0 },
|
||||||
|
currentPage: { type: Number, default: 1 },
|
||||||
|
pageSize: { type: Number, default: 50 },
|
||||||
|
openLabel: { type: String, default: '' },
|
||||||
|
renameLabel: { type: String, default: '' },
|
||||||
|
shareLabel: { type: String, default: '' },
|
||||||
|
deleteLabel: { type: String, default: '' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:searchKeyword',
|
||||||
|
'update:sortBy',
|
||||||
|
'node-click',
|
||||||
|
'open',
|
||||||
|
'node-action',
|
||||||
|
'size-change',
|
||||||
|
'current-change'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const searchValue = computed({
|
||||||
|
get: () => props.searchKeyword,
|
||||||
|
set: (value) => emit('update:searchKeyword', value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortValue = computed({
|
||||||
|
get: () => props.sortBy,
|
||||||
|
set: (value) => emit('update:sortBy', value)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.kb-tree-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-select {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tree-state {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tree-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input,
|
||||||
|
.sort-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<CardListSection
|
||||||
|
class="vertical-section"
|
||||||
|
:title="title"
|
||||||
|
:items="items"
|
||||||
|
:empty-text="emptyText"
|
||||||
|
:tag-type="tagType"
|
||||||
|
:tag-label="tagLabel"
|
||||||
|
:tag-mapper="tagMapper"
|
||||||
|
:fallback-description="fallbackDescription"
|
||||||
|
:meta-mapper="metaMapper"
|
||||||
|
:show-load-more="showLoadMore"
|
||||||
|
:loading="loading"
|
||||||
|
:load-more-label="loadMoreLabel"
|
||||||
|
:loading-label="loadingLabel"
|
||||||
|
:action-label="actionLabel"
|
||||||
|
:item-key-mapper="(item) => item?.externalId || item?.external_id || item?.id"
|
||||||
|
:item-title-mapper="(item) => item?.name || ''"
|
||||||
|
:item-description-mapper="(item) => item?.description || ''"
|
||||||
|
@open="$emit('open', $event)"
|
||||||
|
@load-more="$emit('load-more')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import CardListSection from '@/components/list/CardListSection.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
items: { type: Array, default: () => [] },
|
||||||
|
emptyText: { type: String, default: '' },
|
||||||
|
tagType: { type: String, default: '' },
|
||||||
|
tagLabel: { type: String, default: '' },
|
||||||
|
tagMapper: { type: Function, default: null },
|
||||||
|
fallbackDescription: { type: String, default: '' },
|
||||||
|
metaMapper: { type: Function, default: null },
|
||||||
|
showLoadMore: { type: Boolean, default: false },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
loadMoreLabel: { type: String, default: '' },
|
||||||
|
loadingLabel: { type: String, default: '' },
|
||||||
|
actionLabel: { type: String, default: '' }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['open', 'load-more']);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="kb-templates-panel">
|
||||||
|
<div class="kb-templates-inner" v-loading="loading">
|
||||||
|
<TemplateListSection
|
||||||
|
:title="title"
|
||||||
|
:items="items"
|
||||||
|
:empty-text="emptyText"
|
||||||
|
:fallback-description="fallbackDescription"
|
||||||
|
:tag-mapper="tagMapper"
|
||||||
|
:meta-mapper="metaMapper"
|
||||||
|
:action-label="actionLabel"
|
||||||
|
@open="(item) => emit('open', item)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import TemplateListSection from '@/components/template/TemplateListSection.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
items: { type: Array, default: () => [] },
|
||||||
|
emptyText: { type: String, default: '' },
|
||||||
|
fallbackDescription: { type: String, default: '' },
|
||||||
|
tagMapper: { type: Function, default: null },
|
||||||
|
metaMapper: { type: Function, default: null },
|
||||||
|
actionLabel: { type: String, default: '' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['open']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.kb-templates-panel {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
width: 320px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-templates-inner {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-templates-inner :deep(.card-list-section) {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-templates-inner :deep(.section-content) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.kb-templates-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auth-container">
|
||||||
|
<header class="auth-nav">
|
||||||
|
<div class="nav-right">
|
||||||
|
<AppDropdown trigger="click" class="nav-item" @command="$emit('change-language', $event)">
|
||||||
|
<span class="nav-link">
|
||||||
|
<el-icon :size="18"><Flag v-if="currentLanguage === 'en'" /><ChatLineRound v-else /></el-icon>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="en" :icon="'Flag'">
|
||||||
|
{{ t('language.english') }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="zh" :icon="'ChatLineRound'">
|
||||||
|
{{ t('language.chinese') }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</AppDropdown>
|
||||||
|
|
||||||
|
<div class="nav-item theme-switch">
|
||||||
|
<span class="nav-link" @click="$emit('toggle-theme')">
|
||||||
|
<el-icon :size="18"><Moon v-if="isDarkMode" /><Sunny v-else /></el-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="auth-form-wrapper">
|
||||||
|
<div class="auth-header">
|
||||||
|
<h2>{{ systemName }}</h2>
|
||||||
|
<p>{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Flag, ChatLineRound, Moon, Sunny } from '@element-plus/icons-vue';
|
||||||
|
import AppDropdown from '@/components/base/AppDropdown.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
currentLanguage: { type: String, default: 'en' },
|
||||||
|
isDarkMode: { type: Boolean, default: false },
|
||||||
|
systemName: { type: String, default: 'Yoresee' },
|
||||||
|
subtitle: { type: String, default: '' }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['change-language', 'toggle-theme']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
color: var(--text-medium);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: var(--bg-medium);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form-wrapper {
|
||||||
|
margin: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form-wrapper:hover {
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-light);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.auth-form-wrapper {
|
||||||
|
padding: var(--spacing-lg) var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<TopNav
|
||||||
|
:system-name="systemName"
|
||||||
|
:current-language="currentLanguage"
|
||||||
|
:is-dark-mode="isDarkMode"
|
||||||
|
:user-avatar="userAvatar"
|
||||||
|
:username="username"
|
||||||
|
@change-language="$emit('change-language', $event)"
|
||||||
|
@toggle-theme="$emit('toggle-theme')"
|
||||||
|
@logout="$emit('logout')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="page-main">
|
||||||
|
<SideNav
|
||||||
|
:active-menu="activeMenu"
|
||||||
|
:menu-items="sideMenuItems"
|
||||||
|
:scene="sidebarScene"
|
||||||
|
@menu-select="$emit('menu-select', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="page-content" :class="contentPaddingClass">
|
||||||
|
<TitleBar v-if="layout === 'list' && showHeader">
|
||||||
|
<template #title>
|
||||||
|
{{ title }}
|
||||||
|
</template>
|
||||||
|
<template v-if="$slots.actions" #actions>
|
||||||
|
<slot name="actions" />
|
||||||
|
</template>
|
||||||
|
</TitleBar>
|
||||||
|
<div class="page-header" v-else-if="showHeader">
|
||||||
|
<h2 class="page-title">{{ title }}</h2>
|
||||||
|
<div class="page-actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, useSlots } from 'vue';
|
||||||
|
import SideNav from '@/components/layout/SideNav.vue';
|
||||||
|
import TopNav from '@/components/layout/TopNav.vue';
|
||||||
|
import TitleBar from '@/components/layout/TitleBar.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
systemName: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
currentLanguage: {
|
||||||
|
type: String,
|
||||||
|
default: 'zh-CN'
|
||||||
|
},
|
||||||
|
isDarkMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
userAvatar: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
activeMenu: {
|
||||||
|
type: String,
|
||||||
|
default: 'home'
|
||||||
|
},
|
||||||
|
sideMenuItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
sidebarScene: {
|
||||||
|
type: String,
|
||||||
|
default: 'home'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
type: String,
|
||||||
|
default: 'default'
|
||||||
|
},
|
||||||
|
contentPadding: {
|
||||||
|
type: String,
|
||||||
|
default: 'lg'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['change-language', 'toggle-theme', 'logout', 'menu-select']);
|
||||||
|
|
||||||
|
const slots = useSlots();
|
||||||
|
const showHeader = computed(() => Boolean(props.title || slots.actions));
|
||||||
|
const contentPaddingClass = computed(() => `page-content--${props.contentPadding || 'lg'}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-container {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content--lg {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content--xl {
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
padding-bottom: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions :deep(.page-action-btn) {
|
||||||
|
padding: 8px 14px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions :deep(.page-action-btn.el-button--primary) {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions :deep(.page-action-btn.el-button--primary:hover),
|
||||||
|
.page-actions :deep(.page-action-btn.el-button--primary:focus) {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.page-content {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="panel-sidebar-container"
|
||||||
|
:class="{ collapsed, resizing, 'edge-left': resizeEdge === 'left', 'edge-right': resizeEdge === 'right' }"
|
||||||
|
:style="containerStyle"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showResizer && resizeEdge === 'left'"
|
||||||
|
class="panel-sidebar-resizer"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
@mousedown="$emit('resize-start', $event)"
|
||||||
|
></div>
|
||||||
|
<aside class="panel-sidebar" :style="panelStyle">
|
||||||
|
<slot name="header"></slot>
|
||||||
|
<slot></slot>
|
||||||
|
</aside>
|
||||||
|
<div
|
||||||
|
v-if="showResizer && resizeEdge === 'right'"
|
||||||
|
class="panel-sidebar-resizer"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
@mousedown="$emit('resize-start', $event)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
collapsed: { type: Boolean, default: false },
|
||||||
|
resizing: { type: Boolean, default: false },
|
||||||
|
resizeEdge: { type: String, default: 'right' },
|
||||||
|
showResizer: { type: Boolean, default: true },
|
||||||
|
containerStyle: { type: [Object, Array], default: () => ({}) },
|
||||||
|
panelStyle: { type: [Object, Array], default: () => ({}) }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['resize-start']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel-sidebar-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-sidebar-container.edge-right {
|
||||||
|
width: calc(var(--sidebar-width) + 6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-sidebar-container.collapsed {
|
||||||
|
width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-sidebar-container.resizing,
|
||||||
|
.panel-sidebar-container.resizing .panel-sidebar,
|
||||||
|
.panel-sidebar-container.resizing .panel-sidebar-resizer {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-sidebar-container.edge-right .panel-sidebar {
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-sidebar-container.edge-left {
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-sidebar-resizer {
|
||||||
|
width: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: col-resize;
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-sidebar-resizer:hover {
|
||||||
|
background-color: var(--bg-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .panel-sidebar-container,
|
||||||
|
:global(.dark-mode) .panel-sidebar {
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .panel-sidebar-resizer {
|
||||||
|
background-color: var(--bg-medium);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .panel-sidebar-resizer:hover {
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="side-nav" :class="{ 'collapsed': isCollapsed }">
|
||||||
|
<el-menu :default-active="currentActiveMenu" class="side-menu" @select="handleMenuSelect">
|
||||||
|
<el-menu-item v-for="item in filteredMenuItems" :key="item.key" :index="item.key">
|
||||||
|
<el-icon v-if="item.icon">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<span class="menu-text">{{ getMenuLabel(item) }}</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
<button class="collapse-btn" @click="toggleCollapse"
|
||||||
|
:title="isCollapsed ? t('common.expand') : t('common.collapse')">
|
||||||
|
<el-icon>
|
||||||
|
<DArrowRight v-if="isCollapsed" />
|
||||||
|
<DArrowLeft v-else />
|
||||||
|
</el-icon>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { House, Collection, Document, Tickets, Search, DArrowRight, DArrowLeft } from '@element-plus/icons-vue';
|
||||||
|
import { querySideBarDisplay } from '@/services/auth';
|
||||||
|
import { useApiAction } from '@/composables/actions/useApiAction';
|
||||||
|
import {
|
||||||
|
getSideBarDisplayTabsCache,
|
||||||
|
setSideBarDisplayTabsCache
|
||||||
|
} from '@/composables/layout/useSideBarDisplayCache';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { runSilent, runWithLoading } = useApiAction({ t });
|
||||||
|
|
||||||
|
const isCollapsed = ref(localStorage.getItem('sideNavCollapsed') === 'true');
|
||||||
|
|
||||||
|
const toggleCollapse = () => {
|
||||||
|
isCollapsed.value = !isCollapsed.value;
|
||||||
|
localStorage.setItem('sideNavCollapsed', isCollapsed.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 接收当前激活的菜单作为 props
|
||||||
|
const defaultMenuItems = [
|
||||||
|
{ key: 'home', labelKey: 'navigation.home', icon: House, route: '/' },
|
||||||
|
{ key: 'search', labelKey: 'navigation.search', icon: Search, route: '/search' },
|
||||||
|
{ key: 'documents', labelKey: 'navigation.myDocuments', icon: Document, route: '/mydocuments' },
|
||||||
|
{ key: 'knowledge-base', labelKey: 'navigation.knowledgeBase', icon: Collection, route: '/knowledge-base' },
|
||||||
|
{ key: 'templates', labelKey: 'navigation.templates', icon: Tickets, route: '/templates' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
activeMenu: {
|
||||||
|
type: String,
|
||||||
|
default: 'home'
|
||||||
|
},
|
||||||
|
menuItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
scene: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 定义 emit 事件
|
||||||
|
const emit = defineEmits(['menuSelect']);
|
||||||
|
|
||||||
|
// 计算属性:当前激活的菜单项
|
||||||
|
const currentActiveMenu = computed(() => props.activeMenu);
|
||||||
|
const resolvedMenuItems = computed(() => (
|
||||||
|
Array.isArray(props.menuItems) && props.menuItems.length ? props.menuItems : defaultMenuItems
|
||||||
|
));
|
||||||
|
const filterLoading = ref(false);
|
||||||
|
const sceneKey = computed(() => (props.scene || '').trim());
|
||||||
|
const displayTabs = ref(getSideBarDisplayTabsCache(sceneKey.value) || []);
|
||||||
|
|
||||||
|
const filteredMenuItems = computed(() => {
|
||||||
|
if (!sceneKey.value) {
|
||||||
|
return resolvedMenuItems.value;
|
||||||
|
}
|
||||||
|
const tabsSet = new Set(displayTabs.value);
|
||||||
|
return resolvedMenuItems.value.filter((item) => tabsSet.has(item.key));
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadDisplayTabs = async () => {
|
||||||
|
const scene = sceneKey.value;
|
||||||
|
if (!scene) {
|
||||||
|
displayTabs.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedTabs = getSideBarDisplayTabsCache(scene);
|
||||||
|
if (Array.isArray(cachedTabs)) {
|
||||||
|
displayTabs.value = cachedTabs;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayTabs.value = [];
|
||||||
|
await runWithLoading(
|
||||||
|
filterLoading,
|
||||||
|
() =>
|
||||||
|
runSilent(
|
||||||
|
() => querySideBarDisplay(scene),
|
||||||
|
{
|
||||||
|
context: 'loadSideBarDisplay',
|
||||||
|
onSuccess: (resp) => {
|
||||||
|
const tabs = resp.display_tabs || [];
|
||||||
|
setSideBarDisplayTabsCache(scene, tabs);
|
||||||
|
displayTabs.value = tabs;
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
// fail closed so privileged menu is not exposed by client fallback.
|
||||||
|
displayTabs.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMenuLabel = (item) => {
|
||||||
|
if (item.label) {
|
||||||
|
return item.label;
|
||||||
|
}
|
||||||
|
if (item.labelKey) {
|
||||||
|
return t(item.labelKey);
|
||||||
|
}
|
||||||
|
return item.key;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => sceneKey.value,
|
||||||
|
() => {
|
||||||
|
loadDisplayTabs();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理菜单选择
|
||||||
|
const handleMenuSelect = (key) => {
|
||||||
|
emit('menuSelect', key);
|
||||||
|
const selected = filteredMenuItems.value.find((item) => item.key === key);
|
||||||
|
if (selected?.route) {
|
||||||
|
router.push(selected.route);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'home') {
|
||||||
|
router.push('/');
|
||||||
|
} else if (key === 'search') {
|
||||||
|
router.push('/search');
|
||||||
|
} else if (key === 'documents') {
|
||||||
|
router.push('/mydocuments');
|
||||||
|
} else if (key === 'knowledge-base') {
|
||||||
|
router.push('/knowledge-base');
|
||||||
|
} else if (key === 'templates') {
|
||||||
|
router.push('/templates');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.side-nav {
|
||||||
|
width: 240px;
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 0.3s ease, background-color 0.2s ease, border-color 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav.collapsed {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu .el-menu-item {
|
||||||
|
height: 48px;
|
||||||
|
line-height: 48px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
color: var(--text-medium);
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding-left: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu .el-menu-item:hover {
|
||||||
|
background-color: var(--primary-light);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu .el-menu-item.is-active {
|
||||||
|
background-color: var(--primary-light);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-right: 3px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-text {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 160px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: max-width 0.25s ease, opacity 0.2s ease, transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav.collapsed .menu-text {
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: -12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn .el-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.side-nav {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav.collapsed {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<div class="title-bar" :class="{ 'title-bar--compact': compact }">
|
||||||
|
<div class="title-bar__left">
|
||||||
|
<el-button v-if="showBack" text class="title-bar__back" @click="emit('back')">
|
||||||
|
<el-icon><ArrowLeft /></el-icon>
|
||||||
|
{{ backText }}
|
||||||
|
</el-button>
|
||||||
|
<div v-if="$slots.title" class="title-bar__title">
|
||||||
|
<slot name="title" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="title-bar__actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ArrowLeft } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
showBack: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
backText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
compact: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['back']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.title-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
padding-bottom: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar--compact {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__back {
|
||||||
|
height: 36px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__back:hover,
|
||||||
|
.title-bar__back:focus {
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-color: var(--primary-light);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .title-bar__back:hover,
|
||||||
|
.dark-mode .title-bar__back:focus {
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-color: var(--primary-light);
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__back :deep(.el-icon) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__actions :deep(.el-button),
|
||||||
|
.title-bar__actions :deep(.el-button--small) {
|
||||||
|
height: 32px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__actions :deep(.el-button--primary) {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__actions :deep(.el-button--primary:hover),
|
||||||
|
.title-bar__actions :deep(.el-button--primary:focus) {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.title-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
<template>
|
||||||
|
<header class="top-nav">
|
||||||
|
<div class="nav-left">
|
||||||
|
<router-link class="system-link" to="/">
|
||||||
|
<h1 class="system-title">{{ systemName }}</h1>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="nav-center">
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
:placeholder="t('search.placeholder')"
|
||||||
|
clearable
|
||||||
|
class="top-search-input"
|
||||||
|
@keyup.enter="submitSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon :size="16">
|
||||||
|
<Search />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
<div class="nav-right">
|
||||||
|
<AppDropdown trigger="click" @command="emit('change-language', $event)" class="nav-item">
|
||||||
|
<span class="nav-link">
|
||||||
|
<el-icon :size="18">
|
||||||
|
<Flag v-if="currentLanguage === 'en'" />
|
||||||
|
<ChatLineRound v-else />
|
||||||
|
</el-icon>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="en" :icon="'Flag'">
|
||||||
|
{{ t('language.english') }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="zh" :icon="'ChatLineRound'">
|
||||||
|
{{ t('language.chinese') }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</AppDropdown>
|
||||||
|
|
||||||
|
<div class="nav-item theme-switch">
|
||||||
|
<span class="nav-link" @click="emit('toggle-theme')">
|
||||||
|
<el-icon :size="18">
|
||||||
|
<Moon v-if="isDarkMode" />
|
||||||
|
<Sunny v-else />
|
||||||
|
</el-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-item">
|
||||||
|
<span class="nav-link notification-link" @click="goToNotifications">
|
||||||
|
<span v-if="hasUnread" class="notification-dot" />
|
||||||
|
<el-icon :size="18">
|
||||||
|
<Bell />
|
||||||
|
</el-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppDropdown trigger="click" class="nav-item">
|
||||||
|
<span class="user-info">
|
||||||
|
<AppAvatar :src="userAvatar" :name="username" :size="24" />
|
||||||
|
<span class="username">{{ username }}</span>
|
||||||
|
<el-icon class="el-icon--right">
|
||||||
|
<ArrowDown />
|
||||||
|
</el-icon>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item v-if="showUserCenter" @click="goToUserCenter">{{ t('user.center') }}</el-dropdown-item>
|
||||||
|
<el-dropdown-item v-if="showSystemManage" @click="handleSystemManage">
|
||||||
|
{{ t('system.management') }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item divided @click="emit('logout')">{{ t('button.logout') }}</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</AppDropdown>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { ArrowDown, Flag, ChatLineRound, Moon, Sunny, Bell, Search } from '@element-plus/icons-vue';
|
||||||
|
import { queryTopNavDisplay } from '@/services/auth';
|
||||||
|
import { listNotifications } from '@/services/api';
|
||||||
|
import { useApiAction } from '@/composables/actions/useApiAction';
|
||||||
|
import AppDropdown from '@/components/base/AppDropdown.vue';
|
||||||
|
import AppAvatar from '@/components/base/AppAvatar.vue';
|
||||||
|
import {
|
||||||
|
getTopNavDisplayMenusCache,
|
||||||
|
setTopNavDisplayMenusCache
|
||||||
|
} from '@/composables/layout/useTopNavDisplayCache';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
systemName: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
currentLanguage: {
|
||||||
|
type: String,
|
||||||
|
default: 'zh'
|
||||||
|
},
|
||||||
|
isDarkMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
userAvatar: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['change-language', 'toggle-theme', 'logout']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const { runSilent } = useApiAction({ t });
|
||||||
|
const showSystemManage = ref(false);
|
||||||
|
const showUserCenter = ref(false);
|
||||||
|
const hasUnread = ref(false);
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
|
||||||
|
const goToUserCenter = () => {
|
||||||
|
router.push('/user_info/profile');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSystemManage = () => {
|
||||||
|
router.push('/manage');
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNotifications = () => {
|
||||||
|
router.push('/user_info/notifications');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitSearch = () => {
|
||||||
|
const q = `${searchKeyword.value || ''}`.trim();
|
||||||
|
router.push({
|
||||||
|
path: '/search',
|
||||||
|
query: q ? { q } : {}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTopNavDisplay = (menus = []) => {
|
||||||
|
showUserCenter.value = menus.includes('user-center');
|
||||||
|
showSystemManage.value = menus.includes('system-manage');
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTopNavDisplay = async () => {
|
||||||
|
const cachedMenus = getTopNavDisplayMenusCache();
|
||||||
|
if (Array.isArray(cachedMenus)) {
|
||||||
|
applyTopNavDisplay(cachedMenus);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runSilent(
|
||||||
|
() => queryTopNavDisplay(),
|
||||||
|
{
|
||||||
|
context: 'loadTopNavDisplay',
|
||||||
|
onSuccess: (resp) => {
|
||||||
|
const menus = resp.display_menus || [];
|
||||||
|
setTopNavDisplayMenusCache(menus);
|
||||||
|
applyTopNavDisplay(menus);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showUserCenter.value = false;
|
||||||
|
showSystemManage.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUnreadNotifications = async () => {
|
||||||
|
await runSilent(
|
||||||
|
() => listNotifications({ page: 1, page_size: 1, status: 'unread' }),
|
||||||
|
{
|
||||||
|
context: 'loadUnreadNotifications',
|
||||||
|
onSuccess: (resp) => {
|
||||||
|
hasUnread.value = Number(resp.total) > 0;
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
hasUnread.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnreadEvent = (event) => {
|
||||||
|
if (!event?.detail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasUnread.value = Boolean(event.detail.hasUnread);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
searchKeyword.value = `${route.query.q || ''}`.trim();
|
||||||
|
loadTopNavDisplay();
|
||||||
|
loadUnreadNotifications();
|
||||||
|
window.addEventListener('notifications:unread', handleUnreadEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('notifications:unread', handleUnreadEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.q,
|
||||||
|
(q) => {
|
||||||
|
searchKeyword.value = `${q || ''}`.trim();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.top-nav {
|
||||||
|
height: 60px;
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: 0 var(--spacing-xl);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-link {
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-link:hover .system-title {
|
||||||
|
color: var(--primary-color);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-search-input {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
color: var(--text-medium);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: var(--bg-medium);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-link {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: #ef4444;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 2px var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .notification-dot {
|
||||||
|
box-shadow: 0 0 0 2px var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info:hover {
|
||||||
|
background-color: var(--bg-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
margin-left: var(--spacing-sm);
|
||||||
|
margin-right: 4px;
|
||||||
|
color: var(--text-medium);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.top-nav {
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-center {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card-list-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3 class="section-title">{{ title }}</h3>
|
||||||
|
<el-button
|
||||||
|
v-if="showViewAll"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
class="section-link"
|
||||||
|
@click="$emit('view-all')"
|
||||||
|
>
|
||||||
|
{{ viewAllLabel }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<el-empty v-if="showEmpty" :description="emptyText" />
|
||||||
|
<el-card
|
||||||
|
v-for="item in items"
|
||||||
|
:key="resolveItemKey(item)"
|
||||||
|
class="section-item"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="item-name">{{ resolveItemTitle(item) }}</span>
|
||||||
|
<AppTag v-if="resolveTag(item)" :type="resolveTag(item).type" size="small">
|
||||||
|
{{ resolveTag(item).label }}
|
||||||
|
</AppTag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="resolveItemDescription(item) || fallbackDescription" class="item-description">
|
||||||
|
{{ resolveItemDescription(item) || fallbackDescription }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="resolveMetaRows(item).length > 0" class="item-meta">
|
||||||
|
<div
|
||||||
|
v-for="(row, index) in resolveMetaRows(item)"
|
||||||
|
:key="`${row.label}-${index}`"
|
||||||
|
class="meta-item"
|
||||||
|
>
|
||||||
|
<span class="meta-label">{{ row.label }}:</span>
|
||||||
|
<span class="meta-value">{{ row.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showActions" class="item-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="secondaryActionLabel"
|
||||||
|
size="small"
|
||||||
|
@click="$emit('secondary-action', item)"
|
||||||
|
>
|
||||||
|
{{ secondaryActionLabel }}
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="actionLabel" size="small" type="primary" @click="$emit('open', item)">
|
||||||
|
{{ actionLabel }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div v-if="showLoadMore" class="load-more">
|
||||||
|
<el-button :loading="loading" plain @click="$emit('load-more')">
|
||||||
|
{{ loading ? loadingLabel : loadMoreLabel }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import AppTag from '@/components/base/AppTag.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
items: { type: Array, default: () => [] },
|
||||||
|
emptyText: { type: String, default: '' },
|
||||||
|
fallbackDescription: { type: String, default: '' },
|
||||||
|
actionLabel: { type: String, default: '' },
|
||||||
|
secondaryActionLabel: { type: String, default: '' },
|
||||||
|
tagType: { type: String, default: '' },
|
||||||
|
tagLabel: { type: String, default: '' },
|
||||||
|
tagMapper: { type: Function, default: null },
|
||||||
|
metaMapper: { type: Function, default: null },
|
||||||
|
showViewAll: { type: Boolean, default: false },
|
||||||
|
viewAllLabel: { type: String, default: '' },
|
||||||
|
showLoadMore: { type: Boolean, default: false },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
loadMoreLabel: { type: String, default: '' },
|
||||||
|
loadingLabel: { type: String, default: '' },
|
||||||
|
itemKeyMapper: { type: Function, default: null },
|
||||||
|
itemTitleMapper: { type: Function, default: null },
|
||||||
|
itemDescriptionMapper: { type: Function, default: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['open', 'secondary-action', 'load-more', 'view-all']);
|
||||||
|
|
||||||
|
const showEmpty = computed(() => props.items.length === 0 && props.emptyText);
|
||||||
|
const showActions = computed(() => Boolean(props.actionLabel || props.secondaryActionLabel));
|
||||||
|
|
||||||
|
const resolveItemKey = (item) => {
|
||||||
|
if (props.itemKeyMapper) return props.itemKeyMapper(item);
|
||||||
|
return item?.externalId || item?.id || item?.external_id || item?.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveItemTitle = (item) => {
|
||||||
|
if (props.itemTitleMapper) return props.itemTitleMapper(item);
|
||||||
|
return item?.name || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveItemDescription = (item) => {
|
||||||
|
if (props.itemDescriptionMapper) return props.itemDescriptionMapper(item);
|
||||||
|
return item?.description || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTag = (item) => {
|
||||||
|
if (props.tagMapper) return props.tagMapper(item);
|
||||||
|
if (props.tagType && props.tagLabel) {
|
||||||
|
return { type: props.tagType, label: props.tagLabel };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveMetaRows = (item) => {
|
||||||
|
if (!props.metaMapper) return [];
|
||||||
|
return props.metaMapper(item) || [];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-list-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-content {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-item {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-item:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-description {
|
||||||
|
margin: var(--spacing-sm) 0;
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.section-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .section-item {
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .item-name {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .item-description {
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .meta-label {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .meta-value {
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<template>
|
||||||
|
<div class="common-list" :class="{ 'is-dark': isDark, 'is-tree': mode === 'tree' }">
|
||||||
|
<CommonListToolbar
|
||||||
|
:show-title-bar="showTitleBar"
|
||||||
|
:show-search="showSearch"
|
||||||
|
:title="title"
|
||||||
|
:search-placeholder="searchPlaceholder"
|
||||||
|
:search-query="searchValue"
|
||||||
|
@update:search-query="searchValue = $event"
|
||||||
|
@search="emitSearch"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<slot name="title">
|
||||||
|
{{ title }}
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
<template #toolbar-actions>
|
||||||
|
<slot name="toolbar-actions" />
|
||||||
|
</template>
|
||||||
|
<template #toolbar-right>
|
||||||
|
<slot name="toolbar-right" />
|
||||||
|
</template>
|
||||||
|
</CommonListToolbar>
|
||||||
|
<template v-if="rows.length === 0">
|
||||||
|
<div class="list-empty">
|
||||||
|
<el-empty :description="emptyText" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<CommonListTable
|
||||||
|
v-if="mode !== 'tree'"
|
||||||
|
:rows="rows"
|
||||||
|
:columns="columns"
|
||||||
|
:display-columns="displayColumns"
|
||||||
|
:grid-template-columns="gridTemplateColumns"
|
||||||
|
:current-page="paginationPage"
|
||||||
|
:page-size="paginationPageSize"
|
||||||
|
:tree-toggle-column-key="treeToggleColumnKey"
|
||||||
|
:resolve-row-key="resolveRowKey"
|
||||||
|
:align-class="alignClass"
|
||||||
|
:build-grid-template="buildGridTemplate"
|
||||||
|
:resolve-serial-number="resolveSerialNumber"
|
||||||
|
>
|
||||||
|
<template v-for="name in forwardedSlotNames" :key="`table-${name}`" #[name]="slotProps">
|
||||||
|
<slot :name="name" v-bind="slotProps || {}" />
|
||||||
|
</template>
|
||||||
|
</CommonListTable>
|
||||||
|
|
||||||
|
<CommonListTreeTable
|
||||||
|
v-else
|
||||||
|
:tree-toggle-width="treeToggleWidth"
|
||||||
|
:tree-data-columns="treeDataColumns"
|
||||||
|
:tree-data-grid-template="treeDataGridTemplate"
|
||||||
|
:current-page="paginationPage"
|
||||||
|
:page-size="paginationPageSize"
|
||||||
|
:tree-loading="treeLoading"
|
||||||
|
:tree-flat-rows="treeFlatRows"
|
||||||
|
:max-tree-indent-width="maxTreeIndentWidth"
|
||||||
|
:toggle-scroll-left="toggleScrollLeft"
|
||||||
|
:tree-indent="treeIndent"
|
||||||
|
:tree-base-indent="treeBaseIndent"
|
||||||
|
:tree-column-resolved-key="treeColumnResolvedKey"
|
||||||
|
:resolve-row-key="resolveRowKey"
|
||||||
|
:align-class="alignClass"
|
||||||
|
:toggle-tree-node="toggleTreeNode"
|
||||||
|
:is-tree-column="isTreeColumn"
|
||||||
|
:build-grid-template="buildGridTemplate"
|
||||||
|
:resolve-serial-number="resolveSerialNumber"
|
||||||
|
@toggle-scroll="setToggleScrollLeft"
|
||||||
|
>
|
||||||
|
<template v-for="name in forwardedSlotNames" :key="`tree-${name}`" #[name]="slotProps">
|
||||||
|
<slot :name="name" v-bind="slotProps || {}" />
|
||||||
|
</template>
|
||||||
|
</CommonListTreeTable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<CommonListPagination
|
||||||
|
:show-pagination="showPagination"
|
||||||
|
:current-page="paginationPage"
|
||||||
|
:page-size="paginationPageSize"
|
||||||
|
:page-sizes="pageSizes"
|
||||||
|
:total="paginationTotal"
|
||||||
|
:pagination-layout="paginationLayout"
|
||||||
|
@update:current-page="paginationPage = $event"
|
||||||
|
@update:page-size="paginationPageSize = $event"
|
||||||
|
@page-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useCommonListState } from '@/composables/list/useCommonListState';
|
||||||
|
import { useForwardedSlotNames } from '@/composables/list/useForwardedSlotNames';
|
||||||
|
import CommonListToolbar from '@/components/list/common/CommonListToolbar.vue';
|
||||||
|
import CommonListPagination from '@/components/list/common/CommonListPagination.vue';
|
||||||
|
import CommonListTable from '@/components/list/common/CommonListTable.vue';
|
||||||
|
import CommonListTreeTable from '@/components/list/common/CommonListTreeTable.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
rows: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
rowKey: {
|
||||||
|
type: [String, Function],
|
||||||
|
default: 'id'
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'table'
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
isDark: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showPagination: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
currentPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
pageSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 10
|
||||||
|
},
|
||||||
|
pageSizes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [10, 20, 50, 100]
|
||||||
|
},
|
||||||
|
paginationLayout: {
|
||||||
|
type: String,
|
||||||
|
default: 'total, prev, pager, next, jumper'
|
||||||
|
},
|
||||||
|
showSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showTitleBar: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
searchQuery: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
searchPlaceholder: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
showIndexColumn: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
indexColumnLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '序号'
|
||||||
|
},
|
||||||
|
indexColumnWidth: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 72
|
||||||
|
},
|
||||||
|
indexColumnAlign: {
|
||||||
|
type: String,
|
||||||
|
default: 'center'
|
||||||
|
},
|
||||||
|
treeLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
treeChildrenKey: {
|
||||||
|
type: String,
|
||||||
|
default: 'children'
|
||||||
|
},
|
||||||
|
treeColumnKey: {
|
||||||
|
type: String,
|
||||||
|
default: 'label'
|
||||||
|
},
|
||||||
|
treeColumnLabel: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
treeIndent: {
|
||||||
|
type: Number,
|
||||||
|
default: 16
|
||||||
|
},
|
||||||
|
treeBaseIndent: {
|
||||||
|
type: Number,
|
||||||
|
default: 6
|
||||||
|
},
|
||||||
|
treeDefaultExpandAll: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
treeExpandedKeys: {
|
||||||
|
type: Array,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
treeKeyField: {
|
||||||
|
type: [String, Function],
|
||||||
|
default: 'id'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:currentPage',
|
||||||
|
'update:pageSize',
|
||||||
|
'page-change',
|
||||||
|
'size-change',
|
||||||
|
'update:searchQuery',
|
||||||
|
'search',
|
||||||
|
'update:treeExpandedKeys',
|
||||||
|
'tree-toggle'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const forwardedSlotNames = useForwardedSlotNames(['title', 'toolbar-actions', 'toolbar-right']);
|
||||||
|
|
||||||
|
const {
|
||||||
|
treeToggleColumnKey,
|
||||||
|
treeToggleWidth,
|
||||||
|
displayColumns,
|
||||||
|
gridTemplateColumns,
|
||||||
|
treeDataColumns,
|
||||||
|
treeDataGridTemplate,
|
||||||
|
buildGridTemplate,
|
||||||
|
resolveRowKey,
|
||||||
|
treeColumnResolvedKey,
|
||||||
|
paginationPage,
|
||||||
|
paginationPageSize,
|
||||||
|
paginationTotal,
|
||||||
|
searchValue,
|
||||||
|
emitSearch,
|
||||||
|
toggleScrollLeft,
|
||||||
|
setToggleScrollLeft,
|
||||||
|
toggleTreeNode,
|
||||||
|
isTreeColumn,
|
||||||
|
treeFlatRows,
|
||||||
|
maxTreeIndentWidth,
|
||||||
|
handlePageChange,
|
||||||
|
handleSizeChange,
|
||||||
|
alignClass,
|
||||||
|
resolveSerialNumber
|
||||||
|
} = useCommonListState(props, emit);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.common-list {
|
||||||
|
--list-cell-bg: var(--bg-white);
|
||||||
|
--list-cell-text: var(--text-dark);
|
||||||
|
--list-cell-border: #d6dbe3;
|
||||||
|
--list-head-bg: #e5ebf2;
|
||||||
|
--list-head-text: #1f2937;
|
||||||
|
--list-head-border: #aeb8c6;
|
||||||
|
--list-pagination-bg: var(--bg-white);
|
||||||
|
--list-pagination-border: #d6dbe3;
|
||||||
|
--list-pagination-text: var(--text-medium);
|
||||||
|
--list-pagination-strong-text: var(--text-dark);
|
||||||
|
--list-pagination-item-bg: #ffffff;
|
||||||
|
--list-pagination-item-border: #d6dbe3;
|
||||||
|
--list-pagination-item-hover-bg: #f3f7ff;
|
||||||
|
--list-pagination-item-hover-border: #b8cbff;
|
||||||
|
--list-pagination-item-active-bg: var(--primary-color);
|
||||||
|
--list-pagination-item-active-text: #ffffff;
|
||||||
|
--list-pagination-disabled-bg: #f4f6fa;
|
||||||
|
--list-pagination-disabled-text: #b6bfcc;
|
||||||
|
--list-pagination-input-bg: #ffffff;
|
||||||
|
--list-pagination-input-border: #d6dbe3;
|
||||||
|
--list-pagination-input-text: var(--text-dark);
|
||||||
|
--list-pagination-focus-ring: rgba(22, 93, 255, 0.18);
|
||||||
|
background: var(--bg-white);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.common-list.is-tree {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-empty {
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.common-list.is-dark {
|
||||||
|
--list-cell-bg: #161b22;
|
||||||
|
--list-cell-text: #e5e7eb;
|
||||||
|
--list-cell-border: #2a313a;
|
||||||
|
--list-head-bg: #202734;
|
||||||
|
--list-head-text: #e5edf8;
|
||||||
|
--list-head-border: #4a5668;
|
||||||
|
--list-pagination-bg: #141a23;
|
||||||
|
--list-pagination-border: #2a313a;
|
||||||
|
--list-pagination-text: #96a1b4;
|
||||||
|
--list-pagination-strong-text: #e5e7eb;
|
||||||
|
--list-pagination-item-bg: #1a2230;
|
||||||
|
--list-pagination-item-border: #2f3b4d;
|
||||||
|
--list-pagination-item-hover-bg: #233147;
|
||||||
|
--list-pagination-item-hover-border: #45679a;
|
||||||
|
--list-pagination-item-active-bg: #3370ff;
|
||||||
|
--list-pagination-item-active-text: #ffffff;
|
||||||
|
--list-pagination-disabled-bg: #131a24;
|
||||||
|
--list-pagination-disabled-text: #5f6a7d;
|
||||||
|
--list-pagination-input-bg: #1a2230;
|
||||||
|
--list-pagination-input-border: #2f3b4d;
|
||||||
|
--list-pagination-input-text: #e5e7eb;
|
||||||
|
--list-pagination-focus-ring: rgba(64, 128, 255, 0.28);
|
||||||
|
background: #161b22;
|
||||||
|
border-color: #161b22;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<CardListSection
|
||||||
|
class="document-list-section"
|
||||||
|
:title="title"
|
||||||
|
:items="items"
|
||||||
|
:empty-text="emptyText"
|
||||||
|
:action-label="primaryAction"
|
||||||
|
:secondary-action-label="secondaryAction"
|
||||||
|
:show-view-all="showViewAll"
|
||||||
|
:view-all-label="t('common.viewAll')"
|
||||||
|
:item-key-mapper="itemKeyMapper"
|
||||||
|
:item-title-mapper="itemTitleMapper"
|
||||||
|
:item-description-mapper="itemDescriptionMapper"
|
||||||
|
:meta-mapper="metaMapper"
|
||||||
|
@view-all="emit('view-all')"
|
||||||
|
@open="handleView"
|
||||||
|
@secondary-action="handleEdit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import CardListSection from '@/components/list/CardListSection.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
showViewAll: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
singleAction: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
primaryActionLabel: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['view-all', 'view-item', 'edit-item']);
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return t('common.unknown');
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (Number.isNaN(date.getTime())) return dateString;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemKeyMapper = (doc) => doc?.id || doc?.external_id || doc?.externalId || doc?.title;
|
||||||
|
const itemTitleMapper = (doc) => doc?.title || t('document.title');
|
||||||
|
const itemDescriptionMapper = () => '';
|
||||||
|
|
||||||
|
const metaMapper = (doc) => [
|
||||||
|
{ label: t('knowledgeBase.owner'), value: doc?.author || t('common.unknown') },
|
||||||
|
{ label: t('common.updatedAt'), value: formatDate(doc?.updatedAt || doc?.updated_at) }
|
||||||
|
];
|
||||||
|
|
||||||
|
const primaryAction = computed(() => {
|
||||||
|
if (props.singleAction) {
|
||||||
|
return props.primaryActionLabel || t('common.open');
|
||||||
|
}
|
||||||
|
return t('document.view');
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondaryAction = computed(() => {
|
||||||
|
if (props.singleAction) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return t('document.edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleView = (doc) => {
|
||||||
|
emit('view-item', doc);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (doc) => {
|
||||||
|
emit('edit-item', doc);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="showPagination" class="pagination-container">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="innerCurrentPage"
|
||||||
|
v-model:page-size="innerPageSize"
|
||||||
|
:page-sizes="pageSizes"
|
||||||
|
:total="total"
|
||||||
|
:layout="paginationLayout"
|
||||||
|
popper-class="common-list-pagination-popper"
|
||||||
|
:hide-on-single-page="false"
|
||||||
|
@current-change="(page) => $emit('page-change', page)"
|
||||||
|
@size-change="(size) => $emit('size-change', size)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
showPagination: { type: Boolean, default: false },
|
||||||
|
currentPage: { type: Number, default: 1 },
|
||||||
|
pageSize: { type: Number, default: 10 },
|
||||||
|
pageSizes: { type: Array, default: () => [10, 20, 50, 100] },
|
||||||
|
total: { type: Number, default: 0 },
|
||||||
|
paginationLayout: { type: String, default: 'total, prev, pager, next, jumper' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:currentPage', 'update:pageSize', 'page-change', 'size-change']);
|
||||||
|
|
||||||
|
const innerCurrentPage = computed({
|
||||||
|
get: () => props.currentPage,
|
||||||
|
set: (value) => emit('update:currentPage', value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const innerPageSize = computed({
|
||||||
|
get: () => props.pageSize,
|
||||||
|
set: (value) => emit('update:pageSize', value)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pagination-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--list-pagination-border, var(--border-color));
|
||||||
|
background: var(--list-pagination-bg, var(--bg-white));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container :deep(.el-pagination) {
|
||||||
|
--el-pagination-bg-color: transparent;
|
||||||
|
--el-pagination-text-color: var(--list-pagination-text, var(--text-medium));
|
||||||
|
--el-pagination-button-bg-color: var(--list-pagination-item-bg, #ffffff);
|
||||||
|
--el-pagination-button-color: var(--list-pagination-strong-text, var(--text-dark));
|
||||||
|
--el-pagination-button-disabled-bg-color: var(--list-pagination-disabled-bg, #f4f6fa);
|
||||||
|
--el-pagination-button-disabled-color: var(--list-pagination-disabled-text, #b6bfcc);
|
||||||
|
--el-pagination-hover-color: var(--primary-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--list-pagination-text, var(--text-medium));
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container :deep(.btn-prev),
|
||||||
|
.pagination-container :deep(.btn-next),
|
||||||
|
.pagination-container :deep(.el-pager li) {
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
line-height: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--list-pagination-item-bg, #ffffff);
|
||||||
|
color: var(--list-pagination-strong-text, var(--text-dark));
|
||||||
|
border: 1px solid var(--list-pagination-item-border, var(--border-color));
|
||||||
|
transition: all 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container :deep(.btn-prev:hover:not(:disabled)),
|
||||||
|
.pagination-container :deep(.btn-next:hover:not(:disabled)),
|
||||||
|
.pagination-container :deep(.el-pager li:not(.is-active):hover) {
|
||||||
|
background: var(--list-pagination-item-hover-bg, #f3f7ff);
|
||||||
|
border-color: var(--list-pagination-item-hover-border, #b8cbff);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container :deep(.btn-prev:disabled),
|
||||||
|
.pagination-container :deep(.btn-next:disabled),
|
||||||
|
.pagination-container :deep(.el-pager li.is-disabled) {
|
||||||
|
background: var(--list-pagination-disabled-bg, #f4f6fa);
|
||||||
|
color: var(--list-pagination-disabled-text, #b6bfcc);
|
||||||
|
border-color: var(--list-pagination-item-border, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container :deep(.el-pager li.is-active) {
|
||||||
|
background: var(--list-pagination-item-active-bg, var(--primary-color));
|
||||||
|
color: var(--list-pagination-item-active-text, #ffffff);
|
||||||
|
border-color: var(--list-pagination-item-active-bg, var(--primary-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container :deep(.btn-quicknext),
|
||||||
|
.pagination-container :deep(.btn-quickprev) {
|
||||||
|
color: var(--list-pagination-text, var(--text-medium));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container :deep(.el-pagination__total),
|
||||||
|
.pagination-container :deep(.el-pagination__jump) {
|
||||||
|
color: var(--list-pagination-text, var(--text-medium));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container :deep(.el-pagination__jump .el-input__wrapper),
|
||||||
|
.pagination-container :deep(.el-pagination__sizes .el-input__wrapper),
|
||||||
|
.pagination-container :deep(.el-pagination__sizes .el-select__wrapper) {
|
||||||
|
background: var(--list-pagination-input-bg, #ffffff);
|
||||||
|
border: 1px solid var(--list-pagination-input-border, var(--border-color));
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container :deep(.el-pagination__jump .el-input__wrapper:hover),
|
||||||
|
.pagination-container :deep(.el-pagination__sizes .el-input__wrapper:hover),
|
||||||
|
.pagination-container :deep(.el-pagination__sizes .el-select__wrapper:hover) {
|
||||||
|
border-color: var(--list-pagination-item-hover-border, #b8cbff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container :deep(.el-pagination__jump .el-input.is-focus .el-input__wrapper),
|
||||||
|
.pagination-container :deep(.el-pagination__sizes .el-select .el-input.is-focus .el-input__wrapper),
|
||||||
|
.pagination-container :deep(.el-pagination__sizes .el-select.is-focused .el-select__wrapper) {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px var(--list-pagination-focus-ring, rgba(22, 93, 255, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container :deep(.el-pagination__jump .el-input__inner),
|
||||||
|
.pagination-container :deep(.el-pagination__sizes .el-input__inner),
|
||||||
|
.pagination-container :deep(.el-pagination__sizes .el-select__selected-item),
|
||||||
|
.pagination-container :deep(.el-pagination__sizes .el-select__placeholder) {
|
||||||
|
color: var(--list-pagination-input-text, var(--text-dark));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container :deep(.el-pagination__sizes .el-input) {
|
||||||
|
width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.common-list-pagination-popper .el-select-dropdown__item) {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode .common-list-pagination-popper.el-popper) {
|
||||||
|
background: #1a2230;
|
||||||
|
border-color: #2f3b4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode .common-list-pagination-popper .el-select-dropdown__item) {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode .common-list-pagination-popper .el-select-dropdown__item.is-hovering),
|
||||||
|
:global(.dark-mode .common-list-pagination-popper .el-select-dropdown__item:hover) {
|
||||||
|
background: #233147;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode .common-list-pagination-popper .el-select-dropdown__item.is-selected) {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #3370ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="displayColumns.length > 0" class="list-table-scroll">
|
||||||
|
<div class="list-head" :style="{ gridTemplateColumns: resizableGridTemplate }">
|
||||||
|
<div
|
||||||
|
v-for="column in effectiveColumns"
|
||||||
|
:key="`head-${column.key}`"
|
||||||
|
class="list-cell list-cell--head"
|
||||||
|
:class="[column.className, alignClass(column.headerAlign || column.align)]"
|
||||||
|
>
|
||||||
|
<slot :name="`header-${column.key}`" :column="column">
|
||||||
|
{{ column.key === treeToggleColumnKey ? '' : column.label }}
|
||||||
|
</slot>
|
||||||
|
<span
|
||||||
|
v-if="column.key !== treeToggleColumnKey"
|
||||||
|
class="col-resize-handle"
|
||||||
|
@mousedown="startResize($event, column)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-body">
|
||||||
|
<div
|
||||||
|
v-for="(row, rowIndex) in rows"
|
||||||
|
:key="resolveRowKey(row, rowIndex)"
|
||||||
|
class="list-row"
|
||||||
|
:style="{ gridTemplateColumns: resizableGridTemplate }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="column in effectiveColumns"
|
||||||
|
:key="`${resolveRowKey(row, rowIndex)}-${column.key}`"
|
||||||
|
class="list-cell"
|
||||||
|
:class="[column.className, alignClass(column.align)]"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
:name="`cell-${column.key}`"
|
||||||
|
:row="row"
|
||||||
|
:row-index="rowIndex"
|
||||||
|
:column="column"
|
||||||
|
:value="row?.[column.key]"
|
||||||
|
>
|
||||||
|
<template v-if="column.isIndexColumn">
|
||||||
|
{{ resolveSerialNumber(rowIndex, currentPage, pageSize) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ row?.[column.key] ?? '-' }}
|
||||||
|
</template>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useColumnResize } from '@/composables/list/useColumnResize.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
rows: { type: Array, default: () => [] },
|
||||||
|
columns: { type: Array, default: () => [] },
|
||||||
|
displayColumns: { type: Array, default: () => [] },
|
||||||
|
gridTemplateColumns: { type: String, default: '1fr' },
|
||||||
|
currentPage: { type: Number, default: 1 },
|
||||||
|
pageSize: { type: Number, default: 10 },
|
||||||
|
treeToggleColumnKey: { type: String, default: '__tree_toggle__' },
|
||||||
|
resolveRowKey: { type: Function, required: true },
|
||||||
|
alignClass: { type: Function, required: true },
|
||||||
|
buildGridTemplate: { type: Function, required: true },
|
||||||
|
resolveSerialNumber: { type: Function, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayColumnsRef = computed(() => props.displayColumns);
|
||||||
|
|
||||||
|
const { effectiveColumns, gridTemplateColumns: resizableGridTemplate, startResize } =
|
||||||
|
useColumnResize(displayColumnsRef, (cols) => props.buildGridTemplate(cols));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.list-table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head,
|
||||||
|
.list-row {
|
||||||
|
display: grid;
|
||||||
|
min-width: max-content;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row:last-child .list-cell {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import '@/styles/list-cell.css';
|
||||||
|
@import '@/styles/column-resize.css';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="showTitleBar || showSearch" class="list-titlebar">
|
||||||
|
<div class="list-title">
|
||||||
|
<slot name="title">
|
||||||
|
{{ title }}
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<div class="list-title-actions">
|
||||||
|
<slot name="toolbar-actions" />
|
||||||
|
<el-input
|
||||||
|
v-if="showSearch"
|
||||||
|
v-model="innerSearchQuery"
|
||||||
|
:placeholder="searchPlaceholder"
|
||||||
|
clearable
|
||||||
|
class="list-search"
|
||||||
|
@clear="emitSearch"
|
||||||
|
@input="emitSearch"
|
||||||
|
/>
|
||||||
|
<slot name="toolbar-right" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
showTitleBar: { type: Boolean, default: false },
|
||||||
|
showSearch: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
searchPlaceholder: { type: String, default: '' },
|
||||||
|
searchQuery: { type: String, default: '' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:searchQuery', 'search']);
|
||||||
|
|
||||||
|
const innerSearchQuery = computed({
|
||||||
|
get: () => props.searchQuery,
|
||||||
|
set: (value) => emit('update:searchQuery', value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitSearch = () => {
|
||||||
|
emit('search', innerSearchQuery.value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.list-titlebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-search {
|
||||||
|
max-width: 320px;
|
||||||
|
flex: 1;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.common-list.is-dark) .list-titlebar {
|
||||||
|
background: #161b22;
|
||||||
|
border-bottom-color: #2a313a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tree-table-scroll">
|
||||||
|
<div
|
||||||
|
class="tree-table"
|
||||||
|
:style="{
|
||||||
|
'--tree-toggle-width': `${resolvedToggleWidth}px`,
|
||||||
|
'--tree-data-template': resizableDataTemplate
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="tree-head">
|
||||||
|
<div class="tree-toggle-head list-cell list-cell--head" />
|
||||||
|
<div class="tree-data-head">
|
||||||
|
<div
|
||||||
|
v-for="column in effectiveDataColumns"
|
||||||
|
:key="`tree-head-${column.key}`"
|
||||||
|
class="list-cell list-cell--head"
|
||||||
|
:class="[column.className, alignClass(column.headerAlign || column.align)]"
|
||||||
|
>
|
||||||
|
<slot :name="`header-${column.key}`" :column="column">
|
||||||
|
{{ column.label }}
|
||||||
|
</slot>
|
||||||
|
<span
|
||||||
|
class="col-resize-handle"
|
||||||
|
@mousedown="startResize($event, column)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="tree-body" v-loading="treeLoading">
|
||||||
|
<div
|
||||||
|
v-for="(row, rowIndex) in treeFlatRows"
|
||||||
|
:key="resolveRowKey(row.raw, rowIndex)"
|
||||||
|
class="tree-row"
|
||||||
|
>
|
||||||
|
<!-- Sticky indent/toggle cell -->
|
||||||
|
<div class="tree-toggle-cell">
|
||||||
|
<div
|
||||||
|
class="tree-toggle-inner-content"
|
||||||
|
:style="{ paddingLeft: `${row.level * treeIndent + treeBaseIndent}px` }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="row.hasChildren"
|
||||||
|
class="tree-toggle"
|
||||||
|
type="button"
|
||||||
|
@click.stop="toggleTreeNode(row)"
|
||||||
|
>
|
||||||
|
<el-icon :size="14">
|
||||||
|
<Minus v-if="row.expanded" />
|
||||||
|
<Plus v-else />
|
||||||
|
</el-icon>
|
||||||
|
</button>
|
||||||
|
<span v-else class="tree-leaf-indicator" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data cells -->
|
||||||
|
<div class="tree-data-row">
|
||||||
|
<div
|
||||||
|
v-for="(column, columnIndex) in effectiveDataColumns"
|
||||||
|
:key="`${resolveRowKey(row.raw, rowIndex)}-${column.key}`"
|
||||||
|
class="list-cell list-cell--tree"
|
||||||
|
:class="[column.className, alignClass(column.align)]"
|
||||||
|
>
|
||||||
|
<template v-if="isTreeColumn(column, columnIndex)">
|
||||||
|
<div class="tree-cell">
|
||||||
|
<slot name="tree-cell" :row="row.raw" :level="row.level">
|
||||||
|
<span class="tree-node-label">{{ row.raw?.[treeColumnResolvedKey] ?? '-' }}</span>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.isIndexColumn">
|
||||||
|
<slot
|
||||||
|
:name="`cell-${column.key}`"
|
||||||
|
:row="row.raw"
|
||||||
|
:row-index="rowIndex"
|
||||||
|
:column="column"
|
||||||
|
:value="row.raw?.[column.key]"
|
||||||
|
>
|
||||||
|
{{ resolveSerialNumber(rowIndex, currentPage, pageSize) }}
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<slot
|
||||||
|
:name="`cell-${column.key}`"
|
||||||
|
:row="row.raw"
|
||||||
|
:row-index="rowIndex"
|
||||||
|
:column="column"
|
||||||
|
:value="row.raw?.[column.key]"
|
||||||
|
>
|
||||||
|
{{ row.raw?.[column.key] ?? '-' }}
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Plus, Minus } from '@element-plus/icons-vue';
|
||||||
|
import { useColumnResize } from '@/composables/list/useColumnResize.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
treeToggleWidth: { type: Number, default: 81 },
|
||||||
|
treeDataColumns: { type: Array, default: () => [] },
|
||||||
|
treeDataGridTemplate: { type: String, default: '1fr' },
|
||||||
|
currentPage: { type: Number, default: 1 },
|
||||||
|
pageSize: { type: Number, default: 10 },
|
||||||
|
treeLoading: { type: Boolean, default: false },
|
||||||
|
treeFlatRows: { type: Array, default: () => [] },
|
||||||
|
maxTreeIndentWidth: { type: Number, default: 0 },
|
||||||
|
toggleScrollLeft: { type: Number, default: 0 },
|
||||||
|
treeIndent: { type: Number, default: 16 },
|
||||||
|
treeBaseIndent: { type: Number, default: 6 },
|
||||||
|
treeColumnResolvedKey: { type: String, default: 'label' },
|
||||||
|
resolveRowKey: { type: Function, required: true },
|
||||||
|
alignClass: { type: Function, required: true },
|
||||||
|
toggleTreeNode: { type: Function, required: true },
|
||||||
|
isTreeColumn: { type: Function, required: true },
|
||||||
|
buildGridTemplate: { type: Function, required: true },
|
||||||
|
resolveSerialNumber: { type: Function, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['toggle-scroll']);
|
||||||
|
|
||||||
|
const resolvedToggleWidth = computed(() =>
|
||||||
|
Math.max(props.treeToggleWidth, props.maxTreeIndentWidth)
|
||||||
|
);
|
||||||
|
|
||||||
|
const treeDataColumnsRef = computed(() => props.treeDataColumns);
|
||||||
|
|
||||||
|
const { effectiveColumns: effectiveDataColumns, gridTemplateColumns: resizableDataTemplate, startResize } =
|
||||||
|
useColumnResize(treeDataColumnsRef, (cols) => props.buildGridTemplate(cols));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tree-table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: max-content;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--tree-toggle-width) 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toggle-head {
|
||||||
|
justify-content: center;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-data-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--tree-data-template);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--tree-toggle-width) 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toggle-cell {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--list-cell-bg, var(--bg-white));
|
||||||
|
border-right: 1px solid var(--list-cell-border, var(--border-color));
|
||||||
|
border-bottom: 1px solid var(--list-cell-border, #d6dbe3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toggle-inner-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-data-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--tree-data-template);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-cell--tree {
|
||||||
|
border-bottom: 1px solid var(--list-cell-border, #d6dbe3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-row:last-child .tree-toggle-cell,
|
||||||
|
.tree-row:last-child .list-cell--tree {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toggle {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--list-cell-text, var(--text-dark));
|
||||||
|
background: var(--list-cell-bg, var(--bg-white));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toggle:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-leaf-indicator {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #9aa4b2;
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import '@/styles/list-cell.css';
|
||||||
|
@import '@/styles/column-resize.css';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
<template>
|
||||||
|
<el-tabs v-model="activeTab" class="common-tabs">
|
||||||
|
<el-tab-pane :label="tabListLabel" name="list">
|
||||||
|
<CommonList
|
||||||
|
:rows="inviteList"
|
||||||
|
:columns="inviteColumns"
|
||||||
|
:is-dark="isDarkMode"
|
||||||
|
row-key="code"
|
||||||
|
:empty-text="t('message.empty')"
|
||||||
|
:show-pagination="isSystemMode"
|
||||||
|
:total="isSystemMode ? inviteTotal : inviteList.length"
|
||||||
|
v-model:current-page="invitePage"
|
||||||
|
v-model:page-size="invitePageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
@page-change="handleInvitePageChange"
|
||||||
|
:show-search="isSystemMode"
|
||||||
|
v-model:search-query="inviteKeyword"
|
||||||
|
:search-placeholder="t('common.search')"
|
||||||
|
@search="handleInviteSearch"
|
||||||
|
:show-title-bar="isSystemMode"
|
||||||
|
:title="tabListLabel"
|
||||||
|
>
|
||||||
|
<template #cell-status="{ value }">
|
||||||
|
<AppTag :type="inviteStatusType(value)" size="small">
|
||||||
|
{{ inviteStatusLabel(value) }}
|
||||||
|
</AppTag>
|
||||||
|
</template>
|
||||||
|
<template #cell-usage="{ row }">
|
||||||
|
{{ row.used }}/{{ row.max === null ? '-' : row.max }}
|
||||||
|
</template>
|
||||||
|
<template #cell-code="{ row }">
|
||||||
|
<el-tooltip :content="row.note || t('user.invite.notePlaceholder')" placement="top">
|
||||||
|
<span class="invite-code" @click="copyInviteCode(row.code)">{{ row.code }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #cell-actions="{ row }">
|
||||||
|
<el-button size="small" text type="primary" @click="handlePauseInvite(row)">
|
||||||
|
{{ row.disabled ? t('user.invite.resume') : t('user.invite.pause') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" text type="danger" @click="handleDeleteInvite(row)">
|
||||||
|
{{ t('user.invite.delete') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</CommonList>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane :label="tabRecordsLabel" name="records">
|
||||||
|
<CommonList
|
||||||
|
:rows="inviteRecords"
|
||||||
|
:columns="recordColumns"
|
||||||
|
:is-dark="isDarkMode"
|
||||||
|
row-key="row_key"
|
||||||
|
:empty-text="recordsEmptyText"
|
||||||
|
:show-pagination="isSystemMode"
|
||||||
|
:total="isSystemMode ? recordTotal : inviteRecords.length"
|
||||||
|
v-model:current-page="recordPage"
|
||||||
|
v-model:page-size="recordPageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
@page-change="handleRecordPageChange"
|
||||||
|
:show-search="isSystemMode"
|
||||||
|
v-model:search-query="recordKeyword"
|
||||||
|
:search-placeholder="t('common.search')"
|
||||||
|
@search="handleRecordSearch"
|
||||||
|
:show-title-bar="isSystemMode"
|
||||||
|
:title="tabRecordsLabel"
|
||||||
|
>
|
||||||
|
<template #cell-status="{ value }">
|
||||||
|
<AppTag :type="value === 'success' ? 'success' : 'warning'" size="small">
|
||||||
|
{{ value === 'success' ? recordsSuccessLabel : recordsFailedLabel }}
|
||||||
|
</AppTag>
|
||||||
|
</template>
|
||||||
|
</CommonList>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<InviteCreateDialog v-model="showCreateDialog" @submit="handleCreateInvite" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { ElMessageBox } from 'element-plus';
|
||||||
|
import CommonList from '@/components/list/CommonList.vue';
|
||||||
|
import InviteCreateDialog from '@/components/manage/InviteCreateDialog.vue';
|
||||||
|
import AppTag from '@/components/base/AppTag.vue';
|
||||||
|
import { useServerTable } from '@/composables/list/useServerTable';
|
||||||
|
import { isActionCancelled, useApiAction } from '@/composables/actions/useApiAction';
|
||||||
|
import { listInvitations, listInvitationRecords, createInvitation, updateInvitation, deleteInvitation } from '@/services/api';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'user',
|
||||||
|
validator: (value) => ['user', 'system'].includes(value)
|
||||||
|
},
|
||||||
|
isDarkMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { runApi } = useApiAction({ t });
|
||||||
|
|
||||||
|
const isSystemMode = computed(() => props.mode === 'system');
|
||||||
|
const activeTab = ref('list');
|
||||||
|
const inviteLoaded = ref(false);
|
||||||
|
const recordsLoaded = ref(false);
|
||||||
|
const showCreateDialog = ref(false);
|
||||||
|
|
||||||
|
const tabListLabel = computed(() => t(isSystemMode.value ? 'system.invite.tabs.list' : 'user.invite.tabs.list'));
|
||||||
|
const tabRecordsLabel = computed(() => t(isSystemMode.value ? 'system.invite.tabs.records' : 'user.invite.tabs.records'));
|
||||||
|
const recordsEmptyText = computed(() => t(isSystemMode.value ? 'system.invite.records.empty' : 'user.invite.records.empty'));
|
||||||
|
const recordsSuccessLabel = computed(() => t(isSystemMode.value ? 'system.invite.records.success' : 'user.invite.records.success'));
|
||||||
|
const recordsFailedLabel = computed(() => t(isSystemMode.value ? 'system.invite.records.failed' : 'user.invite.records.failed'));
|
||||||
|
|
||||||
|
const inviteColumns = computed(() => {
|
||||||
|
const baseColumns = [
|
||||||
|
{ key: 'code', label: t('user.invite.code'), minWidth: 180 },
|
||||||
|
{ key: 'status', label: t('user.invite.status'), minWidth: 120, align: 'center' },
|
||||||
|
{ key: 'usage', label: t('user.invite.usage'), minWidth: 110, align: 'center' },
|
||||||
|
{ key: 'created_at', label: t('user.invite.createdAt'), minWidth: 160 }
|
||||||
|
];
|
||||||
|
if (isSystemMode.value) {
|
||||||
|
baseColumns.push({ key: 'created_by', label: t('user.invite.createdBy'), minWidth: 140 });
|
||||||
|
}
|
||||||
|
baseColumns.push(
|
||||||
|
{ key: 'expires_at', label: t('user.invite.expiresAt'), minWidth: 160 },
|
||||||
|
{ key: 'actions', label: t('user.invite.actions'), minWidth: 160, align: 'center', headerAlign: 'center' }
|
||||||
|
);
|
||||||
|
return baseColumns;
|
||||||
|
});
|
||||||
|
|
||||||
|
const recordColumns = computed(() => {
|
||||||
|
if (isSystemMode.value) {
|
||||||
|
return [
|
||||||
|
{ key: 'code', label: t('system.invite.records.code'), minWidth: 180 },
|
||||||
|
{ key: 'used_by', label: t('system.invite.records.usedBy'), minWidth: 160 },
|
||||||
|
{ key: 'used_at', label: t('system.invite.records.usedAt'), minWidth: 180 },
|
||||||
|
{ key: 'status', label: t('system.invite.records.result'), minWidth: 120, align: 'center' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ key: 'code', label: t('user.invite.records.code'), minWidth: 180 },
|
||||||
|
{ key: 'used_by', label: t('user.invite.records.usedBy'), minWidth: 160 },
|
||||||
|
{ key: 'used_at', label: t('user.invite.records.usedAt'), minWidth: 180 },
|
||||||
|
{ key: 'status', label: t('user.invite.records.result'), minWidth: 120, align: 'center' }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const inviteStatusType = (status) => {
|
||||||
|
if (status === 'active') return 'success';
|
||||||
|
if (status === 'expired') return 'info';
|
||||||
|
return 'warning';
|
||||||
|
};
|
||||||
|
|
||||||
|
const inviteStatusLabel = (status) => {
|
||||||
|
if (status === 'active') return t('user.invite.active');
|
||||||
|
if (status === 'expired') return t('user.invite.expired');
|
||||||
|
return t('user.invite.disabled');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateYYYYMMDD = (date) => {
|
||||||
|
const pad = (num) => `${num}`.padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toRfc3339EndOfDay = (dateStr) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
if (dateStr.includes('T')) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
const [year, month, day] = dateStr.split('-').map((value) => Number(value));
|
||||||
|
if (!year || !month || !day) return '';
|
||||||
|
const local = new Date(year, month - 1, day, 23, 59, 59);
|
||||||
|
return local.toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveInviteStatus = (invite) => {
|
||||||
|
if (invite.disabled) return 'disabled';
|
||||||
|
if (invite.expires_at) {
|
||||||
|
const expireTs = Date.parse(invite.expires_at);
|
||||||
|
if (!Number.isNaN(expireTs) && expireTs < Date.now()) return 'expired';
|
||||||
|
}
|
||||||
|
if (typeof invite.max_used_cnt === 'number' && typeof invite.used_cnt === 'number' && invite.used_cnt >= invite.max_used_cnt) {
|
||||||
|
return 'expired';
|
||||||
|
}
|
||||||
|
return 'active';
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNumber = (value, fallback = null) => {
|
||||||
|
if (value === null || value === undefined) return fallback;
|
||||||
|
const num = Number(value);
|
||||||
|
return Number.isFinite(num) ? num : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapInviteRow = (invite) => {
|
||||||
|
const row = {
|
||||||
|
code: invite.code,
|
||||||
|
created_at: invite.created_at || '-',
|
||||||
|
expires_at: invite.expires_at || '-',
|
||||||
|
status: resolveInviteStatus(invite),
|
||||||
|
max: toNumber(invite.max_used_cnt, null),
|
||||||
|
used: toNumber(invite.used_cnt, 0),
|
||||||
|
disabled: invite.disabled,
|
||||||
|
note: invite.note || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSystemMode.value) {
|
||||||
|
row.created_by = invite.created_by_name || invite.created_by_external_id || t('common.unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapRecordRow = (record) => ({
|
||||||
|
row_key: record.row_key || `${record.code || ''}_${record.used_at || ''}_${record.status || ''}`,
|
||||||
|
code: record.code,
|
||||||
|
used_by: record.used_by || '-',
|
||||||
|
used_at: record.used_at || '-',
|
||||||
|
status: record.status || 'failed'
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
rows: inviteList,
|
||||||
|
page: invitePage,
|
||||||
|
pageSize: invitePageSize,
|
||||||
|
total: inviteTotal,
|
||||||
|
keyword: inviteKeyword,
|
||||||
|
load: loadInvitations,
|
||||||
|
handlePageChange: changeInvitePage,
|
||||||
|
handleSearch: triggerInviteSearch
|
||||||
|
} = useServerTable({
|
||||||
|
initialPageSize: 10,
|
||||||
|
fetcher: ({ page, page_size, keyword }) =>
|
||||||
|
listInvitations(
|
||||||
|
isSystemMode.value
|
||||||
|
? {
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
keyword
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
only_mine: true,
|
||||||
|
page: 1,
|
||||||
|
page_size: 50
|
||||||
|
}
|
||||||
|
),
|
||||||
|
mapRows: (resp) => (resp.invitations || []).map(mapInviteRow),
|
||||||
|
mapTotal: (resp, rows) => (isSystemMode.value ? resp.total : rows.length),
|
||||||
|
onError: (err) => {
|
||||||
|
console.error('listInvitations failed', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
rows: inviteRecords,
|
||||||
|
page: recordPage,
|
||||||
|
pageSize: recordPageSize,
|
||||||
|
total: recordTotal,
|
||||||
|
keyword: recordKeyword,
|
||||||
|
load: loadInvitationRecords,
|
||||||
|
handlePageChange: changeRecordPage,
|
||||||
|
handleSearch: triggerRecordSearch
|
||||||
|
} = useServerTable({
|
||||||
|
initialPageSize: 10,
|
||||||
|
fetcher: ({ page, page_size, keyword }) =>
|
||||||
|
listInvitationRecords(
|
||||||
|
isSystemMode.value
|
||||||
|
? {
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
keyword
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
only_mine: true,
|
||||||
|
page: 1,
|
||||||
|
page_size: 100
|
||||||
|
}
|
||||||
|
),
|
||||||
|
mapRows: (resp) => {
|
||||||
|
const records = [...(resp.records || [])];
|
||||||
|
if (!isSystemMode.value) {
|
||||||
|
records.sort((a, b) => (b.used_at || '').localeCompare(a.used_at || ''));
|
||||||
|
}
|
||||||
|
return records.map(mapRecordRow);
|
||||||
|
},
|
||||||
|
mapTotal: (resp, rows) => (isSystemMode.value ? resp.total : rows.length),
|
||||||
|
onError: (err) => {
|
||||||
|
console.error('listInvitationRecords failed', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchInvitations = async () => {
|
||||||
|
const resp = await loadInvitations();
|
||||||
|
if (resp) {
|
||||||
|
inviteLoaded.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchInvitationRecords = async () => {
|
||||||
|
const resp = await loadInvitationRecords();
|
||||||
|
if (resp) {
|
||||||
|
recordsLoaded.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateInvite = async (payload) => {
|
||||||
|
let expiresAt = payload.expires_at;
|
||||||
|
if (payload.expire_type === 'days' && payload.expire_days) {
|
||||||
|
const target = new Date();
|
||||||
|
target.setDate(target.getDate() + Number(payload.expire_days));
|
||||||
|
expiresAt = formatDateYYYYMMDD(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
await runApi(
|
||||||
|
async () => {
|
||||||
|
await createInvitation({
|
||||||
|
expires_at: expiresAt ? toRfc3339EndOfDay(expiresAt) : undefined,
|
||||||
|
max_used_cnt: payload.limit_enabled ? payload.max_usage : undefined,
|
||||||
|
note: payload.note
|
||||||
|
});
|
||||||
|
await fetchInvitations();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
context: 'createInvitation',
|
||||||
|
successMessage: t('message.success')
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePauseInvite = async (row) => {
|
||||||
|
if (!row?.code) return;
|
||||||
|
|
||||||
|
await runApi(
|
||||||
|
async () => {
|
||||||
|
await updateInvitation({
|
||||||
|
code: row.code,
|
||||||
|
disabled: !row.disabled
|
||||||
|
});
|
||||||
|
await fetchInvitations();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
context: 'updateInvitation',
|
||||||
|
successMessage: t('message.success')
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteInvite = async (row) => {
|
||||||
|
if (!row?.code) return;
|
||||||
|
|
||||||
|
await runApi(
|
||||||
|
async () => {
|
||||||
|
await ElMessageBox.confirm(t('message.confirmDelete'), t('user.invite.delete'), {
|
||||||
|
confirmButtonText: t('button.confirm'),
|
||||||
|
cancelButtonText: t('button.cancel'),
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
await deleteInvitation(row.code);
|
||||||
|
await fetchInvitations();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
context: 'deleteInvitation',
|
||||||
|
successMessage: t('message.deleteSuccess'),
|
||||||
|
ignoreError: isActionCancelled
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInvitePageChange = async (page) => {
|
||||||
|
if (!isSystemMode.value) return;
|
||||||
|
await changeInvitePage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecordPageChange = async (page) => {
|
||||||
|
if (!isSystemMode.value) return;
|
||||||
|
await changeRecordPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInviteSearch = () => {
|
||||||
|
if (!isSystemMode.value) return;
|
||||||
|
triggerInviteSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecordSearch = () => {
|
||||||
|
if (!isSystemMode.value) return;
|
||||||
|
triggerRecordSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyInviteCode = async (code) => {
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
await runApi(
|
||||||
|
async () => {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
context: 'copy invite code',
|
||||||
|
successMessage: t('common.copySuccess'),
|
||||||
|
errorMessage: t('common.copyFailed')
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
showCreateDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(activeTab, async (tab) => {
|
||||||
|
if (tab === 'list') {
|
||||||
|
if (!inviteLoaded.value || !isSystemMode.value) {
|
||||||
|
await fetchInvitations();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab === 'records') {
|
||||||
|
if (!recordsLoaded.value || !isSystemMode.value) {
|
||||||
|
await fetchInvitationRecords();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchInvitations();
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
openCreateDialog
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.invite-code {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="t('user.invite.createTitle')"
|
||||||
|
width="520px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@keydown.esc.prevent="handleCancel"
|
||||||
|
>
|
||||||
|
<el-form :model="formState" label-position="top" @submit.prevent>
|
||||||
|
<el-form-item :label="t('user.invite.expiresAt')" required>
|
||||||
|
<div class="invite-expire-row">
|
||||||
|
<el-radio-group v-model="formState.expireType" class="invite-expire-options">
|
||||||
|
<el-radio value="days">{{ t('user.invite.expireByDays') }}</el-radio>
|
||||||
|
<el-radio value="date">{{ t('user.invite.expireByDate') }}</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
<div class="invite-expire-input">
|
||||||
|
<el-input-number
|
||||||
|
v-if="formState.expireType === 'days'"
|
||||||
|
v-model="formState.expireDays"
|
||||||
|
:min="1"
|
||||||
|
:max="365"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
<el-date-picker
|
||||||
|
v-else
|
||||||
|
v-model="formState.expiresAt"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:placeholder="t('user.invite.expiresAt')"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item :label="t('user.invite.limitUsage')">
|
||||||
|
<div class="invite-field-row invite-field-row--inline">
|
||||||
|
<el-switch v-model="formState.limitEnabled" />
|
||||||
|
<el-input-number
|
||||||
|
v-model="formState.maxUsage"
|
||||||
|
:min="1"
|
||||||
|
:max="9999"
|
||||||
|
:disabled="!formState.limitEnabled"
|
||||||
|
style="width: 160px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item :label="t('user.invite.note')">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.note"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
:placeholder="t('user.invite.notePlaceholder')"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="handleCancel">{{ t('button.cancel') }}</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit">
|
||||||
|
{{ t('button.confirm') }}
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'submit', 'cancel']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const formState = reactive({
|
||||||
|
expireType: 'days',
|
||||||
|
expireDays: 7,
|
||||||
|
expiresAt: '',
|
||||||
|
limitEnabled: false,
|
||||||
|
maxUsage: 10,
|
||||||
|
note: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
formState.expireType = 'days';
|
||||||
|
formState.expireDays = 7;
|
||||||
|
formState.expiresAt = '';
|
||||||
|
formState.limitEnabled = false;
|
||||||
|
formState.maxUsage = 10;
|
||||||
|
formState.note = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('cancel');
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (formState.expireType === 'date' && !formState.expiresAt) {
|
||||||
|
ElMessage.error(t('user.invite.expiresAtRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formState.expireType === 'days' && (!formState.expireDays || formState.expireDays < 1)) {
|
||||||
|
ElMessage.error(t('user.invite.expireDaysRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formState.limitEnabled && (!formState.maxUsage || formState.maxUsage < 1)) {
|
||||||
|
ElMessage.error(t('user.invite.maxUsageRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('submit', {
|
||||||
|
expire_type: formState.expireType,
|
||||||
|
expire_days: formState.expireType === 'days' ? formState.expireDays : undefined,
|
||||||
|
expires_at: formState.expireType === 'date' ? formState.expiresAt : undefined,
|
||||||
|
limit_enabled: formState.limitEnabled,
|
||||||
|
max_usage: formState.limitEnabled ? formState.maxUsage : undefined,
|
||||||
|
note: formState.note?.trim() || undefined
|
||||||
|
});
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(nextVisible) => {
|
||||||
|
if (nextVisible) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.invite-field-row {
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-expire-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-expire-options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-expire-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-field-row--inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode :deep(.el-input-number__decrease),
|
||||||
|
.dark-mode :deep(.el-input-number__increase) {
|
||||||
|
background-color: var(--bg-medium);
|
||||||
|
color: var(--text-dark);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode :deep(.el-switch) {
|
||||||
|
--el-switch-on-color: #3a7afe;
|
||||||
|
--el-switch-off-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode :deep(.el-date-editor),
|
||||||
|
.dark-mode :deep(.el-date-editor .el-input__wrapper) {
|
||||||
|
background-color: var(--bg-medium);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode :deep(.el-date-editor .el-input__inner) {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<ManageLayout>
|
||||||
|
<ManageSection :title="sectionTitle" :plain="sectionPlain" :body-padding="bodyPadding">
|
||||||
|
<CommonList
|
||||||
|
:rows="rows"
|
||||||
|
:columns="columns"
|
||||||
|
:is-dark="isDark"
|
||||||
|
:row-key="rowKey"
|
||||||
|
:mode="mode"
|
||||||
|
:empty-text="emptyText"
|
||||||
|
:show-pagination="showPagination"
|
||||||
|
:total="total"
|
||||||
|
:current-page="currentPage"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="pageSizes"
|
||||||
|
:pagination-layout="paginationLayout"
|
||||||
|
:show-search="showSearch"
|
||||||
|
:search-query="searchQuery"
|
||||||
|
:search-placeholder="searchPlaceholder"
|
||||||
|
:show-index-column="showIndexColumn"
|
||||||
|
:index-column-label="indexColumnLabel"
|
||||||
|
:index-column-width="indexColumnWidth"
|
||||||
|
:index-column-align="indexColumnAlign"
|
||||||
|
:show-title-bar="showTitleBar"
|
||||||
|
:title="title"
|
||||||
|
:tree-loading="treeLoading"
|
||||||
|
:tree-column-key="treeColumnKey"
|
||||||
|
:tree-key-field="treeKeyField"
|
||||||
|
@update:current-page="emit('update:currentPage', $event)"
|
||||||
|
@update:page-size="emit('update:pageSize', $event)"
|
||||||
|
@page-change="emit('page-change', $event)"
|
||||||
|
@size-change="emit('size-change', $event)"
|
||||||
|
@update:search-query="emit('update:searchQuery', $event)"
|
||||||
|
@search="emit('search', $event)"
|
||||||
|
>
|
||||||
|
<template v-for="name in forwardedSlotNames" :key="name" #[name]="slotProps">
|
||||||
|
<slot :name="name" v-bind="slotProps || {}" />
|
||||||
|
</template>
|
||||||
|
</CommonList>
|
||||||
|
</ManageSection>
|
||||||
|
</ManageLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useForwardedSlotNames } from '@/composables/list/useForwardedSlotNames';
|
||||||
|
import ManageLayout from '@/components/manage/ManageLayout.vue';
|
||||||
|
import ManageSection from '@/components/manage/ManageSection.vue';
|
||||||
|
import CommonList from '@/components/list/CommonList.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
sectionTitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
sectionPlain: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
bodyPadding: {
|
||||||
|
type: String,
|
||||||
|
default: 'none'
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
isDark: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
rowKey: {
|
||||||
|
type: [String, Function],
|
||||||
|
default: 'id'
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'table'
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
showPagination: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
currentPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
pageSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 10
|
||||||
|
},
|
||||||
|
pageSizes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [10, 20, 50, 100]
|
||||||
|
},
|
||||||
|
paginationLayout: {
|
||||||
|
type: String,
|
||||||
|
default: 'total, prev, pager, next, jumper'
|
||||||
|
},
|
||||||
|
showSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
searchQuery: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
searchPlaceholder: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
showIndexColumn: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
indexColumnLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '序号'
|
||||||
|
},
|
||||||
|
indexColumnWidth: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 72
|
||||||
|
},
|
||||||
|
indexColumnAlign: {
|
||||||
|
type: String,
|
||||||
|
default: 'center'
|
||||||
|
},
|
||||||
|
showTitleBar: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
treeLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
treeColumnKey: {
|
||||||
|
type: String,
|
||||||
|
default: 'name'
|
||||||
|
},
|
||||||
|
treeKeyField: {
|
||||||
|
type: [String, Function],
|
||||||
|
default: 'id'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:currentPage',
|
||||||
|
'update:pageSize',
|
||||||
|
'page-change',
|
||||||
|
'size-change',
|
||||||
|
'update:searchQuery',
|
||||||
|
'search'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const forwardedSlotNames = useForwardedSlotNames(['default']);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
<template>
|
||||||
|
<PageLayout
|
||||||
|
:system-name="systemName"
|
||||||
|
:current-language="currentLanguage"
|
||||||
|
:is-dark-mode="isDarkMode"
|
||||||
|
:user-avatar="userAvatar"
|
||||||
|
:username="userInfo?.username || t('common.user')"
|
||||||
|
:active-menu="activeMenu"
|
||||||
|
:side-menu-items="manageMenuItems"
|
||||||
|
sidebar-scene="manage"
|
||||||
|
:title="''"
|
||||||
|
content-padding="xl"
|
||||||
|
@change-language="handleLanguageChange"
|
||||||
|
@toggle-theme="toggleTheme"
|
||||||
|
@logout="handleLogout"
|
||||||
|
@menu-select="handleMenuSelect"
|
||||||
|
>
|
||||||
|
<ManageLayout>
|
||||||
|
<TitleBar :show-back="true" :compact="true" :back-text="t('common.back')" @back="router.back()">
|
||||||
|
<template #actions>
|
||||||
|
<el-button type="primary" @click="openEditDialog">
|
||||||
|
{{ t('document.edit') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</TitleBar>
|
||||||
|
|
||||||
|
<ManageSection plain>
|
||||||
|
<InfoStatsCard
|
||||||
|
:title="entityInfo?.name || t('common.unknown')"
|
||||||
|
:description="entityInfo?.description || t('common.unknown')"
|
||||||
|
:stats="entityStats"
|
||||||
|
/>
|
||||||
|
</ManageSection>
|
||||||
|
|
||||||
|
<ManageSection>
|
||||||
|
<CommonList
|
||||||
|
:rows="memberRows"
|
||||||
|
:columns="memberColumns"
|
||||||
|
:is-dark="isDarkMode"
|
||||||
|
row-key="external_id"
|
||||||
|
:empty-text="t('message.empty')"
|
||||||
|
:show-pagination="true"
|
||||||
|
:total="memberTotal"
|
||||||
|
v-model:current-page="memberPage"
|
||||||
|
v-model:page-size="memberPageSize"
|
||||||
|
:page-sizes="[6]"
|
||||||
|
@page-change="handleMemberPageChange"
|
||||||
|
:show-search="true"
|
||||||
|
v-model:search-query="memberSearch"
|
||||||
|
:search-placeholder="t('common.search')"
|
||||||
|
@search="handleMemberSearch"
|
||||||
|
:show-title-bar="true"
|
||||||
|
>
|
||||||
|
<template #title>{{ t(entityLabels.memberListKey) }}</template>
|
||||||
|
<template #toolbar-right>
|
||||||
|
<el-button size="small" type="primary" @click="openMemberDialog">
|
||||||
|
{{ t(entityLabels.manageMembersKey) }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<template #cell-actions="{ row }">
|
||||||
|
<el-button size="small" text type="danger" @click="removeMember(row)">
|
||||||
|
{{ t('document.delete') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</CommonList>
|
||||||
|
</ManageSection>
|
||||||
|
</ManageLayout>
|
||||||
|
|
||||||
|
<el-dialog v-model="showEditDialog" :title="t('document.edit')" width="480px">
|
||||||
|
<el-form label-position="top" :model="editForm">
|
||||||
|
<el-form-item :label="t('common.name')">
|
||||||
|
<el-input v-model="editForm.name" maxlength="50" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('common.description')">
|
||||||
|
<el-input
|
||||||
|
v-model="editForm.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showEditDialog = false">{{ t('button.cancel') }}</el-button>
|
||||||
|
<el-button type="primary" :loading="editing" @click="submitEdit">
|
||||||
|
{{ t('button.confirm') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="showMemberDialog" :title="t(entityLabels.memberListKey)" width="680px">
|
||||||
|
<div class="member-dialog">
|
||||||
|
<div class="member-dialog__list">
|
||||||
|
<CommonList
|
||||||
|
:rows="memberCandidates"
|
||||||
|
:columns="candidateColumns"
|
||||||
|
:is-dark="isDarkMode"
|
||||||
|
row-key="external_id"
|
||||||
|
:empty-text="t('message.empty')"
|
||||||
|
:show-pagination="true"
|
||||||
|
:total="candidateTotal"
|
||||||
|
v-model:current-page="candidatePage"
|
||||||
|
v-model:page-size="candidatePageSize"
|
||||||
|
:page-sizes="[3]"
|
||||||
|
@page-change="handleCandidatePageChange"
|
||||||
|
:show-search="true"
|
||||||
|
v-model:search-query="candidateSearch"
|
||||||
|
:search-placeholder="t('common.search')"
|
||||||
|
@search="handleCandidateSearch"
|
||||||
|
:show-title-bar="true"
|
||||||
|
:title="t(entityLabels.memberListKey)"
|
||||||
|
>
|
||||||
|
<template #cell-actions="{ row }">
|
||||||
|
<el-checkbox
|
||||||
|
v-model="selectedMemberIds"
|
||||||
|
:label="row.external_id"
|
||||||
|
class="checkbox-only"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</CommonList>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showMemberDialog = false">{{ t('button.cancel') }}</el-button>
|
||||||
|
<el-button type="primary" :loading="savingMembers" @click="submitMemberUpdate">
|
||||||
|
{{ t('button.confirm') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</PageLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, onMounted } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useUserStore } from '@/store/user';
|
||||||
|
import { useManageShell } from '@/composables/shell/useManageShell';
|
||||||
|
import { useServerTable } from '@/composables/list/useServerTable';
|
||||||
|
import { usePageBoot } from '@/composables/shell/usePageBoot';
|
||||||
|
import { isActionCancelled, useApiAction } from '@/composables/actions/useApiAction';
|
||||||
|
import { usePageTitle } from '@/composables/usePageTitle';
|
||||||
|
import PageLayout from '@/components/layout/PageLayout.vue';
|
||||||
|
import TitleBar from '@/components/layout/TitleBar.vue';
|
||||||
|
import ManageLayout from '@/components/manage/ManageLayout.vue';
|
||||||
|
import ManageSection from '@/components/manage/ManageSection.vue';
|
||||||
|
import CommonList from '@/components/list/CommonList.vue';
|
||||||
|
import InfoStatsCard from '@/components/shared/InfoStatsCard.vue';
|
||||||
|
import {
|
||||||
|
getOrgNode,
|
||||||
|
listOrgNodeMembers,
|
||||||
|
updateOrgNode,
|
||||||
|
getUserGroup,
|
||||||
|
listUserGroupMembers,
|
||||||
|
updateUserGroup,
|
||||||
|
listUsers
|
||||||
|
} from '@/services/api';
|
||||||
|
import { User } from '@element-plus/icons-vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
entityType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
validator: (value) => ['organization', 'user-group'].includes(value)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const { locale, t } = useI18n();
|
||||||
|
const { runApi } = useApiAction({ t });
|
||||||
|
|
||||||
|
const entityAdapters = {
|
||||||
|
organization: {
|
||||||
|
activeMenu: 'manage-organization',
|
||||||
|
memberListKey: 'system.organization.memberList',
|
||||||
|
manageMembersKey: 'system.organization.manageMembers',
|
||||||
|
loadDetail: async (externalId) => {
|
||||||
|
const resp = await getOrgNode(externalId, { include_children: false });
|
||||||
|
return resp.org_node;
|
||||||
|
},
|
||||||
|
loadMembers: listOrgNodeMembers,
|
||||||
|
update: updateOrgNode
|
||||||
|
},
|
||||||
|
'user-group': {
|
||||||
|
activeMenu: 'manage-user-group',
|
||||||
|
memberListKey: 'system.userGroup.memberList',
|
||||||
|
manageMembersKey: 'system.userGroup.manageMembers',
|
||||||
|
loadDetail: async (externalId) => {
|
||||||
|
const resp = await getUserGroup(externalId);
|
||||||
|
return resp.user_group;
|
||||||
|
},
|
||||||
|
loadMembers: listUserGroupMembers,
|
||||||
|
update: updateUserGroup
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const entityAdapter = computed(() => entityAdapters[props.entityType]);
|
||||||
|
const entityLabels = computed(() => ({
|
||||||
|
memberListKey: entityAdapter.value.memberListKey,
|
||||||
|
manageMembersKey: entityAdapter.value.manageMembersKey
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
systemName,
|
||||||
|
activeMenu,
|
||||||
|
isDarkMode,
|
||||||
|
userInfo,
|
||||||
|
userAvatar,
|
||||||
|
manageMenuItems,
|
||||||
|
currentLanguage,
|
||||||
|
initLanguage,
|
||||||
|
fetchSystemInfo,
|
||||||
|
handleLanguageChange,
|
||||||
|
toggleTheme,
|
||||||
|
handleLogout,
|
||||||
|
handleMenuSelect
|
||||||
|
} = useManageShell({
|
||||||
|
locale,
|
||||||
|
router,
|
||||||
|
userStore,
|
||||||
|
defaultActiveMenu: entityAdapter.value.activeMenu
|
||||||
|
});
|
||||||
|
const { boot } = usePageBoot({ initLanguage, fetchSystemInfo });
|
||||||
|
|
||||||
|
const entityInfo = ref(null);
|
||||||
|
const pageTitleLabel = computed(() => {
|
||||||
|
return props.entityType === 'user-group'
|
||||||
|
? t('pageTitle.userGroup')
|
||||||
|
: t('pageTitle.organization');
|
||||||
|
});
|
||||||
|
usePageTitle(pageTitleLabel, computed(() => entityInfo.value?.name || ''));
|
||||||
|
const entityStats = computed(() => [
|
||||||
|
{ key: 'members', icon: User, label: t('common.members'), value: entityInfo.value?.member_count ?? 0 }
|
||||||
|
]);
|
||||||
|
const {
|
||||||
|
rows: memberRows,
|
||||||
|
page: memberPage,
|
||||||
|
pageSize: memberPageSize,
|
||||||
|
total: memberTotal,
|
||||||
|
keyword: memberSearch,
|
||||||
|
load: loadEntityMembers,
|
||||||
|
handlePageChange: handleMemberPageChange,
|
||||||
|
handleSearch: handleMemberSearch
|
||||||
|
} = useServerTable({
|
||||||
|
initialPageSize: 6,
|
||||||
|
fetcher: async ({ page, page_size, keyword }) => {
|
||||||
|
const externalId = getExternalId();
|
||||||
|
if (!externalId) {
|
||||||
|
return { users: [], total: 0 };
|
||||||
|
}
|
||||||
|
return entityAdapter.value.loadMembers({
|
||||||
|
external_id: externalId,
|
||||||
|
keyword,
|
||||||
|
page,
|
||||||
|
page_size
|
||||||
|
});
|
||||||
|
},
|
||||||
|
mapRows: (resp) => resp.users || [],
|
||||||
|
onError: (err) => {
|
||||||
|
console.error('load entity members failed', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
rows: memberCandidates,
|
||||||
|
page: candidatePage,
|
||||||
|
pageSize: candidatePageSize,
|
||||||
|
total: candidateTotal,
|
||||||
|
keyword: candidateSearch,
|
||||||
|
load: loadMemberCandidates,
|
||||||
|
handlePageChange: handleCandidatePageChange,
|
||||||
|
handleSearch: handleCandidateSearch
|
||||||
|
} = useServerTable({
|
||||||
|
initialPageSize: 3,
|
||||||
|
fetcher: ({ page, page_size, keyword }) =>
|
||||||
|
listUsers({
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
keyword
|
||||||
|
}),
|
||||||
|
mapRows: (resp) => resp.users || [],
|
||||||
|
onError: (err) => {
|
||||||
|
console.error('listUsers failed', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedMemberIds = ref([]);
|
||||||
|
const savingMembers = ref(false);
|
||||||
|
|
||||||
|
const showEditDialog = ref(false);
|
||||||
|
const editing = ref(false);
|
||||||
|
const editForm = ref({
|
||||||
|
external_id: '',
|
||||||
|
name: '',
|
||||||
|
description: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const showMemberDialog = ref(false);
|
||||||
|
|
||||||
|
const memberColumns = computed(() => [
|
||||||
|
{ key: 'username', label: t('common.name'), minWidth: 160 },
|
||||||
|
{ key: 'email', label: t('user.email') || 'Email', minWidth: 220, flex: 1.4 },
|
||||||
|
{ key: 'actions', label: t('common.actions'), minWidth: 120, align: 'center' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const candidateColumns = computed(() => [
|
||||||
|
{ key: 'username', label: t('common.name'), minWidth: 160 },
|
||||||
|
{ key: 'email', label: t('user.email') || 'Email', minWidth: 220, flex: 1.4 },
|
||||||
|
{ key: 'actions', label: t('common.actions'), minWidth: 140, align: 'center' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getExternalId = () => route.params.externalID;
|
||||||
|
|
||||||
|
const loadEntityDetail = async () => {
|
||||||
|
const externalId = getExternalId();
|
||||||
|
if (!externalId) {
|
||||||
|
entityInfo.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entityInfo.value = await runApi(
|
||||||
|
async () => entityAdapter.value.loadDetail(externalId),
|
||||||
|
{
|
||||||
|
context: 'load entity detail',
|
||||||
|
showErrorMessage: false,
|
||||||
|
fallback: null
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMemberDialog = async () => {
|
||||||
|
showMemberDialog.value = true;
|
||||||
|
candidateSearch.value = '';
|
||||||
|
candidatePage.value = 1;
|
||||||
|
selectedMemberIds.value = memberRows.value.map((member) => member.external_id).filter(Boolean);
|
||||||
|
await loadMemberCandidates();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitMemberUpdate = async () => {
|
||||||
|
if (savingMembers.value || !entityInfo.value?.external_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
savingMembers.value = true;
|
||||||
|
await runApi(
|
||||||
|
async () => {
|
||||||
|
const memberIds = Array.from(new Set(selectedMemberIds.value)).filter(Boolean);
|
||||||
|
await entityAdapter.value.update({
|
||||||
|
external_id: entityInfo.value.external_id,
|
||||||
|
sync_members: true,
|
||||||
|
member_user_external_ids: memberIds
|
||||||
|
});
|
||||||
|
showMemberDialog.value = false;
|
||||||
|
await loadEntityMembers();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
context: 'update entity members',
|
||||||
|
successMessage: t('message.success'),
|
||||||
|
onFinally: () => {
|
||||||
|
savingMembers.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMember = async (row) => {
|
||||||
|
if (!row?.external_id || !entityInfo.value?.external_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runApi(
|
||||||
|
async () => {
|
||||||
|
await ElMessageBox.confirm(t('message.confirmDelete'), t('document.delete'), {
|
||||||
|
confirmButtonText: t('button.confirm'),
|
||||||
|
cancelButtonText: t('button.cancel'),
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
const remaining = memberRows.value
|
||||||
|
.filter((member) => member.external_id !== row.external_id)
|
||||||
|
.map((member) => member.external_id);
|
||||||
|
await entityAdapter.value.update({
|
||||||
|
external_id: entityInfo.value.external_id,
|
||||||
|
sync_members: true,
|
||||||
|
member_user_external_ids: remaining
|
||||||
|
});
|
||||||
|
await loadEntityMembers();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
context: 'remove member',
|
||||||
|
successMessage: t('message.deleteSuccess'),
|
||||||
|
ignoreError: isActionCancelled
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = () => {
|
||||||
|
if (!entityInfo.value?.external_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editForm.value = {
|
||||||
|
external_id: entityInfo.value.external_id,
|
||||||
|
name: entityInfo.value.name || '',
|
||||||
|
description: entityInfo.value.description || ''
|
||||||
|
};
|
||||||
|
showEditDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitEdit = async () => {
|
||||||
|
if (editing.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editForm.value.name.trim()) {
|
||||||
|
ElMessage.warning(t('message.warning'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editing.value = true;
|
||||||
|
await runApi(
|
||||||
|
async () => {
|
||||||
|
await entityAdapter.value.update({
|
||||||
|
external_id: editForm.value.external_id,
|
||||||
|
name: editForm.value.name.trim(),
|
||||||
|
description: editForm.value.description.trim()
|
||||||
|
});
|
||||||
|
showEditDialog.value = false;
|
||||||
|
await loadEntityDetail();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
context: 'update entity',
|
||||||
|
successMessage: t('message.success'),
|
||||||
|
onFinally: () => {
|
||||||
|
editing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
boot(loadEntityDetail, loadEntityMembers);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.member-dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-dialog__list {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-only :deep(.el-checkbox__label) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<div class="manage-layout">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.manage-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<section class="manage-section" :class="{ 'manage-section--plain': plain }">
|
||||||
|
<div v-if="showHeader" class="section-header">
|
||||||
|
<slot name="header">
|
||||||
|
<h3 class="section-title">{{ title }}</h3>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<div class="section-body" :class="{ 'section-body--padded': bodyPadding === 'md' }">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, useSlots } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
plain: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
bodyPadding: {
|
||||||
|
type: String,
|
||||||
|
default: 'none'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const slots = useSlots();
|
||||||
|
const showHeader = computed(() => Boolean(props.title || slots.header));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.manage-section {
|
||||||
|
background: var(--bg-white);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-section--plain {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-body--padded {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .manage-section {
|
||||||
|
background: #161b22;
|
||||||
|
border-color: #2b2f36;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .manage-section--plain {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .section-header {
|
||||||
|
background: #161b22;
|
||||||
|
border-bottom-color: #2b2f36;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div class="info-stats-card">
|
||||||
|
<h2 class="info-title">{{ title }}</h2>
|
||||||
|
<p class="info-description">{{ description }}</p>
|
||||||
|
|
||||||
|
<div class="info-stats">
|
||||||
|
<div
|
||||||
|
v-for="(stat, index) in stats"
|
||||||
|
:key="stat.key || `${stat.label}-${index}`"
|
||||||
|
class="stat-item"
|
||||||
|
>
|
||||||
|
<el-icon v-if="stat.icon">
|
||||||
|
<component :is="stat.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<span>{{ formatStatText(stat) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
stats: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatStatText = (stat) => {
|
||||||
|
if (stat?.text) return stat.text;
|
||||||
|
if (stat?.label && Object.prototype.hasOwnProperty.call(stat, 'value')) {
|
||||||
|
return `${stat.label}: ${stat.value}`;
|
||||||
|
}
|
||||||
|
return stat?.label || '';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.info-stats-card {
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-title {
|
||||||
|
margin: 0 0 var(--spacing-md) 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-description {
|
||||||
|
margin: 0 0 var(--spacing-lg) 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-medium);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
color: var(--text-medium);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .el-icon {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.info-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .info-stats-card {
|
||||||
|
background: var(--bg-medium);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .info-title {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .info-description {
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-mode) .stat-item {
|
||||||
|
color: var(--text-medium);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="visible && users.length > 0"
|
||||||
|
class="mention-user-list"
|
||||||
|
:style="{ top: position.y + 'px', left: position.x + 'px' }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(user, index) in users"
|
||||||
|
:key="user.external_id"
|
||||||
|
class="mention-user-item"
|
||||||
|
:class="{ 'is-active': index === activeIndex }"
|
||||||
|
@mousedown.prevent="emit('select', user)"
|
||||||
|
>
|
||||||
|
<AppAvatar
|
||||||
|
:src="user.avatar"
|
||||||
|
:name="user.nickname || user.username"
|
||||||
|
:size="24"
|
||||||
|
/>
|
||||||
|
<span class="mention-user-name">{{ user.nickname || user.username }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, nextTick } from 'vue';
|
||||||
|
import { listUsers } from '@/services/api/membership.js';
|
||||||
|
import AppAvatar from '@/components/base/AppAvatar.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
keyword: { type: String, default: '' },
|
||||||
|
position: { type: Object, default: () => ({ x: 0, y: 0 }) },
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
activeIndex: { type: Number, default: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select']);
|
||||||
|
|
||||||
|
const users = ref([]);
|
||||||
|
let debounceTimer = null;
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.visible, props.keyword],
|
||||||
|
([visible, keyword]) => {
|
||||||
|
if (!visible) {
|
||||||
|
users.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const result = await listUsers({ keyword, page: 1, page_size: 8 });
|
||||||
|
users.value = result.users || [];
|
||||||
|
} catch {
|
||||||
|
users.value = [];
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mention-user-list {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
background: var(--bg-white, #fff);
|
||||||
|
border: 1px solid var(--border-color, #c9cdd4);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 260px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-user-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary, #1d2129);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-user-item:hover,
|
||||||
|
.mention-user-item.is-active {
|
||||||
|
background: var(--primary-light, #e8f0ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-user-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="title"
|
||||||
|
width="520px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@keydown.esc.prevent="handleCancel"
|
||||||
|
>
|
||||||
|
<el-form label-position="top" :model="formState" @submit.prevent>
|
||||||
|
<el-form-item :label="t('templates.nameLabel')" required>
|
||||||
|
<el-input v-model="formState.name" maxlength="100" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('templates.descLabel')">
|
||||||
|
<el-input v-model="formState.description" type="textarea" :rows="3" maxlength="255" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('templates.scopeLabel')">
|
||||||
|
<el-select v-model="formState.scope" style="width: 100%">
|
||||||
|
<el-option value="own" :label="t('templates.scopeOwn')" />
|
||||||
|
<el-option v-if="showKbScope" value="knowledge_base" :label="t('templates.scopeKb')" />
|
||||||
|
<el-option value="public" :label="t('templates.scopePublic')" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('templates.tagsLabel')">
|
||||||
|
<el-input v-model="formState.tags" :placeholder="t('templates.tagsPlaceholder')" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="showContent" :label="t('templates.contentLabel')" required>
|
||||||
|
<el-input
|
||||||
|
v-model="formState.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="6"
|
||||||
|
:placeholder="t('templates.contentPlaceholder')"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="handleCancel">{{ t('button.cancel') }}</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" @click="handleSubmit">
|
||||||
|
{{ t('button.confirm') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
showContent: { type: Boolean, default: true },
|
||||||
|
showKbScope: { type: Boolean, default: true },
|
||||||
|
initialName: { type: String, default: '' },
|
||||||
|
initialDescription: { type: String, default: '' },
|
||||||
|
initialScope: { type: String, default: 'own' },
|
||||||
|
initialTags: { type: [String, Array], default: '' },
|
||||||
|
initialContent: { type: String, default: '' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'submit', 'cancel']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const formState = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
scope: 'own',
|
||||||
|
tags: '',
|
||||||
|
content: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeTags = (tags) => {
|
||||||
|
if (Array.isArray(tags)) {
|
||||||
|
return tags.join(', ');
|
||||||
|
}
|
||||||
|
return tags || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
formState.name = props.initialName || '';
|
||||||
|
formState.description = props.initialDescription || '';
|
||||||
|
formState.scope = props.initialScope || 'own';
|
||||||
|
formState.tags = normalizeTags(props.initialTags);
|
||||||
|
formState.content = props.initialContent || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('cancel');
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!formState.name.trim()) {
|
||||||
|
ElMessage.error(t('templates.nameRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.showContent && !formState.content.trim()) {
|
||||||
|
ElMessage.error(t('templates.emptyContent'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = formState.tags
|
||||||
|
? formState.tags.split(',').map((tag) => tag.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
emit('submit', {
|
||||||
|
name: formState.name.trim(),
|
||||||
|
description: formState.description || '',
|
||||||
|
scope: formState.scope,
|
||||||
|
tags,
|
||||||
|
content: formState.content
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(nextVisible) => {
|
||||||
|
if (nextVisible) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<CardListSection
|
||||||
|
:title="title"
|
||||||
|
:items="items"
|
||||||
|
:empty-text="emptyText"
|
||||||
|
:fallback-description="fallbackDescription"
|
||||||
|
:action-label="actionLabel"
|
||||||
|
:tag-mapper="tagMapper"
|
||||||
|
:meta-mapper="metaMapper"
|
||||||
|
:item-key-mapper="(item) => item?.id || item?.external_id || item?.externalId"
|
||||||
|
:item-title-mapper="(item) => item?.name || ''"
|
||||||
|
:item-description-mapper="(item) => item?.description || ''"
|
||||||
|
@open="$emit('open', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import CardListSection from '@/components/list/CardListSection.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
items: { type: Array, default: () => [] },
|
||||||
|
emptyText: { type: String, default: '' },
|
||||||
|
fallbackDescription: { type: String, default: '' },
|
||||||
|
actionLabel: { type: String, default: '' },
|
||||||
|
tagMapper: { type: Function, default: null },
|
||||||
|
metaMapper: { type: Function, default: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['open']);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="template-card"
|
||||||
|
:class="{
|
||||||
|
'is-selected': isSelected,
|
||||||
|
'is-blank': Boolean(item?.is_blank),
|
||||||
|
'is-grid': layout === 'grid'
|
||||||
|
}"
|
||||||
|
@click="emit('select', item)"
|
||||||
|
>
|
||||||
|
<div v-if="layout === 'grid'" class="template-card-preview">
|
||||||
|
{{ previewText }}
|
||||||
|
</div>
|
||||||
|
<div class="template-card-title">
|
||||||
|
{{ item?.name }}
|
||||||
|
</div>
|
||||||
|
<div class="template-card-desc">
|
||||||
|
{{ item?.description || fallbackDescription }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showPreviewButton" class="template-card-actions">
|
||||||
|
<el-button class="template-preview-btn" size="small" @click.stop="emit('preview', item)">
|
||||||
|
{{ t('common.preview') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
item: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
selectedTemplateId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
fallbackDescription: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
type: String,
|
||||||
|
default: 'list'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select', 'preview']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const isSelected = computed(() => props.selectedTemplateId === String(props.item?.id || ''));
|
||||||
|
|
||||||
|
const previewText = computed(() => {
|
||||||
|
if (props.item?.is_blank) {
|
||||||
|
return '+';
|
||||||
|
}
|
||||||
|
const text = String(props.item?.name || '').trim();
|
||||||
|
return text ? text.slice(0, 1).toUpperCase() : 'T';
|
||||||
|
});
|
||||||
|
|
||||||
|
const showPreviewButton = computed(() => !props.item?.is_blank);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.template-card {
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #eef0f4;
|
||||||
|
padding: 12px 8px 12px 12px;
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card:hover {
|
||||||
|
background-color: #f7f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card.is-selected {
|
||||||
|
background-color: #f0f5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card-preview {
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2f65e2;
|
||||||
|
background: linear-gradient(135deg, #e9f0ff 0%, #f7faff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card.is-blank .template-card-preview {
|
||||||
|
border: 1px dashed #91b0ff;
|
||||||
|
background: linear-gradient(135deg, #edf3ff 0%, #f8fbff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card-desc {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #3370ff;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card.is-selected::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card.is-grid {
|
||||||
|
border: 1px solid #e7ebf2;
|
||||||
|
border-radius: 12px;
|
||||||
|
min-height: 158px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card.is-grid:hover {
|
||||||
|
background-color: #f8faff;
|
||||||
|
border-color: #d7e2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card.is-grid.is-selected {
|
||||||
|
border-color: #6b93ff;
|
||||||
|
background: #f2f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card.is-grid::before {
|
||||||
|
left: auto;
|
||||||
|
top: auto;
|
||||||
|
bottom: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 8px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card:hover .template-card-actions {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-preview-btn {
|
||||||
|
border-radius: 8px;
|
||||||
|
border-color: color-mix(in srgb, #3370ff 35%, white);
|
||||||
|
background: color-mix(in srgb, #3370ff 8%, white);
|
||||||
|
color: #2f65e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-preview-btn:hover,
|
||||||
|
.template-preview-btn:focus {
|
||||||
|
border-color: #3370ff;
|
||||||
|
color: #2456d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-card {
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-card:hover {
|
||||||
|
background-color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-card.is-selected {
|
||||||
|
background-color: #0b1f3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-card-title {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-card-desc {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-card-preview {
|
||||||
|
background: linear-gradient(135deg, #152235 0%, #1f314c 100%);
|
||||||
|
color: #8db0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-card.is-blank .template-card-preview {
|
||||||
|
border-color: #4c8dff;
|
||||||
|
background: linear-gradient(135deg, #102038 0%, #173054 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-card.is-grid {
|
||||||
|
border-color: #243040;
|
||||||
|
background: #0f1218;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-card.is-grid:hover {
|
||||||
|
border-color: #35517e;
|
||||||
|
background: #121a26;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-card.is-grid.is-selected {
|
||||||
|
border-color: #4c8dff;
|
||||||
|
background: #142237;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-preview-btn {
|
||||||
|
border-color: rgba(76, 141, 255, 0.6);
|
||||||
|
background: rgba(76, 141, 255, 0.16);
|
||||||
|
color: #9ec0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-preview-btn:hover,
|
||||||
|
.dark-mode .template-preview-btn:focus {
|
||||||
|
border-color: rgba(118, 169, 255, 0.92);
|
||||||
|
color: #dbe9ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<div class="template-pane" v-loading="loading">
|
||||||
|
<div v-if="items.length === 0" class="template-empty">
|
||||||
|
<el-empty :description="emptyText" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="template-list" :class="`template-list--${layout}`">
|
||||||
|
<TemplatePickerCard
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
:item="item"
|
||||||
|
:layout="layout"
|
||||||
|
:selected-template-id="selectedTemplateId"
|
||||||
|
:fallback-description="fallbackDescription"
|
||||||
|
@select="emit('select', $event)"
|
||||||
|
@preview="openPreviewDialog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TemplatePreviewDialog
|
||||||
|
v-model="showPreviewDialog"
|
||||||
|
:title="previewDialogTitle"
|
||||||
|
:content="previewContent"
|
||||||
|
:document-type="previewingTemplate?.type || '1'"
|
||||||
|
:is-dark-mode="isDarkMode"
|
||||||
|
@closed="closePreviewDialog"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useUserStore } from '@/store/user';
|
||||||
|
import TemplatePickerCard from '@/components/template/TemplatePickerCard.vue';
|
||||||
|
import TemplatePreviewDialog from '@/components/template/TemplatePreviewDialog.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
selectedTemplateId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
fallbackDescription: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
type: String,
|
||||||
|
default: 'list'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const isDarkMode = computed(() => Boolean(userStore.darkMode));
|
||||||
|
const showPreviewDialog = ref(false);
|
||||||
|
const previewingTemplate = ref(null);
|
||||||
|
|
||||||
|
const previewContent = computed(() => String(previewingTemplate.value?.content || ''));
|
||||||
|
const previewDialogTitle = computed(() => {
|
||||||
|
const name = String(previewingTemplate.value?.name || '').trim();
|
||||||
|
if (!name) {
|
||||||
|
return t('templates.previewTitle');
|
||||||
|
}
|
||||||
|
return `${t('templates.previewTitle')} · ${name}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const openPreviewDialog = (tpl) => {
|
||||||
|
previewingTemplate.value = tpl || null;
|
||||||
|
showPreviewDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePreviewDialog = () => {
|
||||||
|
showPreviewDialog.value = false;
|
||||||
|
previewingTemplate.value = null;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.template-pane {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-list--grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-empty {
|
||||||
|
padding: var(--spacing-md) 0;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-empty :deep(.el-empty) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.template-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-list--grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="title"
|
||||||
|
width="840px"
|
||||||
|
append-to-body
|
||||||
|
@closed="emit('closed')"
|
||||||
|
>
|
||||||
|
<div class="template-preview-dialog-body">
|
||||||
|
<div v-if="!content" class="template-preview-empty">
|
||||||
|
<el-empty :description="t('templates.contentEmpty')" />
|
||||||
|
</div>
|
||||||
|
<DocumentPreviewViewer
|
||||||
|
v-else
|
||||||
|
class="template-preview-render"
|
||||||
|
:content="content"
|
||||||
|
:document-type="documentType"
|
||||||
|
:is-template="true"
|
||||||
|
:is-dark-mode="isDarkMode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">{{ t('button.cancel') }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import DocumentPreviewViewer from '@/components/document/render/DocumentPreviewViewer.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
documentType: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: '1'
|
||||||
|
},
|
||||||
|
isDarkMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'closed']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.template-preview-dialog-body {
|
||||||
|
min-height: 360px;
|
||||||
|
max-height: 64vh;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-preview-empty {
|
||||||
|
min-height: 280px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-preview-render {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .template-preview-dialog-body {
|
||||||
|
background: #141a22;
|
||||||
|
border-color: #2a313a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" :title="title" width="520px">
|
||||||
|
<el-form label-position="top" class="template-settings-form">
|
||||||
|
<el-form-item :label="t('templates.nameLabel')" required>
|
||||||
|
<el-input
|
||||||
|
v-model="formState.name"
|
||||||
|
maxlength="100"
|
||||||
|
show-word-limit
|
||||||
|
:placeholder="t('templates.nameLabel')"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('templates.descLabel')">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="300"
|
||||||
|
show-word-limit
|
||||||
|
:placeholder="t('templates.descLabel')"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('templates.public')">
|
||||||
|
<el-switch v-model="formState.is_public" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">{{ t('button.cancel') }}</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" @click="emit('submit')">
|
||||||
|
{{ t('button.confirm') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
formState: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'submit']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.template-settings-form :deep(.el-switch) {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { useAsyncAction } from '@/composables/actions/useAsyncAction';
|
||||||
|
|
||||||
|
export const isActionCancelled = (error) => {
|
||||||
|
if (error === null || error === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error === 'cancel' || error === 'close';
|
||||||
|
}
|
||||||
|
const message = error?.message || '';
|
||||||
|
return message === 'cancel' || message === 'close';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useApiAction(options = {}) {
|
||||||
|
const {
|
||||||
|
t,
|
||||||
|
defaultErrorKey = 'common.requestFailed',
|
||||||
|
defaultErrorMessage = 'Request failed'
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const { pending, runAsync } = useAsyncAction();
|
||||||
|
|
||||||
|
const resolveErrorMessage = (message) => {
|
||||||
|
if (message) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
if (typeof t === 'function' && defaultErrorKey) {
|
||||||
|
return t(defaultErrorKey);
|
||||||
|
}
|
||||||
|
return defaultErrorMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldIgnore = (error, ignoreError) => {
|
||||||
|
if (typeof ignoreError === 'function') {
|
||||||
|
return ignoreError(error);
|
||||||
|
}
|
||||||
|
return Boolean(ignoreError);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApiError = (error, options = {}) => {
|
||||||
|
const {
|
||||||
|
context = 'api request',
|
||||||
|
errorMessage,
|
||||||
|
showErrorMessage = true,
|
||||||
|
logError = true,
|
||||||
|
ignoreError = false
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (shouldIgnore(error, ignoreError)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logError) {
|
||||||
|
console.error(`${context} failed`, error);
|
||||||
|
}
|
||||||
|
if (showErrorMessage) {
|
||||||
|
ElMessage.error(resolveErrorMessage(errorMessage));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createApiErrorHandler = (options = {}) => (error) => {
|
||||||
|
handleApiError(error, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runApi = async (action, options = {}) => {
|
||||||
|
const {
|
||||||
|
successMessage,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
onFinally,
|
||||||
|
rethrow = false,
|
||||||
|
fallback = null
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return runAsync(action, {
|
||||||
|
rethrow,
|
||||||
|
fallback,
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
if (successMessage) {
|
||||||
|
ElMessage.success(successMessage);
|
||||||
|
}
|
||||||
|
if (typeof onSuccess === 'function') {
|
||||||
|
await onSuccess(result);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
handleApiError(error, options);
|
||||||
|
if (typeof onError === 'function') {
|
||||||
|
await onError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFinally
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSilent = async (action, options = {}) =>
|
||||||
|
runApi(action, {
|
||||||
|
showErrorMessage: false,
|
||||||
|
logError: false,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
|
||||||
|
const runWithLoading = async (loadingRef, action, options = {}) => {
|
||||||
|
if (!loadingRef || typeof loadingRef.value !== 'boolean') {
|
||||||
|
return runApi(action, options);
|
||||||
|
}
|
||||||
|
if (loadingRef.value) {
|
||||||
|
return options.fallback ?? null;
|
||||||
|
}
|
||||||
|
loadingRef.value = true;
|
||||||
|
const originalOnFinally = options.onFinally;
|
||||||
|
try {
|
||||||
|
return await runApi(action, {
|
||||||
|
...options,
|
||||||
|
onFinally: async () => {
|
||||||
|
if (typeof originalOnFinally === 'function') {
|
||||||
|
await originalOnFinally();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loadingRef.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pending,
|
||||||
|
runApi,
|
||||||
|
runSilent,
|
||||||
|
runWithLoading,
|
||||||
|
handleApiError,
|
||||||
|
createApiErrorHandler
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
export function useAsyncAction() {
|
||||||
|
const pendingCount = ref(0);
|
||||||
|
const pending = computed(() => pendingCount.value > 0);
|
||||||
|
|
||||||
|
const runAsync = async (action, options = {}) => {
|
||||||
|
const {
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
onFinally,
|
||||||
|
rethrow = false,
|
||||||
|
fallback = null
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (typeof action !== 'function') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingCount.value += 1;
|
||||||
|
try {
|
||||||
|
const result = await action();
|
||||||
|
if (typeof onSuccess === 'function') {
|
||||||
|
await onSuccess(result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof onError === 'function') {
|
||||||
|
await onError(error);
|
||||||
|
}
|
||||||
|
if (rethrow) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
} finally {
|
||||||
|
pendingCount.value = Math.max(0, pendingCount.value - 1);
|
||||||
|
if (typeof onFinally === 'function') {
|
||||||
|
await onFinally();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pending,
|
||||||
|
runAsync
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export function useInlineCommentAnchor({
|
||||||
|
commentList,
|
||||||
|
onAnchorClick,
|
||||||
|
onAnchorHover,
|
||||||
|
onAnchorRemove,
|
||||||
|
createTempComment,
|
||||||
|
deleteRemoteComment
|
||||||
|
}) {
|
||||||
|
const handleContentClick = (item) => {
|
||||||
|
if (item?.anchor_id && typeof onAnchorClick === 'function') {
|
||||||
|
onAnchorClick(item.anchor_id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHover = (item, hovering) => {
|
||||||
|
if (item?.anchor_id && typeof onAnchorHover === 'function') {
|
||||||
|
onAnchorHover(item.anchor_id, hovering);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeRemoveAnchor = (anchorId, list = commentList.value) => {
|
||||||
|
const id = `${anchorId || ''}`.trim();
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasAnyComment = (list || []).some((entry) => `${entry?.anchor_id || ''}`.trim() === id);
|
||||||
|
if (hasAnyComment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof onAnchorRemove === 'function') {
|
||||||
|
onAnchorRemove([id]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInlineAnchorAdd = (payload) => {
|
||||||
|
const rawId = typeof payload === 'string' ? payload : payload?.id;
|
||||||
|
const id = `${rawId || ''}`.trim();
|
||||||
|
if (!id) return;
|
||||||
|
if (commentList.value.some((item) => item.anchor_id === id && item.editing)) return;
|
||||||
|
commentList.value.unshift(createTempComment({ parent: null, anchorId: id }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInlineAnchorRemove = async (ids) => {
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
const targets = commentList.value.filter((item) => idSet.has(item.anchor_id));
|
||||||
|
commentList.value = commentList.value.filter((item) => !idSet.has(item.anchor_id));
|
||||||
|
await Promise.all(
|
||||||
|
targets
|
||||||
|
.filter((item) => item.external_id)
|
||||||
|
.map((item) => deleteRemoteComment(item.external_id).catch(() => {}))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleContentClick,
|
||||||
|
handleHover,
|
||||||
|
maybeRemoveAnchor,
|
||||||
|
handleInlineAnchorAdd,
|
||||||
|
handleInlineAnchorRemove
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import {
|
||||||
|
listDocumentComments,
|
||||||
|
createDocumentComment,
|
||||||
|
deleteDocumentComment,
|
||||||
|
updateDocumentComment
|
||||||
|
} from '@/services/api';
|
||||||
|
import { resolveFileUrl } from '@/utils/fileUrl';
|
||||||
|
|
||||||
|
export function useInlineCommentCrud({
|
||||||
|
t,
|
||||||
|
getDocId,
|
||||||
|
getInlineEnabled,
|
||||||
|
getUserInfo,
|
||||||
|
commentList,
|
||||||
|
userDisplayName,
|
||||||
|
maybeRemoveAnchor,
|
||||||
|
onCommentMutated
|
||||||
|
}) {
|
||||||
|
const buildLocalId = () => `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
const createTempComment = ({ parent, anchorId }) => {
|
||||||
|
const userInfo = getUserInfo?.();
|
||||||
|
return {
|
||||||
|
local_id: buildLocalId(),
|
||||||
|
external_id: '',
|
||||||
|
parent_external_id: parent?.external_id || '',
|
||||||
|
content: '',
|
||||||
|
created_at: '',
|
||||||
|
creator_name: userDisplayName.value,
|
||||||
|
creator_user_external_id: userInfo?.external_id || '',
|
||||||
|
creator_avatar: resolveFileUrl(userInfo?.avatar || ''),
|
||||||
|
anchor_id: `${anchorId || parent?.anchor_id || ''}`.trim(),
|
||||||
|
editing: true,
|
||||||
|
draft: '',
|
||||||
|
saving: false
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const canModify = (item) => {
|
||||||
|
if (!item) return false;
|
||||||
|
if (!item.external_id) return true;
|
||||||
|
const userInfo = getUserInfo?.();
|
||||||
|
const currentExternalId = userInfo?.external_id;
|
||||||
|
if (!currentExternalId) return false;
|
||||||
|
if (item.creator_user_external_id === currentExternalId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return userInfo?.username === 'admin';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActions = (item) => {
|
||||||
|
const editable = canModify(item);
|
||||||
|
return [
|
||||||
|
{ key: 'copy', label: t('common.copy') },
|
||||||
|
{ key: 'edit', label: t('common.edit'), disabled: !editable },
|
||||||
|
{ key: 'delete', label: t('document.commentDelete'), danger: true, disabled: !editable }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertAfter = (list, predicate, item) => {
|
||||||
|
const index = list.findIndex(predicate);
|
||||||
|
if (index === -1) {
|
||||||
|
list.unshift(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.splice(index + 1, 0, item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReply = (item) => {
|
||||||
|
const temp = createTempComment({ parent: item });
|
||||||
|
insertAfter(commentList.value, (entry) => entry.external_id === item.external_id, temp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (item) => {
|
||||||
|
if (!item || item.editing) return;
|
||||||
|
item.draft = item.content || '';
|
||||||
|
item.editing = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = (item) => {
|
||||||
|
if (!item) return;
|
||||||
|
if (!item.external_id) {
|
||||||
|
const nextList = commentList.value.filter((entry) => entry.local_id !== item.local_id);
|
||||||
|
commentList.value = nextList;
|
||||||
|
maybeRemoveAnchor(item.anchor_id, nextList);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
item.editing = false;
|
||||||
|
item.draft = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async (item) => {
|
||||||
|
if (!item) return;
|
||||||
|
const content = (item.draft || '').trim();
|
||||||
|
if (!content) {
|
||||||
|
ElMessage.error(t('document.inlineCommentContentRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!item.anchor_id) {
|
||||||
|
ElMessage.error(t('document.inlineCommentAnchorMissing'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.saving) return;
|
||||||
|
|
||||||
|
const docId = getDocId?.();
|
||||||
|
if (!docId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.saving = true;
|
||||||
|
try {
|
||||||
|
if (item.external_id) {
|
||||||
|
const resp = await updateDocumentComment({
|
||||||
|
external_id: item.external_id,
|
||||||
|
content
|
||||||
|
});
|
||||||
|
const saved = resp.comment;
|
||||||
|
item.content = saved?.content || content;
|
||||||
|
item.created_at = saved?.created_at || item.created_at;
|
||||||
|
item.creator_name = saved?.creator_name || item.creator_name;
|
||||||
|
item.creator_avatar = saved?.creator_avatar || item.creator_avatar;
|
||||||
|
item.creator_user_external_id = saved?.creator_user_external_id || item.creator_user_external_id;
|
||||||
|
item.editing = false;
|
||||||
|
item.draft = '';
|
||||||
|
if (typeof onCommentMutated === 'function') {
|
||||||
|
onCommentMutated({ type: 'update', comment_id: item.external_id, anchor_id: item.anchor_id });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const resp = await createDocumentComment({
|
||||||
|
document_external_id: docId,
|
||||||
|
content,
|
||||||
|
parent_external_id: item.parent_external_id || undefined,
|
||||||
|
anchor_id: item.anchor_id,
|
||||||
|
mentioned_user_external_ids: item.mentionedUserExternalIds || []
|
||||||
|
});
|
||||||
|
const saved = resp.comment;
|
||||||
|
item.content = saved?.content || content;
|
||||||
|
item.external_id = saved?.external_id || item.external_id;
|
||||||
|
item.local_id = item.external_id || item.local_id;
|
||||||
|
item.created_at = saved?.created_at || new Date().toISOString();
|
||||||
|
item.creator_name = saved?.creator_name || item.creator_name;
|
||||||
|
item.creator_avatar = saved?.creator_avatar || item.creator_avatar;
|
||||||
|
item.creator_user_external_id = saved?.creator_user_external_id || item.creator_user_external_id;
|
||||||
|
item.anchor_id = saved?.anchor_id || item.anchor_id;
|
||||||
|
item.editing = false;
|
||||||
|
item.draft = '';
|
||||||
|
item.mentionedUserExternalIds = [];
|
||||||
|
if (typeof onCommentMutated === 'function') {
|
||||||
|
onCommentMutated({ type: 'create', comment_id: item.external_id, anchor_id: item.anchor_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(t('common.requestFailed'));
|
||||||
|
} finally {
|
||||||
|
item.saving = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCommentItem = async (item) => {
|
||||||
|
if (!item) return;
|
||||||
|
const deletedAnchorId = item.anchor_id;
|
||||||
|
|
||||||
|
if (item.external_id) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
t('document.commentDeleteConfirm'),
|
||||||
|
t('document.commentDelete'),
|
||||||
|
{
|
||||||
|
confirmButtonText: t('button.confirm'),
|
||||||
|
cancelButtonText: t('button.cancel'),
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await deleteDocumentComment(item.external_id);
|
||||||
|
if (typeof onCommentMutated === 'function') {
|
||||||
|
onCommentMutated({ type: 'delete', comment_id: item.external_id, anchor_id: deletedAnchorId });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextList = commentList.value.filter((entry) => entry.local_id !== item.local_id);
|
||||||
|
commentList.value = nextList;
|
||||||
|
maybeRemoveAnchor(item.anchor_id, nextList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyComment = async (item) => {
|
||||||
|
const text = item?.content || '';
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
ElMessage.success(t('common.copySuccess'));
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(t('common.copyFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = (item, action) => {
|
||||||
|
if (action === 'copy') {
|
||||||
|
copyComment(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'edit') {
|
||||||
|
if (!canModify(item)) return;
|
||||||
|
startEdit(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'delete') {
|
||||||
|
if (!canModify(item)) return;
|
||||||
|
deleteCommentItem(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCommentTime = (value) => {
|
||||||
|
if (!value) return '';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadComments = async (loadVersionRef) => {
|
||||||
|
const requestVersion = ++loadVersionRef.value;
|
||||||
|
const requestDocId = getDocId?.();
|
||||||
|
const inlineEnabled = !!getInlineEnabled?.();
|
||||||
|
const pendingEditingDrafts = commentList.value
|
||||||
|
.filter((item) => item && !item.external_id && item.editing)
|
||||||
|
.map((item) => ({ ...item }));
|
||||||
|
|
||||||
|
if (!requestDocId || !inlineEnabled) {
|
||||||
|
commentList.value = [];
|
||||||
|
if (requestVersion === loadVersionRef.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await listDocumentComments({
|
||||||
|
document_external_id: requestDocId,
|
||||||
|
page: 1,
|
||||||
|
page_size: 100
|
||||||
|
});
|
||||||
|
if (requestVersion !== loadVersionRef.value || requestDocId !== getDocId?.()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const remoteComments = (resp.comments || [])
|
||||||
|
.filter((item) => item.anchor_id)
|
||||||
|
.map((item) => ({
|
||||||
|
local_id: item.external_id || item.anchor_id,
|
||||||
|
anchor_id: item.anchor_id,
|
||||||
|
external_id: item.external_id,
|
||||||
|
parent_external_id: item.parent_external_id || '',
|
||||||
|
content: item.content,
|
||||||
|
created_at: item.created_at,
|
||||||
|
creator_name: item.creator_name,
|
||||||
|
creator_user_external_id: item.creator_user_external_id,
|
||||||
|
creator_avatar: item.creator_avatar,
|
||||||
|
editing: false,
|
||||||
|
draft: '',
|
||||||
|
saving: false
|
||||||
|
}));
|
||||||
|
commentList.value = [...pendingEditingDrafts, ...remoteComments];
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (requestVersion !== loadVersionRef.value || requestDocId !== getDocId?.()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
commentList.value = pendingEditingDrafts;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createTempComment,
|
||||||
|
getActions,
|
||||||
|
handleReply,
|
||||||
|
handleAction,
|
||||||
|
saveEdit,
|
||||||
|
cancelEdit,
|
||||||
|
formatCommentTime,
|
||||||
|
loadComments,
|
||||||
|
deleteRemoteComment: deleteDocumentComment
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export const flattenCommentTree = (items) => {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const childrenMap = new Map();
|
||||||
|
const idSet = new Set();
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item?.external_id) {
|
||||||
|
idSet.add(item.external_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const parentId = item.parent_external_id || '';
|
||||||
|
if (!childrenMap.has(parentId)) {
|
||||||
|
childrenMap.set(parentId, []);
|
||||||
|
}
|
||||||
|
childrenMap.get(parentId).push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
const walk = (node, level) => {
|
||||||
|
node.level = level;
|
||||||
|
result.push(node);
|
||||||
|
if (!node.external_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const children = childrenMap.get(node.external_id) || [];
|
||||||
|
children.forEach((child) => walk(child, level + 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const roots = [];
|
||||||
|
items.forEach((item) => {
|
||||||
|
const parentId = item.parent_external_id || '';
|
||||||
|
if (!parentId || !idSet.has(parentId)) {
|
||||||
|
roots.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
roots.forEach((root) => walk(root, 0));
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getIndentStyle = (item) => ({
|
||||||
|
paddingLeft: `${Math.min(item?.level || 0, 3) * 16}px`
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buildReplyLabel = (item, comments, t) => {
|
||||||
|
if (!item?.parent_external_id) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const parent = comments.find((entry) => entry.external_id === item.parent_external_id);
|
||||||
|
return t('document.commentReplyTo', { name: parent?.creator_name || t('document.commentUnknown') });
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { flattenCommentTree, getIndentStyle as buildIndentStyle, buildReplyLabel } from './inline-comments/tree';
|
||||||
|
import { useInlineCommentAnchor } from './inline-comments/anchor';
|
||||||
|
import { useInlineCommentCrud } from './inline-comments/crud';
|
||||||
|
|
||||||
|
export function useInlineComments(options = {}) {
|
||||||
|
const {
|
||||||
|
t,
|
||||||
|
getDocId,
|
||||||
|
getInlineEnabled,
|
||||||
|
getUserInfo,
|
||||||
|
onAnchorClick,
|
||||||
|
onAnchorHover,
|
||||||
|
onAnchorRemove,
|
||||||
|
onCommentMutated
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const commentList = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadVersion = ref(0);
|
||||||
|
|
||||||
|
const inlineEmptyText = computed(() => t('document.inlineCommentEmpty'));
|
||||||
|
const userDisplayName = computed(() => {
|
||||||
|
const userInfo = getUserInfo?.();
|
||||||
|
return userInfo?.nickname || userInfo?.username || t('common.me');
|
||||||
|
});
|
||||||
|
const titleWithCount = computed(() => {
|
||||||
|
const count = commentList.value.length;
|
||||||
|
return count > 0 ? `${count}` : '';
|
||||||
|
});
|
||||||
|
const displayComments = computed(() => flattenCommentTree(commentList.value));
|
||||||
|
const getIndentStyle = (item) => buildIndentStyle(item);
|
||||||
|
const replyLabel = (item) => buildReplyLabel(item, commentList.value, t);
|
||||||
|
|
||||||
|
let maybeRemoveAnchor = () => {};
|
||||||
|
|
||||||
|
const commentCrud = useInlineCommentCrud({
|
||||||
|
t,
|
||||||
|
getDocId,
|
||||||
|
getInlineEnabled,
|
||||||
|
getUserInfo,
|
||||||
|
commentList,
|
||||||
|
userDisplayName,
|
||||||
|
maybeRemoveAnchor: (...args) => maybeRemoveAnchor(...args),
|
||||||
|
onCommentMutated
|
||||||
|
});
|
||||||
|
|
||||||
|
const anchorBridge = useInlineCommentAnchor({
|
||||||
|
commentList,
|
||||||
|
onAnchorClick,
|
||||||
|
onAnchorHover,
|
||||||
|
onAnchorRemove,
|
||||||
|
createTempComment: commentCrud.createTempComment,
|
||||||
|
deleteRemoteComment: commentCrud.deleteRemoteComment
|
||||||
|
});
|
||||||
|
maybeRemoveAnchor = anchorBridge.maybeRemoveAnchor;
|
||||||
|
|
||||||
|
const loadComments = async () => {
|
||||||
|
const requestVersion = loadVersion.value + 1;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await commentCrud.loadComments(loadVersion);
|
||||||
|
} finally {
|
||||||
|
if (requestVersion === loadVersion.value) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => getDocId?.(),
|
||||||
|
async () => {
|
||||||
|
commentList.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
await loadComments();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => !!getInlineEnabled?.(),
|
||||||
|
async (enabled) => {
|
||||||
|
if (!enabled) {
|
||||||
|
commentList.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadComments();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inlineEmptyText,
|
||||||
|
titleWithCount,
|
||||||
|
displayComments,
|
||||||
|
getIndentStyle,
|
||||||
|
getActions: commentCrud.getActions,
|
||||||
|
replyLabel,
|
||||||
|
handleAction: commentCrud.handleAction,
|
||||||
|
handleReply: commentCrud.handleReply,
|
||||||
|
handleContentClick: anchorBridge.handleContentClick,
|
||||||
|
handleHover: anchorBridge.handleHover,
|
||||||
|
saveEdit: commentCrud.saveEdit,
|
||||||
|
cancelEdit: commentCrud.cancelEdit,
|
||||||
|
formatCommentTime: commentCrud.formatCommentTime,
|
||||||
|
handleInlineAnchorAdd: anchorBridge.handleInlineAnchorAdd,
|
||||||
|
handleInlineAnchorRemove: anchorBridge.handleInlineAnchorRemove,
|
||||||
|
reload: loadComments
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user