Преглед изворни кода

项目管理:
- 新增添加项目成员
- 重写文件夹权限管理,完成封装自定义权限组件(不完全通用)
文档管理:
- 恢复下载逻辑(直接使用<a>标签下载)
- 审核按钮页面基本绘制弹出框,待接口同步(其中在线文档编辑最后进行)

Huanyi пре 1 дан
родитељ
комит
9d3c51623a

+ 13 - 1
src/api/document/document/index.ts

@@ -1,5 +1,5 @@
 import request from '@/utils/request';
-import { DocumentForm, DocumentQuery, DocumentVO } from './types';
+import { DocumentForm, DocumentQuery, DocumentVO, DocumentMarkForm } from './types';
 import { AxiosPromise } from 'axios';
 
 /**
@@ -25,3 +25,15 @@ export const addDocument = (data: DocumentForm) => {
     data: data
   });
 };
+
+/**
+ * 标识文档
+ * @param data
+ */
+export const markDocument = (data: DocumentMarkForm) => {
+  return request({
+    url: '/document/document/mark',
+    method: 'put',
+    data: data
+  });
+};

+ 15 - 0
src/api/document/document/types.ts

@@ -148,3 +148,18 @@ export interface DocumentQuery extends PageQuery {
    */
   folderId?: string | number;
 }
+
+/**
+ * 文档标识表单
+ */
+export interface DocumentMarkForm {
+  /**
+   * 文档ID
+   */
+  id: string | number;
+
+  /**
+   * 文档标识类型
+   */
+  type: string;
+}

+ 12 - 0
src/api/document/folder/index.ts

@@ -74,3 +74,15 @@ export const listProject = (query?: ProjectQuery): AxiosPromise<ProjectVO[]> =>
     params: query
   });
 };
+
+/**
+ * 查询项目下的文件夹列表
+ * @param projectId 项目ID
+ */
+export const listFolderOnProject = (projectId: string | number): AxiosPromise<FolderListVO[]> => {
+  return request({
+    url: '/document/folder/listOnProject',
+    method: 'get',
+    params: { projectId }
+  });
+};

+ 38 - 1
src/api/project/management/index.ts

@@ -1,6 +1,6 @@
 import request from '@/utils/request';
 import { AxiosPromise } from 'axios';
-import { ManagementVO, ManagementForm, ManagementQuery, ProjectMemberVO, ProjectMemberQuery, UserNotInProjectQuery, UserVO, InviteMemberForm, CenterInfoVO, CenterInfoQuery, MemberNotInCenterVO, MemberNotInCenterQuery, InviteCenterMemberForm, CenterMemberVO, CenterMemberQuery } from '@/api/project/management/types';
+import { ManagementVO, ManagementForm, ManagementQuery, ProjectMemberVO, ProjectMemberQuery, UserNotInProjectQuery, UserVO, InviteMemberForm, CenterInfoVO, CenterInfoQuery, MemberNotInCenterVO, MemberNotInCenterQuery, InviteCenterMemberForm, CenterMemberVO, CenterMemberQuery, AddProjectMemberForm, AssignFoldersForm, GetFoldersResponse } from '@/api/project/management/types';
 
 /**
  * 查询项目管理列表
@@ -176,3 +176,40 @@ export const queryCenterMember = (query: CenterMemberQuery): AxiosPromise<Center
     params: query
   });
 };
+
+/**
+ * 添加项目成员
+ * @param data
+ */
+export const addProjectMember = (data: AddProjectMemberForm) => {
+  return request({
+    url: '/project/management/queryProjectMemberAddMember',
+    method: 'post',
+    data: data
+  });
+};
+
+/**
+ * 获取用户文件夹权限
+ * @param userId 用户ID
+ * @param projectId 项目ID
+ */
+export const getFolders = (userId: string | number, projectId: string | number): AxiosPromise<GetFoldersResponse> => {
+  return request({
+    url: '/project/management/getFolders',
+    method: 'get',
+    params: { userId, projectId }
+  });
+};
+
+/**
+ * 分配文件夹权限
+ * @param data
+ */
+export const assignFolders = (data: AssignFoldersForm) => {
+  return request({
+    url: '/project/management/queryProjectMemberAssignFolders',
+    method: 'put',
+    data: data
+  });
+};

+ 85 - 0
src/api/project/management/types.ts

@@ -516,3 +516,88 @@ export interface CenterMemberQuery extends PageQuery {
    */
   center?: string;
 }
+
+/**
+ * 添加项目成员表单
+ */
+export interface AddProjectMemberForm {
+  /**
+   * 项目ID
+   */
+  projectId: string | number;
+
+  /**
+   * 手机号
+   */
+  phoneNumber: string;
+
+  /**
+   * 昵称
+   */
+  nickname: string;
+
+  /**
+   * 用户名
+   */
+  username: string;
+
+  /**
+   * 密码
+   */
+  password: string;
+
+  /**
+   * 邮箱
+   */
+  email?: string;
+
+  /**
+   * 性别
+   */
+  gender?: string;
+
+  /**
+   * 角色ID列表
+   */
+  roleIds: number[];
+
+  /**
+   * 部门ID
+   */
+  deptId: number;
+
+  /**
+   * 文件夹权限
+   */
+  folders: string;
+}
+
+/**
+ * 分配文件夹权限表单
+ */
+export interface AssignFoldersForm {
+  /**
+   * 用户ID
+   */
+  userId: string | number;
+
+  /**
+   * 项目ID
+   */
+  projectId: string | number;
+
+  /**
+   * 文件夹权限
+   */
+  folders: string;
+}
+
+/**
+ * 获取用户文件夹权限响应
+ */
+export interface GetFoldersResponse {
+  /**
+   * 文件夹权限
+   */
+  folders: string;
+}

+ 242 - 0
src/components/DataPermisionTree/TreeNode.vue

@@ -0,0 +1,242 @@
+<template>
+    <div class="tree-node">
+        <div class="node-content" :class="{ disabled: isDisabled }">
+            <span v-if="hasChildren" class="expand-icon" @click="handleExpand">
+                <svg v-if="isExpanded" width="16" height="16" viewBox="0 0 16 16">
+                    <path d="M8 4L12 8L8 12L11 8L8 4Z" fill="currentColor" transform="rotate(90 8 8)" />
+                </svg>
+                <svg v-else width="16" height="16" viewBox="0 0 16 16">
+                    <path d="M4 8L8 12L12 8L8 4L4 8Z" fill="currentColor" />
+                </svg>
+            </span>
+            <span v-else class="expand-placeholder"></span>
+
+            <label class="checkbox-container">
+                <input type="checkbox" :checked="isChecked" :indeterminate="isIndeterminate" :disabled="isDisabled"
+                    @change="handleCheck" />
+                <span class="checkmark"></span>
+            </label>
+
+            <span class="node-label">
+                <slot :node="node" :data="node">
+                    <span>{{ node.name }}</span>
+                </slot>
+            </span>
+        </div>
+
+        <div v-if="isExpanded && hasChildren" class="children">
+            <tree-node v-for="child in node.children" :key="child.id" :node="child" :selected-keys="selectedKeys"
+                :indeterminate-keys="indeterminateKeys" :disabled-keys="disabledKeys" :expanded-keys="expandedKeys"
+                @check="handleChildCheck" @expand="handleChildExpand">
+                <template #default="{ node: childNode, data }">
+                    <slot :node="childNode" :data="data"></slot>
+                </template>
+            </tree-node>
+        </div>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import type { TreeData } from './types'
+import { Folder, Document, Location, OfficeBuilding } from '@element-plus/icons-vue'
+
+interface Props {
+    node: TreeData
+    selectedKeys: number[]
+    indeterminateKeys: Set<number>
+    disabledKeys: Set<number>
+    expandedKeys: Set<number>
+}
+
+interface Emits {
+    (e: 'check', node: TreeData, checked: boolean): void
+    (e: 'expand', nodeId: number): void
+}
+
+const props = defineProps<Props>()
+const emit = defineEmits<Emits>()
+
+// 检查节点是否被选中
+const isChecked = computed(() => {
+    return props.selectedKeys.includes(props.node.id)
+})
+
+// 检查节点是否为半选状态
+const isIndeterminate = computed(() => {
+    return props.indeterminateKeys.has(props.node.id)
+})
+
+// 检查节点是否被禁用
+const isDisabled = computed(() => {
+    return props.disabledKeys.has(props.node.id)
+})
+
+// 检查是否有子节点
+const hasChildren = computed(() => {
+    return props.node.children && props.node.children.length > 0
+})
+
+// 检查是否展开
+const isExpanded = computed(() => {
+    return props.expandedKeys.has(props.node.id)
+})
+
+// 处理选中状态变化
+const handleCheck = (event: Event) => {
+    const target = event.target as HTMLInputElement
+    emit('check', props.node, target.checked)
+}
+
+// 处理展开/折叠
+const handleExpand = () => {
+    emit('expand', props.node.id)
+}
+
+// 处理子节点选中
+const handleChildCheck = (node: TreeData, checked: boolean) => {
+    emit('check', node, checked)
+}
+
+// 处理子节点展开
+const handleChildExpand = (nodeId: number) => {
+    emit('expand', nodeId)
+}
+</script>
+
+<style scoped>
+.tree-node {
+    width: 100%;
+}
+
+.node-content {
+    display: flex;
+    align-items: center;
+    padding: 4px 0;
+    cursor: pointer;
+}
+
+.node-content.disabled {
+    cursor: not-allowed;
+    opacity: 0.6;
+}
+
+.node-content.disabled input {
+    cursor: not-allowed;
+}
+
+.expand-icon {
+    width: 16px;
+    height: 16px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 4px;
+    cursor: pointer;
+    color: #666;
+}
+
+.expand-icon:hover {
+    color: #333;
+}
+
+.expand-placeholder {
+    width: 16px;
+    height: 16px;
+    margin-right: 4px;
+}
+
+.checkbox-container {
+    display: flex;
+    align-items: center;
+    margin-right: 8px;
+    position: relative;
+}
+
+.checkbox-container input {
+    position: absolute;
+    opacity: 0;
+    cursor: pointer;
+    height: 0;
+    width: 0;
+}
+
+.checkmark {
+    height: 16px;
+    width: 16px;
+    background-color: #fff;
+    border: 1px solid #dcdfe6;
+    border-radius: 2px;
+    transition: all 0.3s;
+    position: relative;
+}
+
+.checkbox-container:hover input~.checkmark {
+    border-color: #409eff;
+}
+
+.checkbox-container input:checked~.checkmark {
+    background-color: #409eff;
+    border-color: #409eff;
+}
+
+.checkbox-container input:indeterminate~.checkmark {
+    background-color: #409eff;
+    border-color: #409eff;
+}
+
+.checkbox-container input:checked~.checkmark::after {
+    content: "";
+    position: absolute;
+    left: 5px;
+    top: 1px;
+    width: 4px;
+    height: 8px;
+    border: solid white;
+    border-width: 0 2px 2px 0;
+    transform: rotate(45deg);
+}
+
+.checkbox-container input:indeterminate~.checkmark::after {
+    content: "";
+    position: absolute;
+    left: 3px;
+    top: 6px;
+    width: 8px;
+    height: 2px;
+    background-color: white;
+}
+
+.checkbox-container input:disabled~.checkmark {
+    background-color: #f5f7fa;
+    border-color: #e4e7ed;
+    cursor: not-allowed;
+}
+
+.checkbox-container input:disabled:checked~.checkmark {
+    background-color: #c0c4cc;
+    border-color: #c0c4cc;
+}
+
+.node-label {
+    font-size: 14px;
+    color: #606266;
+    flex: 1;
+    user-select: none;
+}
+
+.node-icon {
+    margin-right: 8px;
+    color: #606266;
+}
+
+.node-content.disabled .node-label {
+    color: #c0c4cc;
+}
+
+.children {
+    margin-left: 20px;
+    border-left: 1px dashed #e4e7ed;
+    padding-left: 10px;
+}
+</style>

+ 140 - 0
src/components/DataPermisionTree/demo.vue

@@ -0,0 +1,140 @@
+<template>
+    <div class="demo-container">
+        <h2>Data Permission Tree Demo</h2>
+
+        <div class="demo-section">
+            <h3>Basic Usage</h3>
+            <data-permission-tree :data="treeData" :selected-keys="selectedKeys"
+                @update:selectedKeys="handleSelectedKeysChange" @check="handleCheck" />
+        </div>
+
+        <div class="demo-section">
+            <h3>Disabled State</h3>
+            <data-permission-tree :data="treeData" :selected-keys="selectedKeys" :disabled="true"
+                @update:selectedKeys="handleSelectedKeysChange" @check="handleCheck" />
+        </div>
+
+        <div class="selected-info">
+            <h3>Selected Keys:</h3>
+            <p>{{ selectedKeys }}</p>
+        </div>
+
+        <div class="instructions">
+            <h3>Expected Behavior:</h3>
+            <ul>
+                <li>Select "中国" (ID: 1) → UI shows all children selected, but only records value: 1</li>
+                <li>Select "湖北省" (ID: 2) → UI shows "中国" indeterminate, "湖北省" and children selected, records: 2</li>
+                <li>Select "成都市" (ID: 6) → UI shows "中国" and "长沙省" indeterminate, "成都市" selected, records: 6</li>
+            </ul>
+        </div>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import DataPermissionTree from './index.vue'
+import type { TreeData } from './types'
+
+// 树形数据示例 (matching the requirement)
+const treeData: TreeData[] = [
+    {
+        id: 1,
+        name: '中国',
+        children: [
+            {
+                id: 2,
+                name: '湖北省',
+                children: [
+                    { id: 3, name: '武汉市' },
+                    { id: 4, name: '孝感市' }
+                ]
+            },
+            {
+                id: 5,
+                name: '长沙省',
+                children: [
+                    { id: 6, name: '成都市' }
+                ]
+            }
+        ]
+    },
+    {
+        id: 7,
+        name: '测试文件夹'
+    }
+]
+
+// 选中的键值
+const selectedKeys = ref<number[]>([])
+
+// 处理选中键值变化
+const handleSelectedKeysChange = (keys: number[]) => {
+    selectedKeys.value = keys
+}
+
+// 处理选中事件
+const handleCheck = (keys: { checked: number[], indeterminate: number[] }) => {
+    console.log('Checked keys:', keys.checked)
+    console.log('Indeterminate keys:', keys.indeterminate)
+}
+</script>
+
+<style scoped>
+.demo-container {
+    padding: 20px;
+    max-width: 800px;
+    margin: 0 auto;
+}
+
+.demo-section {
+    margin-bottom: 30px;
+    padding: 20px;
+    border: 1px solid #e4e7ed;
+    border-radius: 4px;
+}
+
+.demo-section h3 {
+    margin-top: 0;
+    color: #303133;
+}
+
+.selected-info {
+    padding: 20px;
+    background-color: #f5f7fa;
+    border-radius: 4px;
+}
+
+.selected-info h3 {
+    margin-top: 0;
+    color: #303133;
+}
+
+.selected-info p {
+    font-family: monospace;
+    background-color: #fff;
+    padding: 10px;
+    border-radius: 4px;
+    border: 1px solid #e4e7ed;
+}
+
+.instructions {
+    padding: 20px;
+    background-color: #fffbe6;
+    border-radius: 4px;
+    border: 1px solid #ffe58f;
+}
+
+.instructions h3 {
+    margin-top: 0;
+    color: #303133;
+}
+
+.instructions ul {
+    margin: 0;
+    padding-left: 20px;
+}
+
+.instructions li {
+    margin-bottom: 10px;
+}
+</style>

