feat: 初始化网页
This commit is contained in:
commit
b08bbc5550
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
不要每次都开启vue服务器
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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张图片 - 3列,4: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>
|
||||
|
|
@ -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) > Dev database tree(1) > 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>
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { createApp } from 'vue'
|
||||
import './index.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")],
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue