migrate from github

This commit is contained in:
2026-07-15 22:51:56 +08:00
commit a1cccc8068
215 changed files with 38318 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
dist-ssr
*.log
*.local
.DS_Store
.git
.vscode
.idea
src/gen/
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8080
+25
View File
@@ -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
View File
@@ -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"]
+30
View File
@@ -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;"]
+5
View File
@@ -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
View File
@@ -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>
+11
View File
@@ -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;
}
}
+6388
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -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

+1
View File
@@ -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
View File
@@ -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>
+1
View File
@@ -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

+75
View File
@@ -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>
+126
View File
@@ -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>
+196
View File
@@ -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>
+87
View File
@@ -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>
+105
View File
@@ -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>
+277
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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>
+101
View File
@@ -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>
+211
View File
@@ -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>
+260
View File
@@ -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>
+490
View File
@@ -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>
+299
View File
@@ -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>
+332
View File
@@ -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>
+494
View File
@@ -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>
+159
View File
@@ -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')"
>
&lt;/&gt;
</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>
+147
View File
@@ -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>
+195
View File
@@ -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>
+113
View File
@@ -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>
+271
View File
@@ -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>
+132
View File
@@ -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>
+348
View File
@@ -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>
+247
View File
@@ -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>
+331
View File
@@ -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>
+440
View File
@@ -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>
+13
View File
@@ -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>
+86
View File
@@ -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>
+103
View File
@@ -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>
+97
View File
@@ -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>
+135
View File
@@ -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
};
}
+47
View File
@@ -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