浏览代码

feat: 项目流汇总页面结构框架及拖拽

qinlaiqiao 3 年之前
父节点
当前提交
1703b8d110
共有 29 个文件被更改,包括 1325 次插入102 次删除
  1. 11 0
      package-lock.json
  2. 3 0
      package.json
  3. 5 1
      src/components/index.ts
  4. 246 0
      src/components/resizable-layout/ResizableLayout.vue
  5. 287 0
      src/components/resizable-layout/ResizableLayoutItem.vue
  6. 178 0
      src/constants/emitter.ts
  7. 30 0
      src/constants/provideInject.ts
  8. 23 10
      src/views/main-frame/style.scss
  9. 54 48
      src/views/project/Project.vue
  10. 111 25
      src/views/project/overview/Overview.vue
  11. 0 0
      src/views/project/overview/scripts/.gitkeep
  12. 3 9
      src/views/project/overview/style.scss
  13. 67 2
      src/views/project/process/Process.vue
  14. 75 0
      src/views/project/process/style.scss
  15. 31 4
      src/views/project/style.scss
  16. 64 2
      src/views/project/summary/Summary.vue
  17. 11 0
      src/views/project/summary/components/bottom-tabs/BottomTabs.vue
  18. 5 0
      src/views/project/summary/components/bottom-tabs/style.scss
  19. 11 0
      src/views/project/summary/components/cost-table/CostTable.vue
  20. 5 0
      src/views/project/summary/components/cost-table/style.scss
  21. 17 0
      src/views/project/summary/components/project-explorer/ProjectExplorer.vue
  22. 47 0
      src/views/project/summary/components/project-explorer/style.scss
  23. 0 0
      src/views/project/summary/components/scripts/.gitkeep
  24. 11 0
      src/views/project/summary/components/std-bill/StdBill.vue
  25. 5 0
      src/views/project/summary/components/std-bill/style.scss
  26. 11 0
      src/views/project/summary/components/tool-bar/ToolBar.vue
  27. 8 0
      src/views/project/summary/components/tool-bar/style.scss
  28. 4 0
      src/views/project/summary/style.scss
  29. 2 1
      vite.config.ts

+ 11 - 0
package-lock.json

@@ -1962,6 +1962,12 @@
         "@types/koa": "*"
       }
     },
+    "@types/lodash": {
+      "version": "4.14.176",
+      "resolved": "http://192.168.1.90:4873/@types%2flodash/-/lodash-4.14.176.tgz",
+      "integrity": "sha1-ZBFQ/BzaNvv6Mp3mA7uxddfuIMA=",
+      "dev": true
+    },
     "@types/mime": {
       "version": "1.3.2",
       "resolved": "http://192.168.1.90:4873/@types%2fmime/-/mime-1.3.2.tgz",
@@ -10129,6 +10135,11 @@
         "through2": "^2.0.0"
       }
     },
+    "mitt": {
+      "version": "3.0.0",
+      "resolved": "http://192.168.1.90:4873/mitt/-/mitt-3.0.0.tgz",
+      "integrity": "sha1-ae+b1cgP9vV0c+jYkybQHEFL4L0="
+    },
     "mixin-deep": {
       "version": "1.3.2",
       "resolved": "http://192.168.1.90:4873/mixin-deep/-/mixin-deep-1.3.2.tgz",

+ 3 - 0
package.json

@@ -19,6 +19,8 @@
     "echarts": "^5.2.2",
     "element-plus": "^1.1.0-beta.24",
     "koa-bodyparser": "^4.3.0",
+    "lodash": "^4.17.21",
+    "mitt": "^3.0.0",
     "vue": "^3.2.16",
     "vue-router": "^4.0.12"
   },
@@ -30,6 +32,7 @@
     "@types/animejs": "^3.1.4",
     "@types/jest": "^26.0.23",
     "@types/koa-bodyparser": "^4.3.1",
+    "@types/lodash": "^4.14.176",
     "@typescript-eslint/eslint-plugin": "^5.3.0",
     "@typescript-eslint/parser": "^5.3.0",
     "@vitejs/plugin-vue": "^1.9.3",

+ 5 - 1
src/components/index.ts

@@ -3,7 +3,11 @@
  */
 import type { App } from 'vue';
 import Iconfont from './iconfont/Iconfont.vue';
+import ResizableLayout from './resizable-layout/ResizableLayout.vue';
+import ResizableLayoutItem from './resizable-layout/ResizableLayoutItem.vue';
 
 export default function (app: App<Element>) {
     app.component(Iconfont.name, Iconfont);
-}
+    app.component(ResizableLayout.name, ResizableLayout);
+    app.component(ResizableLayoutItem.name, ResizableLayoutItem);
+}

+ 246 - 0
src/components/resizable-layout/ResizableLayout.vue

@@ -0,0 +1,246 @@
+<template>
+  <section class="resizable-layout" :style="styleObj" ref="layoutRef">
+    <slot></slot>
+  </section>
+</template>
+
+<script lang="ts">
+import { defineComponent, provide, PropType } from 'vue';
+import mitt, { Emitter } from 'mitt';
+import ResizableLayoutItem from './ResizableLayoutItem.vue';
+import { EmitterType } from '@/constants/emitter';
+import ProvideInject from '@/constants/provideInject';
+
+interface DOMElement extends HTMLElement {
+  [key: string]: any;
+}
+
+type Direction = 'horizontal' | 'vertical';
+
+/**
+ * 由于 Composition Api 的限制,通过slot动态插入的子组件难以直接访问父组件定义在 setup 中的方法(父组件也难以直接访问子组件定义在 setup 中的方法),
+ * 故此组件(ResizableLayout)以及子组件(ResizableLayoutItem)使用传统的 Options API 开发。
+ * 事实上,通过控制台打印,发现通过setup中的getCurrentInstance()获取到的实例中有一个 parent 属性和 ctx 属性,分别可以直接获取父组件实例和setup中定义的方法,
+ * 使用时类似于这种方式 (getCurrentInstance().parent as any).ctx 和 (getCurrentInstance() as any).ctx。
+ * 本组件第一版就是通过这种方式实现父子组件的通信,但是 Vue 官方文档中没有找到任何相关信息,且官方文档中说明了:文档中没说明的功能,代表它不存在。
+ * 更重要的一点是:项目在 build 上线之后(即NODE_ENV=production), (getCurrentInstance() as any).ctx这种方式就失效了,需要通过(getCurrentInstance() as any).ctx._.setupState才能获取,
+ * 为了避免之后 Vue 版本更新可能改变内部 api 导致组件失效,因此决定使用Options API
+ */
+
+export default defineComponent({
+  name: 'resizable-layout',
+  props: {
+    // 布局的方向:横向或纵向
+    direction: {
+      type: String as PropType<Direction>,
+      default: 'horizontal',
+      validator: (direction: Direction) => {
+        return ['horizontal', 'vertical'].includes(direction);
+      },
+    },
+    // 防抖函数中需要延迟的毫秒数
+    debounce: {
+      type: Number,
+      default: 100,
+    },
+    // 防抖函数中允许被延迟的最大值
+    maxWait: {
+      type: Number,
+      default: Number.MAX_SAFE_INTEGER,
+    },
+  },
+  data() {
+    return {
+      layoutItemElements: [] as Array<DOMElement>, // 子组件 **(resizable-layout-item)** 的 DOM 实例数组
+      layoutItemInstances: [] as any[], // 子组件的Vue实例数组
+      layoutItemsMinSize: [] as number[], // 子组件的最小尺寸数组
+    };
+  },
+  computed: {
+    // 将 props 中的 direction 转为 css3 中 flex-direction 属性对应的值
+    flexDirection(): string {
+      return this.direction === 'vertical' ? 'column' : 'row';
+    },
+    styleObj(): { flexDirection: string } {
+      return {
+        flexDirection: this.flexDirection,
+      };
+    },
+  },
+  setup(props, context) {
+    const { slots } = context;
+    if (slots.default) {
+      const children = slots.default();
+      if (
+        children.some(
+          child => child.type !== ResizableLayoutItem && !child.type.toString().startsWith('Symbol(') // Symbol(Comment) 代表注释
+        )
+      ) {
+        console.error('<resizable-layout> 中只能直接包含 <resizable-layout-item> 子组件', children);
+      }
+    }
+  },
+  created() {
+    this.init();
+  },
+  mounted() {
+    this.setHtmlDataAttribute();
+  },
+  methods: {
+    // 初始化
+    init() {
+      const self = this as any;
+      provide(ProvideInject.DIRECTION, self.flexDirection);
+      provide(ProvideInject.DEBOUNCE, self.debounce);
+      provide(ProvideInject.MAX_WAIT, self.maxWait);
+      // provide(ProvideInject.BAR_WIDTH, this.barWidth);
+      // provide(ProvideInject.DOTTED, this.dotted);
+
+      // 事件监听器
+      self.emitter = mitt() as Emitter;
+
+      self.currentMoveId = null; // symbol 类型,当前move事件的唯一标志
+
+      // 拖拽条移动事件监听
+      if (self.flexDirection === 'column') {
+        self.emitter.on(EmitterType.DRAG_BAR_MOVE, self.verticalHandler);
+      } else {
+        self.emitter.on(EmitterType.DRAG_BAR_MOVE, self.horizontalHandler);
+      }
+    },
+
+    // 给子组件设置 html 的 data- 属性
+    // data-index 子组件(resizable-layout-item)的索引,即位于父组件中第几个(从 0 开始)
+    // data-size 子组件(resizable-layout-item)在鼠标刚刚按下的时候的尺寸
+    setHtmlDataAttribute() {
+      const parentElement = this.$refs.layoutRef as HTMLElement;
+      const childElements = parentElement.children;
+      for (let i = 0; i < childElements.length; i += 1) {
+        const element = childElements[i] as HTMLElement;
+        element.setAttribute('data-index', `${i}`);
+        element.setAttribute('data-size', `${element.offsetWidth}`);
+        this.layoutItemElements[i] = element;
+      }
+    },
+
+    // 约定大写字母开头表示为供外界调用的方法
+    // 当布局改变时(如子组件通过 v-if 动态添加或删除)刷新布局
+    Refresh() {
+      setTimeout(() => {
+        this.layoutItemElements.length = 0;
+        // 重新设置 html 的 data- 属性
+        this.setHtmlDataAttribute();
+
+        this.layoutItemInstances.map(instance => {
+          // 在 ResizableLayoutItem 中的 unmounted 函数中添加了 $unmounted 字段,
+          // 标记该对象已经 unmounted
+          if (!instance.$unmounted) {
+            instance.RequestEmitter();
+          }
+          return instance;
+        });
+        // 清空子组件的Vue实例数组
+        this.layoutItemInstances.length = 0;
+        // 清空子组件的最小尺寸数组
+        this.layoutItemsMinSize.length = 0;
+      }, 100);
+    },
+
+    // 约定大写字母开头表示为供外界调用的方法
+    // 供子组件调用,给子组件返回 emitter
+    GiveEmitter({ instance, index, minSize }: Comp.ResizableLayout.IDataParam) {
+      instance.RegisterEmitter((this as any).emitter);
+      this.layoutItemInstances[index] = instance;
+      this.layoutItemsMinSize[index] = minSize;
+    },
+
+    // 返回 data-size
+    getSize(element: HTMLElement) {
+      return parseInt(element.getAttribute('data-size') || '0', 10);
+    },
+
+    // 重新分配子组件的 flex-grow
+    assignChildrenFlexGrow(index: number, distance: number) {
+      // 此时仍然是当前的move事件,即鼠标未松开
+
+      // 需要调整大小的子组件是否都满足 min-width(min-height) 的要求,初始值设为 true,假设都满足
+      let allFitMinWidth = true;
+      // 需要调整大小的子组件
+      for (let i = index, sign = 1; i <= index + 1; i += 1, sign = -sign) {
+        const flexGrow = this.getSize(this.layoutItemElements[i]) + sign * distance;
+        if (flexGrow < this.layoutItemsMinSize[i]) {
+          allFitMinWidth = false;
+          break;
+        }
+      }
+      // allFitMinWidth为false,则终止重新分配
+      if (!allFitMinWidth) {
+        return;
+      }
+
+      // 需要调整大小的子组件的前面的子组件
+      // 他们保持原来的尺寸就好
+      for (let i = 0; i < index; i += 1) {
+        this.layoutItemInstances[i].AdjustFlexGrow(this.getSize(this.layoutItemElements[i]));
+      }
+
+      // 需要调整大小的子组件
+      for (let i = index, sign = 1; i <= index + 1; i += 1, sign = -sign) {
+        const flexGrow = this.getSize(this.layoutItemElements[i]) + sign * distance;
+        // 通知子组件调整自身大小
+        this.layoutItemInstances[i].AdjustFlexGrow(flexGrow);
+        // 通知子组件触发 resize 事件
+        this.layoutItemInstances[i].EmitResize();
+      }
+
+      // 需要调整大小的子组件的后面的子组件
+      // 他们保持原来的尺寸就好
+      for (let i = index + 2; i < this.layoutItemInstances.length; i += 1) {
+        this.layoutItemInstances[i].AdjustFlexGrow(this.getSize(this.layoutItemElements[i]));
+      }
+    },
+
+    // 拖拽事件监听处理器
+    moveHandler(sizeProperty: string, moveParam: Comp.ResizableLayout.IMoveParam) {
+      const { distance, index, moveId } = moveParam;
+      if ((this as any).currentMoveId !== moveId) {
+        // 一个新的move事件,即鼠标松开了之后重新按下之后的move事件
+        (this as any).currentMoveId = moveId;
+        // 设置子组件的 data-size,用于记录在 "重新分配子组件的 flex-grow" 之前,各子组件的宽度
+        this.layoutItemElements.map(element => {
+          element.setAttribute('data-size', `${element[sizeProperty]}`);
+          return element;
+        });
+      }
+      this.assignChildrenFlexGrow(index, distance);
+    },
+
+    // 水平布局拖拽监听
+    horizontalHandler(moveParam: Comp.ResizableLayout.IMoveParam) {
+      this.moveHandler('offsetWidth', moveParam);
+    },
+
+    // 垂直布局拖拽监听
+    verticalHandler(moveParam: Comp.ResizableLayout.IMoveParam) {
+      this.moveHandler('offsetHeight', moveParam);
+    },
+  },
+});
+</script>
+
+<style lang="scss" scoped>
+.resizable-layout {
+  position: relative;
+  display: flex;
+  width: 100%;
+  height: 100%;
+
+  ::v-deep > .resizable-layout-item {
+    &:last-child {
+      > .drag-bar {
+        display: none;
+      }
+    }
+  }
+}
+</style>