+ 251 - 0
src/components/DataPermisionTree/index.vue

@@ -0,0 +1,251 @@
+<template>
+    <div class="data-permission-tree">
+        <tree-node v-for="node in treeData" :key="node.id" :node="node" :selected-keys="uiSelectedKeys"
+            :indeterminate-keys="indeterminateKeys" :disabled-keys="disabledKeys" :expanded-keys="expandedKeys"
+            @check="handleCheck" @expand="handleExpand">
+            <template #default="{ node: treeNode, data }">
+                <slot name="default" :node="treeNode" :data="data">
+                    <span>{{ data.name }}</span>
+                </slot>
+            </template>
+        </tree-node>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch, onMounted } from 'vue'
+import TreeNode from './TreeNode.vue'
+import type { TreeData, CheckedKeys } from './types'
+
+interface Props {
+    data?: TreeData[]
+    selectedKeys?: number[]
+    disabled?: boolean
+}
+
+interface Emits {
+    (e: 'update:selectedKeys', keys: number[]): void
+    (e: 'check', keys: CheckedKeys): void
+}
+
+const props = withDefaults(defineProps<Props>(), {
+    data: () => [],
+    selectedKeys: () => [],
+    disabled: false
+})
+
+const emit = defineEmits<Emits>()
+
+// 内部选中键值
+const innerSelectedKeys = ref<number[]>([])
+const expandedKeys = ref<Set<number>>(new Set())
+const indeterminateKeys = ref<Set<number>>(new Set())
+
+// 计算禁用的键值
+const disabledKeys = computed(() => {
+    const keys = new Set<number>()
+    if (props.disabled) {
+        const collectKeys = (nodes: TreeData[]) => {
+            nodes.forEach(node => {
+                keys.add(node.id)
+                if (node.children && node.children.length > 0) {
+                    collectKeys(node.children)
+                }
+            })
+        }
+        collectKeys(props.data)
+    }
+    return keys
+})
+
+// 初始化选中状态
+onMounted(() => {
+    innerSelectedKeys.value = [...props.selectedKeys]
+    updateIndeterminateState()
+
+    // 默认展开所有节点
+    // 默认只展开根节点
+    if (props.data && props.data.length > 0) {
+        props.data.forEach(node => {
+            expandedKeys.value.add(node.id)
+        })
+    }
+})
+
+// 监听外部选中状态变化
+watch(() => props.selectedKeys, (newVal) => {
+    innerSelectedKeys.value = [...newVal]
+    updateIndeterminateState()
+})
+
+// 更新半选状态
+const updateIndeterminateState = () => {
+    const newIndeterminateKeys = new Set<number>()
+
+    const traverse = (nodes: TreeData[]) => {
+        nodes.forEach(node => {
+            if (node.children && node.children.length > 0) {
+                // 递归处理子节点
+                traverse(node.children)
+
+                // 检查当前节点是否为半选状态
+                const isNodeSelected = innerSelectedKeys.value.includes(node.id)
+
+                // 检查是否有子节点直接被选中
+                const hasDirectlySelectedChild = node.children.some(child => {
+                    return innerSelectedKeys.value.includes(child.id)
+                })
+
+                // 检查是否有子节点被选中或半选
+                const hasCheckedOrIndeterminateChild = node.children.some(child => {
+                    return innerSelectedKeys.value.includes(child.id) || newIndeterminateKeys.has(child.id)
+                })
+
+                // 半选条件:
+                // 1. 节点本身未被选中
+                // 2. 至少有一个子节点被直接选中或半选
+                // 3. 即使所有子节点都被选中,如果不是用户直接选择的父节点,仍显示为半选
+                if (!isNodeSelected && hasCheckedOrIndeterminateChild) {
+                    newIndeterminateKeys.add(node.id)
+                }
+            }
+        })
+    }
+
+    traverse(props.data)
+    indeterminateKeys.value = newIndeterminateKeys
+}
+
+// 获取节点的所有后代节点ID
+const getAllDescendantIds = (node: TreeData): number[] => {
+    const ids: number[] = []
+
+    const traverse = (children: TreeData[] | undefined) => {
+        if (children && children.length > 0) {
+            children.forEach(child => {
+                ids.push(child.id)
+                traverse(child.children)
+            })
+        }
+    }
+
+    traverse(node.children)
+    return ids
+}
+
+// 查找指定ID节点的路径(包含该节点本身)
+const findNodePath = (nodes: TreeData[], targetId: number): TreeData[] | null => {
+    for (const node of nodes) {
+        if (node.id === targetId) {
+            return [node]
+        }
+
+        if (node.children && node.children.length > 0) {
+            const childPath = findNodePath(node.children, targetId)
+            if (childPath) {
+                return [node, ...childPath]
+            }
+        }
+    }
+    return null
+}
+
+// 处理节点选中
+const handleCheck = (node: TreeData, checked: boolean) => {
+    if (props.disabled || disabledKeys.value.has(node.id)) {
+        return
+    }
+
+    let newSelectedKeys = [...innerSelectedKeys.value]
+    const keyIndex = newSelectedKeys.indexOf(node.id)
+
+    if (checked) {
+        // 选中节点:只添加当前节点
+        if (keyIndex === -1) {
+            newSelectedKeys.push(node.id)
+        }
+
+        // 获取所有后代节点ID并从选中列表中移除
+        const descendantIds = getAllDescendantIds(node)
+        newSelectedKeys = newSelectedKeys.filter(id => !descendantIds.includes(id))
+
+        // 获取所有祖先节点ID并从选中列表中移除
+        const path = findNodePath(props.data, node.id)
+        if (path) {
+            // 排除当前节点本身,只获取祖先节点
+            const ancestorIds = path.slice(0, -1).map(ancestor => ancestor.id)
+            newSelectedKeys = newSelectedKeys.filter(id => !ancestorIds.includes(id))
+        }
+    } else {
+        // 取消选中节点
+        if (keyIndex !== -1) {
+            newSelectedKeys.splice(keyIndex, 1)
+        }
+    }
+
+    innerSelectedKeys.value = newSelectedKeys
+    updateIndeterminateState()
+
+    // 触发事件
+    emit('update:selectedKeys', newSelectedKeys)
+    emit('check', {
+        checked: newSelectedKeys,
+        indeterminate: Array.from(indeterminateKeys.value)
+    })
+}
+
+// 处理展开/折叠
+const handleExpand = (nodeId: number) => {
+    const newExpandedKeys = new Set(expandedKeys.value)
+    if (newExpandedKeys.has(nodeId)) {
+        newExpandedKeys.delete(nodeId)
+    } else {
+        newExpandedKeys.add(nodeId)
+    }
+    expandedKeys.value = newExpandedKeys
+}
+
+// 计算最终的树数据
+const treeData = computed(() => props.data)
+
+// 计算UI显示的选中键值
+const uiSelectedKeys = computed(() => {
+    const keys = new Set<number>()
+
+    const traverse = (nodes: TreeData[]) => {
+        nodes.forEach(node => {
+            // 如果节点本身被选中,或者有祖先节点被选中,则UI显示为选中
+            if (innerSelectedKeys.value.includes(node.id) || isAncestorSelected(node.id)) {
+                keys.add(node.id)
+            }
+
+            if (node.children && node.children.length > 0) {
+                traverse(node.children)
+            }
+        })
+    }
+
+    // 辅助函数:检查节点是否有祖先节点被选中
+    const isAncestorSelected = (nodeId: number): boolean => {
+        const path = findNodePath(props.data, nodeId)
+        if (path) {
+            // 检查路径上除了当前节点外的其他节点是否有被选中的
+            return path.slice(0, -1).some(node => innerSelectedKeys.value.includes(node.id))
+        }
+        return false
+    }
+
+    traverse(props.data)
+    return Array.from(keys)
+})
+
+// 计算选中键值(实际值)
+const selectedKeys = computed(() => innerSelectedKeys.value)
+</script>
+
+<style scoped>
+.data-permission-tree {
+    width: 100%;
+    /* 移除内部高度限制和滚动条,由外部容器控制 */
+}
+</style>

+ 11 - 0
src/components/DataPermisionTree/types.ts

@@ -0,0 +1,11 @@
+export interface TreeData {
+    id: number
+    name: string
+    type?: number
+    children?: TreeData[]
+}
+
+export interface CheckedKeys {
+    checked: number[]
+    indeterminate: number[]
+}

+ 23 - 4
src/lang/modules/document/document/en_US.ts

