feat: 初始化网页

This commit is contained in:
DKJ 2026-02-06 14:07:11 +08:00
commit b08bbc5550
23 changed files with 10393 additions and 0 deletions

69
.gitignore vendored Normal file
View File

@ -0,0 +1,69 @@
# Dependencies
node_modules/
# Build outputs
dist/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Vite specific
dist/
# Editor directories and files
.vscode/
.idea/
.DS_Store # macOS
Thumbs.db # Windows
# Project-specific

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1
notation.md Normal file
View File

@ -0,0 +1 @@
不要每次都开启vue服务器

2797
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "mobile-integrataion-development",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vueuse/core": "^10.11.0",
"lucide-vue-next": "^0.562.0",
"quill": "^2.0.2",
"uuid": "^9.0.1",
"vue": "^3.5.13"
},
"devDependencies": {
"@types/uuid": "^10.0.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"vite": "^6.2.0",
"vue-tsc": "^2.2.0"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

941
src/App.vue Normal file
View File

@ -0,0 +1,941 @@
<script setup lang="ts">
import { ref } from 'vue'
import RibbonMenu from './components/RibbonMenu.vue'
import TreeView from './components/TreeView.vue'
import ContentPanel from './components/ContentPanel.vue'
import type { TreeNode } from './components/TreeView.vue'
const selectedNode = ref<TreeNode | null>(null)
const treeViewRef = ref<InstanceType<typeof TreeView> | null>(null)
const handleNodeSelect = (node: TreeNode) => {
selectedNode.value = node
}
const handleUpdateNodeContent = (nodeId: string, content: any) => {
// TreeView
treeViewRef.value?.updateNodeContent(nodeId, content)
}
</script>
<template>
<div class="app">
<!-- Top Ribbon Menu -->
<header class="app-header">
<RibbonMenu />
</header>
<!-- Main Content Area -->
<main class="app-main">
<!-- Left Sidebar - Tree View -->
<aside class="app-sidebar">
<TreeView ref="treeViewRef" @select="handleNodeSelect" />
</aside>
<!-- Right Content Panel -->
<section class="app-content">
<ContentPanel
:selected-node="selectedNode"
@update:node-content="handleUpdateNodeContent"
/>
</section>
</main>
</div>
</template>
<style>
/* ============================================
SystemWeaver Style - Requirements Manager
Traditional Engineering Software UI
============================================ */
/* CSS Variables */
:root {
/* Colors - Cool Gray Palette */
--color-bg-primary: #f0f0f0;
--color-bg-secondary: #e8e8e8;
--color-bg-tertiary: #e0e0e0;
--color-bg-hover: #d0d0d0;
--color-bg-selected: #cde8ff;
--color-bg-active: #b8d4f0;
--color-border: #a0a0a0;
--color-border-light: #c0c0c0;
--color-border-dark: #808080;
--color-text-primary: #333333;
--color-text-secondary: #666666;
--color-text-muted: #999999;
--color-text-inverse: #ffffff;
--color-accent: #0078d4;
--color-accent-hover: #106ebe;
/* Status Colors */
--color-status-work: #ffc107;
--color-status-released: #28a745;
--color-status-accept: #28a745;
--color-status-reject: #dc3545;
--color-status-pending: #ffc107;
--color-status-not-handled: #6c757d;
--color-status-cs-released: #17a2b8;
/* Spacing */
--spacing-xs: 2px;
--spacing-sm: 4px;
--spacing-md: 8px;
--spacing-lg: 12px;
--spacing-xl: 16px;
/* Font */
--font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
--font-size-xs: 10px;
--font-size-sm: 11px;
--font-size-md: 12px;
--font-size-lg: 13px;
/* Borders */
--border-width: 1px;
--border-radius: 2px;
}
/* Reset & Base */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: var(--font-family);
font-size: var(--font-size-md);
color: var(--color-text-primary);
background-color: var(--color-bg-primary);
overflow: hidden;
}
/* ============================================
App Layout
============================================ */
.app {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
}
.app-header {
flex-shrink: 0;
background-color: var(--color-bg-secondary);
border-bottom: var(--border-width) solid var(--color-border);
}
.app-main {
display: flex;
flex: 1;
overflow: hidden;
}
.app-sidebar {
width: 25%;
min-width: 280px;
max-width: 400px;
background-color: var(--color-bg-primary);
border-right: var(--border-width) solid var(--color-border);
overflow: hidden;
display: flex;
flex-direction: column;
}
.app-content {
flex: 1;
background-color: var(--color-bg-primary);
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ============================================
Ribbon Menu
============================================ */
.ribbon-menu {
display: flex;
flex-direction: column;
}
/* Menu Bar */
.menu-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(to bottom, #f5f5f5, #e0e0e0);
border-bottom: var(--border-width) solid var(--color-border-light);
padding: 0 var(--spacing-sm);
}
.menu-items {
display: flex;
}
.menu-item {
padding: var(--spacing-sm) var(--spacing-lg);
font-size: var(--font-size-md);
font-weight: 500;
color: var(--color-text-primary);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.menu-item:hover {
background-color: var(--color-bg-hover);
}
.menu-item.active {
background: linear-gradient(to bottom, #e8f4fc, #d0e8f8);
border-bottom: 2px solid var(--color-accent);
color: var(--color-accent);
}
.menu-actions {
display: flex;
gap: var(--spacing-sm);
}
.menu-action-btn {
padding: var(--spacing-sm);
background: transparent;
border: none;
cursor: pointer;
color: var(--color-text-secondary);
}
.menu-action-btn:hover {
color: var(--color-text-primary);
background-color: var(--color-bg-hover);
}
/* Toolbar */
.toolbar {
display: flex;
padding: var(--spacing-sm);
background: linear-gradient(to bottom, #f8f8f8, #e8e8e8);
border-bottom: var(--border-width) solid var(--color-border-light);
gap: var(--spacing-xs);
overflow-x: auto;
}
.toolbar-group {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 var(--spacing-sm);
border-right: var(--border-width) solid var(--color-border-light);
}
.toolbar-group:last-child {
border-right: none;
}
.toolbar-buttons {
display: flex;
gap: var(--spacing-xs);
}
.toolbar-btn {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--spacing-sm);
min-width: 48px;
background: linear-gradient(to bottom, #ffffff, #e8e8e8);
border: var(--border-width) solid var(--color-border-light);
border-radius: var(--border-radius);
cursor: pointer;
transition: all 0.15s ease;
}
.toolbar-btn:hover {
background: linear-gradient(to bottom, #f0f8ff, #d0e8f8);
border-color: var(--color-accent);
}
.toolbar-btn:active {
background: linear-gradient(to bottom, #d0e8f8, #b8d4f0);
}
.toolbar-btn-icon {
color: var(--color-text-primary);
margin-bottom: 2px;
}
.toolbar-btn-label {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
text-align: center;
white-space: nowrap;
}
.toolbar-group-title {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
margin-top: var(--spacing-xs);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Context Bar */
.context-bar {
display: flex;
flex-direction: column;
background-color: var(--color-bg-secondary);
border-bottom: var(--border-width) solid var(--color-border);
}
.context-path {
display: flex;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
gap: var(--spacing-sm);
border-bottom: var(--border-width) solid var(--color-border-light);
}
.context-label {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-weight: 500;
}
.context-value {
font-size: var(--font-size-sm);
color: var(--color-text-primary);
}
.context-tabs {
display: flex;
padding: var(--spacing-xs) var(--spacing-md);
gap: var(--spacing-xs);
}
.context-tab {
padding: var(--spacing-xs) var(--spacing-md);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
}
.context-tab:hover {
color: var(--color-text-primary);
background-color: var(--color-bg-hover);
}
.context-tab.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
background-color: var(--color-bg-selected);
}
/* ============================================
Tree View
============================================ */
.tree-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.tree-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-md);
background: linear-gradient(to bottom, #f5f5f5, #e8e8e8);
border-bottom: var(--border-width) solid var(--color-border);
}
.tree-header-title {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--font-size-md);
font-weight: 600;
color: var(--color-text-primary);
}
.tree-header-icon {
color: var(--color-accent);
}
.tree-header-actions {
display: flex;
gap: var(--spacing-xs);
}
.tree-header-btn {
padding: var(--spacing-xs);
background: transparent;
border: var(--border-width) solid var(--color-border-light);
border-radius: var(--border-radius);
cursor: pointer;
color: var(--color-text-secondary);
}
.tree-header-btn:hover {
background-color: var(--color-bg-hover);
color: var(--color-text-primary);
}
.tree-content {
flex: 1;
overflow: auto;
}
.tree-column-headers {
display: flex;
padding: var(--spacing-xs) var(--spacing-sm);
background-color: var(--color-bg-secondary);
border-bottom: var(--border-width) solid var(--color-border);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-secondary);
position: sticky;
top: 0;
z-index: 10;
}
.tree-col-name {
flex: 1;
min-width: 150px;
}
.tree-col-status,
.tree-col-version,
.tree-col-next,
.tree-col-pss {
width: 70px;
text-align: center;
}
.tree-nodes {
padding: var(--spacing-xs) 0;
}
.tree-node-wrapper {
display: flex;
flex-direction: column;
}
.tree-node {
display: flex;
align-items: center;
padding: 2px var(--spacing-sm);
cursor: pointer;
transition: all 0.1s ease;
border-bottom: var(--border-width) solid transparent;
}
.tree-node:hover {
background-color: var(--color-bg-hover);
}
.tree-node.selected {
background-color: var(--color-bg-selected);
border-bottom-color: var(--color-accent);
}
.tree-node-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin-right: 2px;
cursor: pointer;
color: var(--color-text-secondary);
}
.tree-node-spacer {
width: 14px;
}
.tree-node-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-right: var(--spacing-xs);
}
.icon-folder,
.icon-folder-open {
color: #daa520;
}
.icon-requirement {
color: var(--color-accent);
}
.icon-subsystem {
color: #6b8e23;
}
.icon-component {
color: #cd853f;
}
.tree-node-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--font-size-md);
color: var(--color-text-primary);
}
.tree-children {
display: flex;
flex-direction: column;
}
/* Status styling for tree nodes */
.status-work {
color: var(--color-status-work);
}
.status-released,
.status-cs-released {
color: var(--color-status-released);
}
/* ============================================
Content Panel
============================================ */
.content-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-md);
background: linear-gradient(to bottom, #f5f5f5, #e8e8e8);
border-bottom: var(--border-width) solid var(--color-border);
}
.panel-header-left {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.panel-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text-primary);
}
.panel-divider {
color: var(--color-border);
}
.panel-subtitle {
font-size: var(--font-size-md);
color: var(--color-text-secondary);
}
.panel-header-right {
display: flex;
gap: var(--spacing-xs);
}
.panel-header-btn {
padding: var(--spacing-xs);
background: transparent;
border: var(--border-width) solid var(--color-border-light);
border-radius: var(--border-radius);
cursor: pointer;
color: var(--color-text-secondary);
}
.panel-header-btn:hover {
background-color: var(--color-bg-hover);
color: var(--color-text-primary);
}
/* Search Bar */
.panel-search-bar {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--color-bg-secondary);
border-bottom: var(--border-width) solid var(--color-border-light);
}
.search-input-wrapper {
flex: 1;
display: flex;
align-items: center;
background-color: #ffffff;
border: var(--border-width) solid var(--color-border-light);
border-radius: var(--border-radius);
padding: var(--spacing-xs) var(--spacing-sm);
}
.search-icon {
color: var(--color-text-muted);
margin-right: var(--spacing-sm);
}
.search-input {
flex: 1;
border: none;
outline: none;
font-size: var(--font-size-md);
color: var(--color-text-primary);
background: transparent;
}
.search-input::placeholder {
color: var(--color-text-muted);
}
.search-btn {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-md);
background: linear-gradient(to bottom, #ffffff, #e8e8e8);
border: var(--border-width) solid var(--color-border-light);
border-radius: var(--border-radius);
cursor: pointer;
font-size: var(--font-size-sm);
color: var(--color-text-primary);
}
.search-btn:hover {
background: linear-gradient(to bottom, #f0f8ff, #d0e8f8);
border-color: var(--color-accent);
}
/* Table */
.panel-table-container {
flex: 1;
overflow: auto;
background-color: #ffffff;
}
.panel-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-md);
}
.panel-table thead {
position: sticky;
top: 0;
z-index: 10;
}
.panel-table th {
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
font-weight: 600;
color: var(--color-text-secondary);
background: linear-gradient(to bottom, #f5f5f5, #e8e8e8);
border-bottom: var(--border-width) solid var(--color-border);
white-space: nowrap;
}
.panel-table td {
padding: var(--spacing-xs) var(--spacing-md);
border-bottom: var(--border-width) solid var(--color-border-light);
white-space: nowrap;
}
.panel-table tbody tr {
cursor: pointer;
transition: background-color 0.1s ease;
}
.panel-table tbody tr:hover {
background-color: var(--color-bg-hover);
}
.panel-table tbody tr.selected {
background-color: var(--color-bg-selected);
}
.col-checkbox {
width: 30px;
text-align: center;
}
.col-name {
min-width: 250px;
}
.col-req-id {
width: 80px;
}
.col-handshake {
width: 100px;
}
.col-status {
width: 90px;
}
.col-version {
width: 70px;
}
.col-last-change {
width: 80px;
}
.name-cell {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.row-icon {
color: var(--color-text-muted);
}
.row-label {
color: var(--color-text-primary);
}
.checkbox-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
cursor: pointer;
color: var(--color-text-secondary);
}
.checkbox-btn:hover {
color: var(--color-accent);
}
/* Status Badges */
.status-badge {
display: inline-block;
padding: 1px 6px;
font-size: var(--font-size-sm);
font-weight: 500;
border-radius: var(--border-radius);
text-align: center;
}
.status-accept {
background-color: #d4edda;
color: #155724;
border: var(--border-width) solid #c3e6cb;
}
.status-reject {
background-color: #f8d7da;
color: #721c24;
border: var(--border-width) solid #f5c6cb;
}
.status-not-handled {
background-color: #e2e3e5;
color: #383d41;
border: var(--border-width) solid #d6d8db;
}
.status-cs-released {
background-color: #d1ecf1;
color: #0c5460;
border: var(--border-width) solid #bee5eb;
}
.status-work {
background-color: #fff3cd;
color: #856404;
border: var(--border-width) solid #ffeeba;
}
/* Context Menu Placeholder */
.context-menu-placeholder {
position: absolute;
top: 50%;
right: 20%;
z-index: 100;
pointer-events: none;
}
.context-menu {
background-color: #ffffff;
border: var(--border-width) solid var(--color-border);
border-radius: var(--border-radius);
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.15);
min-width: 140px;
padding: var(--spacing-xs) 0;
}
.context-menu-item {
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
font-size: var(--font-size-md);
color: var(--color-text-primary);
}
.context-menu-item:hover {
background-color: var(--color-bg-selected);
}
/* Bottom Panel */
.bottom-panel {
border-top: var(--border-width) solid var(--color-border);
background-color: var(--color-bg-secondary);
max-height: 150px;
}
.bottom-panel-header {
padding: var(--spacing-xs) var(--spacing-md);
background: linear-gradient(to bottom, #f0f0f0, #e0e0e0);
border-bottom: var(--border-width) solid var(--color-border-light);
}
.bottom-panel-title {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-secondary);
}
.bottom-panel-content {
overflow: auto;
max-height: 120px;
}
.bottom-panel-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
}
.bottom-panel-table th {
padding: var(--spacing-xs) var(--spacing-md);
text-align: left;
font-weight: 600;
color: var(--color-text-secondary);
background-color: var(--color-bg-tertiary);
border-bottom: var(--border-width) solid var(--color-border-light);
}
.bottom-panel-table td {
padding: var(--spacing-xs) var(--spacing-md);
border-bottom: var(--border-width) solid var(--color-border-light);
}
/* Status Bar */
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-xs) var(--spacing-md);
background: linear-gradient(to bottom, #e8e8e8, #d0d0d0);
border-top: var(--border-width) solid var(--color-border);
}
.status-bar-left {
display: flex;
gap: var(--spacing-lg);
}
.status-item {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 2px;
}
.status-indicator.not-handled {
background-color: var(--color-status-not-handled);
}
.status-indicator.version-mismatch {
background-color: #ffc107;
}
.status-indicator.reject {
background-color: var(--color-status-reject);
}
.status-indicator.accept {
background-color: var(--color-status-accept);
}
.status-bar-btn {
padding: var(--spacing-xs) var(--spacing-md);
font-size: var(--font-size-sm);
color: var(--color-text-primary);
background: linear-gradient(to bottom, #ffffff, #e8e8e8);
border: var(--border-width) solid var(--color-border-light);
border-radius: var(--border-radius);
cursor: pointer;
}
.status-bar-btn:hover {
background: linear-gradient(to bottom, #f0f8ff, #d0e8f8);
border-color: var(--color-accent);
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-border-dark);
}
/* Selection */
::selection {
background-color: var(--color-bg-selected);
color: var(--color-text-primary);
}
</style>

View File

@ -0,0 +1,875 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import {
Settings,
HelpCircle,
FileText,
Edit3,
Shield,
History,
Eye,
GitCompare,
Type,
Image as ImageIcon,
Table as TableIcon,
Video as VideoIcon,
RefreshCw
} from 'lucide-vue-next'
import RichTextEditor from './RichTextEditor.vue'
import VersionDiffViewer from './VersionDiffViewer.vue'
import HorizontalTable from './HorizontalTable.vue'
import { clearTreeDataFromStorage } from '../utils/treeStorage'
import type { TreeNode } from './TreeView.vue'
const props = defineProps<{
selectedNode?: TreeNode | null
}>()
const emit = defineEmits<{
(e: 'update:nodeContent', nodeId: string, content: any): void
}>()
// RichTextEditor ref
const richTextEditorRef = ref<InstanceType<typeof RichTextEditor> | null>(null)
//
const isTerminalNode = computed(() => {
return props.selectedNode?.isTerminal === true
})
//
const viewMode = ref<'metadata' | 'content' | 'history' | 'diff'>('metadata')
//
const selectedVersions = ref<[string, string]>(['', ''])
const showVersionSelector = ref(false)
//
const nodeMetadata = computed(() => {
if (!props.selectedNode) return []
return [
{ field: '名称', value: props.selectedNode.label },
{ field: '创建日期', value: props.selectedNode.createDate || '-' },
{ field: '访问权限', value: props.selectedNode.access || '-' },
{ field: '所有者', value: props.selectedNode.owner || '-' },
{ field: '最后修改人', value: props.selectedNode.lastChangedBy || '-' },
{ field: '最后修改日期', value: props.selectedNode.lastChangedDate || '-' },
{ field: '版本', value: props.selectedNode.version || '-' },
{ field: '状态', value: props.selectedNode.status || '-' }
]
})
//
const childNodes = computed(() => {
if (!props.selectedNode?.children) return []
return props.selectedNode.children
})
//
const versionHistory = computed(() => {
return props.selectedNode?.versions || []
})
//
const switchView = (mode: 'metadata' | 'content' | 'history' | 'diff') => {
viewMode.value = mode
}
//
const openVersionDiff = () => {
if (versionHistory.value.length >= 2) {
selectedVersions.value = [
versionHistory.value[versionHistory.value.length - 2].version,
versionHistory.value[versionHistory.value.length - 1].version
]
showVersionSelector.value = true
}
}
//
const confirmVersionDiff = () => {
showVersionSelector.value = false
viewMode.value = 'diff'
}
//
const viewVersion = (version: string) => {
//
console.log('查看版本:', version)
}
//
const compareWithNext = (currentIndex: number) => {
if (currentIndex < versionHistory.value.length - 1) {
selectedVersions.value = [
versionHistory.value[currentIndex].version,
versionHistory.value[currentIndex + 1].version
]
viewMode.value = 'diff'
}
}
//
const getStatusClass = (status?: string) => {
switch (status) {
case 'work': return 'status-work'
case 'released': return 'status-released'
case 'approved': return 'status-approved'
case 'rejected': return 'status-rejected'
case 'CS Released': return 'status-cs-released'
default: return ''
}
}
//
const resetData = () => {
if (confirm('确定要重置数据吗?这将清除所有更改并恢复到初始状态。')) {
clearTreeDataFromStorage()
window.location.reload()
}
}
</script>
<template>
<div class="content-panel">
<!-- Panel Header -->
<div class="panel-header">
<div class="panel-header-left">
<span class="panel-title">{{ isTerminalNode ? '末端节点详情' : '节点元数据' }}</span>
<span class="panel-divider">|</span>
<span class="panel-subtitle">{{ selectedNode?.label || '请选择一个节点' }}</span>
</div>
<div class="panel-header-right">
<template v-if="isTerminalNode">
<button
:class="['panel-header-btn', { active: viewMode === 'metadata' }]"
@click="switchView('metadata')"
title="元数据"
>
<FileText :size="14" />
</button>
<button
:class="['panel-header-btn', { active: viewMode === 'content' }]"
@click="switchView('content')"
title="内容编辑"
>
<Edit3 :size="14" />
</button>
<button
:class="['panel-header-btn', { active: viewMode === 'history' }]"
@click="switchView('history')"
title="版本历史"
>
<History :size="14" />
</button>
<button
:class="['panel-header-btn', { active: viewMode === 'diff' }]"
@click="openVersionDiff"
title="版本对比"
>
<GitCompare :size="14" />
</button>
</template>
<button class="panel-header-btn" title="Settings">
<Settings :size="14" />
</button>
<button class="panel-header-btn" title="重置数据" @click="resetData">
<RefreshCw :size="14" />
</button>
<button class="panel-header-btn" title="Help">
<HelpCircle :size="14" />
</button>
</div>
</div>
<!-- 非末端节点元数据表格 -->
<div v-if="!isTerminalNode && viewMode === 'metadata'" class="panel-content">
<!-- 当前节点元数据 -->
<HorizontalTable
:items="nodeMetadata"
title="当前节点信息"
:icon="Shield"
/>
<!-- 子节点列表 -->
<div class="metadata-section" v-if="childNodes.length > 0">
<h3 class="section-title">
<FileText :size="16" />
子节点列表 ({{ childNodes.length }})
</h3>
<table class="children-table">
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>所有者</th>
<th>最后修改</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="child in childNodes" :key="child.id">
<td>
<span :class="['node-type-icon', child.isTerminal ? 'terminal' : 'branch']">
{{ child.isTerminal ? '📄' : '📁' }}
</span>
{{ child.label }}
</td>
<td>{{ child.isTerminal ? '末端节点' : '分支节点' }}</td>
<td>{{ child.owner || '-' }}</td>
<td>{{ child.lastChangedDate || '-' }}</td>
<td>
<span :class="['status-badge', getStatusClass(child.status)]">
{{ child.status || 'work' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 末端节点元数据视图 -->
<div v-else-if="isTerminalNode && viewMode === 'metadata'" class="panel-content">
<HorizontalTable
:items="nodeMetadata"
title="节点元数据"
:icon="Shield"
/>
<!-- 内容预览 -->
<div class="metadata-section content-preview-section" v-if="selectedNode?.content">
<h3 class="section-title">
<Eye :size="16" />
内容预览
</h3>
<div class="content-preview">
<RichTextEditor
:initial-blocks="selectedNode?.content"
:read-only="true"
/>
</div>
</div>
</div>
<!-- 末端节点富文本编辑器 -->
<div v-else-if="isTerminalNode && viewMode === 'content'" class="panel-content editor-view">
<div class="editor-header">
<div class="header-left">
<h3 class="section-title">
<Edit3 :size="16" />
<span>内容编辑</span>
</h3>
<div class="editor-toolbar">
<button class="toolbar-btn" @click="richTextEditorRef?.addTextBlock()">
<Type :size="14" />
<span>Text</span>
</button>
<button class="toolbar-btn" @click="richTextEditorRef?.addImageBlock()">
<ImageIcon :size="14" />
<span>Image</span>
</button>
<button class="toolbar-btn" @click="richTextEditorRef?.addTableBlock()">
<TableIcon :size="14" />
<span>Table</span>
</button>
<button class="toolbar-btn" @click="richTextEditorRef?.addVideoBlock()">
<VideoIcon :size="14" />
<span>Video</span>
</button>
</div>
</div>
<span class="version-info">当前版本: {{ selectedNode?.version || '(1)' }}</span>
</div>
<div class="editor-container">
<RichTextEditor
ref="richTextEditorRef"
:initial-blocks="selectedNode?.content"
:read-only="false"
@update:content="(content) => emit('update:nodeContent', selectedNode?.id || '', content)"
/>
</div>
</div>
<!-- 末端节点版本历史 -->
<div v-else-if="isTerminalNode && viewMode === 'history'" class="panel-content">
<div class="history-header">
<h3 class="section-title">
<History :size="16" />
<span>版本历史</span>
</h3>
<span class="version-count"> {{ versionHistory.length }} 个版本</span>
</div>
<div class="history-list" v-if="versionHistory.length > 0">
<div
v-for="(ver, index) in versionHistory"
:key="ver.version"
class="history-item"
>
<div class="history-version">
<span class="version-tag">{{ ver.version }}</span>
<span v-if="index === versionHistory.length - 1" class="current-badge">当前</span>
</div>
<div class="history-info">
<div class="history-meta">
<span class="history-date">{{ ver.date }}</span>
<span class="history-author">{{ ver.author }}</span>
</div>
<div class="history-actions">
<button class="view-btn" @click="viewVersion(ver.version)">
<Eye :size="12" />
查看
</button>
<button
v-if="index < versionHistory.length - 1"
class="compare-btn"
@click="compareWithNext(index)"
>
<GitCompare :size="12" />
对比
</button>
</div>
</div>
</div>
</div>
<div v-else class="empty-history">
暂无版本历史
</div>
</div>
<!-- 末端节点版本差异对比 -->
<div v-else-if="isTerminalNode && viewMode === 'diff'" class="panel-content diff-view">
<VersionDiffViewer
:versions="versionHistory"
:version-a="selectedVersions[0]"
:version-b="selectedVersions[1]"
@close="switchView('content')"
/>
</div>
<!-- 版本选择对话框 -->
<div v-if="showVersionSelector" class="version-selector-modal">
<div class="modal-overlay" @click="showVersionSelector = false"></div>
<div class="modal-content">
<h3>选择要对比的版本</h3>
<div class="version-selectors">
<div class="version-select-group">
<label>旧版本:</label>
<select v-model="selectedVersions[0]">
<option v-for="ver in versionHistory" :key="ver.version" :value="ver.version">
{{ ver.version }} - {{ ver.date }} by {{ ver.author }}
</option>
</select>
</div>
<div class="version-select-group">
<label>新版本:</label>
<select v-model="selectedVersions[1]">
<option v-for="ver in versionHistory" :key="ver.version" :value="ver.version">
{{ ver.version }} - {{ ver.date }} by {{ ver.author }}
</option>
</select>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showVersionSelector = false">取消</button>
<button class="btn btn-primary" @click="confirmVersionDiff">对比</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.content-panel {
flex: 1;
display: flex;
flex-direction: column;
background: white;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: linear-gradient(to bottom, #f8f9fa, #e9ecef);
border-bottom: 1px solid #d0d0d0;
}
.panel-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.panel-title {
font-weight: 600;
color: #333;
}
.panel-divider {
color: #999;
}
.panel-subtitle {
color: #666;
}
.panel-header-right {
display: flex;
align-items: center;
gap: 4px;
}
.panel-header-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
background: transparent;
cursor: pointer;
border-radius: 3px;
color: #666;
}
.panel-header-btn:hover {
background: #f0f0f0;
border-color: #ccc;
}
.panel-header-btn.active {
background: #0078d4;
color: white;
border-color: #0078d4;
}
.panel-content {
flex: 1;
overflow: auto;
padding: 16px;
}
.children-table,
.version-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.children-table th,
.children-table td,
.version-table th,
.version-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.children-table th,
.version-table th {
background: #f5f5f5;
font-weight: 600;
color: #333;
}
.node-type-icon {
margin-right: 6px;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
}
.status-work {
background: #fff3cd;
color: #856404;
}
.status-released {
background: #d4edda;
color: #155724;
}
.status-approved {
background: #cce5ff;
color: #004085;
}
.status-rejected {
background: #f8d7da;
color: #721c24;
}
.status-cs-released {
background: #e2e3e5;
color: #383d41;
}
.version-tag {
font-family: monospace;
font-weight: 600;
color: #0078d4;
}
.view-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: 1px solid #ddd;
background: white;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
color: #666;
}
.view-btn:hover {
background: #f0f0f0;
border-color: #ccc;
}
.editor-view {
display: flex;
flex-direction: column;
padding: 0;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.section-title {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: 14px;
font-weight: 600;
color: #333;
white-space: nowrap;
}
.section-title span {
display: inline-flex;
align-items: center;
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 6px;
}
.toolbar-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: linear-gradient(to bottom, #ffffff, #f0f0f0);
border: 1px solid #c0c0c0;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
color: #333;
transition: all 0.2s;
}
.toolbar-btn:hover {
background: linear-gradient(to bottom, #f0f8ff, #d0e8f8);
border-color: #0078d4;
}
.version-info {
font-size: 12px;
color: #666;
background: #e9ecef;
padding: 4px 8px;
border-radius: 3px;
white-space: nowrap;
}
.editor-container {
flex: 1;
overflow: auto;
padding: 16px;
}
.diff-view {
padding: 0;
}
/* 内容预览 */
.content-preview-section {
margin-top: 16px;
}
.content-preview {
background: #fafafa;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 12px;
max-height: 400px;
overflow: auto;
}
/* 版本历史 */
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
}
.version-count {
font-size: 12px;
color: #666;
background: #e9ecef;
padding: 4px 8px;
border-radius: 3px;
}
.history-list {
padding: 16px;
}
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 8px;
background: white;
transition: all 0.2s;
}
.history-item:hover {
border-color: #0078d4;
box-shadow: 0 2px 4px rgba(0, 120, 212, 0.1);
}
.history-version {
display: flex;
align-items: center;
gap: 8px;
}
.current-badge {
font-size: 10px;
color: #0078d4;
background: #cde8ff;
padding: 2px 6px;
border-radius: 3px;
font-weight: 600;
}
.history-info {
display: flex;
align-items: center;
gap: 16px;
}
.history-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: #666;
}
.history-date {
font-family: monospace;
}
.history-actions {
display: flex;
gap: 8px;
}
.view-btn,
.compare-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: 1px solid #ddd;
background: white;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
color: #666;
transition: all 0.2s;
}
.view-btn:hover,
.compare-btn:hover {
background: #f0f0f0;
border-color: #ccc;
}
.compare-btn {
color: #0078d4;
border-color: #0078d4;
}
.compare-btn:hover {
background: #0078d4;
color: white;
}
.empty-history {
padding: 48px;
text-align: center;
color: #999;
font-size: 14px;
}
/* 版本选择对话框 */
.version-selector-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
min-width: 400px;
z-index: 1001;
}
.modal-content h3 {
margin: 0 0 20px 0;
font-size: 16px;
color: #333;
}
.version-selectors {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.version-select-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.version-select-group label {
font-size: 13px;
font-weight: 500;
color: #666;
}
.version-select-group select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
background: white;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.btn {
padding: 8px 16px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
border: 1px solid transparent;
}
.btn-primary {
background: #0078d4;
color: white;
border-color: #0078d4;
}
.btn-primary:hover {
background: #106ebe;
}
.btn-secondary {
background: white;
color: #666;
border-color: #ddd;
}
.btn-secondary:hover {
background: #f5f5f5;
}
/* 开发者设置对话框 */
.feature-toggles {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.feature-toggle {
display: flex;
align-items: center;
}
.feature-toggle label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
cursor: pointer;
}
.feature-toggle input[type="checkbox"] {
width: 16px;
height: 16px;
}
</style>

View File

@ -0,0 +1,133 @@
<script setup lang="ts">
import { computed } from 'vue'
interface TableItem {
field: string
value: string
}
const props = defineProps<{
items: TableItem[]
title?: string
icon?: any
}>()
//
const horizontalData = computed(() => {
if (props.items.length === 0) return []
//
const headerRow = props.items.map(item => item.field)
//
const dataRow = props.items.map(item => item.value)
return [
{ type: 'header', cells: headerRow },
{ type: 'data', cells: dataRow }
]
})
</script>
<template>
<div class="horizontal-table-container">
<div v-if="title" class="table-title">
<component v-if="icon" :is="icon" :size="16" />
{{ title }}
</div>
<div class="horizontal-table-wrapper">
<table class="horizontal-table">
<tbody>
<tr v-for="(row, index) in horizontalData" :key="index" :class="row.type">
<td v-for="(cell, cellIndex) in row.cells" :key="cellIndex" class="table-cell">
<span v-if="row.type === 'header'" class="cell-header">{{ cell }}</span>
<span v-else class="cell-value">{{ cell }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.horizontal-table-container {
margin-bottom: 24px;
}
.table-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e0e0e0;
}
.horizontal-table-wrapper {
overflow-x: auto;
background: #fafafa;
border-radius: 4px;
border: 1px solid #e0e0e0;
}
.horizontal-table {
border-collapse: collapse;
min-width: 100%;
font-size: 13px;
}
.horizontal-table tr.header {
background: #f0f0f0;
}
.horizontal-table tr.data {
background: #ffffff;
}
.table-cell {
padding: 10px 16px;
border-right: 1px solid #e0e0e0;
border-bottom: 1px solid #e0e0e0;
white-space: nowrap;
min-width: 120px;
}
.table-cell:last-child {
border-right: none;
}
.cell-header {
font-weight: 600;
color: #666;
display: block;
}
.cell-value {
color: #333;
display: block;
font-weight: 500;
}
/* 滚动条样式 */
.horizontal-table-wrapper::-webkit-scrollbar {
height: 8px;
}
.horizontal-table-wrapper::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.horizontal-table-wrapper::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.horizontal-table-wrapper::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>

View File

@ -0,0 +1,239 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ImageIcon, X } from 'lucide-vue-next'
interface ImageItem {
id: string
url: string
name: string
}
const props = defineProps<{
images: ImageItem[]
maxDisplay?: number
}>()
const emit = defineEmits<{
(e: 'remove', id: string): void
(e: 'view', index: number): void
(e: 'viewAll'): void
}>()
const displayImages = computed(() => {
const max = props.maxDisplay || 9
return props.images.slice(0, max)
})
const remainingCount = computed(() => {
const max = props.maxDisplay || 9
return Math.max(0, props.images.length - max)
})
//
const gridStyle = computed(() => {
//
return {
display: 'grid',
gap: '8px',
width: '100%'
}
})
//
const gridColumns = computed(() => {
const count = displayImages.value.length
if (count === 1) return '1fr'
if (count === 2 || count === 4) return 'repeat(2, 1fr)'
return 'repeat(3, 1fr)'
})
//
const getItemStyle = (_index: number) => {
const count = displayImages.value.length
// 1 -
if (count === 1) {
return {
aspectRatio: '16/9',
gridColumn: '1 / -1'
}
}
// 2 -
if (count === 2) {
return {
aspectRatio: '16/9'
}
}
// 3 -
if (count === 3) {
return {
aspectRatio: '4/3'
}
}
// 4 - 2x2
if (count === 4) {
return {
aspectRatio: '16/9'
}
}
// 5-6 - 34:3
if (count === 5 || count === 6) {
return {
aspectRatio: '4/3'
}
}
// 7 -
return {
aspectRatio: '1/1'
}
}
const handleView = (index: number) => {
emit('view', index)
}
const handleViewAll = () => {
emit('viewAll')
}
const handleRemove = (id: string, event: Event) => {
event.stopPropagation()
emit('remove', id)
}
</script>
<template>
<div class="image-grid-container">
<div class="image-grid" :style="{...gridStyle, gridTemplateColumns: gridColumns}">
<div
v-for="(image, index) in displayImages"
:key="image.id"
class="image-item"
:style="getItemStyle(index)"
@click="handleView(index)"
>
<img :src="image.url" :alt="image.name" class="image-img" />
<button
class="image-remove-btn"
@click="handleRemove(image.id, $event)"
>
<X :size="14" />
</button>
<!-- 超出数量显示 -->
<div v-if="index === displayImages.length - 1 && remainingCount > 0" class="image-overlay">
<span class="remaining-count">+{{ remainingCount }}</span>
</div>
</div>
</div>
<!-- 查看全部按钮 -->
<button
v-if="images.length > (maxDisplay || 9)"
class="view-all-btn"
@click="handleViewAll"
>
<ImageIcon :size="16" />
<span>查看全部 ({{ images.length }})</span>
</button>
</div>
</template>
<style scoped>
.image-grid-container {
margin: 4px 0;
width: 100%;
}
.image-grid {
width: 100%;
}
.image-item {
position: relative;
cursor: pointer;
overflow: hidden;
border-radius: 2px;
background-color: #f0f0f0;
width: 100%;
}
.image-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
display: block;
}
.image-item:hover .image-img {
transform: scale(1.05);
}
.image-remove-btn {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
z-index: 10;
}
.image-item:hover .image-remove-btn {
opacity: 1;
}
.image-remove-btn:hover {
background: rgba(0, 0, 0, 0.8);
}
.image-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
}
.remaining-count {
color: white;
font-size: 20px;
font-weight: 600;
}
.view-all-btn {
margin-top: 8px;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #f0f0f0;
border: 1px solid #d0d0d0;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
color: #666;
transition: all 0.2s;
}
.view-all-btn:hover {
background: #e0e0e0;
border-color: #b0b0b0;
}
</style>