+ 287 - 0
src/components/resizable-layout/ResizableLayoutItem.vue

@@ -0,0 +1,287 @@
+<template>
+  <section class="resizable-layout-item" :style="styleObj" ref="layoutItemRef">
+    <div class="content">
+      <slot></slot>
+    </div>
+    <div
+        class="drag-bar"
+        :class="[isColumn ? 'column' : 'horizontal', dragBarActive ? 'active' : '']"
+        :style="dragBarStyleObj"
+        @mousedown.prevent="handleDragBarMouseDown"
+        ref="dragBarRef"
+    >
+      <div class="drag-bar-inner"></div>
+      <div class="drag-handle" :style="dragHandleStyleObj">
+        <i class="dot"/>
+        <i class="dot"/>
+        <i class="dot"/>
+      </div>
+    </div>
+  </section>
+</template>
+
+<script lang="ts">
+import {defineComponent} from 'vue';
+import {Emitter} from 'mitt';
+import {debounce} from 'lodash';
+import {EmitterType} from '@/constants/emitter';
+import ProvideInject from '@/constants/provideInject';
+
+export default defineComponent({
+  name: 'resizable-layout-item',
+  props: {
+    // flex 子布局对应的权重
+    weight: {
+      type: Number,
+      default: 1,
+    },
+    // 最小宽度,仅在水平布局时有效
+    minWidth: {
+      type: Number,
+      default: 32,
+    },
+    // 最小高度,仅在垂直布局时有效
+    minHeight: {
+      type: Number,
+      default: 32,
+    },
+  },
+  data() {
+    return {
+      dragBarActive: false,
+    };
+  },
+  emits: ['resize'],
+  inject: [ProvideInject.DIRECTION, ProvideInject.DEBOUNCE, ProvideInject.MAX_WAIT],
+  computed: {
+    // 是否是垂直布局
+    isColumn(): boolean {
+      return (this as any).direction === 'column';
+    },
+
+    // 最小尺寸,水平布局时为最小宽度,垂直布局时为最小高度
+    minSize(): string {
+      return (this as any).isColumn ? 'minHeight' : 'minWidth';
+    },
+
+    // layout-item 的样式
+    styleObj(): any {
+      return {
+        flexGrow: `${this.weight}`,
+        [this.minSize]: `${(this as any)[this.minSize]}px`, // minWidth: `${props.minWidth}px`,
+        flexDirection: (this as any).direction,
+      };
+    },
+
+    // 拖拽条的样式
+    dragBarStyleObj(): any {
+      const self = this as any;
+      const width = self.isColumn ? '100%' : '5px';
+      const height = self.isColumn ? '5px' : '100%';
+      const cursor = self.isColumn ? 'ns-resize' : 'ew-resize';
+      const flexDirection = self.isColumn ? 'row' : 'column';
+      return {width, height, cursor, flexDirection};
+    },
+
+    // 拖拽手柄的样式
+    dragHandleStyleObj() {
+      const self = this as any;
+      const width = self.isColumn ? '36px' : '9px';
+      const height = self.isColumn ? '9px' : '36px';
+      const cursor = self.isColumn ? 'ns-resize' : 'ew-resize';
+      return {width, height, cursor};
+    },
+  },
+  created() {
+    const self = this as any;
+    self.emitter = null;
+    self.debouncedEmit = debounce(
+        () => {
+          const size = this.isColumn
+              ? (this.$refs.layoutItemRef as HTMLElement).offsetHeight
+              : (this.$refs.layoutItemRef as HTMLElement).offsetWidth;
+          this.$emit('resize', size);
+        },
+        self.debounce,
+        {maxWait: self.maxWait}
+    );
+  },
+  mounted() {
+    // 向父组件要emitter实例
+    this.RequestEmitter();
+  },
+  unmounted() {
+    (this as any).$unmounted = true; // 标记该对象已经 unmounted
+  },
+  methods: {
+    // 获取当前 layoutItem 在父元素中的索引
+    getLayoutItemIndex() {
+      const index = (this.$refs.layoutItemRef as HTMLElement).getAttribute('data-index') as string;
+      return parseInt(index, 10);
+    },
+
+    // 约定大写字母开头表示为供外界调用的方法
+    // 调整 flex-grow 的大小
+    AdjustFlexGrow(value: number) {
+      (this.$refs.layoutItemRef as HTMLElement).style.flexGrow = `${value}`;
+    },
+
+    // 约定大写字母开头表示为供外界调用的方法
+    // 父组件调用该方法,获取到父组件的emitter实例
+    RegisterEmitter(mitter: Emitter) {
+      (this as any).emitter = mitter;
+    },
+
+    // 约定大写字母开头表示为供外界调用的方法
+    // 向外触发 resize 事件
+    EmitResize() {
+      (this as any).debouncedEmit();
+    },
+
+    // 约定大写字母开头表示为供外界调用的方法
+    // 向父组件要emitter实例
+    RequestEmitter() {
+      setTimeout(() => {
+        if (this.$parent && this.$refs.layoutItemRef) {
+          (this.$parent as any).GiveEmitter({
+            instance: this,
+            index: this.getLayoutItemIndex(),
+            minSize: (this as any)[this.minSize],
+          } as Comp.ResizableLayout.IDataParam);
+        }
+      }, 500);
+    },
+
+    // 鼠标松开时,移除 mouse 事件
+    detachMouseEvent() {
+      document.onmouseup = () => {
+        document.onmousemove = null;
+        document.onmouseup = null;
+
+        // 加入 setTimeout 是为了解决快速拖动导致布局抖动的问题
+        setTimeout(() => {
+          this.dragBarActive = false;
+        }, 150);
+        return false;
+      };
+    },
+    // 向外触发 drag-bar-move
+    emitMoveEvent(distance: number, moveId: symbol) {
+      (this as any).emitter &&
+      (this as any).emitter.emit(EmitterType.DRAG_BAR_MOVE, {
+        index: this.getLayoutItemIndex(),
+        distance,
+        moveId,
+      } as Comp.ResizableLayout.IMoveParam);
+    },
+    // 拖拽条移动
+    handleDragBarMouseDown(downEvent: MouseEvent) {
+      this.dragBarActive = true;
+      const moveId = Symbol('move事件的唯一标志');
+      if ((this as any).direction !== 'column') {
+        // 水平布局
+        const originClientX = downEvent.clientX; // 拖动条距离视口左边的距离
+        document.onmousemove = (moveEvent: MouseEvent) => {
+          const distance = moveEvent.clientX - originClientX; // 当前鼠标移动的距离
+          // 告诉父组件鼠标移动的距离
+          this.emitMoveEvent(distance, moveId);
+          return false;
+        };
+      } else {
+        // 垂直布局
+        const originClientY = downEvent.clientY; // 拖动条距离视口左边的距离
+        document.onmousemove = (moveEvent: MouseEvent) => {
+          const distance = moveEvent.clientY - originClientY; // 当前鼠标移动的距离
+          // 告诉父组件鼠标移动的距离
+          this.emitMoveEvent(distance, moveId);
+          return false;
+        };
+      }
+      this.detachMouseEvent();
+    },
+  },
+});
+</script>
+
+<style lang="scss" scoped>
+.resizable-layout-item {
+  display: flex;
+  flex-basis: 0;
+
+  > .content {
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    flex-grow: 1;
+  }
+
+  > .drag-bar {
+    position: relative;
+    z-index: 1000;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    flex-grow: 0;
+    flex-shrink: 0;
+
+    &.horizontal {
+      &:hover,
+      &.active {
+        .drag-bar-inner {
+          transform: scaleX(3);
+        }
+
+        .drag-handle {
+          padding: 9px 0;
+          flex-direction: column;
+        }
+      }
+    }
+
+    &.column {
+      &:hover,
+      &.active {
+        .drag-bar-inner {
+          transform: scaleY(3);
+        }
+
+        .drag-handle {
+          padding: 0 9px;
+          flex-direction: row;
+        }
+      }
+    }
+
+    &:hover,
+    &.active {
+      .drag-handle {
+        display: flex;
+      }
+    }
+
+    .drag-bar-inner {
+      position: absolute;
+      left: 0;
+      right: 0;
+      top: 0;
+      bottom: 0;
+    }
+
+    .drag-handle {
+      display: none;
+      justify-content: space-around;
+      align-items: center;
+      position: relative;
+      background-color: #f1f4f9;
+      border: 1px solid #ddd;
+
+      .dot {
+        width: 3px;
+        height: 3px;
+        border-radius: 50%;
+        background-color: #8a9194;
+      }
+    }
+  }
+}
+</style>