@@ -12,7 +12,9 @@ export default {
     cancel: 'Cancel',
     search: 'Search',
     reset: 'Reset',
-    audit: 'Audit'
+    audit: 'Audit',
+    mark: 'Mark',
+    download: 'Download'
   },
   // Menu
   menu: {
@@ -32,7 +34,8 @@ export default {
     addCountry: 'Add Country',
     addCenter: 'Add Center',
     confirmEdit: 'Confirm Edit',
-    addDocument: 'Add Document'
+    addDocument: 'Add Document',
+    markDocument: 'Mark Document'
   },
   // Form
   form: {
@@ -79,7 +82,12 @@ export default {
     addDocumentSuccess: 'Document added successfully',
     addDocumentFailed: 'Failed to add document',
     searchSubmitterFailed: 'Failed to search submitter',
-    getDocumentListFailed: 'Failed to get document list'
+    getDocumentListFailed: 'Failed to get document list',
+    noFileToPreview: 'No file available for preview',
+    noFileToDownload: 'No file available for download',
+    markDocument: 'Marking document: {name}',
+    markSuccess: 'Mark successfully',
+    markFailed: 'Mark failed'
   },
   // Validation Rules
   rule: {
@@ -111,6 +119,15 @@ export default {
     submitterRequired: 'Please select submitter',
     fileRequired: 'Please upload file'
   },
+  // Mark Form
+  markForm: {
+    specification: 'Document Specification',
+    specificationPlaceholder: 'Please select document specification'
+  },
+  // Mark Validation Rules
+  markRule: {
+    typeRequired: 'Please select document specification'
+  },
   // Empty State
   empty: {
     description: 'Please select a folder to view documents'
@@ -120,7 +137,7 @@ export default {
     fileName: 'File Name',
     fileNamePlaceholder: 'Please enter file name',
     index: 'No.',
-    name: 'Plan/File Name',
+    name: 'Name',
     specification: 'Specification',
     planDocumentType: 'Plan Document Type',
     submitter: 'Submitter',
@@ -134,3 +151,5 @@ export default {
     action: 'Action'
   }
 };
+
+

+ 38 - 5
src/lang/modules/document/document/zh_CN.ts

@@ -12,7 +12,9 @@ export default {
     cancel: '取 消',
     search: '搜 索',
     reset: '重 置',
-    audit: '审核'
+    audit: '审核',
+    mark: '标识',
+    download: '下载'
   },
   // 菜单
   menu: {
@@ -32,7 +34,9 @@ export default {
     addCountry: '新增国家',
     addCenter: '新增中心',
     confirmEdit: '确认修改信息',
-    addDocument: '添加文档'
+    addDocument: '添加文档',
+    markDocument: '标识文档',
+    auditDocument: '审核文档'
   },
   // 表单
   form: {
@@ -79,7 +83,14 @@ export default {
     addDocumentSuccess: '添加文档成功',
     addDocumentFailed: '添加文档失败',
     searchSubmitterFailed: '搜索递交人失败',
-    getDocumentListFailed: '获取文档列表失败'
+    getDocumentListFailed: '获取文档列表失败',
+    noFileToPreview: '该文档暂无文件可预览',
+    noFileToDownload: '该文档暂无文件可下载',
+    markDocument: '正在标识文档:{name}',
+    markSuccess: '标识成功',
+    markFailed: '标识失败',
+    auditSuccess: '审核成功',
+    auditFailed: '审核失败'
   },
   // 验证规则
   rule: {
@@ -111,6 +122,28 @@ export default {
     submitterRequired: '请选择递交人',
     fileRequired: '请上传文件'
   },
+  // 标识表单
+  markForm: {
+    specification: '文档标识',
+    specificationPlaceholder: '请选择文档标识'
+  },
+  // 审核表单
+  auditForm: {
+    result: '审核结果',
+    pass: '通过',
+    reject: '驳回',
+    reason: '驳回理由',
+    reasonPlaceholder: '请输入驳回理由'
+  },
+  // 标识验证规则
+  markRule: {
+    typeRequired: '请选择文档标识'
+  },
+  // 审核验证规则
+  auditRule: {
+    resultRequired: '请选择审核结果',
+    reasonRequired: '请输入驳回理由'
+  },
   // 空状态
   empty: {
     description: '请选择文件夹查看文档列表'
@@ -120,7 +153,7 @@ export default {
     fileName: '文件名',
     fileNamePlaceholder: '请输入文件名',
     index: '序号',
-    name: '计划文件名称/文件名称',
+    name: '文件名称',
     specification: '文档标识',
     planDocumentType: '计划文件类型',
     submitter: '计划递交人',
@@ -133,4 +166,4 @@ export default {
     updateTime: '更新时间',
     action: '操作'
   }
-};
+};

+ 14 - 0
src/router/demo.ts

@@ -0,0 +1,14 @@
+import { RouteRecordRaw } from 'vue-router'
+
+const demoRoutes: RouteRecordRaw[] = [
+    {
+        path: '/demo/permission-tree',
+        component: () => import('@/views/demo/PermissionTreeDemo.vue'),
+        meta: {
+            title: '权限树演示',
+            icon: 'tree'
+        }
+    }
+]
+
+export default demoRoutes

+ 12 - 1
src/router/index.ts

@@ -2,6 +2,9 @@ import { createWebHistory, createRouter, RouteRecordRaw } from 'vue-router';
 /* Layout */
 import Layout from '@/layout/index.vue';
 
+// 导入演示路由
+import demoRoutes from './demo';
+
 /**
  * Note: 路由配置项
  *
@@ -88,6 +91,14 @@ export const constantRoutes: RouteRecordRaw[] = [
         meta: { title: '{"zh_CN":"个人中心","en_US":"Personal Center"}', icon: 'user' }
       }
     ]
+  },
+  // 添加演示路由
+  {
+    path: '/demo',
+    component: Layout,
+    redirect: '/demo/permission-tree',
+    meta: { title: '演示', icon: 'example' },
+    children: demoRoutes
   }
 ];
 
@@ -111,4 +122,4 @@ const router = createRouter({
   }
 });
 
-export default router;
+export default router;

+ 255 - 13
src/views/dashboard/workbench/index.vue

@@ -1,8 +1,10 @@
 <script setup lang="ts">
-import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
-import { Document, Upload, FolderOpened, MessageBox, Clock } from '@element-plus/icons-vue';
+import { ref, onMounted, onUnmounted, nextTick, watch, reactive } from 'vue';
+import { Document, Upload, FolderOpened, MessageBox, Clock, Search } from '@element-plus/icons-vue';
 import * as echarts from 'echarts';
 import type { EChartsOption } from 'echarts';
+import { ElMessage } from 'element-plus';
+import request from '@/utils/request';
 
 // 统计数据
 const statistics = ref({
@@ -56,13 +58,118 @@ const cardConfigs = [
 const pieChartRef = ref<HTMLDivElement>();
 let pieChartInstance: echarts.ECharts | null = null;
 
+// 项目列表相关
+const loading = ref(false);
+const projectList = ref<any[]>([]);
+const total = ref(0);
+const queryParams = reactive({
+  content: '',
+  pageNum: 1,
+  pageSize: 10
+});
+
+// 获取项目列表
+const getProjectList = async () => {
+  try {
+    loading.value = true;
+    const res = await request({
+      url: '/project/management/listOnDashboardWorkbench',
+      method: 'get',
+      params: queryParams
+    });
+
+    if (res.code === 200) {
+      projectList.value = res.rows || [];
+      total.value = res.total || 0;
+    } else {
+      ElMessage.error(res.msg || '获取项目列表失败');
+    }
+  } catch (error) {
+    console.error('获取项目列表失败:', error);
+    ElMessage.error('获取项目列表失败');
+  } finally {
+    loading.value = false;
+  }
+};
+
+// 搜索项目
+const handleSearch = () => {
+  queryParams.pageNum = 1;
+  getProjectList();
+};
+
+// 重置搜索
+const resetSearch = () => {
+  queryParams.content = '';
+  handleSearch();
+};
+
+// 处理分页变化
+const handleCurrentChange = (val: number) => {
+  queryParams.pageNum = val;
+  getProjectList();
+};
+
+// 处理每页条数变化
+const handleSizeChange = (val: number) => {
+  queryParams.pageSize = val;
+  queryParams.pageNum = 1;
+  getProjectList();
+};
+
+// 格式化时间显示
+const formatTime = (timeStr: string, dateOnly = false) => {
+  if (!timeStr) return '-';
+  try {
+    const options: Intl.DateTimeFormatOptions = dateOnly 
+      ? {
+          year: 'numeric',
+          month: '2-digit',
+          day: '2-digit'
+        }
+      : {
+          year: 'numeric',
+          month: '2-digit',
+          day: '2-digit',
+          hour: '2-digit',
+          minute: '2-digit',
+          second: '2-digit',
+          hour12: false
+        };
+    
+    const formatted = new Date(timeStr).toLocaleString('zh-CN', options);
+    return formatted.replace(/\//g, '-');
+  } catch (e) {
+    return timeStr; // 如果解析失败,返回原始字符串
+  }
+};
+
+// 获取进度条状态
+const getProgressStatus = (percentage: number) => {
+  if (percentage === null || percentage === undefined) return '';
+  if (percentage < 30) return 'exception';
+  if (percentage < 70) return 'warning';
+  return 'success';
+};
+
+// 查看项目详情
+const viewProjectDetail = (row: any) => {
+  // TODO: 实现跳转到项目详情页
+  console.log('View project detail:', row);
+  // 这里可以添加路由跳转逻辑,例如:
+  // router.push({
+  //   path: '/project/detail',
+  //   query: { id: row.id }
+  // });
+};
+
 // 获取统计数据
 const fetchStatistics = async () => {
   try {
     // TODO: 替换为实际的API调用
     // const res = await getWorkbenchStatistics();
     // statistics.value = res.data;
-    
+
     // 模拟数据
     statistics.value = {
       pendingUpload: 12,
@@ -79,14 +186,14 @@ const fetchStatistics = async () => {
 // 初始化饼状图
 const initPieChart = () => {
   if (!pieChartRef.value) return;
-  
+
   // 如果已存在实例,先销毁
   if (pieChartInstance) {
     pieChartInstance.dispose();
   }
-  
+
   pieChartInstance = echarts.init(pieChartRef.value);
-  
+
   const option: EChartsOption = {
     tooltip: {
       trigger: 'item',
@@ -137,7 +244,7 @@ const initPieChart = () => {
       }
     ]
   };
-  
+
   pieChartInstance.setOption(option);
 };
 
@@ -154,7 +261,10 @@ const handleResize = () => {
 };
 
 onMounted(async () => {
-  await fetchStatistics();
+  await Promise.all([
+    fetchStatistics(),
+    getProjectList()
+  ]);
   await nextTick();
   initPieChart();
   window.addEventListener('resize', handleResize);
@@ -172,8 +282,8 @@ onUnmounted(() => {
   <div class="workbench-container">
     <!-- 第一行:统计卡片 -->
     <div class="statistics-cards">
-      <el-card 
-        v-for="card in cardConfigs" 
+      <el-card
+        v-for="card in cardConfigs"
         :key="card.key"
         class="stat-card"
         shadow="hover"
@@ -193,7 +303,7 @@ onUnmounted(() => {
         </div>
       </el-card>
     </div>
-    
+
     <!-- 第二行:饼状图 -->
     <div class="chart-section">
       <el-card class="chart-card" shadow="hover">
@@ -205,6 +315,114 @@ onUnmounted(() => {
         <div ref="pieChartRef" class="pie-chart"></div>
       </el-card>
     </div>
+
+    <!-- 第三行:项目列表 -->
+    <div class="project-list">
+      <el-card shadow="hover">
+        <template #header>
+          <div class="card-header">
+            <span class="header-title">参与项目</span>
+          </div>
+        </template>
+
+        <!-- 搜索栏 -->
+        <div class="search-container">
+          <el-input
+            v-model="queryParams.content"
+            placeholder="请输入项目编号/项目名/PM/PD/CTA"
+            style="width: 300px"
+            clearable
+            @keyup.enter="handleSearch"
+          >
+            <template #append>
+              <el-button :icon="Search" @click="handleSearch" />
+            </template>
+          </el-input>
+          <el-button @click="resetSearch">重置</el-button>
+        </div>
+
+        <!-- 项目表格 -->
+        <el-table
+          v-loading="loading"
+          :data="projectList"
+          border
+          style="width: 100%; margin-top: 15px"
+        >
+          <el-table-column prop="id" label="ID" width="80" align="center" />
+          <el-table-column prop="code" label="项目编号" min-width="150" />
+          <el-table-column prop="name" label="项目名称" min-width="180" show-overflow-tooltip />
+          
+          <!-- 项目进度相关 -->
+          <el-table-column prop="onTimeSubmissionRate" label="按时提交率" min-width="130" align="center">
+            <template #default="{ row }">
+              {{ row.onTimeSubmissionRate !== null ? (row.onTimeSubmissionRate * 100).toFixed(2) + '%' : '-' }}
+            </template>
+          </el-table-column>
+          
+          <el-table-column prop="lateSubmissionCount" label="逾期提交数" min-width="110" align="center" />
+          
+          <el-table-column prop="submissionProgress" label="提交进度" min-width="150">
+            <template #default="{ row }">
+              <el-progress 
+                :percentage="row.submissionProgress || 0" 
+                :show-text="false" 
+                :status="getProgressStatus(row.submissionProgress)"
+              />
+              <span style="margin-left: 10px">{{ row.submissionProgress !== null ? row.submissionProgress + '%' : '-' }}</span>
+            </template>
+          </el-table-column>
+          
+          <!-- 项目负责人 -->
+          <el-table-column prop="pdGpd" label="PD/GPD" min-width="150" show-overflow-tooltip />
+          <el-table-column prop="pmGpm" label="PM/GPM" min-width="150" show-overflow-tooltip />
+          <el-table-column prop="ctaGcta" label="CTA/GCTA" min-width="150" show-overflow-tooltip />
+          
+          <!-- 时间信息 -->
+          <el-table-column prop="createTime" label="创建时间" min-width="160" align="center">
+            <template #default="{ row }">
+              {{ formatTime(row.createTime) }}
+            </template>
+          </el-table-column>
+          
+          <el-table-column prop="updateTime" label="更新时间" min-width="160" align="center">
+            <template #default="{ row }">
+              {{ formatTime(row.updateTime) }}
+            </template>
+          </el-table-column>
+          
+          <el-table-column prop="startTime" label="开始时间" min-width="120" align="center">
+            <template #default="{ row }">
+              {{ formatTime(row.startTime, true) }}
+            </template>
+          </el-table-column>
+          
+          <el-table-column prop="endTime" label="结束时间" min-width="120" align="center">
+            <template #default="{ row }">
+              {{ formatTime(row.endTime, true) }}
+            </template>
+          </el-table-column>
+          
+          <el-table-column label="操作" width="100" fixed="right" align="center">
+            <template #default="{ row }">
+              <el-button link type="primary" size="small" @click="viewProjectDetail(row)">详情</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+
+        <!-- 分页 -->
+        <div class="pagination-container">
+          <el-pagination
+            v-model:current-page="queryParams.pageNum"
+            v-model:page-size="queryParams.pageSize"
+            :page-sizes="[10, 20, 30, 50]"
+            layout="total, sizes, prev, pager, next, jumper"
+            :total="total"
+            @size-change="handleSizeChange"
+            @current-change="handleCurrentChange"
+          />
+        </div>
+      </el-card>
+    </div>
   </div>
 </template>
 
@@ -281,9 +499,33 @@ onUnmounted(() => {
     }
   }
 
-  .chart-section {
+  .chart-section,
+  .project-list {
     margin-top: 20px;
-    
+  }
+
+  .project-list {
+    .search-container {
+      margin-bottom: 20px;
+      display: flex;
+      gap: 10px;
+    }
+
+    .pagination-container {
+      margin-top: 20px;
+      display: flex;
+      justify-content: flex-end;
+    }
+
+    :deep(.el-table) {
+      .cell {
+        white-space: nowrap;
+      }
+    }
+  }
+
+  .chart-section {
+
     .chart-card {
       :deep(.el-card__header) {
         padding: 16px 20px;

+ 181 - 0
src/views/demo/FixVerification.vue

@@ -0,0 +1,181 @@
+<template>
+    <div class="fix-verification">
+        <el-card shadow="never">
+            <template #header>
+                <h3>修复验证 - 权限树行为测试</h3>
+            </template>
+
+            <div class="test-content">
+                <p>验证当选中"成都市"时,"四川省"是否保持半选状态而不是全选状态</p>
+
+                <div class="test-scenario">
+                    <h4>测试场景:</h4>
+                    <ol>
+                        <li>初始状态:无任何选中项</li>
+                        <li>操作:选中"成都市"</li>
+                        <li>预期结果:"成都市"被选中,"四川省"显示为半选状态(-)</li>
+                        <li>关键点:"四川省"不应该显示为全选状态</li>
+                    </ol>
+                </div>
+
+                <div class="tree-container">
+                    <data-permission-tree :data="treeData" :selected-keys="selectedKeys"
+                        @update:selectedKeys="handleSelectedKeysChange" @check="handleCheck" />
+                </div>
+
+                <div class="selected-info">
+                    <h4>当前选中的节点ID:</h4>
+                    <p>{{ selectedKeys }}</p>
+
+                    <h4>当前半选的节点ID:</h4>
+                    <p>{{ indeterminateKeys }}</p>
+                </div>
+
+                <div class="actions">
+                    <el-button @click="selectChengdu">选中成都市</el-button>
+                    <el-button @click="selectAllSichuan">选中四川省所有城市</el-button>
+                    <el-button @click="clearSelection">清空选择</el-button>
+                </div>
+            </div>
+        </el-card>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import DataPermissionTree from '@/components/DataPermisionTree/index.vue'
+import type { TreeData, CheckedKeys } from '@/components/DataPermisionTree/types'
+
+// 树形数据示例 - 模拟地理层级结构
+const treeData: TreeData[] = [
+    {
+        id: 1,
+        name: '中国',
+        children: [
+            {
+                id: 2,
+                name: '四川省',
+                children: [
+                    { id: 3, name: '成都市' },
+                    { id: 4, name: '绵阳市' },
+                    { id: 5, name: '德阳市' }
+                ]
+            },
+            {
+                id: 6,
+                name: '广东省',
+                children: [
+                    { id: 7, name: '广州市' },
+                    { id: 8, name: '深圳市' },
+                    { id: 9, name: '珠海市' }
+                ]
+            }
+        ]
+    }
+]
+
+// 选中的键值
+const selectedKeys = ref<number[]>([])
+
+// 半选的键值
+const indeterminateKeys = ref<number[]>([])
+
+// 处理选中键值变化
+const handleSelectedKeysChange = (keys: number[]) => {
+    selectedKeys.value = keys
+}
+
+// 处理选中事件
+const handleCheck = (keys: CheckedKeys) => {
+    console.log('Checked keys:', keys.checked)
+    console.log('Indeterminate keys:', keys.indeterminate)
+    selectedKeys.value = keys.checked
+    indeterminateKeys.value = keys.indeterminate
+}
+
+// 选中成都市
+const selectChengdu = () => {
+    selectedKeys.value = [3]
+}
+
+// 选中四川省所有城市
+const selectAllSichuan = () => {
+    selectedKeys.value = [3, 4, 5]
+}
+
+// 清空选择
+const clearSelection = () => {
+    selectedKeys.value = []
+}
+</script>
+
+<style scoped>
+.fix-verification {
+    padding: 20px;
+    max-width: 800px;
+    margin: 0 auto;
+}
+
+.test-content {
+    padding: 20px 0;
+}
+
+.test-scenario {
+    background-color: #f0f9eb;
+    padding: 15px;
+    border-radius: 4px;
+    margin-bottom: 20px;
+}
+
+.test-scenario h4 {
+    margin-top: 0;
+    color: #303133;
+}
+
+.test-scenario ol {
+    padding-left: 20px;
+    margin: 10px 0 0 0;
+}
+
+.test-scenario li {
+    margin: 8px 0;
+}
+
+.tree-container {
+    margin: 20px 0;
+    padding: 15px;
+    border: 1px solid #e4e7ed;
+    border-radius: 4px;
+    background-color: #f5f7fa;
+    min-height: 200px;
+}
+
+.selected-info {
+    margin-top: 20px;
+    padding: 15px;
+    background-color: #ecf5ff;
+    border-radius: 4px;
+}
+
+.selected-info h4 {
+    margin-top: 0;
+    color: #303133;
+}
+
+.selected-info p {
+    font-family: monospace;
+    background-color: #fff;
+    padding: 10px;
+    border-radius: 4px;
+    border: 1px solid #e4e7ed;
+    margin: 10px 0 0 0;
+}
+
+.actions {
+    margin-top: 20px;
+}
+
+.actions .el-button {
+    margin-right: 10px;
+}
+</style>

+ 221 - 0
src/views/demo/PermissionTreeDemo.vue

@@ -0,0 +1,221 @@
+<template>
+    <div class="permission-tree-demo">
+        <el-card shadow="never">
+            <template #header>
+                <h3>权限树演示</h3>
+            </template>
+
+            <div class="demo-content">
+                <p>此演示展示了权限树的行为:</p>
+                <ul>
+                    <li>当选中"成都市"时,即使"四川省"下的所有城市都被选中,"四川省"也不会显示为全选状态</li>
+                    <li>父节点将始终保持半选状态(-),以表明其子节点被部分选择</li>
+                    <li>这避免了暗示整个层级都被完全选中的误解</li>
+                </ul>
+
+                <el-tabs v-model="activeTab">
+                    <el-tab-pane label="基础演示" name="basic">
+                        <div class="tree-container">
+                            <data-permission-tree :data="treeData" :selected-keys="selectedKeys"
+                                @update:selectedKeys="handleSelectedKeysChange" @check="handleCheck" />
+                        </div>
+                    </el-tab-pane>
+
+                    <el-tab-pane label="全选功能演示" name="selectAll">
+                        <div class="tree-container">
+                            <el-checkbox v-model="isSelectAll" @change="handleSelectAllChange">
+                                全选所有权限
+                            </el-checkbox>
+
+                            <data-permission-tree v-if="!isSelectAll" :data="treeData" :selected-keys="selectedKeys"
+                                @update:selectedKeys="handleSelectedKeysChange" @check="handleCheck" />
+
+                            <div v-else class="select-all-message">
+                                <p>已启用全选模式,所有权限都将被授予。</p>
+                                <p>树形结构已被隐藏以避免混淆。</p>
+                            </div>
+                        </div>
+                    </el-tab-pane>
+
+                    <el-tab-pane label="禁用状态演示" name="disabled">
+                        <div class="tree-container">
+                            <el-checkbox v-model="isDisabled" style="margin-bottom: 15px;">
+                                禁用权限选择
+                            </el-checkbox>
+
+                            <data-permission-tree :data="treeData" :selected-keys="selectedKeys" :disabled="isDisabled"
+                                @update:selectedKeys="handleSelectedKeysChange" @check="handleCheck" />
+                        </div>
+                    </el-tab-pane>
+                </el-tabs>
+
+                <div class="selected-info">
+                    <h4>当前选中的节点ID:</h4>
+                    <p>{{ selectedKeys }}</p>
+                </div>
+
+                <div class="instructions">
+                    <h4>使用说明:</h4>
+                    <ol>
+                        <li><strong>基础演示</strong>:展示树形结构的基本交互和半选状态行为</li>
+                        <li><strong>全选功能演示</strong>:当启用全选时,树形结构会隐藏,避免用户误操作</li>
+                        <li><strong>禁用状态演示</strong>:展示组件在禁用状态下的表现</li>
+                    </ol>
+                </div>
+            </div>
+        </el-card>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import DataPermissionTree from '@/components/DataPermisionTree/index.vue'
+import type { TreeData } from '@/components/DataPermisionTree/types'
+
+// 活动标签页
+const activeTab = ref('basic')
+
+// 是否全选
+const isSelectAll = ref(false)
+
+// 是否禁用
+const isDisabled = ref(false)
+
+// 树形数据示例 - 模拟地理层级结构
+const treeData: TreeData[] = [
+    {
+        id: 1,
+        name: '中国',
+        children: [
+            {
+                id: 2,
+                name: '四川省',
+                children: [
+                    { id: 3, name: '成都市' },
+                    { id: 4, name: '绵阳市' },
+                    { id: 5, name: '德阳市' }
+                ]
+            },
+            {
+                id: 6,
+                name: '广东省',
+                children: [
+                    { id: 7, name: '广州市' },
+                    { id: 8, name: '深圳市' },
+                    { id: 9, name: '珠海市' }
+                ]
+            }
+        ]
+    }
+]
+
+// 默认选中"成都市"
+const selectedKeys = ref<number[]>([3])
+
+// 处理选中键值变化
+const handleSelectedKeysChange = (keys: number[]) => {
+    selectedKeys.value = keys
+}
+
+// 处理选中事件
+const handleCheck = (keys: { checked: number[], indeterminate: number[] }) => {
+    console.log('Checked keys:', keys.checked)
+    console.log('Indeterminate keys:', keys.indeterminate)
+}
+
+// 处理全选变化
+const handleSelectAllChange = (value: boolean) => {
+    isSelectAll.value = value
+    if (value) {
+        // 在全选模式下,可以设置一个特殊值表示全选
+        selectedKeys.value = [-1] // -1表示全选
+    } else {
+        // 退出全选模式时,恢复之前的选中状态或默认状态
+        selectedKeys.value = [3] // 恢复默认选中"成都市"
+    }
+}
+</script>
+
+<style scoped>
+.permission-tree-demo {
+    padding: 20px;
+    max-width: 800px;
+    margin: 0 auto;
+}
+
+.demo-content {
+    padding: 20px 0;
+}
+
+.demo-content ul {
+    margin: 15px 0;
+    padding-left: 20px;
+}
+
+.demo-content li {
+    margin: 8px 0;
+    line-height: 1.5;
+}
+
+.tree-container {
+    margin: 20px 0;
+    padding: 15px;
+    border: 1px solid #e4e7ed;
+    border-radius: 4px;
+    background-color: #f5f7fa;
+    min-height: 200px;
+}
+
+.select-all-message {
+    text-align: center;
+    padding: 40px 20px;
+    color: #909399;
+}
+
+.select-all-message p {
+    margin: 10px 0;
+}
+
+.selected-info {
+    margin-top: 20px;
+    padding: 15px;
+    background-color: #ecf5ff;
+    border-radius: 4px;
+}
+
+.selected-info h4 {
+    margin-top: 0;
+    color: #303133;
+}
+
+.selected-info p {
+    font-family: monospace;
+    background-color: #fff;
+    padding: 10px;
+    border-radius: 4px;
+    border: 1px solid #e4e7ed;
+    margin: 10px 0 0 0;
+}
+
+.instructions {
+    margin-top: 20px;
+    padding: 15px;
+    background-color: #f0f9eb;
+    border-radius: 4px;
+    border-left: 4px solid #67c23a;
+}
+
+.instructions h4 {
+    margin-top: 0;
+    color: #303133;
+}
+
+.instructions ol {
+    padding-left: 20px;
+    margin: 10px 0 0 0;
+}
+
+.instructions li {
+    margin: 8px 0;
+}
+</style>

+ 144 - 0
src/views/demo/TestPermissionTree.vue

@@ -0,0 +1,144 @@
+<template>
+    <div class="test-permission-tree">
+        <el-card shadow="never">
+            <template #header>
+                <h3>权限树测试</h3>
+            </template>
+
+            <div class="test-content">
+                <p>测试当选中"成都市"时,"四川省"是否保持半选状态而不是全选状态</p>
+
+                <div class="tree-container">
+                    <data-permission-tree :data="treeData" :selected-keys="selectedKeys"
+                        @update:selectedKeys="handleSelectedKeysChange" @check="handleCheck" />
+                </div>
+
+                <div class="selected-info">
+                    <h4>当前选中的节点ID:</h4>
+                    <p>{{ selectedKeys }}</p>
+
+                    <h4>当前半选的节点ID:</h4>
+                    <p>{{ indeterminateKeys }}</p>
+                </div>
+
+                <div class="actions">
+                    <el-button @click="selectChengdu">选中成都市</el-button>
+                    <el-button @click="clearSelection">清空选择</el-button>
+                </div>
+            </div>
+        </el-card>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import DataPermissionTree from '@/components/DataPermisionTree/index.vue'
+import type { TreeData, CheckedKeys } from '@/components/DataPermisionTree/types'
+
+// 树形数据示例 - 模拟地理层级结构
+const treeData: TreeData[] = [
+    {
+        id: 1,
+        name: '中国',
+        children: [
+            {
+                id: 2,
+                name: '四川省',
+                children: [
+                    { id: 3, name: '成都市' },
+                    { id: 4, name: '绵阳市' },
+                    { id: 5, name: '德阳市' }
+                ]
+            },
+            {
+                id: 6,
+                name: '广东省',
+                children: [
+                    { id: 7, name: '广州市' },
+                    { id: 8, name: '深圳市' },
+                    { id: 9, name: '珠海市' }
+                ]
+            }
+        ]
+    }
+]
+
+// 选中的键值
+const selectedKeys = ref<number[]>([])
+
+// 半选的键值
+const indeterminateKeys = ref<number[]>([])
+
+// 处理选中键值变化
+const handleSelectedKeysChange = (keys: number[]) => {
+    selectedKeys.value = keys
+}
+
+// 处理选中事件
+const handleCheck = (keys: CheckedKeys) => {
+    console.log('Checked keys:', keys.checked)
+    console.log('Indeterminate keys:', keys.indeterminate)
+    selectedKeys.value = keys.checked
+    indeterminateKeys.value = keys.indeterminate
+}
+
+// 选中成都市
+const selectChengdu = () => {
+    selectedKeys.value = [3]
+}
+
+// 清空选择
+const clearSelection = () => {
+    selectedKeys.value = []
+}
+</script>
+
+<style scoped>
+.test-permission-tree {
+    padding: 20px;
+    max-width: 800px;
+    margin: 0 auto;
+}
+
+.test-content {
+    padding: 20px 0;
+}
+
+.tree-container {
+    margin: 20px 0;
+    padding: 15px;
+    border: 1px solid #e4e7ed;
+    border-radius: 4px;
+    background-color: #f5f7fa;
+    min-height: 200px;
+}
+
+.selected-info {
+    margin-top: 20px;
+    padding: 15px;
+    background-color: #ecf5ff;
+    border-radius: 4px;
+}
+
+.selected-info h4 {
+    margin-top: 0;
+    color: #303133;
+}
+
+.selected-info p {
+    font-family: monospace;
+    background-color: #fff;
+    padding: 10px;
+    border-radius: 4px;
+    border: 1px solid #e4e7ed;
+    margin: 10px 0 0 0;
+}
+
+.actions {
+    margin-top: 20px;
+}
+
+.actions .el-button {
+    margin-right: 10px;
+}
+</style>

+ 139 - 0
src/views/demo/TreeDemo.vue

@@ -0,0 +1,139 @@
+<template>
+    <div class="tree-demo">
+        <h2>自定义树形组件演示</h2>
+
+        <div class="demo-section">
+            <h3>功能说明</h3>
+            <ul>
+                <li>这是一个完全使用原生HTML/CSS/JavaScript实现的树形组件</li>
+                <li>不依赖任何第三方UI库</li>
+                <li>父节点在子节点被部分选中时显示为半选状态</li>
+                <li>即使所有子节点都被选中,父节点也不会自动变为全选状态</li>
+            </ul>
+        </div>
+
+        <div class="demo-section">
+            <h3>演示案例</h3>
+            <p>如您所述:选择"成都市"时,其父级"四川省"应该显示为半选状态而不是全选状态</p>
+
+            <div class="tree-container">
+                <data-permission-tree :data="treeData" v-model="selectedKeys" />
+            </div>
+
+            <div class="result-section">
+                <h4>当前选中的节点:</h4>
+                <p>{{ selectedKeys }}</p>
+
+                <h4>说明:</h4>
+                <ul>
+                    <li>尝试选中"成都市",观察"四川省"的状态</li>
+                    <li>"四川省"应该显示为半选状态(方框中有横线)</li>
+                    <li>即使您选中"绵阳市"和"德阳市","四川省"仍然保持半选状态</li>
+                    <li>只有直接点击"四川省"复选框才会使其变为全选状态</li>
+                </ul>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import DataPermissionTree from '@/components/DataPermisionTree/index.vue'
+import type { TreeData } from '@/components/DataPermisionTree/types'
+
+// 树形数据
+const treeData: TreeData[] = [
+    {
+        id: 1,
+        name: '中国',
+        children: [
+            {
+                id: 2,
+                name: '四川省',
+                children: [
+                    { id: 3, name: '成都市' },
+                    { id: 4, name: '绵阳市' },
+                    { id: 5, name: '德阳市' }
+                ]
+            },
+            {
+                id: 6,
+                name: '广东省',
+                children: [
+                    { id: 7, name: '广州市' },
+                    { id: 8, name: '深圳市' },
+                    { id: 9, name: '珠海市' }
+                ]
+            }
+        ]
+    }
+]
+
+// 选中的节点ID
+const selectedKeys = ref<number[]>([])
+</script>
+
+<style scoped>
+.tree-demo {
+    padding: 20px;
+    max-width: 800px;
+    margin: 0 auto;
+}
+
+.demo-section {
+    margin-bottom: 30px;
+}
+
+.demo-section h3 {
+    color: #333;
+    margin-bottom: 15px;
+}
+
+.demo-section ul {
+    padding-left: 20px;
+}
+
+.demo-section li {
+    margin-bottom: 8px;
+    line-height: 1.5;
+}
+
+.tree-container {
+    margin: 20px 0;
+    padding: 20px;
+    border: 1px solid #ddd;
+    border-radius: 4px;
+    background-color: #f9f9f9;
+    min-height: 200px;
+}
+
+.result-section {
+    margin-top: 20px;
+    padding: 15px;
+    background-color: #e8f4ff;
+    border-radius: 4px;
+}
+
+.result-section h4 {
+    margin-top: 0;
+    color: #333;
+}
+
+.result-section p {
+    font-family: monospace;
+    background-color: #fff;
+    padding: 10px;
+    border-radius: 4px;
+    border: 1px solid #ddd;
+}
+
+.result-section ul {
+    padding-left: 20px;
+    margin: 10px 0;
+}
+
+.result-section li {
+    margin-bottom: 8px;
+    line-height: 1.5;
+}
+</style>

+ 120 - 0
src/views/demo/TreeTest.vue

@@ -0,0 +1,120 @@
+<template>
+    <div class="tree-test">
+        <el-card shadow="never">
+            <template #header>
+                <h3>树形组件测试</h3>
+            </template>
+
+            <div class="test-content">
+                <p>测试目标:当选中"成都市"时,"四川省"应该保持半选状态,而不是全选状态</p>
+
+                <div class="tree-container">
+                    <data-permission-tree :data="treeData" :selected-keys="selectedKeys"
+                        @update:selectedKeys="handleSelectedKeysChange" @check="handleCheck" />
+                </div>
+
+                <div class="info-panel">
+                    <h4>当前选中的节点:</h4>
+                    <p>{{ selectedKeys }}</p>
+
+                    <h4>当前半选的节点:</h4>
+                    <p>{{ indeterminateKeys }}</p>
+                </div>
+            </div>
+        </el-card>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import DataPermissionTree from '@/components/DataPermisionTree/index.vue'
+import type { TreeData, CheckedKeys } from '@/components/DataPermisionTree/types'
+
+// 树形数据示例 - 模拟地理层级结构
+const treeData: TreeData[] = [
+    {
+        id: 1,
+        name: '中国',
+        children: [
+            {
+                id: 2,
+                name: '四川省',
+                children: [
+                    { id: 3, name: '成都市' },
+                    { id: 4, name: '绵阳市' },
+                    { id: 5, name: '德阳市' }
+                ]
+            },
+            {
+                id: 6,
+                name: '广东省',
+                children: [
+                    { id: 7, name: '广州市' },
+                    { id: 8, name: '深圳市' },
+                    { id: 9, name: '珠海市' }
+                ]
+            }
+        ]
+    }
+]
+
+// 选中键值
+const selectedKeys = ref<number[]>([])
+
+// 半选键值
+const indeterminateKeys = ref<number[]>([])
+
+// 处理选中键值变化
+const handleSelectedKeysChange = (keys: number[]) => {
+    selectedKeys.value = keys
+}
+
+// 处理选中事件
+const handleCheck = (keys: CheckedKeys) => {
+    console.log('Checked keys:', keys.checked)
+    console.log('Indeterminate keys:', keys.indeterminate)
+    indeterminateKeys.value = keys.indeterminate
+}
+</script>
+
+<style scoped>
+.tree-test {
+    padding: 20px;
+    max-width: 800px;
+    margin: 0 auto;
+}
+
+.test-content {
+    padding: 20px 0;
+}
+
+.tree-container {
+    margin: 20px 0;
+    padding: 15px;
+    border: 1px solid #e4e7ed;
+    border-radius: 4px;
+    background-color: #f5f7fa;
+    min-height: 200px;
+}
+
+.info-panel {
+    margin-top: 20px;
+    padding: 15px;
+    background-color: #ecf5ff;
+    border-radius: 4px;
+}
+
+.info-panel h4 {
+    margin-top: 0;
+    color: #303133;
+}
+
+.info-panel p {
+    font-family: monospace;
+    background-color: #fff;
+    padding: 10px;
+    border-radius: 4px;
+    border: 1px solid #e4e7ed;
+    margin: 10px 0 0 0;
+}
+</style>

+ 412 - 173
src/views/document/folder/document.vue

@@ -11,17 +11,12 @@
       <div class="content-wrapper">
         <div class="tree-container">
           <div class="tree-header">
-            <el-button v-hasPermi="['document:folder:add']" type="primary" icon="Plus" size="small" @click="handleAddFolder">{{ t('document.document.button.newFolder') }}</el-button>
+            <el-button v-hasPermi="['document:folder:add']" type="primary" icon="Plus" size="small"
+              @click="handleAddFolder">{{ t('document.document.button.newFolder') }}</el-button>
           </div>
           <el-scrollbar class="tree-scrollbar">
-            <el-tree
-              v-loading="loading"
-              :data="treeData"
-              :props="treeProps"
-              node-key="id"
-              default-expand-all
-              :expand-on-click-node="false"
-            >
+            <el-tree v-loading="loading" :data="treeData" :props="treeProps" node-key="id" default-expand-all
+              :expand-on-click-node="false">
               <template #default="{ node, data }">
                 <span class="custom-tree-node">
                   <el-icon>
@@ -33,7 +28,9 @@
                   <span class="node-label" @click="handleFolderClick(data)">{{ node.label }}</span>
                   <span class="node-actions">
                     <span class="menu-trigger" @click="toggleMenu($event, data)">
-                      <el-icon><MoreFilled /></el-icon>
+                      <el-icon>
+                        <MoreFilled />
+                      </el-icon>
                     </span>
                   </span>
                 </span>
@@ -41,39 +38,27 @@
             </el-tree>
           </el-scrollbar>
         </div>
-        
+
         <!-- 一级菜单 -->
         <ul class="primary-menu" v-if="activeMenu !== null" :style="primaryMenuStyle">
-          <li 
-            class="menu-item has-submenu" 
-            v-hasPermi="['document:folder:add']"
-            @click.stop="toggleSubmenu($event)"
-          >
+          <li class="menu-item has-submenu" v-hasPermi="['document:folder:add']" @click.stop="toggleSubmenu($event)">
             <span>{{ t('document.document.menu.add') }}</span>
-            <el-icon class="arrow-icon"><ArrowRight /></el-icon>
+            <el-icon class="arrow-icon">
+              <ArrowRight />
+            </el-icon>
           </li>
-          <li 
-            class="menu-item" 
-            v-hasPermi="['document:folder:edit']"
-            @click="handleMenuItemClick('edit', currentMenuData)"
-          >
+          <li class="menu-item" v-hasPermi="['document:folder:edit']"
+            @click="handleMenuItemClick('edit', currentMenuData)">
             <span>{{ t('document.document.menu.edit') }}</span>
           </li>
-          <li 
-            class="menu-item" 
-            v-hasPermi="['document:folder:remove']"
-            @click="handleMenuItemClick('delete', currentMenuData)"
-          >
+          <li class="menu-item" v-hasPermi="['document:folder:remove']"
+            @click="handleMenuItemClick('delete', currentMenuData)">
             <span>{{ t('document.document.menu.delete') }}</span>
           </li>
         </ul>
-        
+
         <!-- 二级菜单 -->
-        <ul 
-          class="secondary-menu" 
-          v-if="showSecondaryMenu"
-          :style="secondaryMenuStyle"
-        >
+        <ul class="secondary-menu" v-if="showSecondaryMenu" :style="secondaryMenuStyle">
           <!-- 国家或中心:显示中心和文件夹 -->
           <template v-if="currentMenuData && (currentMenuData.type === 1 || currentMenuData.type === 2)">
             <li class="menu-item" @click="handleMenuItemClick('add:2', currentMenuData)">
@@ -88,51 +73,59 @@
             <li class="menu-item" @click="handleMenuItemClick('add:0', currentMenuData)">
               <span>{{ t('document.document.menu.folder') }}</span>
             </li>
-            <li class="menu-item" v-hasPermi="['document:document:add']" @click="handleMenuItemClick('add:document', currentMenuData)">
+            <li class="menu-item" v-hasPermi="['document:document:add']"
+              @click="handleMenuItemClick('add:document', currentMenuData)">
               <span>{{ t('document.document.menu.document') }}</span>
             </li>
           </template>
         </ul>
-        
+
         <div class="content-container">
           <!-- 文档列表展示区域 -->
           <div v-if="selectedFolder" class="document-list-container">
             <!-- 搜索栏 -->
             <el-form :model="documentQueryParams" :inline="true" class="search-form">
               <el-form-item :label="t('document.document.documentList.fileName')">
-                <el-input
-                  v-model="documentQueryParams.name"
-                  :placeholder="t('document.document.documentList.fileNamePlaceholder')"
-                  clearable
-                  style="width: 240px"
-                  @keyup.enter="handleDocumentQuery"
-                />
+                <el-input v-model="documentQueryParams.name"
+                  :placeholder="t('document.document.documentList.fileNamePlaceholder')" clearable style="width: 240px"
+                  @keyup.enter="handleDocumentQuery" />
               </el-form-item>
               <el-form-item>
-                <el-button type="primary" icon="Search" @click="handleDocumentQuery">{{ t('document.document.button.search') }}</el-button>
-                <el-button icon="Refresh" @click="handleDocumentReset">{{ t('document.document.button.reset') }}</el-button>
+                <el-button type="primary" icon="Search" @click="handleDocumentQuery">{{
+                  t('document.document.button.search')
+                }}</el-button>
+                <el-button icon="Refresh" @click="handleDocumentReset">{{ t('document.document.button.reset')
+                }}</el-button>
               </el-form-item>
             </el-form>
 
             <!-- 文档列表 -->
             <el-table v-loading="documentLoading" :data="documentList" border style="margin-top: 10px">
-              <el-table-column type="index" width="55" align="center" :label="t('document.document.documentList.index')" />
-              <el-table-column prop="name" :label="t('document.document.documentList.name')" min-width="150" show-overflow-tooltip />
-              <el-table-column prop="specification" :label="t('document.document.documentList.specification')" min-width="120" show-overflow-tooltip />
-              <el-table-column prop="planDocumentType" :label="t('document.document.documentList.planDocumentType')" width="120" align="center">
+              <el-table-column type="index" width="55" align="center"
+                :label="t('document.document.documentList.index')" />
+              <el-table-column prop="name" :label="t('document.document.documentList.name')" min-width="150"
+                show-overflow-tooltip />
+              <el-table-column prop="specification" :label="t('document.document.documentList.specification')"
+                min-width="120" show-overflow-tooltip />
+              <el-table-column prop="planDocumentType" :label="t('document.document.documentList.planDocumentType')"
+                width="120" align="center">
                 <template #default="scope">
-                  <dict-tag v-if="scope.row.planDocumentType" :options="plan_document_type" :value="scope.row.planDocumentType" />
+                  <dict-tag v-if="scope.row.planDocumentType" :options="plan_document_type"
+                    :value="scope.row.planDocumentType" />
                   <span v-else>-</span>
                 </template>
               </el-table-column>
-              <el-table-column prop="submitter" :label="t('document.document.documentList.submitter')" width="120" align="center" />
-              <el-table-column prop="submitDeadline" :label="t('document.document.documentList.submitDeadline')" width="110" align="center">
+              <el-table-column prop="submitter" :label="t('document.document.documentList.submitter')" width="120"
+                align="center" />
+              <el-table-column prop="submitDeadline" :label="t('document.document.documentList.submitDeadline')"
+                width="110" align="center">
                 <template #default="scope">
                   <span v-if="scope.row.submitDeadline">{{ parseTime(scope.row.submitDeadline, '{y}-{m}-{d}') }}</span>
                   <span v-else>-</span>
                 </template>
               </el-table-column>
-              <el-table-column prop="submitTime" :label="t('document.document.documentList.submitTime')" width="160" align="center">
+              <el-table-column prop="submitTime" :label="t('document.document.documentList.submitTime')" width="160"
+                align="center">
                 <template #default="scope">
                   <span v-if="scope.row.submitTime">{{ parseTime(scope.row.submitTime) }}</span>
                   <span v-else>-</span>
@@ -157,43 +150,40 @@
                   <span v-else>-</span>
                 </template>
               </el-table-column>
-              <el-table-column prop="note" :label="t('document.document.documentList.note')" min-width="150" show-overflow-tooltip />
-              <el-table-column prop="createTime" :label="t('document.document.documentList.createTime')" width="160" align="center">
+              <el-table-column prop="note" :label="t('document.document.documentList.note')" min-width="150"
+                show-overflow-tooltip />
+              <el-table-column prop="createTime" :label="t('document.document.documentList.createTime')" width="160"
+                align="center">
                 <template #default="scope">
                   <span v-if="scope.row.createTime">{{ parseTime(scope.row.createTime) }}</span>
                   <span v-else>-</span>
                 </template>
               </el-table-column>
-              <el-table-column prop="updateTime" :label="t('document.document.documentList.updateTime')" width="160" align="center">
+              <el-table-column prop="updateTime" :label="t('document.document.documentList.updateTime')" width="160"
+                align="center">
                 <template #default="scope">
                   <span v-if="scope.row.updateTime">{{ parseTime(scope.row.updateTime) }}</span>
                   <span v-else>-</span>
                 </template>
               </el-table-column>
-              <el-table-column :label="t('document.document.documentList.action')" width="100" align="center" fixed="right">
+              <el-table-column :label="t('document.document.documentList.action')" width="150" align="center"
+                fixed="right">
                 <template #default="scope">
-                  <el-button
-                    v-hasPermi="['document:document:audit']"
-                    type="primary"
-                    link
-                    :icon="Select"
-                    @click="handleAudit(scope.row)"
-                    :title="t('document.document.button.audit')"
-                  />
+                  <el-button v-hasPermi="['document:document:audit']" type="primary" link :icon="Select"
+                    @click="handleAudit(scope.row)" :title="t('document.document.button.audit')" />
+                  <el-button v-hasPermi="['document:document:mark']" type="primary" link icon="Flag"
+                    @click="handleMark(scope.row)" :title="t('document.document.button.mark')" />
+                  <el-button type="primary" link icon="Download" @click="handleDownload(scope.row)"
+                    :title="t('document.document.button.download')" :disabled="!scope.row.url" />
                 </template>
               </el-table-column>
             </el-table>
 
             <!-- 分页 -->
-            <pagination
-              v-show="documentTotal > 0"
-              v-model:page="documentQueryParams.pageNum"
-              v-model:limit="documentQueryParams.pageSize"
-              :total="documentTotal"
-              @pagination="getDocumentList"
-            />
+            <pagination v-show="documentTotal > 0" v-model:page="documentQueryParams.pageNum"
+              v-model:limit="documentQueryParams.pageSize" :total="documentTotal" @pagination="getDocumentList" />
           </div>
-          
+
           <!-- 空状态 -->
           <el-empty v-else :description="t('document.document.empty.description')">
           </el-empty>
@@ -212,22 +202,19 @@
             <el-radio :label="false">{{ t('document.document.form.noRestriction') }}</el-radio>
             <el-radio :label="true">{{ t('document.document.form.restricted') }}</el-radio>
           </el-radio-group>
-          <el-input-number 
-            v-if="isRestricted" 
-            v-model="restrictionLevelValue" 
-            :min="0" 
-            :max="10000" 
-            style="width: 100%; margin-top: 10px;" 
-            :placeholder="t('document.document.form.restrictionLevelPlaceholder')" 
-          />
+          <el-input-number v-if="isRestricted" v-model="restrictionLevelValue" :min="0" :max="10000"
+            style="width: 100%; margin-top: 10px;"
+            :placeholder="t('document.document.form.restrictionLevelPlaceholder')" />
         </el-form-item>
         <el-form-item :label="t('document.document.form.note')" prop="note">
-          <el-input v-model="form.note" type="textarea" :rows="4" :placeholder="t('document.document.form.notePlaceholder')" />
+          <el-input v-model="form.note" type="textarea" :rows="4"
+            :placeholder="t('document.document.form.notePlaceholder')" />
         </el-form-item>
       </el-form>
       <template #footer>
         <div class="dialog-footer">
-          <el-button :loading="buttonLoading" type="primary" @click="submitForm">{{ t('document.document.button.submit') }}</el-button>
+          <el-button :loading="buttonLoading" type="primary" @click="submitForm">{{ t('document.document.button.submit')
+          }}</el-button>
           <el-button @click="cancel">{{ t('document.document.button.cancel') }}</el-button>
         </div>
       </template>
@@ -236,77 +223,111 @@
     <!-- 添加文档对话框 -->
     <el-dialog v-model="documentDialog.visible" :title="documentDialog.title" width="700px" append-to-body>
       <el-form ref="documentFormRef" :model="documentForm" :rules="documentRules" label-width="140px">
-        <el-form-item :label="documentForm.type === 1 ? t('document.document.documentForm.planName') : t('document.document.documentForm.name')" prop="name">
-          <el-input v-model="documentForm.name" :placeholder="t('document.document.documentForm.namePlaceholder')" clearable />
+        <el-form-item
+          :label="documentForm.type === 1 ? t('document.document.documentForm.planName') : t('document.document.documentForm.name')"
+          prop="name">
+          <el-input v-model="documentForm.name" :placeholder="t('document.document.documentForm.namePlaceholder')"
+            clearable />
         </el-form-item>
-        
+
         <el-form-item :label="t('document.document.documentForm.type')" prop="type" v-if="hasAddPlanPermission">
           <el-radio-group v-model="documentForm.type" @change="handleDocumentTypeChange">
             <el-radio :label="0">{{ t('document.document.documentForm.normalDocument') }}</el-radio>
             <el-radio :label="1">{{ t('document.document.documentForm.planDocument') }}</el-radio>
           </el-radio-group>
         </el-form-item>
-        
+
         <el-form-item :label="t('document.document.documentForm.submitter')" prop="submitterId">
           <template v-if="documentForm.type === 0">
             <el-input v-model="currentUserName" disabled />
           </template>
           <template v-else>
-            <el-select
-              v-model="documentForm.submitterId"
-              filterable
-              remote
-              reserve-keyword
-              :placeholder="t('document.document.documentForm.submitterPlaceholder')"
-              :remote-method="searchSubmitters"
-              :loading="submitterSearchLoading"
-              style="width: 100%"
-            >
-              <el-option
-                v-for="submitter in submitterOptions"
-                :key="submitter.id"
-                :label="`${submitter.name} / ${submitter.dept} --- ${submitter.phoneNumber}`"
-                :value="submitter.id"
-              />
+            <el-select v-model="documentForm.submitterId" filterable remote reserve-keyword
+              :placeholder="t('document.document.documentForm.submitterPlaceholder')" :remote-method="searchSubmitters"
+              :loading="submitterSearchLoading" style="width: 100%">
+              <el-option v-for="submitter in submitterOptions" :key="submitter.id"
+                :label="`${submitter.name} / ${submitter.dept} --- ${submitter.phoneNumber}`" :value="submitter.id" />
             </el-select>
           </template>
         </el-form-item>
-        
-        <el-form-item v-if="documentForm.type === 1" :label="t('document.document.documentForm.submitDeadline')" prop="submitDeadline">
-          <el-date-picker
-            v-model="documentForm.submitDeadline"
-            type="date"
-            value-format="YYYY-MM-DD"
-            :placeholder="t('document.document.documentForm.submitDeadlinePlaceholder')"
-            style="width: 100%"
-          />
+
+        <el-form-item v-if="documentForm.type === 1" :label="t('document.document.documentForm.submitDeadline')"
+          prop="submitDeadline">
+          <el-date-picker v-model="documentForm.submitDeadline" type="date" value-format="YYYY-MM-DD"
+            :placeholder="t('document.document.documentForm.submitDeadlinePlaceholder')" style="width: 100%" />
         </el-form-item>
-        
-        <el-form-item v-if="documentForm.type === 1" :label="t('document.document.documentForm.planType')" prop="planType">
-          <el-select v-model="documentForm.planType" :placeholder="t('document.document.documentForm.planTypePlaceholder')" clearable style="width: 100%">
+
+        <el-form-item v-if="documentForm.type === 1" :label="t('document.document.documentForm.planType')"
+          prop="planType">
+          <el-select v-model="documentForm.planType"
+            :placeholder="t('document.document.documentForm.planTypePlaceholder')" clearable style="width: 100%">
             <el-option v-for="dict in plan_document_type" :key="dict.value" :label="dict.label" :value="dict.value" />
           </el-select>
         </el-form-item>
-        
+
         <el-form-item :label="t('document.document.documentForm.file')" :prop="documentForm.type === 0 ? 'ossId' : ''">
           <fileUpload v-model="uploadedFileId" :limit="1" />
         </el-form-item>
-        
+
         <el-form-item v-if="documentForm.submitTime" :label="t('document.document.documentForm.submitTime')">
           <el-input v-model="documentForm.submitTime" disabled />
         </el-form-item>
-        
+
         <el-form-item :label="t('document.document.documentForm.note')" prop="note">
-          <el-input v-model="documentForm.note" type="textarea" :rows="4" :placeholder="t('document.document.documentForm.notePlaceholder')" />
+          <el-input v-model="documentForm.note" type="textarea" :rows="4"
+            :placeholder="t('document.document.documentForm.notePlaceholder')" />
         </el-form-item>
       </el-form>
       <template #footer>
         <div class="dialog-footer">
-          <el-button :loading="documentButtonLoading" type="primary" @click="submitDocumentForm">{{ t('document.document.button.submit') }}</el-button>
+          <el-button :loading="documentButtonLoading" type="primary" @click="submitDocumentForm">{{
+            t('document.document.button.submit') }}</el-button>
           <el-button @click="cancelDocument">{{ t('document.document.button.cancel') }}</el-button>
         </div>
       </template>
     </el-dialog>
+
+    <!-- 标识文档对话框 -->
+    <el-dialog v-model="markDialog.visible" :title="markDialog.title" width="500px" append-to-body>
+      <el-form ref="markFormRef" :model="markForm" :rules="markRules" label-width="120px">
+        <el-form-item :label="t('document.document.markForm.specification')" prop="type">
+          <el-select v-model="markForm.type" :placeholder="t('document.document.markForm.specificationPlaceholder')"
+            clearable style="width: 100%">
+            <el-option v-for="dict in specificationDict" :key="dict.value" :label="dict.label" :value="dict.value" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button :loading="markButtonLoading" type="primary" @click="submitMarkForm">{{
+            t('document.document.button.submit') }}</el-button>
+          <el-button @click="cancelMark">{{ t('document.document.button.cancel') }}</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
+    <!-- 审核文档对话框 -->
+    <el-dialog v-model="auditDialog.visible" :title="auditDialog.title" width="500px" append-to-body>
+      <el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-width="120px">
+        <el-form-item :label="t('document.document.auditForm.result')" prop="result">
+          <el-radio-group v-model="auditForm.result">
+            <el-radio label="0">{{ t('document.document.auditForm.pass') }}</el-radio>
+            <el-radio label="1">{{ t('document.document.auditForm.reject') }}</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item v-if="auditForm.result === '1'" :label="t('document.document.auditForm.reason')" prop="reason">
+          <el-input v-model="auditForm.reason" type="textarea" :rows="4"
+            placeholder="{{ t('document.document.auditForm.reasonPlaceholder') }}" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button :loading="auditButtonLoading" type="primary" @click="submitAuditForm">{{
+            t('document.document.button.submit') }}</el-button>
+          <el-button @click="cancelAudit">{{ t('document.document.button.cancel') }}</el-button>
+        </div>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -315,11 +336,11 @@ import { ref, reactive, onMounted, onUnmounted, nextTick, getCurrentInstance, wa
 import { useI18n } from 'vue-i18n';
 import { listFolder, addFolder, delFolder, getFolder, updateFolder } from '@/api/document/folder';
 import { FolderListVO, FolderForm } from '@/api/document/folder/types';
-import { addDocument, listDocument } from '@/api/document/document';
-import { DocumentForm, DocumentQuery, DocumentVO } from '@/api/document/document/types';
+import { addDocument, listDocument, markDocument } from '@/api/document/document';
+import { DocumentForm, DocumentQuery, DocumentVO, DocumentMarkForm } from '@/api/document/document/types';
 import { queryMemberNotInCenter } from '@/api/project/management';
 import { MemberNotInCenterVO, MemberNotInCenterQuery } from '@/api/project/management/types';
-import { Folder, Document, Edit, Delete, Plus, MoreFilled, Location, OfficeBuilding, ArrowRight, Download, Select, Grid, Monitor, Reading } from '@element-plus/icons-vue';
+import { Folder, Document, Edit, Delete, Plus, MoreFilled, Location, OfficeBuilding, ArrowRight, Download, Select, Grid, Monitor, Reading, Flag } from '@element-plus/icons-vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import type { FormInstance } from 'element-plus';
 import type { ComponentInternalInstance } from 'vue';
@@ -340,7 +361,7 @@ const emit = defineEmits<{
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 const { t } = useI18n();
 const userStore = useUserStore();
-const { plan_document_type } = toRefs<any>(proxy?.useDict('plan_document_type'));
+const { plan_document_type, center_file_specification, project_file_specification } = toRefs<any>(proxy?.useDict('plan_document_type', 'center_file_specification', 'project_file_specification'));
 
 // 数据定义
 const loading = ref(false);
@@ -369,6 +390,26 @@ const documentDialog = reactive({
   title: ''
 });
 
+// 标识文档对话框
+const markDialog = reactive({
+  visible: false,
+  title: ''
+});
+
+// 标识表单ref
+const markFormRef = ref<FormInstance>();
+const markButtonLoading = ref(false);
+
+// 审核文档对话框
+const auditDialog = reactive({
+  visible: false,
+  title: ''
+});
+
+// 审核表单ref
+const auditFormRef = ref<FormInstance>();
+const auditButtonLoading = ref(false);
+
 // 当前操作的节点
 const currentNode = ref<FolderListVO | null>(null);
 
@@ -411,6 +452,46 @@ const uploadedFileId = ref<string>('');
 // 文档表单数据
 const documentForm = ref<DocumentForm>({ ...initDocumentFormData });
 
+// 标识表单数据
+const markForm = ref<DocumentMarkForm>({
+  id: 0,
+  type: ''
+});
+
+// 审核表单数据
+interface AuditForm {
+  id: number;
+  result: string; // 0: 通过, 1: 驳回
+  reason: string; // 驳回理由
+}
+
+const auditForm = ref<AuditForm>({
+  id: 0,
+  result: '0', // 默认通过
+  reason: ''
+});
+
+// 审核表单验证规则
+const auditRules = reactive({
+  result: [
+    {
+      required: true,
+      message: t('document.document.auditRule.resultRequired'),
+      trigger: 'change'
+    }
+  ],
+  reason: [
+    {
+      required: true,
+      message: t('document.document.auditRule.reasonRequired'),
+      trigger: 'blur'
+    }
+  ]
+});
+
+// 当前选中的文档
+const currentDocument = ref<DocumentVO | null>(null);
+
 // 递交人搜索相关
 const submitterSearchLoading = ref(false);
 const submitterOptions = ref<MemberNotInCenterVO[]>([]);
@@ -456,19 +537,75 @@ const documentRules = {
   ]
 };
 
+// 标识表单验证规则
+const markRules = {
+  type: [
+    { required: true, message: t('document.document.markRule.typeRequired'), trigger: 'change' }
+  ]
+};
+
 // 树形组件配置
 const treeProps = {
   children: 'children',
   label: 'name'
 };
 
+// 根据当前文件夹获取对应的字典
+const specificationDict = computed(() => {
+  if (!selectedFolder.value) return [];
+
+  // 判断是否在中心底下
+  const isUnderCenter = checkIfUnderCenter(selectedFolder.value.id);
+
+  if (isUnderCenter) {
+    return center_file_specification?.value || [];
+  } else {
+    return project_file_specification?.value || [];
+  }
+});
+
+// 检查文件夹是否在中心底下
+const checkIfUnderCenter = (folderId: string | number): boolean => {
+  // 从当前文件夹往上遍历,如果中间存在着中心类型的文件夹,那么他就是中心层级文件
+  // 递归查找目标节点并收集从根到目标的路径
+  const findPathToNode = (tree: FolderListVO[], targetId: string | number, path: FolderListVO[] = []): FolderListVO[] | null => {
+    for (const node of tree) {
+      const currentPath = [...path, node];
+
+      if (node.id === targetId) {
+        // 找到目标节点,返回路径
+        return currentPath;
+      }
+
+      // 在子节点中递归查找
+      if (node.children && node.children.length > 0) {
+        const result = findPathToNode(node.children, targetId, currentPath);
+        if (result) {
+          return result;
+        }
+      }
+    }
+    return null;
+  };
+
+  // 获取从根到目标文件夹的路径
+  const path = findPathToNode(treeData.value, folderId);
+
+  if (!path) {
+    return false;
+  }
+
+  // 检查路径中是否存在type为1或2的节点(国家或中心)
+  return path.some(node => node.type === 1 || node.type === 2);
+};
+
 // 获取文件夹列表
 const getList = async () => {
   if (!props.projectId) {
     ElMessage.warning(t('document.document.message.projectIdNotExist'));
     return;
   }
-  
+
   loading.value = true;
   try {
     const res = await listFolder({ projectId: props.projectId } as any);
@@ -550,9 +687,9 @@ const getAvailableTypes = () => {
       { label: t('document.document.type.center'), value: 2 }
     ];
   }
-  
+
   const parentType = currentNode.value.type;
-  
+
   if (parentType === 1 || parentType === 2) {
     // 父节点是国家或中心,子节点只能是中心或文件夹
     return [
@@ -621,7 +758,7 @@ const submitForm = () => {
           return; // 用户取消
         }
       }
-      
+
       buttonLoading.value = true;
       try {
         if (dialog.isEdit) {
@@ -649,7 +786,7 @@ const handleEdit = async (data: FolderListVO) => {
   try {
     const res = await getFolder(data.id);
     Object.assign(form.value, res.data);
-    
+
     // 设置限制层级状态
     if (form.value.restrictionLevel === -1) {
       isRestricted.value = false;
@@ -658,7 +795,7 @@ const handleEdit = async (data: FolderListVO) => {
       isRestricted.value = true;
       restrictionLevelValue.value = form.value.restrictionLevel;
     }
-    
+
     currentNode.value = null; // 编辑时不限制类型
     dialog.visible = true;
     dialog.title = t('document.document.dialog.editFolder');
@@ -678,14 +815,14 @@ const handleDelete = async (data: FolderListVO) => {
     ElMessage.warning(t('document.document.message.hasChildren'));
     return;
   }
-  
+
   try {
     await ElMessageBox.confirm(t('document.document.message.deleteConfirm', { name: data.name }), t('document.document.message.deleteTitle'), {
       confirmButtonText: t('document.document.message.confirmButton'),
       cancelButtonText: t('document.document.message.cancelButton'),
       type: 'warning'
     });
-    
+
     loading.value = true;
     await delFolder(data.id);
     ElMessage.success(t('document.document.message.deleteSuccess'));
@@ -713,13 +850,13 @@ const toggleMenu = (event: MouseEvent, data: FolderListVO) => {
   if (event.type !== 'click') {
     return;
   }
-  
+
   event.stopPropagation();
   event.preventDefault();
-  
+
   const trigger = event.currentTarget as HTMLElement;
   const rect = trigger.getBoundingClientRect();
-  
+
   if (activeMenu.value === data.id) {
     // 如果点击的是同一个菜单,关闭它
     closeAllMenus();
@@ -730,7 +867,7 @@ const toggleMenu = (event: MouseEvent, data: FolderListVO) => {
       left: `${rect.left}px`,
       top: `${rect.bottom + 2}px`
     };
-    
+
     activeMenu.value = data.id;
     currentMenuData.value = data;
     showSecondaryMenu.value = false;
@@ -743,13 +880,13 @@ const toggleSubmenu = (event: MouseEvent) => {
   if (event.type !== 'click') {
     return;
   }
-  
+
   event.stopPropagation();
   event.preventDefault();
-  
+
   const target = event.currentTarget as HTMLElement;
   const rect = target.getBoundingClientRect();
-  
+
   if (showSecondaryMenu.value) {
     // 如果已经显示,则关闭
     showSecondaryMenu.value = false;
@@ -759,7 +896,7 @@ const toggleSubmenu = (event: MouseEvent) => {
       left: `${rect.right + 5}px`,
       top: `${rect.top}px`
     };
-    
+
     // 使用 nextTick 确保位置设置后再显示
     nextTick(() => {
       showSecondaryMenu.value = true;
@@ -770,10 +907,10 @@ const toggleSubmenu = (event: MouseEvent) => {
 // 处理菜单项点击
 const handleMenuItemClick = (command: string, data: FolderListVO | null) => {
   if (!data) return;
-  
+
   // 执行命令
   handleCommand(command, data);
-  
+
   // 关闭所有菜单
   closeAllMenus();
 };
@@ -821,12 +958,12 @@ const searchSubmitters = async (query: string) => {
     submitterOptions.value = [];
     return;
   }
-  
+
   // 清除之前的定时器
   if (submitterSearchTimer) {
     clearTimeout(submitterSearchTimer);
   }
-  
+
   // 设置防抖
   submitterSearchTimer = setTimeout(async () => {
     submitterSearchLoading.value = true;
@@ -891,7 +1028,7 @@ const submitDocumentForm = () => {
           submitTime: documentForm.value.submitTime || '',
           note: documentForm.value.note || ''
         };
-        
+
         await addDocument(submitData);
         proxy?.$modal.msgSuccess(t('document.document.message.addDocumentSuccess'));
         documentDialog.visible = false;
@@ -928,10 +1065,10 @@ const handleFolderClick = (data: FolderListVO) => {
 // 查询文档列表
 const getDocumentList = async () => {
   if (!selectedFolder.value) return;
-  
+
   documentLoading.value = true;
   documentQueryParams.folderId = selectedFolder.value.id;
-  
+
   try {
     const res = await listDocument(documentQueryParams);
     documentList.value = res.rows || [];
@@ -959,8 +1096,110 @@ const handleDocumentReset = () => {
 
 // 审核文档
 const handleAudit = (row: DocumentVO) => {
-  console.log('审核文档:', row);
-  ElMessage.info('审核功能待实现');
+  currentDocument.value = row;
+  auditForm.value = {
+    id: row.id,
+    result: '0', // 默认通过
+    reason: ''
+  };
+  auditDialog.visible = true;
+  auditDialog.title = t('document.document.dialog.auditDocument');
+  // 重置表单验证
+  nextTick(() => {
+    auditFormRef.value?.clearValidate();
+  });
+};
+
+// 取消审核
+const cancelAudit = () => {
+  auditDialog.visible = false;
+  auditForm.value = {
+    id: 0,
+    result: '0',
+    reason: ''
+  };
+  currentDocument.value = null;
+};
+
+// 提交审核表单
+const submitAuditForm = () => {
+  auditFormRef.value?.validate(async (valid: boolean) => {
+    if (valid) {
+      auditButtonLoading.value = true;
+      try {
+        // 暂时不与后端进行交互,仅关闭弹窗
+        proxy?.$modal.msgSuccess(t('document.document.message.auditSuccess'));
+        auditDialog.visible = false;
+        // 刷新文档列表
+        await getDocumentList();
+      } catch (error) {
+        console.error(t('document.document.message.auditFailed'), error);
+      } finally {
+        auditButtonLoading.value = false;
+      }
+    }
+  });
+};
+
+// 下载文档
+const handleDownload = (row: DocumentVO) => {
+  if (!row.url) {
+    ElMessage.warning(t('document.document.message.noFileToDownload'));
+    return;
+  }
+
+  // 新建a标签下载文件
+  const a = document.createElement('a');
+  a.href = row.url;
+  a.download = row.fileName || row.name || 'document';
+  document.body.appendChild(a);
+  a.click();
+  document.body.removeChild(a);
+};
+
+// 标识文档
+const handleMark = (row: DocumentVO) => {
+  currentDocument.value = row;
+  markForm.value = {
+    id: row.id,
+    type: ''
+  };
+  markDialog.visible = true;
+  markDialog.title = t('document.document.dialog.markDocument');
+  // 重置表单验证
+  nextTick(() => {
+    markFormRef.value?.clearValidate();
+  });
+};
+
+// 取消标识
+const cancelMark = () => {
+  markDialog.visible = false;
+  markForm.value = {
+    id: 0,
+    type: ''
+  };
+  currentDocument.value = null;
+};
+
+// 提交标识表单
+const submitMarkForm = () => {
+  markFormRef.value?.validate(async (valid: boolean) => {
+    if (valid) {
+      markButtonLoading.value = true;
+      try {
+        await markDocument(markForm.value);
+        proxy?.$modal.msgSuccess(t('document.document.message.markSuccess'));
+        markDialog.visible = false;
+        // 刷新文档列表
+        await getDocumentList();
+      } catch (error) {
+        console.error(t('document.document.message.markFailed'), error);
+      } finally {
+        markButtonLoading.value = false;
+      }
+    }
+  });
 };
 
 // ========== 文件类型判断函数 ==========
@@ -976,9 +1215,9 @@ const isWordFile = (fileName: string): boolean => {
 const isExcelFile = (fileName: string): boolean => {
   if (!fileName) return false;
   const lowerFileName = fileName.toLowerCase();
-  return lowerFileName.endsWith('.xls') || 
-         lowerFileName.endsWith('.xlsx') || 
-         lowerFileName.endsWith('.csv');
+  return lowerFileName.endsWith('.xls') ||
+    lowerFileName.endsWith('.xlsx') ||
+    lowerFileName.endsWith('.csv');
 };
 
 // 判断是否为PPT文档
@@ -1009,14 +1248,14 @@ const handleClickOutside = (event: Event) => {
   if (!activeMenu.value && !showSecondaryMenu.value) {
     return;
   }
-  
+
   const target = event.target as HTMLElement;
-  
+
   // 检查点击是否在菜单内部或触发器上
-  const isClickInsideMenu = target.closest('.primary-menu') || 
-                           target.closest('.secondary-menu') || 
-                           target.closest('.menu-trigger');
-  
+  const isClickInsideMenu = target.closest('.primary-menu') ||
+    target.closest('.secondary-menu') ||
+    target.closest('.menu-trigger');
+
   // 如果点击在菜单外部,立即关闭所有菜单
   if (!isClickInsideMenu) {
     closeAllMenus();
@@ -1097,7 +1336,7 @@ onUnmounted(() => {
 .tree-scrollbar {
   flex: 1;
   overflow: hidden;
-  
+
   :deep(.el-scrollbar__view) {
     padding: 10px;
   }
@@ -1109,29 +1348,29 @@ onUnmounted(() => {
   align-items: center;
   font-size: 14px;
   padding-right: 8px;
-  
+
   .el-icon {
     margin-right: 8px;
     font-size: 16px;
   }
-  
+
   .node-label {
     flex: 1;
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;
     cursor: pointer;
-    
+
     &:hover {
       color: var(--el-color-primary);
     }
   }
-  
+
   .node-actions {
     display: none;
     position: relative;
   }
-  
+
   &:hover .node-actions {
     display: inline-flex;
     gap: 4px;
@@ -1146,7 +1385,7 @@ onUnmounted(() => {
   padding: 4px;
   border-radius: 4px;
   transition: background-color 0.3s, color 0.3s;
-  
+
   &:hover {
     background-color: #f5f7fa;
     color: var(--el-color-primary);
@@ -1161,21 +1400,21 @@ onUnmounted(() => {
 
 .document-list-container {
   width: 100%;
-  
+
   .search-form {
     margin-bottom: 16px;
   }
-  
+
   .file-name-cell {
     display: flex;
     align-items: center;
     gap: 8px;
     padding: 0 8px;
-    
+
     .file-icon {
       flex-shrink: 0;
     }
-    
+
     .file-name-text {
       flex: 1;
       text-align: left;
@@ -1183,11 +1422,11 @@ onUnmounted(() => {
       text-overflow: ellipsis;
       white-space: nowrap;
     }
-    
+
     .download-btn {
       flex-shrink: 0;
       font-size: 16px;
-      
+
       &:hover {
         transform: scale(1.1);
       }
@@ -1211,7 +1450,7 @@ onUnmounted(() => {
   margin: 0;
   list-style: none;
   z-index: 2000;
-  
+
   .menu-item {
     padding: 8px 16px;
     cursor: pointer;
@@ -1249,7 +1488,7 @@ onUnmounted(() => {
   margin: 0;
   list-style: none;
   z-index: 3000;
-  
+
   .menu-item {
     padding: 8px 16px;
     cursor: pointer;
@@ -1263,4 +1502,4 @@ onUnmounted(() => {
     }
   }
 }
-</style>
+</style>

+ 0 - 14
src/views/project/management/detail/pages/centerInfo.vue

@@ -42,20 +42,6 @@
         <el-table-column label="创建时间" align="center" prop="createTime" width="180" />
         <el-table-column label="更新人" align="center" prop="updateBy" width="120" show-overflow-tooltip />
         <el-table-column label="更新时间" align="center" prop="updateTime" width="180" />
-        <el-table-column label="操作" align="center" width="80" fixed="right">
-          <template #default="scope">
-            <el-tooltip content="邀请成员" placement="top">
-              <el-button
-                v-hasPermi="['project:management:queryCenterInfoInviteMember']"
-                type="primary"
-                size="small"
-                link
-                icon="User"
-                @click="handleInviteMember(scope.row)"
-              />
-            </el-tooltip>
-          </template>
-        </el-table-column>
       </el-table>
 
       <pagination

+ 491 - 19
src/views/project/management/detail/pages/projectMember.vue

@@ -2,41 +2,194 @@
   <div class="project-member-page">
     <div class="header-section">
       <h3>{{ t('project.management.detail.menu.projectMember') }}</h3>
+      <el-button v-hasPermi="['project:management:queryProjectMemberAddMember']" type="primary" icon="Plus"
+        @click="handleAddMember">
+        添加成员
+      </el-button>
     </div>
     <el-divider />
 
     <el-card shadow="never">
-      <el-table
-        v-loading="loading"
-        border
-        :data="memberList"
-        style="width: 100%"
-      >
-        <el-table-column type="index" :label="t('project.management.table.id')" width="60" align="center" />
+      <el-table v-loading="loading" border :data="memberList" style="width: 100%">
+        <el-table-column prop="id" :label="t('project.management.table.id')" width="60" align="center" />
         <el-table-column :label="t('project.management.member.name')" align="center" prop="name" width="150" />
-        <el-table-column :label="t('project.management.member.phoneNumber')" align="center" prop="phoneNumber" width="150" />
-        <el-table-column :label="t('project.management.member.dept')" align="center" prop="dept" min-width="200" show-overflow-tooltip />
+        <el-table-column :label="t('project.management.member.phoneNumber')" align="center" prop="phoneNumber"
+          width="150" />
+        <el-table-column :label="t('project.management.member.dept')" align="center" prop="dept" min-width="200"
+          show-overflow-tooltip />
         <el-table-column :label="t('project.management.member.time')" align="center" prop="time" width="180" />
+        <el-table-column label="操作栏" align="center" width="150" fixed="right">
+          <template #default="scope">
+            <el-tooltip content="修改文件夹权限" placement="top">
+              <el-button v-hasPermi="['project:management:queryProjectMemberAssignFolders']" type="primary" link
+                icon="Folder" @click="handleAssignFolders(scope.row)" />
+            </el-tooltip>
+          </template>
+        </el-table-column>
       </el-table>
 
-      <pagination
-        v-show="total > 0"
-        :total="total"
-        v-model:page="queryParams.pageNum"
-        v-model:limit="queryParams.pageSize"
-        @pagination="getList"
-      />
+      <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum"
+        v-model:limit="queryParams.pageSize" @pagination="getList" />
     </el-card>
+
+    <!-- 添加成员对话框 -->
+    <el-dialog v-model="addDialog.visible" :title="addDialog.title" width="800px" append-to-body>
+      <el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="140px">
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="手机号" prop="phoneNumber">
+              <el-input v-model="addForm.phoneNumber" placeholder="请输入手机号" maxlength="11" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="昵称" prop="nickname">
+              <el-input v-model="addForm.nickname" placeholder="请输入昵称" maxlength="30" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="用户名" prop="username">
+              <el-input v-model="addForm.username" placeholder="请输入用户名" maxlength="30" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="密码" prop="password">
+              <el-input v-model="addForm.password" type="password" placeholder="请输入密码" maxlength="20" show-password />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="邮箱" prop="email">
+              <el-input v-model="addForm.email" placeholder="请输入邮箱" maxlength="50" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="性别">
+              <el-select v-model="addForm.gender" placeholder="请选择性别" style="width: 100%">
+                <el-option v-for="dict in sys_user_sex" :key="dict.value" :label="dict.label" :value="dict.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="归属部门" prop="deptId">
+              <el-tree-select v-model="addForm.deptId" :data="deptOptions"
+                :props="{ value: 'id', label: 'label', children: 'children' }" value-key="id" placeholder="请选择归属部门"
+                check-strictly style="width: 100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="角色" prop="roleIds">
+              <el-select v-model="addForm.roleIds" multiple placeholder="请选择角色" style="width: 100%">
+                <el-option v-for="item in roleOptions" :key="item.roleId" :label="item.roleName" :value="item.roleId"
+                  :disabled="item.status == '1'" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row>
+          <el-col :span="24">
+            <el-form-item label="文件夹权限">
+              <div style="width: 100%;">
+                <el-checkbox v-model="isAllFoldersSelected" @change="handleSelectAllFolders"
+                  style="margin-bottom: 10px;">
+                  全选
+                </el-checkbox>
+                <div
+                  style="width: 100%; max-height: 300px; overflow-y: auto; border: 1px solid #dcdfe6; border-radius: 4px; padding: 10px;">
+                  <data-permision-tree ref="addFolderTreeRef" :data="folderOptions"
+                    v-model:selected-keys="addSelectedFolders" @check="handleAddFolderCheck"
+                    :disabled="isAllFoldersSelected">
+                    <template #default="{ node, data }">
+                      <span class="custom-tree-node">
+                        <el-icon>
+                          <Folder v-if="data.type === 0" />
+                          <Location v-else-if="data.type === 1" />
+                          <OfficeBuilding v-else-if="data.type === 2" />
+                          <Document v-else />
+                        </el-icon>
+                        <span>{{ node.name }}</span>
+                      </span>
+                    </template>
+                  </data-permision-tree>
+                </div>
+              </div>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row>
+          <el-col :span="24">
+            <el-form-item label="备注">
+              <el-input v-model="addForm.remark" type="textarea" :rows="4" placeholder="请输入备注" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button :loading="addButtonLoading" type="primary" @click="submitAddForm">确定</el-button>
+          <el-button @click="cancelAdd">取消</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
+    <!-- 修改文件夹权限对话框 -->
+    <el-dialog v-model="folderDialog.visible" :title="folderDialog.title" width="600px" append-to-body>
+      <div style="width: 100%;">
+        <el-checkbox v-model="isAllFoldersSelectedForEdit" @change="handleSelectAllFoldersForEdit"
+          style="margin-bottom: 10px;">
+          全选
+        </el-checkbox>
+        <div
+          style="width: 100%; max-height: 400px; overflow-y: auto; border: 1px solid #dcdfe6; border-radius: 4px; padding: 10px;">
+          <data-permision-tree ref="folderTreeRef" :data="folderOptions" v-model:selected-keys="selectedFolders"
+            @check="handleFolderCheck" :disabled="isAllFoldersSelectedForEdit">
+            <template #default="{ node, data }">
+              <span class="custom-tree-node">
+                <el-icon>
+                  <Folder v-if="data.type === 0" />
+                  <Location v-else-if="data.type === 1" />
+                  <OfficeBuilding v-else-if="data.type === 2" />
+                  <Document v-else />
+                </el-icon>
+                <span>{{ node.name }}</span>
+              </span>
+            </template>
+          </data-permision-tree>
+        </div>
+      </div>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button :loading="folderButtonLoading" type="primary" @click="submitFolderForm">确定</el-button>
+          <el-button @click="cancelFolder">取消</el-button>
+        </div>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { inject, ref, onMounted } from 'vue';
+import { inject, ref, onMounted, getCurrentInstance, toRefs } from 'vue';
 import { useI18n } from 'vue-i18n';
-import { queryProjectMember } from '@/api/project/management';
-import { ProjectMemberVO, ProjectMemberQuery } from '@/api/project/management/types';
+import { queryProjectMember, addProjectMember, getFolders, assignFolders } from '@/api/project/management';
+import { ProjectMemberVO, ProjectMemberQuery, AddProjectMemberForm, AssignFoldersForm } from '@/api/project/management/types';
+import { deptTreeSelect, getUser } from '@/api/system/user';
+import { DeptTreeVO } from '@/api/system/dept/types';
+import { RoleVO } from '@/api/system/role/types';
+import { listFolderOnProject } from '@/api/document/folder';
+import { FolderListVO } from '@/api/document/folder/types';
+import { ElMessage } from 'element-plus';
+import type { FormInstance } from 'element-plus';
+import type { ComponentInternalInstance } from 'vue';
+import { Folder, Location, OfficeBuilding, Document } from '@element-plus/icons-vue';
+import DataPermisionTree from '@/components/DataPermisionTree/index.vue';
 
 const { t } = useI18n();
+const { proxy } = getCurrentInstance() as ComponentInternalInstance;
+const { sys_user_sex } = toRefs<any>(proxy?.useDict('sys_user_sex'));
 
 // 接收从父组件传递的项目ID
 const projectId = inject<any>('projectId');
@@ -46,6 +199,116 @@ const memberList = ref<ProjectMemberVO[]>([]);
 const loading = ref(true);
 const total = ref(0);
 
+// Form refs
+const addFormRef = ref<FormInstance>();
+const folderTreeRef = ref<any>();
+const addFolderTreeRef = ref<any>();
+
+// 添加成员对话框
+const addDialog = ref({
+  visible: false,
+  title: '添加成员'
+});
+
+// 文件夹权限对话框
+const folderDialog = ref({
+  visible: false,
+  title: '修改文件夹权限'
+});
+
+// 按钮 loading
+const addButtonLoading = ref(false);
+const folderButtonLoading = ref(false);
+
+// 部门和角色选项
+const deptOptions = ref<DeptTreeVO[]>([]);
+const roleOptions = ref<RoleVO[]>([]);
+const folderOptions = ref<FolderListVO[]>([]);
+
+// 文件夹选中的ID列表
+const selectedFolders = ref<number[]>([]);
+const addSelectedFolders = ref<number[]>([]);
+
+// 全选文件夹相关
+const isAllFoldersSelected = ref(false);
+const isAllFoldersSelectedForEdit = ref(false);
+
+// 当前操作的用户
+const currentMember = ref<ProjectMemberVO | null>(null);
+
+// 添加成员表单
+const addForm = ref<AddProjectMemberForm>({
+  projectId: 0,
+  phoneNumber: '',
+  nickname: '',
+  username: '',
+  password: '',
+  email: '',
+  gender: '',
+  roleIds: [],
+  deptId: 0,
+  folders: ''
+});
+
+/**
+ * 处理全选文件夹
+ * @param checked 是否选中
+ */
+const handleSelectAllFolders = (checked: boolean) => {
+  if (checked) {
+    // 全选时,设置folders为"*"
+    addForm.value.folders = '*';
+    // 清空树形组件的选中状态,避免冲突
+    addSelectedFolders.value = [];
+  } else {
+    // 取消全选时,清空folders
+    addForm.value.folders = '';
+  }
+};
+
+/**
+ * 处理修改权限时的全选文件夹
+ * @param checked 是否选中
+ */
+const handleSelectAllFoldersForEdit = (checked: boolean) => {
+  if (checked) {
+    // 全选时,设置folders为"*"
+    // 注意:这里我们不直接修改表单,而是在提交时处理
+    // 清空树形组件的选中状态,避免冲突
+    selectedFolders.value = [];
+  }
+};
+
+
+// 表单验证规则
+const addRules = {
+  phoneNumber: [
+    { required: true, message: '请输入手机号', trigger: 'blur' },
+    { pattern: /^1[3456789][0-9]\d{8}$/, message: '请输入正确的手机号', trigger: 'blur' }
+  ],
+  nickname: [
+    { required: true, message: '请输入昵称', trigger: 'blur' }
+  ],
+  username: [
+    { required: true, message: '请输入用户名', trigger: 'blur' },
+    { min: 2, max: 20, message: '用户名长度必须介于2和20之间', trigger: 'blur' }
+  ],
+  password: [
+    { required: true, message: '请输入密码', trigger: 'blur' },
+    { min: 5, max: 20, message: '密码长度必须介于5和20之间', trigger: 'blur' },
+    { pattern: /^[^<>"'|\\]+$/, message: '不能包含非法字符:< > " \' \\ |', trigger: 'blur' }
+  ],
+  email: [
+    { type: 'email', message: '请输入正确的邮箱地址', trigger: ['blur', 'change'] }
+  ],
+  roleIds: [
+    { required: true, message: '请选择角色', trigger: 'change' }
+  ],
+  deptId: [
+    { required: true, message: '请选择归属部门', trigger: 'change' }
+  ]
+};
+
 // 查询参数
 const queryParams = ref<ProjectMemberQuery>({
   pageNum: 1,
@@ -68,6 +331,196 @@ const getList = async () => {
   }
 };
 
+/** 获取部门树 */
+const getDeptTree = async () => {
+  try {
+    const res = await deptTreeSelect();
+    deptOptions.value = res.data;
+  } catch (error) {
+    console.error('Failed to fetch dept tree:', error);
+  }
+};
+
+/** 获取文件夹列表 */
+const getFolderList = async () => {
+  try {
+    const res = await listFolderOnProject(projectId?.value || 0);
+    folderOptions.value = res.data || [];
+  } catch (error) {
+    console.error('Failed to fetch folder list:', error);
+  }
+};
+
+/** 打开添加成员对话框 */
+const handleAddMember = async () => {
+  // 重置表单
+  addForm.value = {
+    projectId: projectId?.value || 0,
+    phoneNumber: '',
+    nickname: '',
+    username: '',
+    password: '',
+    email: '',
+    gender: '',
+    roleIds: [],
+    deptId: undefined,
+    folders: ''
+  };
+  addSelectedFolders.value = [];
+  isAllFoldersSelected.value = false;
+  addFormRef.value?.resetFields();
+
+  // 获取部门和角色数据
+  await getDeptTree();
+  try {
+    const res = await getUser();
+    roleOptions.value = res.data.roles || [];
+  } catch (error) {
+    console.error('Failed to fetch roles:', error);
+  }
+
+  // 获取文件夹列表
+  await getFolderList();
+
+  addDialog.value.visible = true;
+};
+
+/** 处理添加成员文件夹选中 */
+const handleAddFolderCheck = () => {
+  // 如果全选状态下,不处理单个文件夹的选择
+  if (isAllFoldersSelected.value) {
+    return;
+  }
+
+  // 直接使用addSelectedFolders,因为DataPermisionTree已经过滤了顶级节点
+  addForm.value.folders = addSelectedFolders.value.join(',');
+};
+
+/** 提交添加成员表单 */
+const submitAddForm = () => {
+  addFormRef.value?.validate(async (valid: boolean) => {
+    if (valid) {
+      addButtonLoading.value = true;
+      try {
+        // 如果不是全选状态,才需要设置文件夹
+        if (!isAllFoldersSelected.value) {
+          // 直接使用addSelectedFolders,因为DataPermisionTree已经过滤了顶级节点
+          addForm.value.folders = addSelectedFolders.value.join(',');
+        }
+
+        await addProjectMember(addForm.value);
+        ElMessage.success('添加成员成功');
+        addDialog.value.visible = false;
+        await getList();
+      } catch (error) {
+        console.error('Failed to add member:', error);
+      } finally {
+        addButtonLoading.value = false;
+      }
+    }
+  });
+};
+
+/** 取消添加成员 */
+const cancelAdd = () => {
+  addDialog.value.visible = false;
+  addForm.value = {
+    projectId: 0,
+    phoneNumber: '',
+    nickname: '',
+    username: '',
+    password: '',
+    email: '',
+    gender: '',
+    roleIds: [],
+    deptId: 0,
+    folders: ''
+  };
+  addSelectedFolders.value = [];
+  isAllFoldersSelected.value = false;
+};
+
+/** 打开修改文件夹权限对话框 */
+const handleAssignFolders = async (row: ProjectMemberVO) => {
+  currentMember.value = row;
+
+  // 重置全选状态
+  isAllFoldersSelectedForEdit.value = false;
+
+  // 获取文件夹列表
+  await getFolderList();
+
+  // 获取用户当前的文件夹权限
+  try {
+    const res = await getFolders(row.id, projectId?.value || 0);
+    const folders = res.data.folders;
+    // 检查是否为全选状态
+    if (folders === '*') {
+      isAllFoldersSelectedForEdit.value = true;
+      selectedFolders.value = [];
+    } else if (folders) {
+      selectedFolders.value = folders.split(',').map(id => parseInt(id)).filter(id => !isNaN(id));
+    } else {
+      selectedFolders.value = [];
+    }
+  } catch (error) {
+    console.error('Failed to fetch user folders:', error);
+    selectedFolders.value = [];
+  }
+
+  folderDialog.value.visible = true;
+};
+
+/** 处理文件夹选中 */
+const handleFolderCheck = () => {
+  // 如果全选状态下,不处理单个文件夹的选择
+  if (isAllFoldersSelectedForEdit.value) {
+    return;
+  }
+  // 不需要处理,提交时再获取
+};
+
+/** 提交文件夹权限表单 */
+const submitFolderForm = async () => {
+  if (!currentMember.value) return;
+
+  folderButtonLoading.value = true;
+  try {
+    let foldersValue = '';
+
+    // 如果全选状态,设置为"*"
+    if (isAllFoldersSelectedForEdit.value) {
+      foldersValue = '*';
+    } else {
+      // 直接使用selectedFolders,因为DataPermisionTree已经过滤了顶级节点
+      foldersValue = selectedFolders.value.join(',');
+    }
+
+    const data: AssignFoldersForm = {
+      userId: currentMember.value.id,
+      projectId: projectId?.value || 0,
+      folders: foldersValue
+    };
+
+    await assignFolders(data);
+    ElMessage.success('修改文件夹权限成功');
+    folderDialog.value.visible = false;
+    await getList();
+  } catch (error) {
+    console.error('Failed to assign folders:', error);
+  } finally {
+    folderButtonLoading.value = false;
+  }
+};
+
+/** 取消修改文件夹权限 */
+const cancelFolder = () => {
+  folderDialog.value.visible = false;
+  selectedFolders.value = [];
+  currentMember.value = null;
+  isAllFoldersSelectedForEdit.value = false;
+};
+
 // 组件挂载时加载数据
 onMounted(() => {
   getList();
@@ -115,4 +568,23 @@ h3 {
   margin-top: 16px;
 }
 
+.custom-tree-node {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  font-size: 14px;
+  padding-right: 8px;
+}
+
+.custom-tree-node .el-icon {
+  margin-right: 8px;
+  font-size: 16px;
+}
+
+.custom-tree-node span {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
 </style>