View File

@ -0,0 +1,182 @@
<script setup lang="ts">
import { ref } from 'vue'
import {
ChevronLeft,
ChevronRight,
FolderOpen,
Plus,
Copy,
Search,
Filter,
RefreshCw,
Settings,
HelpCircle,
Save,
Eye,
Edit3,
Trash2,
CheckSquare,
FileText,
Layers,
GitBranch,
Bell,
Zap,
BarChart3,
Users,
Link2,
Paperclip,
MessageSquare
} from 'lucide-vue-next'
interface MenuItem {
label: string
active?: boolean
}
interface ToolbarButton {
icon: any
label: string
size?: 'small' | 'medium' | 'large'
}
interface ToolbarGroup {
title: string
buttons: ToolbarButton[]
}
const menuItems: MenuItem[] = [
{ label: 'File' },
{ label: 'Welcome', active: true },
{ label: 'Dashboard' },
{ label: 'Items' },
{ label: 'Projects' },
{ label: 'Shortcuts' },
]
const toolbarGroups: ToolbarGroup[] = [
{
title: 'Navigation',
buttons: [
{ icon: ChevronLeft, label: 'Back' },
{ icon: ChevronRight, label: 'Forward' },
{ icon: FolderOpen, label: 'Open' },
{ icon: Plus, label: 'New' },
]
},
{
title: 'Edit',
buttons: [
{ icon: Copy, label: 'Copy' },
{ icon: Edit3, label: 'Edit' },
{ icon: Trash2, label: 'Delete' },
{ icon: Save, label: 'Save' },
]
},
{
title: 'View',
buttons: [
{ icon: Search, label: 'Find' },
{ icon: Filter, label: 'Filter' },
{ icon: Eye, label: 'Preview' },
{ icon: RefreshCw, label: 'Refresh' },
]
},
{
title: 'Items',
buttons: [
{ icon: FileText, label: 'Attributes' },
{ icon: Layers, label: 'Parts' },
{ icon: GitBranch, label: 'Versions' },
{ icon: CheckSquare, label: 'Status' },
]
},
{
title: 'Tools',
buttons: [
{ icon: Zap, label: 'Quick' },
{ icon: BarChart3, label: 'Reports' },
{ icon: Users, label: 'Team' },
{ icon: Settings, label: 'Settings' },
]
},
{
title: 'Actions',
buttons: [
{ icon: Link2, label: 'Links' },
{ icon: Paperclip, label: 'Attach' },
{ icon: MessageSquare, label: 'Notes' },
{ icon: Bell, label: 'Alerts' },
]
}
]
const activeMenu = ref('Welcome')
const setActiveMenu = (label: string) => {
activeMenu.value = label
}
</script>
<template>
<div class="ribbon-menu">
<!-- Main Menu Bar -->
<div class="menu-bar">
<div class="menu-items">
<button
v-for="item in menuItems"
:key="item.label"
:class="['menu-item', { active: activeMenu === item.label }]"
@click="setActiveMenu(item.label)"
>
{{ item.label }}
</button>
</div>
<div class="menu-actions">
<button class="menu-action-btn" title="Help">
<HelpCircle :size="14" />
</button>
<button class="menu-action-btn" title="Settings">
<Settings :size="14" />
</button>
</div>
</div>
<!-- Toolbar -->
<div class="toolbar">
<div v-for="group in toolbarGroups" :key="group.title" class="toolbar-group">
<div class="toolbar-buttons">
<button
v-for="(btn, btnIndex) in group.buttons"
:key="btnIndex"
class="toolbar-btn"
:title="btn.label"
>
<span class="toolbar-btn-icon">
<component :is="btn.icon" :size="16" />
</span>
<span class="toolbar-btn-label">{{ btn.label }}</span>
</button>
</div>
<span class="toolbar-group-title">{{ group.title }}</span>
</div>
</div>
<!-- Context Bar -->
<div class="context-bar">
<div class="context-path">
<span class="context-label">Navigation:</span>
<span class="context-value">AMA(1) &gt; Dev database tree(1) &gt; Open Attribute requirement contain...</span>
</div>
<div class="context-tabs">
<button class="context-tab active">Overview</button>
<button class="context-tab">Edit</button>
<button class="context-tab">Find</button>
<button class="context-tab">CM</button>
<button class="context-tab">Issues and Notes</button>
<button class="context-tab">Security</button>
<button class="context-tab">RT</button>
<button class="context-tab">Extensions</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,247 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
const props = defineProps<{
modelValue: string
readOnly?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'keydown', event: KeyboardEvent): void
}>()
const contentRef = ref<HTMLDivElement | null>(null)
const isComposing = ref(false)
//
onMounted(() => {
if (contentRef.value && props.modelValue) {
contentRef.value.innerHTML = props.modelValue
}
})
//
const handleInput = () => {
if (contentRef.value && !isComposing.value) {
emit('update:modelValue', contentRef.value.innerHTML)
}
}
//
const handleCompositionStart = () => {
isComposing.value = true
}
const handleCompositionEnd = () => {
isComposing.value = false
handleInput()
}
//
const handleKeydown = (e: KeyboardEvent) => {
//
emit('keydown', e)
//
if (e.key === 'Enter' && !e.shiftKey && !e.defaultPrevented) {
//
//
}
}
//
const handleBlur = () => {
if (contentRef.value) {
emit('update:modelValue', contentRef.value.innerHTML)
}
}
//
watch(() => props.modelValue, (newVal) => {
if (contentRef.value && newVal !== contentRef.value.innerHTML) {
//
const isFocused = document.activeElement === contentRef.value
//
if (!isFocused) {
contentRef.value.innerHTML = newVal
}
}
})
</script>
<template>
<div
v-if="!readOnly"
ref="contentRef"
class="rich-text-block"
contenteditable="true"
@input="handleInput"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@keydown="handleKeydown"
@blur="handleBlur"
data-placeholder="输入内容..."
></div>
<div
v-else
class="rich-text-block read-only"
v-html="modelValue"
></div>
</template>
<style scoped>
.rich-text-block {
min-height: 100px;
padding: 12px;
border: 1px solid #c0c0c0;
border-radius: 4px;
font-size: 14px;
line-height: 1.6;
color: #333;
background-color: #ffffff;
outline: none;
text-align: left;
}
/* 编辑器整体对齐 */
.rich-text-block:has(> [style*="text-align: center"]),
.rich-text-block:has(> [align="center"]) {
text-align: center;
}
.rich-text-block:has(> [style*="text-align: right"]),
.rich-text-block:has(> [align="right"]) {
text-align: right;
}
.rich-text-block:focus {
border-color: #0078d4;
box-shadow: 0 0 0 2px rgba(0, 120, 212, 0.2);
}
.rich-text-block:empty:before {
content: attr(data-placeholder);
color: #999;
font-style: italic;
pointer-events: none;
}
/* 段落样式 */
.rich-text-block :deep(p) {
margin: 0 0 8px 0;
}
.rich-text-block :deep(p:last-child) {
margin-bottom: 0;
}
/* 列表样式 */
.rich-text-block :deep(ul) {
margin: 0 0 8px 0;
padding-left: 24px;
list-style-type: disc;
}
.rich-text-block :deep(ol) {
margin: 0 0 8px 0;
padding-left: 24px;
list-style-type: decimal;
}
.rich-text-block :deep(ul:last-child),
.rich-text-block :deep(ol:last-child) {
margin-bottom: 0;
}
.rich-text-block :deep(li) {
margin-bottom: 4px;
display: list-item;
}
/* 列表对齐样式 - 支持 style 属性和 align 属性 */
.rich-text-block :deep(ul[style*="text-align: center"]),
.rich-text-block :deep(ol[style*="text-align: center"]),
.rich-text-block :deep(ul[style*="center"]),
.rich-text-block :deep(ol[style*="center"]),
.rich-text-block :deep(ul[align="center"]),
.rich-text-block :deep(ol[align="center"]) {
text-align: center;
list-style-position: inside;
padding-left: 0;
}
.rich-text-block :deep(ul[style*="text-align: right"]),
.rich-text-block :deep(ol[style*="text-align: right"]),
.rich-text-block :deep(ul[style*="right"]),
.rich-text-block :deep(ol[style*="right"]),
.rich-text-block :deep(ul[align="right"]),
.rich-text-block :deep(ol[align="right"]) {
text-align: right;
list-style-position: inside;
padding-left: 0;
}
.rich-text-block :deep(ul[style*="text-align: left"]),
.rich-text-block :deep(ol[style*="text-align: left"]),
.rich-text-block :deep(ul[style*="left"]),
.rich-text-block :deep(ol[style*="left"]),
.rich-text-block :deep(ul[align="left"]),
.rich-text-block :deep(ol[align="left"]) {
text-align: left;
list-style-position: inside;
}
/* 也支持li级别的对齐 */
.rich-text-block :deep(li[style*="text-align: center"]),
.rich-text-block :deep(li[style*="center"]),
.rich-text-block :deep(li[align="center"]) {
text-align: center;
list-style-position: inside;
}
.rich-text-block :deep(li[style*="text-align: right"]),
.rich-text-block :deep(li[style*="right"]),
.rich-text-block :deep(li[align="right"]) {
text-align: right;
list-style-position: inside;
}
.rich-text-block :deep(li[style*="text-align: left"]),
.rich-text-block :deep(li[style*="left"]),
.rich-text-block :deep(li[align="left"]) {
text-align: left;
list-style-position: inside;
}
/* 内联样式 */
.rich-text-block :deep(b),
.rich-text-block :deep(strong) {
font-weight: 600;
}
.rich-text-block :deep(i),
.rich-text-block :deep(em) {
font-style: italic;
}
.rich-text-block :deep(u) {
text-decoration: underline;
}
.rich-text-block :deep(s),
.rich-text-block :deep(strike),
.rich-text-block :deep(del) {
text-decoration: line-through;
}
/* div 段落 */
.rich-text-block :deep(div) {
margin: 0 0 4px 0;
}
.rich-text-block :deep(div:last-child) {
margin-bottom: 0;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,385 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { X, Table2Icon } from 'lucide-vue-next'
interface TableConfig {
rows: number
cols: number
hasRowHeader: boolean
hasColHeader: boolean
rowHeaderTitle: string
colHeaderTitle: string
}
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'insert', config: TableConfig): void
}>()
const config = ref<TableConfig>({
rows: 3,
cols: 3,
hasRowHeader: true,
hasColHeader: true,
rowHeaderTitle: '',
colHeaderTitle: ''
})
const close = () => {
emit('update:modelValue', false)
}
const insert = () => {
emit('insert', { ...config.value })
close()
}
watch(() => props.modelValue, (newVal) => {
if (newVal) {
// Reset to defaults when opening
config.value = {
rows: 3,
cols: 3,
hasRowHeader: true,
hasColHeader: true,
rowHeaderTitle: '',
colHeaderTitle: ''
}
}
})
</script>
<template>
<div v-if="modelValue" class="dialog-overlay" @click="close">
<div class="dialog-content" @click.stop>
<div class="dialog-header">
<h3 class="dialog-title">
<Table2Icon :size="18" />
插入表格
</h3>
<button class="dialog-close" @click="close">
<X :size="18" />
</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label>表格大小</label>
<div class="size-inputs">
<div class="input-group">
<span>行数</span>
<input
type="number"
v-model.number="config.rows"
min="1"
max="20"
class="form-input"
/>
</div>
<span class="separator">×</span>
<div class="input-group">
<span>列数</span>
<input
type="number"
v-model.number="config.cols"
min="1"
max="20"
class="form-input"
/>
</div>
</div>
</div>
<div class="form-group">
<label>表头设置</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
v-model="config.hasRowHeader"
/>
<span>行标题首列</span>
</label>
<label class="checkbox-label">
<input
type="checkbox"
v-model="config.hasColHeader"
/>
<span>列标题首行</span>
</label>
</div>
</div>
<div class="form-group" v-if="config.hasRowHeader">
<label>行标题名称</label>
<input
type="text"
v-model="config.rowHeaderTitle"
placeholder="输入行标题(可选)"
class="form-input"
/>
</div>
<div class="form-group" v-if="config.hasColHeader">
<label>列标题名称</label>
<input
type="text"
v-model="config.colHeaderTitle"
placeholder="输入列标题(可选)"
class="form-input"
/>
</div>
<!-- 预览 -->
<div class="preview-section">
<label>预览</label>
<div class="table-preview">
<table>
<thead v-if="config.hasColHeader">
<tr>
<th v-if="config.hasRowHeader" class="corner-header">
{{ config.colHeaderTitle || '' }}
</th>
<th v-for="col in config.cols" :key="'h-'+col">
{{ col }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in config.rows" :key="'r-'+row">
<th v-if="config.hasRowHeader" class="row-header">
{{ config.rowHeaderTitle || '行 ' + row }}
</th>
<td v-for="col in config.cols" :key="'c-'+col">
单元格
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="dialog-footer">
<button class="btn btn-secondary" @click="close">取消</button>
<button class="btn btn-primary" @click="insert">插入表格</button>
</div>
</div>
</div>
</template>
<style scoped>
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog-content {
background: white;
border-radius: 8px;
width: 500px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
}
.dialog-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
margin: 0;
color: #333;
}
.dialog-close {
background: transparent;
border: none;
cursor: pointer;
color: #666;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.dialog-close:hover {
background: #f0f0f0;
color: #333;
}
.dialog-body {
padding: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: #555;
margin-bottom: 8px;
}
.size-inputs {
display: flex;
align-items: flex-end;
gap: 12px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.input-group span {
font-size: 11px;
color: #888;
}
.form-input {
width: 80px;
padding: 6px 10px;
border: 1px solid #d0d0d0;
border-radius: 4px;
font-size: 13px;
text-align: center;
}
.form-input[type="text"] {
width: 100%;
text-align: left;
}
.form-input:focus {
outline: none;
border-color: #0078d4;
}
.separator {
font-size: 16px;
color: #888;
padding-bottom: 6px;
}
.checkbox-group {
display: flex;
gap: 20px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 13px;
color: #555;
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.preview-section {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #e0e0e0;
}
.table-preview {
overflow-x: auto;
margin-top: 8px;
}
.table-preview table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.table-preview th,
.table-preview td {
border: 1px solid #d0d0d0;
padding: 8px 12px;
text-align: center;
}
.table-preview th {
background: #f5f5f5;
font-weight: 600;
}
.table-preview .corner-header {
background: #e8e8e8;
}
.table-preview .row-header {
background: #f8f8f8;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid #e0e0e0;
background: #f9f9f9;
border-radius: 0 0 8px 8px;
}
.btn {
padding: 8px 16px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.btn-secondary {
background: white;
border-color: #d0d0d0;
color: #555;
}
.btn-secondary:hover {
background: #f0f0f0;
}
.btn-primary {
background: #0078d4;
color: white;
border-color: #0078d4;
}
.btn-primary:hover {
background: #106ebe;
border-color: #106ebe;
}
</style>

1854
src/components/TreeView.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,868 @@
<script setup lang="ts">
import { computed } from 'vue'
import { X, FileText, Image, Table, Video } from 'lucide-vue-next'
import type { EditorBlock, VersionData } from './TreeView.vue'
const props = defineProps<{
versions: VersionData[]
versionA: string
versionB: string
}>()
const emit = defineEmits<{
close: []
}>()
//
const getVersionData = (version: string): VersionData | undefined => {
return props.versions.find(v => v.version === version)
}
const versionAData = computed(() => getVersionData(props.versionA))
const versionBData = computed(() => getVersionData(props.versionB))
//
interface DiffResult {
type: 'unchanged' | 'added' | 'removed' | 'modified'
block: EditorBlock
oldBlock?: EditorBlock
changes?: string[]
}
const computeDiff = computed((): DiffResult[] => {
if (!versionAData.value || !versionBData.value) return []
const oldBlocks = versionAData.value.content
const newBlocks = versionBData.value.content
const results: DiffResult[] = []
// 使
const maxLen = Math.max(oldBlocks.length, newBlocks.length)
for (let i = 0; i < maxLen; i++) {
const oldBlock = oldBlocks[i]
const newBlock = newBlocks[i]
if (!oldBlock && newBlock) {
//
results.push({ type: 'added', block: newBlock })
} else if (oldBlock && !newBlock) {
//
results.push({ type: 'removed', block: oldBlock })
} else if (oldBlock && newBlock) {
//
if (oldBlock.type !== newBlock.type) {
// +
results.push({ type: 'removed', block: oldBlock })
results.push({ type: 'added', block: newBlock })
} else {
//
const isModified = compareBlockContent(oldBlock, newBlock)
if (isModified) {
results.push({
type: 'modified',
block: newBlock,
oldBlock: oldBlock,
changes: getChanges(oldBlock, newBlock)
})
} else {
results.push({ type: 'unchanged', block: newBlock })
}
}
}
}
return results
})
//
const compareBlockContent = (oldBlock: EditorBlock, newBlock: EditorBlock): boolean => {
if (oldBlock.type !== newBlock.type) return true
switch (oldBlock.type) {
case 'text':
return oldBlock.content !== (newBlock as any).content
case 'image':
const oldImages = oldBlock.images.join(',')
const newImages = (newBlock as any).images.join(',')
return oldImages !== newImages
case 'table':
const oldData = JSON.stringify(oldBlock.data)
const newData = JSON.stringify((newBlock as any).data)
return oldData !== newData
case 'video':
return oldBlock.src !== (newBlock as any).src || oldBlock.name !== (newBlock as any).name
default:
return false
}
}
//
const getChanges = (oldBlock: EditorBlock, newBlock: EditorBlock): string[] => {
const changes: string[] = []
switch (oldBlock.type) {
case 'text':
if (oldBlock.content !== (newBlock as any).content) {
changes.push('文本内容已修改')
}
break
case 'image':
const oldImages = oldBlock.images
const newImages = (newBlock as any).images
if (newImages.length > oldImages.length) {
changes.push(`新增 ${newImages.length - oldImages.length} 张图片`)
} else if (newImages.length < oldImages.length) {
changes.push(`删除 ${oldImages.length - newImages.length} 张图片`)
} else {
//
const hasChanges = oldImages.some((img, idx) => img !== newImages[idx])
if (hasChanges) changes.push('图片已替换')
}
break
case 'table':
const oldData = oldBlock.data
const newData = (newBlock as any).data
if (newData.length !== oldData.length) {
changes.push(`行数从 ${oldData.length} 变为 ${newData.length}`)
}
if (newData[0]?.length !== oldData[0]?.length) {
changes.push(`列数从 ${oldData[0]?.length || 0} 变为 ${newData[0]?.length || 0}`)
}
if (changes.length === 0) {
changes.push('表格数据已修改')
}
break
case 'video':
if (oldBlock.src !== (newBlock as any).src) {
changes.push('视频已替换')
}
if (oldBlock.name !== (newBlock as any).name) {
changes.push('视频名称已修改')
}
break
}
return changes
}
//
const computeTextDiff = (oldText: string, newText: string): { old: string, new: string } => {
//
const oldParts = oldText.split('')
const newParts = newText.split('')
let oldHtml = ''
let newHtml = ''
const maxLen = Math.max(oldParts.length, newParts.length)
for (let i = 0; i < maxLen; i++) {
const oldChar = oldParts[i]
const newChar = newParts[i]
if (oldChar === newChar) {
oldHtml += oldChar || ''
newHtml += newChar || ''
} else {
if (oldChar) {
oldHtml += `<span class="diff-removed-char">${oldChar}</span>`
}
if (newChar) {
newHtml += `<span class="diff-added-char">${newChar}</span>`
}
}
}
return { old: oldHtml, new: newHtml }
}
//
const getBlockIcon = (type: string) => {
switch (type) {
case 'text': return FileText
case 'image': return Image
case 'table': return Table
case 'video': return Video
default: return FileText
}
}
//
const getDiffClass = (type: string): string => {
switch (type) {
case 'added': return 'diff-added'
case 'removed': return 'diff-removed'
case 'modified': return 'diff-modified'
default: return 'diff-unchanged'
}
}
//
const getDiffLabel = (type: string): string => {
switch (type) {
case 'added': return '新增'
case 'removed': return '删除'
case 'modified': return '修改'
default: return '未变更'
}
}
</script>
<template>
<div class="version-diff-viewer">
<!-- 头部 -->
<div class="diff-header">
<div class="diff-title">
<h3>版本差异对比</h3>
<div class="version-tags">
<span class="version-tag old">{{ versionA }}</span>
<ArrowRight :size="16" class="version-arrow" />
<span class="version-tag new">{{ versionB }}</span>
</div>
</div>
<button class="close-btn" @click="emit('close')">
<X :size="18" />
</button>
</div>
<!-- 统计信息 -->
<div class="diff-stats">
<div class="stat-item added">
<span class="stat-count">{{ computeDiff.filter(d => d.type === 'added').length }}</span>
<span class="stat-label">新增</span>
</div>
<div class="stat-item removed">
<span class="stat-count">{{ computeDiff.filter(d => d.type === 'removed').length }}</span>
<span class="stat-label">删除</span>
</div>
<div class="stat-item modified">
<span class="stat-count">{{ computeDiff.filter(d => d.type === 'modified').length }}</span>
<span class="stat-label">修改</span>
</div>
<div class="stat-item unchanged">
<span class="stat-count">{{ computeDiff.filter(d => d.type === 'unchanged').length }}</span>
<span class="stat-label">未变更</span>
</div>
</div>
<!-- 差异内容 -->
<div class="diff-content">
<div
v-for="(diff, index) in computeDiff"
:key="index"
:class="['diff-block', getDiffClass(diff.type)]"
>
<!-- 差异标签 -->
<div class="diff-block-header">
<component :is="getBlockIcon(diff.block.type)" :size="14" />
<span class="block-type">{{ diff.block.type }}</span>
<span class="diff-badge">{{ getDiffLabel(diff.type) }}</span>
</div>
<!-- 文本块差异 -->
<div v-if="diff.block.type === 'text'" class="text-diff">
<template v-if="diff.type === 'modified' && diff.oldBlock">
<div class="diff-side">
<div class="side-label old">旧版本</div>
<div class="text-content old" v-html="computeTextDiff((diff.oldBlock as any).content, (diff.block as any).content).old"></div>
</div>
<div class="diff-divider"></div>
<div class="diff-side">
<div class="side-label new">新版本</div>
<div class="text-content new" v-html="computeTextDiff((diff.oldBlock as any).content, (diff.block as any).content).new"></div>
</div>
</template>
<template v-else-if="diff.type === 'removed'">
<div class="text-content removed">{{ (diff.block as any).content }}</div>
</template>
<template v-else-if="diff.type === 'added'">
<div class="text-content added">{{ (diff.block as any).content }}</div>
</template>
<template v-else>
<div class="text-content">{{ (diff.block as any).content }}</div>
</template>
</div>
<!-- 图片块差异 -->
<div v-else-if="diff.block.type === 'image'" class="image-diff">
<div v-if="diff.type === 'modified' && diff.oldBlock" class="image-comparison">
<div class="diff-side">
<div class="side-label old">旧版本 ({{ (diff.oldBlock as any).images.length }} )</div>
<div class="image-grid old">
<img
v-for="(img, idx) in (diff.oldBlock as any).images"
:key="idx"
:src="img"
class="diff-image"
/>
</div>
</div>
<div class="diff-divider"></div>
<div class="diff-side">
<div class="side-label new">新版本 ({{ (diff.block as any).images.length }} )</div>
<div class="image-grid new">
<img
v-for="(img, idx) in (diff.block as any).images"
:key="idx"
:src="img"
class="diff-image"
/>
</div>
</div>
</div>
<div v-else class="image-grid">
<img
v-for="(img, idx) in (diff.block as any).images"
:key="idx"
:src="img"
class="diff-image"
/>
</div>
<div v-if="diff.changes" class="changes-list">
<span v-for="change in diff.changes" :key="change" class="change-tag">{{ change }}</span>
</div>
</div>
<!-- 表格块差异 -->
<div v-else-if="diff.block.type === 'table'" class="table-diff">
<div v-if="diff.type === 'modified' && diff.oldBlock" class="table-comparison">
<div class="diff-side">
<div class="side-label old">旧版本</div>
<table class="diff-table old">
<tr v-for="(row, rIdx) in (diff.oldBlock as any).data" :key="rIdx">
<td
v-for="(cell, cIdx) in row"
:key="cIdx"
:class="{
'header-row': (diff.oldBlock as any).hasHeaderRow && rIdx === 0,
'header-col': (diff.oldBlock as any).hasHeaderCol && cIdx === 0
}"
>
{{ cell }}
</td>
</tr>
</table>
</div>
<div class="diff-divider"></div>
<div class="diff-side">
<div class="side-label new">新版本</div>
<table class="diff-table new">
<tr v-for="(row, rIdx) in (diff.block as any).data" :key="rIdx">
<td
v-for="(cell, cIdx) in row"
:key="cIdx"
:class="{
'header-row': (diff.block as any).hasHeaderRow && rIdx === 0,
'header-col': (diff.block as any).hasHeaderCol && cIdx === 0
}"
>
{{ cell }}
</td>
</tr>
</table>
</div>
</div>
<table v-else class="diff-table">
<tr v-for="(row, rIdx) in (diff.block as any).data" :key="rIdx">
<td
v-for="(cell, cIdx) in row"
:key="cIdx"
:class="{
'header-row': (diff.block as any).hasHeaderRow && rIdx === 0,
'header-col': (diff.block as any).hasHeaderCol && cIdx === 0
}"
>
{{ cell }}
</td>
</tr>
</table>
<div v-if="diff.changes" class="changes-list">
<span v-for="change in diff.changes" :key="change" class="change-tag">{{ change }}</span>
</div>
</div>
<!-- 视频块差异 -->
<div v-else-if="diff.block.type === 'video'" class="video-diff">
<div v-if="diff.type === 'modified' && diff.oldBlock" class="video-comparison">
<div class="diff-side">
<div class="side-label old">旧版本</div>
<div class="video-info old">
<Video :size="48" />
<span class="video-name">{{ (diff.oldBlock as any).name }}</span>
</div>
</div>
<div class="diff-divider"></div>
<div class="diff-side">
<div class="side-label new">新版本</div>
<div class="video-info new">
<Video :size="48" />
<span class="video-name">{{ (diff.block as any).name }}</span>
</div>
</div>
</div>
<div v-else class="video-info">
<Video :size="48" />
<span class="video-name">{{ (diff.block as any).name }}</span>
</div>
<div v-if="diff.changes" class="changes-list">
<span v-for="change in diff.changes" :key="change" class="change-tag">{{ change }}</span>
</div>
</div>
</div>
</div>
<!-- 图例说明 -->
<div class="diff-legend">
<div class="legend-item">
<span class="legend-color added"></span>
<span>新增内容</span>
</div>
<div class="legend-item">
<span class="legend-color removed"></span>
<span>删除内容</span>
</div>
<div class="legend-item">
<span class="legend-color modified"></span>
<span>修改内容</span>
</div>
</div>
</div>
</template>
<style scoped>
.version-diff-viewer {
display: flex;
flex-direction: column;
height: 100%;
background: #f5f5f5;
}
.diff-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: white;
border-bottom: 1px solid #e0e0e0;
}
.diff-title {
display: flex;
align-items: center;
gap: 16px;
}
.diff-title h3 {
margin: 0;
font-size: 14px;
color: #333;
}
.version-tags {
display: flex;
align-items: center;
gap: 8px;
}
.version-tag {
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
font-family: monospace;
}
.version-tag.old {
background: #fee;
color: #c33;
}
.version-tag.new {
background: #efe;
color: #3c3;
}
.version-arrow {
color: #999;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
color: #666;
}
.close-btn:hover {
background: #f0f0f0;
}
.diff-stats {
display: flex;
gap: 16px;
padding: 12px 16px;
background: white;
border-bottom: 1px solid #e0e0e0;
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
}
.stat-item.added {
background: #d4edda;
color: #155724;
}
.stat-item.removed {
background: #f8d7da;
color: #721c24;
}
.stat-item.modified {
background: #fff3cd;
color: #856404;
}
.stat-item.unchanged {
background: #e2e3e5;
color: #383d41;
}
.stat-count {
font-weight: 700;
font-size: 16px;
}
.diff-content {
flex: 1;
overflow: auto;
padding: 16px;
}
.diff-block {
margin-bottom: 16px;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.diff-block-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
}
.block-type {
font-size: 12px;
text-transform: uppercase;
color: #666;
font-weight: 500;
}
.diff-badge {
margin-left: auto;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
}
.diff-added .diff-badge {
background: #d4edda;
color: #155724;
}
.diff-removed .diff-badge {
background: #f8d7da;
color: #721c24;
}
.diff-modified .diff-badge {
background: #fff3cd;
color: #856404;
}
.diff-unchanged .diff-badge {
background: #e2e3e5;
color: #383d41;
}
/* 文本差异样式 */
.text-diff {
display: flex;
padding: 16px;
}
.diff-side {
flex: 1;
min-width: 0;
}
.diff-divider {
width: 1px;
background: #e0e0e0;
margin: 0 16px;
}
.side-label {
font-size: 11px;
font-weight: 600;
padding: 4px 8px;
border-radius: 3px;
margin-bottom: 8px;
display: inline-block;
}
.side-label.old {
background: #fee;
color: #c33;
}
.side-label.new {
background: #efe;
color: #3c3;
}
.text-content {
padding: 12px;
background: #fafafa;
border-radius: 4px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.text-content.old {
background: #fee;
}
.text-content.new {
background: #efe;
}
.text-content.removed {
background: #f8d7da;
text-decoration: line-through;
opacity: 0.7;
}
.text-content.added {
background: #d4edda;
}
:deep(.diff-removed-char) {
background: #f8d7da;
text-decoration: line-through;
}
:deep(.diff-added-char) {
background: #d4edda;
font-weight: 600;
}
/* 图片差异样式 */
.image-diff {
padding: 16px;
}
.image-comparison {
display: flex;
}
.image-grid {
display: grid;
gap: 8px;
width: 100%;
}
/* 根据图片数量调整列数 */
.image-grid:has(> :nth-child(1):last-child) {
grid-template-columns: 1fr;
}
.image-grid:has(> :nth-child(2):last-child) {
grid-template-columns: repeat(2, 1fr);
}
.image-grid:has(> :nth-child(3):last-child) {
grid-template-columns: repeat(3, 1fr);
}
.image-grid:has(> :nth-child(4):last-child) {
grid-template-columns: repeat(2, 1fr);
}
.image-grid:has(> :nth-child(5):last-child),
.image-grid:has(> :nth-child(6):last-child) {
grid-template-columns: repeat(3, 1fr);
}
.image-grid:has(> :nth-child(n+7)) {
grid-template-columns: repeat(3, 1fr);
}
.image-grid.old {
opacity: 0.6;
}
.diff-image {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: 4px;
border: 2px solid transparent;
}
.image-grid.old .diff-image {
border-color: #dc3545;
}
.image-grid.new .diff-image {
border-color: #28a745;
}
/* 表格差异样式 */
.table-diff {
padding: 16px;
}
.table-comparison {
display: flex;
}
.diff-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.diff-table td {
padding: 8px 12px;
border: 1px solid #e0e0e0;
}
.diff-table.old {
opacity: 0.7;
}
.diff-table.old td {
background: #fee;
}
.diff-table.new td {
background: #efe;
}
.diff-table .header-row {
background: #e3f2fd;
font-weight: 600;
}
.diff-table .header-col {
background: #f5f5f5;
font-weight: 500;
}
/* 视频差异样式 */
.video-diff {
padding: 16px;
}
.video-comparison {
display: flex;
}
.video-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px;
background: #fafafa;
border-radius: 4px;
color: #666;
}
.video-info.old {
background: #fee;
opacity: 0.7;
}
.video-info.new {
background: #efe;
}
.video-name {
font-size: 13px;
color: #333;
}
/* 变化列表 */
.changes-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e0e0e0;
}
.change-tag {
padding: 2px 8px;
background: #e3f2fd;
color: #1976d2;
border-radius: 3px;
font-size: 11px;
}
/* 图例 */
.diff-legend {
display: flex;
gap: 16px;
padding: 12px 16px;
background: white;
border-top: 1px solid #e0e0e0;
font-size: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 3px;
}
.legend-color.added {
background: #d4edda;
}
.legend-color.removed {
background: #f8d7da;
}
.legend-color.modified {
background: #fff3cd;
}
</style>