+ 178 - 0
src/constants/emitter.ts

@@ -0,0 +1,178 @@
+/* mitt事件相关 */
+import mitt, { Emitter, Handler } from 'mitt';
+import tabStore from '@/store/modules/tabs';
+
+/**
+ * mitt事件枚举,分发和监听的事件类型必须在此定义
+ */
+export enum EmitterType {
+  // 拖动拖拽布局
+  DRAG_BAR_MOVE = 'dragBarMove',
+  // sidebar侧边栏宽度变化
+  SIDEBAR_WIDTH_CHANGE = 'sidebarWidthChange',
+  // 点击了新建项目按钮
+  CLICK_CREATE_PROJECT = 'clickCreateProject',
+  // 重新加载建设项目
+  RELOAD_CONSTRUCTION_PROJECTS = 'reload:construction',
+  // 刷新项目管理-全部 表格
+  REFRESH_ALL_TABLE = 'refresh:allTable',
+  // 更改了项目管理-全部树
+  UPDATE_ALL_TREE = 'update:allTree',
+  // 更改了项目导航树
+  UPDATE_SUBJECT_TREE = 'update:subjectTree',
+  // 更改(恢复)回收站树
+  UPDATE_RECYCLE_TREE = 'update:recycleTree',
+  // 点击了回收站的恢复按钮
+  RECYCLE = 'clickRecycle',
+  // 点击了彻底删除
+  COMPLETELY_REMOVE = 'clickCompletelyRemove',
+  // 更改了造价书节点
+  UPDATE_COST_NODE = 'update:costNode',
+  // 更新了特征及内容文本
+  UPDATE_CONTENT_CHARACTER = 'update:contentCharacter',
+  // 更新造价书列设置
+  UPDATE_COST_SETTING = 'update:costSetting',
+  // 刷新造价书
+  REFRESH_COST = 'refresh:cost',
+  // 刷新造价书按钮有效性
+  REFRESH_COST_BUTTON = 'refresh:costButton',
+  // 选中造价书节点
+  SELECT_COST = 'select:cost',
+  // 设置造价书Loading
+  SET_COST_LOADING = 'set:costLoading',
+  // 刷新项目工料机表格
+  REFRESH_PROJECT_GLJ = 'refresh:projectGlj',
+  // 刷新关联材料表格
+  REFRESH_RELATED_MATERIAL = 'refresh:relatedMaterial',
+  // 刷新建设项目级工料机汇总
+  REFRESH_SUMMARY_GLJ = 'refresh:summaryGlj',
+  // 切换建设项目工料机汇总表格的loading状态
+  TRIGGER_SUMMARY_LOADING = 'trigger:summaryLoading',
+  // 刷新量价表格
+  REFRESH_VOLUME_PRICE = 'refresh:volumePrice',
+  // 刷新定额工料机表格
+  REFRESH_RATION_GLJ = 'refresh:rationGlj',
+  // 刷新定额安装增加费表格
+  REFRESH_RATION_INSTALL = 'refresh:rationInstall',
+  // 刷新费用修改表格
+  REFRESH_EDIT_RULE = 'refresh:editRule',
+  // 刷新计算程序表格
+  REFRESH_CALC_PROGRAM = 'refresh:calcProgram',
+  // 刷新定额子目换算表格
+  REFRESH_RATION_CALC = 'refresh:rationCalc',
+  // 刷新定额库定额列表
+  REFRESH_LIB_RATION = 'refresh:libRation',
+  // 刷新定额库定额列表列设置
+  UPDATE_LIB_RATION_SETTING = 'refresh:libRationSetting',
+  // 刷新定额模板子目
+  RELOAD_RATION_TEMPLATE = 'refresh:rationTemplate',
+  // 刷新信息价表格
+  REFRESH_INFO_PRICE = 'refresh:infoPrice',
+  // 刷新费率页面表格
+  REFRESH_FEE_RATE = 'refresh:feeRate',
+  // 刷新补充定额章节树按钮区有效性
+  REFRESH_RATION_TREE_TOOLS = 'refresh:rationTreeTools',
+  // 刷新补充定额表格
+  REFRESH_CPT_RATION = 'refresh:cptRation',
+  // 刷新安装增加费用项表格
+  REFRESH_INSTALL_FEE_ITEM = 'refresh:installFeeItem',
+  // 项目分享历史变更
+  PROJECT_SHARE_HISTORY_CHANGED = 'projectShareHistoryChanged',
+  // 定额库分享历史变更
+  RATION_LIB_SHARE_HISTORY_CHANGED = 'rationLibShareHistoryChanged',
+  // 人材机库分享历史变更
+  GLJ_LIB_SHARE_HISTORY_CHANGED = 'gljLibShareHistoryChanged',
+  // 更新了子目换算总表数据
+  UPDATE_CPT_COE = 'update:cptCoe',
+  // 关闭计算基数弹窗
+  CLOSE_FORMULA_POPUP = 'close:formulaPopup',
+  // 打开库标签
+  OPEN_LIB_TAB = 'open:libTab',
+  // 添加新成员
+  ADD_NEW_MEMBER = 'addNewMember',
+  // 打开消息中心
+  OPEN_NOTIFICATION = 'openNotification',
+  // 未读消息数量变化
+  UNREAD_COUNT_CHANGE = 'unreadCountChange',
+  // 新消息
+  NEW_MESSAGE = 'newMessage',
+  // 离开 socket 房间
+  LEAVE_ROOM = 'leaveRoom',
+  // 刷新工料机库显示(按选中类别)
+  REFRESH_CPT_GLJ_LIB = 'refresh:cptGljLib',
+  // 变更补充定额库
+  CHANGE_CPT_RATION_LIB = 'change:cptRationLib',
+  // 变更补充人材机库
+  CHANGE_CPT_GLJ_LIB = 'change:cptGljLib',
+  // 刷新书签批注
+  REFRESH_BOOKMARK = 'refresh:bookmark',
+  // 更新显示/隐藏特征按钮
+  UPDATE_SHOW_FEATURE = 'update:isShowFeature',
+  // 造价单书签批注用,hover事件计数器
+  CLEAR_CELL_HOVER_LIST = 'clear:cellHoverList',
+  // 清空人材机组成物
+  CLEAR_GLJ_COMPONENT = 'clear:gljComponent',
+  // 刷新建设项目分享标记
+  REFRESH_CONSTRUCTION_SHARE_MARK = 'refresh:constructionShareMark',
+  // 单位工程信息指标表格刷新
+  REFRESH_TARGET_GRID = 'refresh:targetGrid',
+  // 刷新建设项目汇总
+  REFRESH_CONSTRUCTION_SUMMARY = 'refresh:constructionSummary',
+  // 刷新分享项目数据
+  REFRESH_SHARE_PROJECT = 'refresh:shareProject',
+  // 刷新分享定额库数据
+  REFRESH_SHARE_RATION_LIB = 'refresh:shareRationLib',
+  // 刷新分享人材机库数据
+  REFRESH_SHARE_GLJ_LIB = 'refresh:shareGljLib',
+  // 追加定额库选项
+  APPEND_RATION_LIB_OPTION = 'append:rationLibOption',
+  // 追加人材机库选项
+  APPEND_GLJ_LIB_OPTION = 'append:gljLibOption',
+  // 移除定额库选项
+  REMOVE_RATION_LIB_OPTION = 'remove:rationLibOption',
+  // 移除人材机库选项
+  REMOVE_GLJ_LIB_OPTION = 'remove:gljLibOption',
+  // 刷新项目权限
+  REFRESH_PROJECT_PERMISSION = 'refresh:projectPermission',
+}
+
+// 单例的emitter(有些事件不能用单例,可能会有多建设项目页面)
+const singletonEmitter = mitt();
+
+const emitterMap = new Map<string, Emitter>();
+
+// 已出现的监听枚举
+const listenedMap: Record<string, Handler> = {};
+
+// 获取mitt事件处理方法,如果不传入key,则处理事件的对象为singletonEmitter。即不需要区分emitter的时候,不传入key
+export const getEmitter = (key?: string) => {
+  let emitter: Emitter;
+  if (key) {
+    if (!emitterMap.get(key)) {
+      emitterMap.set(key, mitt());
+    }
+    emitter = emitterMap.get(key) as Emitter;
+  } else {
+    emitter = singletonEmitter;
+  }
+  return {
+    /**
+     * !!!注意!!!:emit监听时,按需增加监听事件名称,同一类型下,不可有重复监听事件名称
+     * 由于组件可能多次渲染(多次进入setup方法),为了避免重复监听事件。
+     */
+    on<T = any>(emitterType: EmitterType, handler: Handler<T>, listenerName?: string) {
+      const listenerKey = `${emitterType}-${listenerName}`;
+      if (listenedMap[listenerKey]) {
+        this.off(emitterType, listenedMap[listenerKey]);
+      }
+      listenedMap[listenerKey] = handler;
+      emitter.on<T>(emitterType, handler);
+    },
+    off<T = any>(emitterType: EmitterType, handler: Handler<T>) {
+      emitter.off<T>(emitterType, handler);
+    },
+    emit<T = any>(emitterType: EmitterType, payload?: T) {
+      emitter.emit<T>(emitterType, payload);
+    },
+  };
+};

+ 30 - 0
src/constants/provideInject.ts

@@ -0,0 +1,30 @@
+// provide/inject枚举
+enum ProvideInject {
+  DIRECTION = 'direction',
+  DEBOUNCE = 'debounce',
+  MAX_WAIT = 'maxWait',
+  // BAR_WIDTH = 'barWidth',
+  // DOTTED = 'dotted',
+  SUBJECT_TREE_DATA = 'subjectTreeData',
+  CUR_TAB_VUEX = 'currentTabVuexData',
+  CUR_PM_TAB = 'currentProjectManageTab',
+  CONSTRUCTION_ID = 'constructionID',
+  CONSTRUCTION_READONLY = 'constructionReadOnly',
+  SINGLE_ID = 'singleID',
+  SINGLE_READONLY = 'singleReadOnly',
+  UNIT_ID = 'unitID',
+  UNIT_READONLY = 'unitReadOnly',
+  UNIT_ACTIVE_KEY = 'unitActiveKey',
+  GLJ_NAV_KEY = 'GljNavKey',
+  COST_SELECTED_ROW_DATA = 'costSelectedRowData',
+  SELECTED_PROJECT_GLJ = 'selectedProjectGLJ',
+  USER_INFO = 'userInfo',
+  // 当前定额库ID
+  CPT_RATION_LIB_ID = 'cptRationLibID',
+  // 选中的补充定额章节树节点
+  CPT_RATION_TREE_SELECTED = 'cptRationTreeSelected',
+  // 选中的补充定额
+  CPT_RATION_SELECTED = 'cptRationSelected',
+}
+
+export default ProvideInject;

+ 23 - 10
src/views/main-frame/style.scss