View File

@ -0,0 +1,522 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { Play, Pause, X } from 'lucide-vue-next'
interface VideoItem {
id: string
url: string
name: string
duration: number
}
const props = defineProps<{
video: VideoItem | null
showClose?: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const videoRef = ref<HTMLVideoElement | null>(null)
const progressRef = ref<HTMLDivElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
// video
const previewVideoRef = ref<HTMLVideoElement | null>(null)
const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const showPreview = ref(false)
const previewTime = ref(0)
const previewX = ref(0) // X
const previewDataUrl = ref('')
//
let previewTimeout: number | null = null
let lastPreviewTime = -1
const progress = computed(() => {
if (duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
// - 使
const extractFrame = async (time: number): Promise<string> => {
if (!canvasRef.value || !props.video?.url) return ''
//
if (Math.abs(time - lastPreviewTime) < 0.3 && previewDataUrl.value) {
return previewDataUrl.value
}
// 使
let previewVideo = previewVideoRef.value
//
if (!previewVideo) {
previewVideo = document.createElement('video')
previewVideo.crossOrigin = 'anonymous'
previewVideo.muted = true
previewVideo.playsInline = true
previewVideo.style.display = 'none'
previewVideo.preload = 'metadata'
document.body.appendChild(previewVideo)
previewVideoRef.value = previewVideo
}
//
if (previewVideo.src !== props.video.url) {
previewVideo.src = props.video.url
//
await new Promise<void>((resolve) => {
const checkMetadata = () => {
if (previewVideo!.readyState >= 1) {
previewVideo!.removeEventListener('loadedmetadata', checkMetadata)
resolve()
}
}
previewVideo!.addEventListener('loadedmetadata', checkMetadata)
//
setTimeout(resolve, 1000)
})
}
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
if (!ctx) return ''
// seeked
await new Promise<void>((resolve) => {
const handleSeeked = () => {
previewVideo!.removeEventListener('seeked', handleSeeked)
resolve()
}
previewVideo!.addEventListener('seeked', handleSeeked, { once: true })
previewVideo!.currentTime = time
//
setTimeout(() => {
previewVideo!.removeEventListener('seeked', handleSeeked)
resolve()
}, 500)
})
// canvas
canvas.width = 160
canvas.height = 90
ctx.drawImage(previewVideo, 0, 0, canvas.width, canvas.height)
const dataUrl = canvas.toDataURL('image/jpeg', 0.7)
lastPreviewTime = time
return dataUrl
}
//
const handleProgressClick = (e: MouseEvent) => {
if (!progressRef.value || !videoRef.value) return
// DOM
if (!(progressRef.value instanceof Element)) return
const rect = progressRef.value.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
const newTime = Math.max(0, Math.min(duration.value, percent * duration.value))
videoRef.value.currentTime = newTime
currentTime.value = newTime
}
const handleProgressMouseMove = (e: MouseEvent) => {
if (!progressRef.value || !videoRef.value || duration.value === 0) return
// DOM
if (!(progressRef.value instanceof Element)) return
const rect = progressRef.value.getBoundingClientRect()
const relativeX = e.clientX - rect.left
const percent = Math.max(0, Math.min(1, relativeX / rect.width))
const newTime = percent * duration.value
//
previewX.value = Math.max(70, Math.min(relativeX, rect.width - 70)) //
previewTime.value = newTime
showPreview.value = true
//
if (previewTimeout) {
clearTimeout(previewTimeout)
}
previewTimeout = window.setTimeout(async () => {
if (showPreview.value) {
previewDataUrl.value = await extractFrame(newTime)
}
}, 80)
}
const handleProgressMouseLeave = () => {
showPreview.value = false
if (previewTimeout) {
clearTimeout(previewTimeout)
previewTimeout = null
}
}
const togglePlay = () => {
if (!videoRef.value) return
if (isPlaying.value) {
videoRef.value.pause()
} else {
videoRef.value.play().catch(err => {
console.error('播放失败:', err)
})
}
}
const handleTimeUpdate = () => {
if (!videoRef.value) return
currentTime.value = videoRef.value.currentTime
}
const handleLoadedMetadata = () => {
if (!videoRef.value) return
duration.value = videoRef.value.duration || props.video?.duration || 0
}
const handlePlay = () => {
isPlaying.value = true
}
const handlePause = () => {
isPlaying.value = false
}
const handleError = () => {
console.error('视频加载错误')
}
//
watch(() => props.video, (newVideo) => {
if (newVideo) {
isPlaying.value = false
currentTime.value = 0
duration.value = newVideo.duration || 0
previewDataUrl.value = ''
lastPreviewTime = -1
}
}, { immediate: true })
onMounted(() => {
if (videoRef.value) {
videoRef.value.addEventListener('timeupdate', handleTimeUpdate)
videoRef.value.addEventListener('loadedmetadata', handleLoadedMetadata)
videoRef.value.addEventListener('play', handlePlay)
videoRef.value.addEventListener('pause', handlePause)
videoRef.value.addEventListener('error', handleError)
}
})
onUnmounted(() => {
if (videoRef.value) {
videoRef.value.removeEventListener('timeupdate', handleTimeUpdate)
videoRef.value.removeEventListener('loadedmetadata', handleLoadedMetadata)
videoRef.value.removeEventListener('play', handlePlay)
videoRef.value.removeEventListener('pause', handlePause)
videoRef.value.removeEventListener('error', handleError)
}
if (previewTimeout) {
clearTimeout(previewTimeout)
}
//
if (previewVideoRef.value && previewVideoRef.value.parentNode) {
previewVideoRef.value.parentNode.removeChild(previewVideoRef.value)
previewVideoRef.value = null
}
})
</script>
<template>
<div v-if="video" class="video-player">
<div class="video-header">
<span class="video-name">{{ video.name }}</span>
<button v-if="showClose !== false" class="video-close" @click="emit('close')">
<X :size="18" />
</button>
</div>
<div class="video-container">
<video
ref="videoRef"
:src="video.url"
class="video-element"
preload="metadata"
@click="togglePlay"
></video>
<div v-if="!isPlaying" class="video-overlay" @click="togglePlay">
<button class="play-btn">
<Play :size="40" fill="white" />
</button>
</div>
</div>
<!-- 进度条 -->
<div class="video-controls">
<button class="control-btn" @click="togglePlay">
<Pause v-if="isPlaying" :size="20" />
<Play v-else :size="20" fill="currentColor" />
</button>
<div class="time-display">
<span>{{ formatTime(currentTime) }}</span>
<span class="time-separator">/</span>
<span>{{ formatTime(duration) }}</span>
</div>
<div
ref="progressRef"
class="progress-bar"
@click="handleProgressClick"
@mousemove="handleProgressMouseMove"
@mouseleave="handleProgressMouseLeave"
>
<div class="progress-track">
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
</div>
<!-- 预览悬浮框 -->
<div
v-if="showPreview && duration > 0"
class="preview-tooltip"
:style="{ left: previewX + 'px' }"
>
<img
v-if="previewDataUrl"
:src="previewDataUrl"
class="preview-image"
alt="预览"
/>
<div v-else class="preview-loading">加载中...</div>
<span class="preview-time">{{ formatTime(previewTime) }}</span>
</div>
</div>
</div>
<!-- 隐藏的画布用于帧提取 -->
<canvas ref="canvasRef" style="display: none;"></canvas>
</div>
</template>
<style scoped>
.video-player {
background: #1a1a1a;
border-radius: 4px;
overflow: hidden;
margin: 4px 0;
width: 100%;
}
.video-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: #252525;
border-bottom: 1px solid #333;
}
.video-name {
font-size: 13px;
color: #e0e0e0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.video-close {
background: transparent;
border: none;
color: #999;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.video-close:hover {
background: #333;
color: #fff;
}
.video-container {
position: relative;
aspect-ratio: 16/9;
background: #000;
}
.video-element {
width: 100%;
height: 100%;
object-fit: contain;
cursor: pointer;
}
.video-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
.video-container:hover .video-overlay {
opacity: 1;
}
.play-btn {
width: 60px;
height: 60px;
border-radius: 50%;
background: rgba(0, 120, 212, 0.9);
border: none;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s, background 0.2s;
}
.play-btn:hover {
transform: scale(1.1);
background: rgba(0, 120, 212, 1);
}
.video-controls {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: #252525;
}
.control-btn {
width: 32px;
height: 32px;
border-radius: 50%;
background: transparent;
border: none;
color: #e0e0e0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.control-btn:hover {
background: #333;
color: #fff;
}
.time-display {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #999;
font-variant-numeric: tabular-nums;
min-width: 80px;
}
.time-separator {
margin: 0 2px;
}
.progress-bar {
flex: 1;
position: relative;
height: 24px;
display: flex;
align-items: center;
cursor: pointer;
}
.progress-track {
width: 100%;
height: 4px;
background: #444;
border-radius: 2px;
overflow: hidden;
transition: height 0.2s;
}
.progress-bar:hover .progress-track {
height: 6px;
}
.progress-fill {
height: 100%;
background: #0078d4;
border-radius: 2px;
transition: width 0.1s linear;
}
.preview-tooltip {
position: absolute;
bottom: 30px;
transform: translateX(-50%);
background: #1a1a1a;
border-radius: 6px;
padding: 6px;
border: 1px solid #444;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
pointer-events: none;
z-index: 100;
min-width: 140px;
}
.preview-image {
width: 140px;
height: 78px;
object-fit: cover;
border-radius: 4px;
display: block;
}
.preview-loading {
width: 140px;
height: 78px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 12px;
}
.preview-time {
display: block;
text-align: center;
font-size: 11px;
color: #ccc;
margin-top: 4px;
}
</style>

37
src/index.css Normal file
View File

@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.625rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

5
src/main.ts Normal file
View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './index.css'
import App from './App.vue'
createApp(App).mount('#app')

50
src/utils/treeStorage.ts Normal file
View File

@ -0,0 +1,50 @@
// 树形数据 localStorage 管理模块
// 注意:使用 any 类型避免循环依赖问题
export type TreeNode = any
const STORAGE_KEY = 'app-tree-data'
// 从 localStorage 获取数据
export const getTreeDataFromStorage = (): TreeNode[] | null => {
try {
const data = localStorage.getItem(STORAGE_KEY)
if (data) {
return JSON.parse(data)
}
} catch (error) {
console.error('Error reading from localStorage:', error)
}
return null
}
// 保存数据到 localStorage
export const saveTreeDataToStorage = (data: TreeNode[]) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
} catch (error) {
console.error('Error saving to localStorage:', error)
}
}
// 清空 localStorage 中的数据
export const clearTreeDataFromStorage = () => {
try {
localStorage.removeItem(STORAGE_KEY)
} catch (error) {
console.error('Error clearing localStorage:', error)
}
}
// 初始化数据:如果有 localStorage 数据则使用,否则使用 mockData 并保存
export const initializeTreeData = (mockData: TreeNode[]): TreeNode[] => {
const storedData = getTreeDataFromStorage()
if (storedData) {
console.log('Loaded tree data from localStorage')
return storedData
}
// 首次加载,保存 mockData 到 localStorage
saveTreeDataToStorage(mockData)
console.log('Initialized tree data with mock data and saved to localStorage')
return mockData
}

84
tailwind.config.cjs Normal file
View File

@ -0,0 +1,84 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
borderRadius: {
xl: "calc(var(--radius) + 4px)",
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
xs: "calc(var(--radius) - 6px)",
},
boxShadow: {
xs: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
"caret-blink": {
"0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite",
},
},
},
plugins: [require("tailwindcss-animate")],
}

24
tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

18
vite.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
base: './',
plugins: [vue()],
server: {
port: 3000,
open: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})