@@ -1,9 +1,9 @@
 .main-frame-page {
-  @apply w-full h-full;
+  @apply relative w-full h-full;
   min-width: $small-screen;
 
   .header {
-    @apply flex items-center fixed top-0 right-0 w-full z-10;
+    @apply flex items-center fixed top-0 right-0 w-full z-20;
     height: 64px;
     background: linear-gradient(90deg, #1d42ab, #2173dc, #1e93ff);
     padding-left: 20px;
@@ -70,7 +70,7 @@
   }
 
   .aside {
-    @apply fixed top-0 left-0 min-h-full;
+    @apply fixed top-0 left-0 min-h-full z-10;
     width: 80px;
     padding-top: 64px;
     box-shadow: 2px 0 8px 0 rgb(29 35 41 / 5%);
@@ -79,7 +79,7 @@
     .menu {
       .item {
         @apply flex justify-center items-center relative cursor-pointer;
-        height: 52px;
+        height: 50px;
         color: #515a6e;
         transition: all .2s ease-in-out;
 
@@ -123,7 +123,7 @@
 
           &.active {
             &::after {
-              bottom: 2px;
+              bottom: 0;
               top: auto;
               left: 0;
               width: 100%;
@@ -136,13 +136,26 @@
   }
 
   .main {
-    @apply min-h-full;
+    @apply absolute overflow-auto;
     background: #f5f7f9;
-    padding-left: 80px;
-    padding-top: 64px;
+    left: 80px;
+    top: 64px;
+    width: calc(100% - 80px);
+    height: calc(100% - 64px);
+
+    &::-webkit-scrollbar-track {
+      background-color: #fff !important;
+    }
+
+    &::-webkit-scrollbar-track:vertical {
+      border-left: 1px solid #e8eaec;
+    }
+
     @media (max-width: $small-screen) {
-      padding-left: 0;
-      padding-top: 114px;
+      left: 0;
+      top: 114px;
+      width: 100%;
+      height: calc(100% - 114px);
     }
   }
 }

+ 54 - 48
src/views/project/Project.vue

@@ -1,10 +1,13 @@
 <script setup lang="ts">
 import {onMounted, ref} from "vue";
+import {useRoute} from 'vue-router'
+
+const route = useRoute()
 </script>
 
 <template>
   <article class="project-page">
-    <header class="header">
+    <header class="header" id="project-header" :class="{'short-header': route.fullPath.startsWith('/project/summary')}">
       <h1 class="name">莲花路电力迁改项目</h1>
       <el-descriptions :column="4" size="mini">
         <el-descriptions-item label="申请单位:">某某建筑公司</el-descriptions-item>
@@ -22,56 +25,59 @@ import {onMounted, ref} from "vue";
           珠海市香洲区某某街道某某路100号
         </el-descriptions-item>
       </el-descriptions>
-      <ul class="nav">
-        <router-link custom to="/project" v-slot="{ navigate, isExactActive }">
-          <li
-              class="item overview"
-              @click="navigate"
-              :class="{ 'exact-active': isExactActive }"
-          >
-            项目概况
-          </li>
-        </router-link>
-        <router-link
-            custom
-            to="/project/process"
-            v-slot="{ navigate, isExactActive }"
-        >
-          <li
-              class="item process"
-              @click="navigate"
-              :class="{ 'exact-active': isExactActive }"
+
+      <el-affix :offset="64">
+        <ul class="nav">
+          <router-link custom to="/project" v-slot="{ navigate, isExactActive }">
+            <li
+                class="item overview"
+                @click="navigate"
+                :class="{ 'exact-active': isExactActive }"
+            >
+              项目概况
+            </li>
+          </router-link>
+          <router-link
+              custom
+              to="/project/process"
+              v-slot="{ navigate, isExactActive }"
           >
-            项目流程
-          </li>
-        </router-link>
-        <router-link
-            custom
-            to="/project/summary"
-            v-slot="{ navigate, isExactActive }"
-        >
-          <li
-              class="item summary"
-              @click="navigate"
-              :class="{ 'exact-active': isExactActive }"
+            <li
+                class="item process"
+                @click="navigate"
+                :class="{ 'exact-active': isExactActive }"
+            >
+              项目流程
+            </li>
+          </router-link>
+          <router-link
+              custom
+              to="/project/summary"
+              v-slot="{ navigate, isExactActive }"
           >
-            项目流汇总
-          </li>
-        </router-link>
-        <router-link
-            custom
-            to="/project/report"
-            v-slot="{ navigate, isExactActive }"
-        >
-          <li
-              class="item report"
-              @click="navigate"
-              :class="{ 'exact-active': isExactActive }"
+            <li
+                class="item summary"
+                @click="navigate"
+                :class="{ 'exact-active': isExactActive }"
+            >
+              项目流汇总
+            </li>
+          </router-link>
+          <router-link
+              custom
+              to="/project/report"
+              v-slot="{ navigate, isExactActive }"
           >
-            报表
-          </li>
-        </router-link>
-      </ul>
+            <li
+                class="item report"
+                @click="navigate"
+                :class="{ 'exact-active': isExactActive }"
+            >
+              报表
+            </li>
+          </router-link>
+        </ul>
+      </el-affix>
     </header>
     <main class="main">
       <router-view></router-view>

+ 111 - 25
src/views/project/overview/Overview.vue

@@ -5,11 +5,10 @@ import * as echarts from 'echarts';
 
 const chart1Ref = ref<HTMLDivElement>()
 const chart2Ref = ref<HTMLDivElement>()
+const chart3Ref = ref<HTMLDivElement>()
 
 onMounted(() => {
-
-  if (chart1Ref.value && chart2Ref.value) {
-    // 基于准备好的dom,初始化echarts实例
+  if (chart1Ref.value && chart2Ref.value && chart3Ref.value) {
     const myChart1 = echarts.init(chart1Ref.value);
     const option1 = {
       xAxis: {
@@ -19,10 +18,18 @@ onMounted(() => {
       yAxis: {
         type: 'value'
       },
+
       series: [
         {
           data: [120, 200, 150, 80, 70, 110, 130, 140, 210, 100, 80, 20],
-          type: 'bar'
+          type: 'bar',
+          itemStyle: {
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              {offset: 0, color: '#83bff6'},
+              {offset: 0.5, color: '#188df0'},
+              {offset: 1, color: '#188df0'}
+            ])
+          },
         }
       ]
     };
@@ -40,13 +47,8 @@ onMounted(() => {
       series: [
         {
           name: 'Access From',
-          legend: {
-            orient: 'vertical',
-            left: 10,
-            show: false
-          },
           type: 'pie',
-          radius: ['50%', '70%'],
+          radius: ['50%', '60%'],
           avoidLabelOverlap: true,
           label: {
             show: false,
@@ -63,11 +65,9 @@ onMounted(() => {
             show: false
           },
           data: [
-            {value: 1048, name: 'Search Engine'},
-            {value: 735, name: 'Direct'},
-            {value: 580, name: 'Email'},
-            {value: 484, name: 'Union Ads'},
-            {value: 300, name: 'Video Ads'}
+            {value: 1048, name: '人工费'},
+            {value: 735, name: '材料费'},
+            {value: 580, name: '其他'},
           ]
         }
       ]
@@ -75,11 +75,63 @@ onMounted(() => {
     myChart2.setOption(option2);
 
 
-    // 基于准备好的dom,初始化echarts实例
-    // const myChart3 = echarts.init(chart1Ref.value);
-    // const option3 = ''
-    // myChart3.setOption(option3);
+    const myChart3 = echarts.init(chart3Ref.value);
+    const option3 = {
+      series: [
+        {
+          type: 'gauge',
+          progress: {
+            show: true,
+            width: 5
+          },
+          axisLine: {
+            lineStyle: {
+              width: 5
+            }
+          },
+          axisTick: {
+            show: false
+          },
+          splitLine: {
+            show: false,
+          },
+          axisLabel: {
+            show: false,
+          },
+          anchor: {
+            show: true,
+            showAbove: true,
+            size: 10,
+            itemStyle: {
+              borderWidth: 2
+            }
+          },
+          title: {
+            show: false
+          },
+          detail: {
+            valueAnimation: true,
+            fontSize: 20,
+            offsetCenter: [0, '70%']
+          },
+          data: [
+            {
+              value: 70
+            }
+          ]
+        }
+      ]
+    };
+    myChart3.setOption(option3);
+
+
+    window.onresize = function () {
+      myChart1.resize()
+      myChart2.resize()
+      myChart3.resize()
+    };
   }
+
 })
 
 const list = [
@@ -114,6 +166,30 @@ const list = [
 ]
 list.push(...list)
 const logList = reactive(list);
+
+const appoveList = reactive([
+  {
+    name: '张三',
+    number: '001',
+    department: '财务部门'
+  },
+  {
+    name: '李四',
+    number: '002',
+    department: '财务部门'
+  },
+  {
+    name: '王五',
+    number: '003',
+    department: '财务部门'
+  },
+  {
+    name: '赵六',
+    number: '004',
+    department: '财务部门'
+  }
+])
+
 </script>
 
 <template>
@@ -150,18 +226,16 @@ const logList = reactive(list);
     </el-row>
     <el-row :gutter="24" class="second-row">
       <el-col :span="18">
-        <el-card shadow="never" class="card-chart1">
+        <el-card shadow="never" header="标题" class="card-chart1">
           <div class="chart1" ref="chart1Ref"></div>
         </el-card>
       </el-col>
       <el-col :span="6">
-        <el-card shadow="never" class="card-chart2">
+        <el-card shadow="never" header="费用占比" class="card-chart2">
           <div class="chart2" ref="chart2Ref"></div>
         </el-card>
-        <el-card shadow="never" class="card-chart3">
-          <div class="chart3" ref="chart3Ref" style="display: flex; justify-content: center; align-items: center">
-            <el-progress type="circle" :percentage="25"/>
-          </div>
+        <el-card shadow="never" header="审批进度" class="card-chart3">
+          <div class="chart3" ref="chart3Ref"></div>
         </el-card>
       </el-col>
     </el-row>
@@ -209,6 +283,18 @@ const logList = reactive(list);
       </el-descriptions>
     </el-card>
 
+    <el-card shadow="never" header="审批人员">
+      <!-- 列表 -->
+      <el-table :data="appoveList">
+        <el-table-column prop="name" label="姓名"/>
+        <el-table-column prop="number" label="工号"/>
+        <el-table-column prop="department" label="所属部门"></el-table-column>
+        <template #empty>
+          <el-empty :image-size="120" description="暂无数据"/>
+        </template>
+      </el-table>
+    </el-card>
+
     <el-card shadow="never" header="操作日志">
       <!-- 列表 -->
       <el-table :data="logList">

src/components/.gitkeep → src/views/project/overview/scripts/.gitkeep


+ 3 - 9
src/views/project/overview/style.scss

@@ -38,21 +38,15 @@
 
     &.second-row {
       .chart1 {
-        height: 400px;
-      }
-
-      .card-chart2 {
-        ::v-deep .el-card__body {
-          padding: 0;
-        }
+        height: 455px;
       }
 
       .chart2 {
-        height: 230px;
+        height: 200px;
       }
 
       .chart3 {
-        height: 154px;
+        height: 140px;
       }
     }
   }

+ 67 - 2
src/views/project/process/Process.vue

@@ -1,9 +1,74 @@
 <script setup lang="ts">
-import { onMounted, reactive, ref } from "vue";
+import {onMounted, reactive, ref} from "vue";
+
+const steps = reactive([
+  {
+    label: '(1) 步骤1',
+  },
+  {
+    label: '(2) 步骤2',
+  },
+  {
+    label: '(3) 步骤3',
+  },
+  {
+    label: '(4) 步骤4',
+  },
+  {
+    label: '(5) 步骤5',
+  },
+  {
+    label: '(6) 步骤6',
+  },
+  {
+    label: '(7) 步骤7',
+  },
+  {
+    label: '(8) 步骤8',
+    children: [
+      {
+        label: '- 步骤8.1',
+      },
+      {
+        label: '- 步骤8.2',
+      },
+    ],
+  },
+  {
+    label: '(9) 步骤9',
+  },
+])
 </script>
 
 <template>
-  <article class="process-page">项目流程</article>
+  <article class="process-page">
+    <header class="header">
+      <ul class="tabs">
+        <li class="tab">预算</li>
+        <li class="tab active">过程预算</li>
+        <li class="tab">过程结算</li>
+        <li class="tab">结算</li>
+        <li class="tab">决算</li>
+      </ul>
+      <el-button type="primary">台账审核</el-button>
+    </header>
+    <div class="wrap">
+      <el-affix :offset="130">
+        <el-card header="步骤" shadow="never" class="step">
+          <el-tree :data="steps" default-expand-all :expand-on-click-node="false"/>
+        </el-card>
+      </el-affix>
+      <el-card shadow="never" class="preview">
+        <div class="area">
+          表单/文件
+        </div>
+        <div class="btn-wrap">
+          <el-button type="success">审核通过</el-button>
+          <el-button type="danger">审核退回</el-button>
+        </div>
+      </el-card>
+    </div>
+  </article>
 </template>
 
 <style lang="scss" src="./style.scss" scoped></style>

+ 75 - 0
src/views/project/process/style.scss

@@ -0,0 +1,75 @@
+.process-page {
+  padding: 16px 24px 60px;
+
+  .header {
+    @apply flex justify-between;
+    .tabs {
+      .tab {
+        @apply inline-block border-none cursor-pointer;
+        background: #fff;
+        border-radius: 3px;
+        margin-right: 8px;
+        padding: 5px 16px;
+        color: #808695;
+        box-shadow: 0 2px 14px 0 rgba(0, 0, 0, 0.04);
+
+        &.active {
+          color: #2d8cf0 !important;
+        }
+
+        &:hover {
+          color: #515a6e;
+        }
+      }
+    }
+
+    .el-button {
+      box-shadow: 0 2px 14px 0 rgba(0, 0, 0, 0.1);
+    }
+  }
+
+  .wrap {
+    @apply flex items-start;
+    margin-top: 16px;
+
+    .el-card {
+      border: none;
+
+      &.step {
+        width: 280px;
+        min-height: 450px;
+      }
+
+      &.preview {
+        @apply flex-1;
+        margin-left: 16px;
+        min-height: 900px;
+
+        .area {
+          @apply flex items-center justify-center;
+          height: 500px;
+          background: #e7e7e7;
+          border-radius: 4px;
+        }
+
+        .btn-wrap {
+          @apply text-right;
+          margin-top: 20px;
+        }
+      }
+
+      ::v-deep .el-tree {
+        .el-tree-node__content {
+          @apply cursor-default;
+          background: transparent !important;
+          height: 30px;
+
+          .el-tree-node__expand-icon {
+            display: none;
+            margin: 0;
+          }
+        }
+      }
+    }
+  }
+}

+ 31 - 4
src/views/project/style.scss

@@ -1,17 +1,37 @@
 .project-page {
+  @apply min-h-full h-full w-full;
   .header {
-    padding: 16px 32px 0 32px;
-    border-bottom: 1px solid #e8eaec;
+    //padding: 16px 32px 0 32px;
     background: #fff;
 
+    &.short-header {
+      .el-descriptions {
+        @apply hidden;
+      }
+
+      .name {
+        padding-bottom: 2px;
+      }
+
+      .nav {
+        padding-top: 0;
+      }
+
+      & + .main {
+        min-height: calc(100% - 90px);
+      }
+    }
+
     .name {
       font-size: 20px;
       font-weight: 500;
       color: #17233d;
-      margin-bottom: 16px;
+      padding: 16px 32px;
     }
 
     ::v-deep .el-descriptions {
+      padding: 0 32px;
+
       .el-descriptions__label {
         margin-right: 0;
       }
@@ -19,7 +39,9 @@
 
     .nav {
       @apply flex h-full items-center select-none;
-      margin-top: 8px;
+      background: #fff;
+      padding: 10px 32px 0;
+      border-bottom: 1px solid #e8eaec;
 
       .item {
         @apply h-full cursor-pointer;
@@ -37,4 +59,9 @@
       }
     }
   }
+
+  .main {
+    @apply relative;
+    min-height: calc(100% - 168px);
+  }
 }

+ 64 - 2
src/views/project/summary/Summary.vue

@@ -1,9 +1,71 @@
 <script setup lang="ts">
-import { onMounted, reactive, ref } from "vue";
+import {onMounted, reactive, ref} from "vue";
+import ProjectExplorer from "./components/project-explorer/ProjectExplorer.vue";
+import ToolBar from "./components/tool-bar/ToolBar.vue";
+import CostTable from "./components/cost-table/CostTable.vue";
+import BottomTabs from "./components/bottom-tabs/BottomTabs.vue";
+import StdBill from "./components/std-bill/StdBill.vue";
+
+const projectExplorerSize = ref(60)
+const mainContentSize = ref(300)
+const mainLeftSize = ref(150)
+const mainRightSize = ref(50)
+
+const mainLeftTopSize = ref(300)
+const mainLeftBottomSize = ref(150)
+
+const handleProjectExplorerSize = (size: number) => {
+  projectExplorerSize.value = size
+}
+const handleMainContentSize = (size: number) => {
+  mainContentSize.value = size
+}
+
+const handleMainLeftSize = (size: number) => {
+  mainLeftSize.value = size
+}
+const handleMainRightSize = (size: number) => {
+  mainRightSize.value = size
+}
+const handleMainLeftTopSize = (size: number) => {
+  mainLeftTopSize.value = size
+}
+const handleMainLeftBottomSize = (size: number) => {
+  mainLeftBottomSize.value = size
+}
 </script>
 
 <template>
-  <article class="report-page">项目流汇总</article>
+  <article class="summary-page">
+    <resizable-layout>
+      <resizable-layout-item :weight="projectExplorerSize" :min-width="200" @resize="handleProjectExplorerSize">
+        <!-- 左侧项目结构浏览器 -->
+        <project-explorer/>
+      </resizable-layout-item>
+      <resizable-layout-item :weight="mainContentSize" :min-width="400" @resize="handleMainContentSize">
+        <!-- 工具栏 -->
+        <tool-bar/>
+        <resizable-layout style="height: calc(100% - 41px)">
+          <resizable-layout-item :weight="mainLeftSize" :min-width="150" @resize="handleMainLeftSize">
+            <resizable-layout direction="vertical">
+              <resizable-layout-item :weight="mainLeftTopSize" :min-width="400" @resize="handleMainLeftTopSize">
+                <!-- 造价书表格 -->
+                <cost-table/>
+              </resizable-layout-item>
+              <resizable-layout-item :weight="mainLeftBottomSize" :min-width="400" @resize="handleMainLeftBottomSize">
+                <!-- 底部 tabs -->
+                <bottom-tabs/>
+              </resizable-layout-item>
+            </resizable-layout>
+          </resizable-layout-item>
+          <!-- 右侧布局:标准清单 -->
+          <resizable-layout-item :weight="mainRightSize" :min-width="150" @resize="handleMainRightSize">
+            <std-bill/>
+          </resizable-layout-item>
+        </resizable-layout>
+      </resizable-layout-item>
+    </resizable-layout>
+  </article>
 </template>
 
 <style lang="scss" src="./style.scss" scoped></style>

+ 11 - 0
src/views/project/summary/components/bottom-tabs/BottomTabs.vue

@@ -0,0 +1,11 @@
+<script setup lang="ts">
+import {onMounted, reactive, ref} from "vue";
+</script>
+
+<template>
+  <section class="bottom-tabs">
+    底部 tabs
+  </section>
+</template>
+
+<style lang="scss" src="./style.scss" scoped></style>

+ 5 - 0
src/views/project/summary/components/bottom-tabs/style.scss

@@ -0,0 +1,5 @@
+.bottom-tabs {
+  @apply h-full ;
+  background: #fff;
+  border: 1px solid #e8eaec;
+}

+ 11 - 0
src/views/project/summary/components/cost-table/CostTable.vue

@@ -0,0 +1,11 @@
+<script setup lang="ts">
+import {onMounted, reactive, ref} from "vue";
+</script>
+
+<template>
+  <section class="cost-table">
+    造价书表
+  </section>
+</template>
+
+<style lang="scss" src="./style.scss" scoped></style>

+ 5 - 0
src/views/project/summary/components/cost-table/style.scss

@@ -0,0 +1,5 @@
+.cost-table {
+  @apply h-full;
+  background: #fff;
+  border: 1px solid #e8eaec;
+}

+ 17 - 0
src/views/project/summary/components/project-explorer/ProjectExplorer.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import {onMounted, reactive, ref} from "vue";
+</script>
+
+<template>
+  <section class="project-explorer">
+      <ul class="tabs">
+        <li class="tab active">原始结构</li>
+        <li class="tab">整理结构</li>
+      </ul>
+      <div class="tree-wrap">
+          树结构
+      </div>
+  </section>
+</template>
+
+<style lang="scss" src="./style.scss" scoped></style>

+ 47 - 0
src/views/project/summary/components/project-explorer/style.scss

@@ -0,0 +1,47 @@
+.project-explorer {
+  @apply h-full;
+  background: #fff;
+  border: 1px solid #e8eaec;
+
+  .tabs {
+    @apply flex;
+    border-bottom: 1px solid #e8eaec;
+    height: 35px;
+
+    .tab {
+      @apply flex-1 text-center cursor-pointer;
+      height: 100%;
+      line-height: 35px;
+      font-size: 14px;
+
+      &:hover {
+        color: #1e93ff;
+      }
+
+      &.active {
+        color: #1e93ff;
+        font-weight: 600;
+      }
+
+      &:first-child {
+        @apply relative;
+        &::after {
+          @apply absolute;
+          content: '';
+          width: 1px;
+          height: 24px;
+          background: #e8eaec;
+          right: 0;
+          top: 6px;
+        }
+      }
+    }
+  }
+
+  .tree-wrap {
+    width: 100%;
+    padding: 8px;
+    height: calc(100% - 36px);
+    overflow: hidden;
+  }
+}

+ 0 - 0
src/views/project/summary/components/scripts/.gitkeep


+ 11 - 0
src/views/project/summary/components/std-bill/StdBill.vue

@@ -0,0 +1,11 @@
+<script setup lang="ts">
+import {onMounted, reactive, ref} from "vue";
+</script>
+
+<template>
+  <section class="std-bill">
+    标准清单
+  </section>
+</template>
+
+<style lang="scss" src="./style.scss" scoped></style>

+ 5 - 0
src/views/project/summary/components/std-bill/style.scss

@@ -0,0 +1,5 @@
+.std-bill {
+  @apply h-full;
+  background: #fff;
+  border: 1px solid #e8eaec;
+}

+ 11 - 0
src/views/project/summary/components/tool-bar/ToolBar.vue

@@ -0,0 +1,11 @@
+<script setup lang="ts">
+import {onMounted, reactive, ref} from "vue";
+</script>
+
+<template>
+  <section class="tool-bar">
+    工具栏
+  </section>
+</template>
+
+<style lang="scss" src="./style.scss" scoped></style>

+ 8 - 0
src/views/project/summary/components/tool-bar/style.scss

@@ -0,0 +1,8 @@
+.tool-bar {
+  @apply flex items-center;
+  background: #fff;
+  height: 36px;
+  padding-left: 20px;
+  margin-bottom: 5px;
+  border: 1px solid #e8eaec;
+}

+ 4 - 0
src/views/project/summary/style.scss

@@ -0,0 +1,4 @@
+.summary-page {
+  @apply absolute h-full left-0 right-0 overflow-hidden;
+  padding: 5px;
+}

+ 2 - 1
vite.config.ts

@@ -12,7 +12,8 @@ export default defineConfig({
             '@': resolve(__dirname, 'src'),
             'controller': resolve(__dirname, 'src/apis/controller'),
             'service': resolve(__dirname, 'src/apis/service'),
-            'utils': resolve(__dirname, 'src/utils')
+            'constants': resolve(__dirname, 'src/constants'),
+            'utils': resolve(__dirname, 'src/utils'),
         }
     },
     css: {