c1dd8def2c
新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。 复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。 补充在线文档字体构建支持、数据库表结构升级和回归测试。 不包含本地台账内容、账号授权记录或历史文件迁移数据。
1191 lines
62 KiB
Vue
1191 lines
62 KiB
Vue
<template>
|
||
<el-dialog
|
||
v-model="accessDialogVisible"
|
||
width="720px"
|
||
class="collaboration-action-dialog access-management-dialog"
|
||
append-to-body
|
||
@closed="closeAccessManagement"
|
||
>
|
||
<template #header>
|
||
<div class="access-dialog-header">
|
||
<button
|
||
v-if="accessPanel !== 'overview'"
|
||
type="button"
|
||
class="access-dialog-header__back"
|
||
aria-label="返回访问与权限"
|
||
@click="handleAccessPanelBack"
|
||
>
|
||
<el-icon><ArrowLeft /></el-icon>
|
||
</button>
|
||
<strong>{{ accessPanelTitle }}</strong>
|
||
</div>
|
||
</template>
|
||
<div v-loading="memberLoading || shareLoading" class="access-management">
|
||
<template v-if="accessPanel === 'overview'">
|
||
<section class="access-section access-section--members">
|
||
<header class="access-section__header">
|
||
<div class="access-section__title">
|
||
<strong>账号授权</strong>
|
||
<span>{{ members.length }} 人</span>
|
||
<span v-if="editRequests.length" class="access-section__pending-count">
|
||
{{ editRequests.length }} 项待处理
|
||
</span>
|
||
</div>
|
||
<button type="button" class="access-section__manage-button" @click="openMemberInvite">
|
||
<el-icon><User /></el-icon>
|
||
<span>管理协作者</span>
|
||
</button>
|
||
</header>
|
||
<div
|
||
v-if="editRequests.length"
|
||
ref="editRequestSectionRef"
|
||
class="edit-request-list"
|
||
tabindex="-1"
|
||
aria-label="待处理的编辑权限申请"
|
||
>
|
||
<div class="edit-request-list__title">待处理申请</div>
|
||
<div v-for="request in editRequests" :key="request.id" class="edit-request-item">
|
||
<div class="access-member-identity">
|
||
<el-avatar :size="36" :class="`collaborator-avatar tone-${collaboratorTone(request.requester_id)}`">
|
||
{{ collaboratorInitial(request.requester_name) }}
|
||
</el-avatar>
|
||
<span>
|
||
<strong>{{ request.requester_name }}</strong>
|
||
<small>{{ request.requester_email }}</small>
|
||
<small>申请编辑权限 · {{ displayDateTime(request.created_at) }}</small>
|
||
</span>
|
||
</div>
|
||
<div class="edit-request-item__actions">
|
||
<el-button
|
||
size="small"
|
||
:loading="resolvingEditRequestId === request.id"
|
||
@click="resolveEditRequest(request, 'REJECTED')"
|
||
>拒绝</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
:loading="resolvingEditRequestId === request.id"
|
||
@click="resolveEditRequest(request, 'APPROVED')"
|
||
>同意</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-loading="memberLoading" class="access-member-list">
|
||
<div v-for="member in members" :key="member.user_id" class="access-member-row">
|
||
<div class="access-member-identity">
|
||
<el-avatar :size="36" :class="`collaborator-avatar tone-${collaboratorTone(member.user_id)}`">
|
||
{{ collaboratorInitial(member.full_name) }}
|
||
</el-avatar>
|
||
<span>
|
||
<strong>{{ member.full_name }}</strong>
|
||
<small>{{ member.email }}</small>
|
||
</span>
|
||
</div>
|
||
<el-tag size="small" effect="plain" class="access-member-role">
|
||
{{ roleLabel(member.role) }}
|
||
</el-tag>
|
||
<span v-if="member.protected_label || member.user_id === accessFile?.owner_id" class="access-member-owner">{{ member.protected_label || "所有者" }}</span>
|
||
<el-button v-else link class="access-member-remove" @click="removeMember(member)">移除</el-button>
|
||
</div>
|
||
<el-empty v-if="members.length === 0 && !memberLoading" description="暂无授权账号" :image-size="56" />
|
||
</div>
|
||
</section>
|
||
|
||
<section class="access-section access-section--settings">
|
||
<div class="access-setting-main">
|
||
<div class="access-setting-heading">
|
||
<span class="access-setting-heading__icon"><el-icon><Share /></el-icon></span>
|
||
<strong>匿名访问</strong>
|
||
</div>
|
||
<div class="access-setting-main__control">
|
||
<span class="access-setting-status" :class="{ 'is-active': shareForm.enabled }">
|
||
<i aria-hidden="true" />{{ shareForm.enabled ? "已开启" : "已关闭" }}
|
||
</span>
|
||
<el-switch
|
||
v-model="shareForm.enabled"
|
||
aria-label="启用匿名访问"
|
||
:disabled="shareSaving"
|
||
@change="handleShareEnabledChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div v-if="shareForm.enabled" class="share-settings-body">
|
||
<div class="share-settings-list">
|
||
<div class="share-setting-row">
|
||
<div class="share-setting-row__label">
|
||
<span><el-icon><Share /></el-icon></span>
|
||
<strong>访问权限</strong>
|
||
</div>
|
||
<el-select
|
||
v-model="shareForm.access_mode"
|
||
aria-label="匿名访问权限"
|
||
:disabled="shareSaving"
|
||
@change="persistShareSettings()"
|
||
>
|
||
<el-option label="可查看" value="VIEW" />
|
||
<el-option label="可编辑" value="EDIT" />
|
||
</el-select>
|
||
</div>
|
||
<div class="share-setting-row">
|
||
<div class="share-setting-row__label">
|
||
<span><el-icon><Clock /></el-icon></span>
|
||
<strong>有效期</strong>
|
||
</div>
|
||
<el-select
|
||
v-model="shareForm.expiry_policy"
|
||
aria-label="匿名访问有效期"
|
||
:disabled="shareSaving"
|
||
@change="persistShareSettings()"
|
||
>
|
||
<el-option label="1 天" value="ONE_DAY" />
|
||
<el-option label="7 天" value="SEVEN_DAYS" />
|
||
<el-option label="30 天" value="THIRTY_DAYS" />
|
||
<el-option label="永久有效" value="PERMANENT" />
|
||
</el-select>
|
||
</div>
|
||
<div class="share-setting-row share-setting-row--password">
|
||
<div class="share-setting-row__label">
|
||
<span><el-icon><Lock /></el-icon></span>
|
||
<strong>访问密码</strong>
|
||
</div>
|
||
<div class="share-password-control">
|
||
<el-switch
|
||
v-model="shareForm.password_enabled"
|
||
aria-label="启用匿名访问密码"
|
||
:disabled="shareSaving"
|
||
@change="handleSharePasswordToggle"
|
||
/>
|
||
<span>{{ shareForm.password_enabled ? "已设置" : "无密码" }}</span>
|
||
<el-button v-if="shareLink?.has_password" link type="primary" :disabled="shareSaving" @click="promptSharePassword">修改密码</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="shareUrl" class="share-link-panel">
|
||
<div class="share-link-panel__label">
|
||
<el-icon><Link /></el-icon>
|
||
<span>匿名访问链接</span>
|
||
</div>
|
||
<div class="share-link-row">
|
||
<el-input :model-value="shareUrl" readonly aria-label="匿名访问链接" />
|
||
<el-button type="primary" @click="copyShareUrl">复制链接</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button type="button" class="access-setting-navigation" @click="openPermissionSettings">
|
||
<span class="access-setting-navigation__copy">
|
||
<strong>权限设置</strong>
|
||
<small>{{ accessPermissionSummary }}</small>
|
||
</span>
|
||
<el-icon><ArrowRight /></el-icon>
|
||
</button>
|
||
</section>
|
||
</template>
|
||
|
||
<section v-else-if="accessPanel === 'permissions'" class="access-section access-section--common">
|
||
<div class="access-permission-row">
|
||
<strong>{{ target.scope === "ledger" ? "允许查看者、编辑者和匿名访问者下载、打印及复制" : "允许编辑者和匿名访问者下载、打印、另存和复制" }}</strong>
|
||
<el-switch
|
||
v-model="commonExportEnabled"
|
||
:disabled="exportPermissionSaving"
|
||
@change="handleCommonExportChange"
|
||
/>
|
||
</div>
|
||
<div class="access-permission-row">
|
||
<strong>允许申请编辑权限</strong>
|
||
<el-switch
|
||
v-model="editRequestEnabled"
|
||
:disabled="editRequestPermissionSaving"
|
||
@change="handleEditRequestPermissionChange"
|
||
/>
|
||
</div>
|
||
<div v-if="accessFile?.file_type === 'cell'" class="access-permission-row">
|
||
<strong>允许所有协作者添加、删除工作表</strong>
|
||
<el-switch
|
||
v-model="sheetStructureEditEnabled"
|
||
:disabled="sheetStructurePermissionSaving"
|
||
@change="handleSheetStructurePermissionChange"
|
||
/>
|
||
</div>
|
||
<button
|
||
v-if="accessFile?.can_transfer_ownership"
|
||
type="button"
|
||
class="access-settings-entry access-settings-entry--transfer"
|
||
@click="openOwnershipTransfer"
|
||
>
|
||
<span>转让文档所有权</span>
|
||
<el-icon><ArrowRight /></el-icon>
|
||
</button>
|
||
</section>
|
||
|
||
</div>
|
||
</el-dialog>
|
||
|
||
<el-dialog
|
||
v-model="memberInviteDialogVisible"
|
||
title="管理协作者"
|
||
width="820px"
|
||
class="collaboration-action-dialog member-invite-dialog"
|
||
append-to-body
|
||
destroy-on-close
|
||
@closed="closeMemberInvite"
|
||
>
|
||
<div class="member-picker-layout">
|
||
<div class="member-picker-directory">
|
||
<div class="member-picker-directory__toolbar">
|
||
<el-input v-model="memberSearch" clearable placeholder="搜索姓名、账号或角色" class="member-picker-search">
|
||
<template #prefix><el-icon><Search /></el-icon></template>
|
||
</el-input>
|
||
</div>
|
||
<div class="member-picker-groups">
|
||
<section class="member-picker-group member-picker-group--contacts">
|
||
<div class="member-picker-group-title">
|
||
<span class="member-picker-group-title__label">{{ directoryLabel }}</span>
|
||
<small>{{ filteredAvailableCandidates.length }} 位可授权</small>
|
||
</div>
|
||
<div class="member-picker-list" role="listbox" :aria-label="`可添加的${directoryLabel}`">
|
||
<button
|
||
v-for="candidate in filteredAvailableCandidates"
|
||
:key="candidate.user_id"
|
||
type="button"
|
||
role="option"
|
||
class="member-picker-contact"
|
||
:class="{
|
||
'is-selected': memberForm.user_ids.includes(candidate.user_id),
|
||
'is-ineligible': !isCandidateEligible(candidate),
|
||
}"
|
||
:aria-selected="memberForm.user_ids.includes(candidate.user_id)"
|
||
:aria-disabled="!isCandidateEligible(candidate)"
|
||
:disabled="!isCandidateEligible(candidate)"
|
||
@click="toggleMemberCandidate(candidate.user_id)"
|
||
>
|
||
<span class="member-picker-contact__choice" :class="{ 'is-selected': memberForm.user_ids.includes(candidate.user_id) }">
|
||
<el-icon v-if="memberForm.user_ids.includes(candidate.user_id)"><Check /></el-icon>
|
||
</span>
|
||
<el-avatar :size="38" :class="`collaborator-avatar tone-${collaboratorTone(candidate.user_id)}`">
|
||
{{ collaboratorInitial(candidate.full_name) }}
|
||
</el-avatar>
|
||
<span class="member-picker-contact__identity">
|
||
<span class="member-picker-contact__name">
|
||
<strong>{{ candidate.full_name }}</strong>
|
||
<el-tag size="small" effect="plain" class="collaboration-role-tag">{{ candidate.role_in_study }}</el-tag>
|
||
</span>
|
||
<small>{{ candidate.email }}</small>
|
||
<small v-if="!isCandidateEligible(candidate)" class="member-picker-contact__restriction">
|
||
当前项目角色不可授予{{ memberForm.role === "MANAGER" ? "管理者" : "编辑者" }}权限
|
||
</small>
|
||
</span>
|
||
</button>
|
||
<el-empty v-if="filteredAvailableCandidates.length === 0" :description="`没有匹配的${directoryLabel}`" :image-size="68" />
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="member-picker-selection">
|
||
<div class="member-picker-selection__header">
|
||
<strong>已选择 <span>({{ selectedMemberCandidates.length }})</span></strong>
|
||
<el-select v-model="memberForm.role" aria-label="文件授权角色" class="member-picker-role" @change="handleMemberRoleChange">
|
||
<el-option v-if="target.scope === 'ledger'" label="可查看" value="VIEWER" />
|
||
<el-option label="可编辑" value="EDITOR" />
|
||
<el-option label="可管理" value="MANAGER" />
|
||
</el-select>
|
||
</div>
|
||
<div v-if="selectedMemberCandidates.length" class="member-picker-selected-list">
|
||
<div v-for="candidate in selectedMemberCandidates" :key="candidate.user_id" class="member-picker-selected-contact">
|
||
<el-avatar :size="40" :class="`collaborator-avatar tone-${collaboratorTone(candidate.user_id)}`">
|
||
{{ collaboratorInitial(candidate.full_name) }}
|
||
</el-avatar>
|
||
<span class="member-picker-selected-contact__identity">
|
||
<span class="member-picker-selected-contact__name">
|
||
<strong>{{ candidate.full_name }}</strong>
|
||
<el-tag size="small" effect="plain" class="collaboration-role-tag">{{ candidate.role_in_study }}</el-tag>
|
||
</span>
|
||
<small>{{ candidate.email }}</small>
|
||
</span>
|
||
<el-button text circle class="member-picker-selected-contact__remove" :aria-label="`移除${candidate.full_name}`" @click="toggleMemberCandidate(candidate.user_id)">
|
||
<el-icon><Close /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-else class="member-picker-selection__empty">
|
||
<div class="member-picker-selection__empty-visual" aria-hidden="true">
|
||
<el-icon><User /></el-icon>
|
||
<span><el-icon><Plus /></el-icon></span>
|
||
</div>
|
||
<strong>暂未选择协作者</strong>
|
||
<span>{{ `从左侧${directoryLabel}中选择需要授权的账号` }}</span>
|
||
</div>
|
||
<div class="member-picker-selection__footer">
|
||
<span v-if="selectedMemberCandidates.length">
|
||
将授予 {{ selectedMemberCandidates.length }} 位账号{{ roleLabel(memberForm.role) }}权限
|
||
</span>
|
||
<span v-else />
|
||
<div>
|
||
<el-button @click="memberInviteDialogVisible = false">取消</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:disabled="selectedMemberCandidates.length === 0"
|
||
:loading="memberSaving"
|
||
@click="saveMember"
|
||
>确定</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-dialog>
|
||
|
||
<el-dialog
|
||
v-model="transferDialogVisible"
|
||
title="请选择新的所有者"
|
||
width="760px"
|
||
class="collaboration-action-dialog ownership-transfer-dialog"
|
||
append-to-body
|
||
destroy-on-close
|
||
@closed="closeOwnershipTransfer"
|
||
>
|
||
<div class="ownership-transfer-layout">
|
||
<div class="ownership-contact-list" role="listbox" aria-label="可转让联系人">
|
||
<div class="ownership-contact ownership-contact--current" aria-disabled="true">
|
||
<span class="ownership-contact__choice is-selected"><el-icon><Check /></el-icon></span>
|
||
<el-avatar :size="36">{{ collaboratorInitial(accessFile?.owner_name || "?") }}</el-avatar>
|
||
<span class="ownership-contact__identity">
|
||
<span class="ownership-contact__name">
|
||
<strong>{{ accessFile?.owner_name || "当前所有者" }}</strong>
|
||
<el-tag v-if="currentOwnerCandidate?.role_in_study" size="small" effect="plain" class="collaboration-role-tag">
|
||
{{ currentOwnerCandidate.role_in_study }}
|
||
</el-tag>
|
||
</span>
|
||
<small>当前所有者</small>
|
||
</span>
|
||
</div>
|
||
<button
|
||
v-for="candidate in transferCandidates"
|
||
:key="candidate.user_id"
|
||
type="button"
|
||
role="option"
|
||
class="ownership-contact ownership-contact--candidate"
|
||
:class="{ 'is-selected': transferOwnerId === candidate.user_id }"
|
||
:aria-selected="transferOwnerId === candidate.user_id"
|
||
@click="transferOwnerId = candidate.user_id"
|
||
>
|
||
<span class="ownership-contact__choice" :class="{ 'is-selected': transferOwnerId === candidate.user_id }">
|
||
<el-icon v-if="transferOwnerId === candidate.user_id"><Check /></el-icon>
|
||
</span>
|
||
<el-avatar :size="36">{{ collaboratorInitial(candidate.full_name) }}</el-avatar>
|
||
<span class="ownership-contact__identity">
|
||
<span class="ownership-contact__name">
|
||
<strong>{{ candidate.full_name }}</strong>
|
||
<el-tag size="small" effect="plain" class="collaboration-role-tag">{{ candidate.role_in_study }}</el-tag>
|
||
</span>
|
||
<small>{{ candidate.email }}</small>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="ownership-selection">
|
||
<template v-if="selectedTransferCandidate">
|
||
<el-avatar :size="82">{{ collaboratorInitial(selectedTransferCandidate.full_name) }}</el-avatar>
|
||
<span class="ownership-selection__name">
|
||
<strong>{{ selectedTransferCandidate.full_name }}</strong>
|
||
<el-tag size="small" effect="plain" class="collaboration-role-tag">{{ selectedTransferCandidate.role_in_study }}</el-tag>
|
||
</span>
|
||
<span class="ownership-selection__email">{{ selectedTransferCandidate.email }}</span>
|
||
</template>
|
||
<el-empty v-else description="暂未选择联系人" :image-size="112" />
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="transferDialogVisible = false">取消</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:disabled="!selectedTransferCandidate"
|
||
:loading="transferringOwnership"
|
||
@click="submitOwnershipTransfer"
|
||
>确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||
import { ElMessage, ElMessageBox } from "element-plus";
|
||
import { ArrowLeft, ArrowRight, Check, Clock, Close, Link, Lock, Plus, Search, Share, User } from "@element-plus/icons-vue";
|
||
import { displayDateTime } from "../../utils/display";
|
||
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
|
||
import { clientRuntime } from "../../runtime";
|
||
import { notifyProjectNotificationsChanged } from "../../composables/useProjectNotifications";
|
||
import { createCollaborationAccessApi } from "../../api/collaborationAccess";
|
||
import type { CollaborationAccessTarget, CollaborationAccessFile as CollaborationFile, CollaborationAccessMember as CollaborationMember, CollaborationAccessCandidate as CollaborationCandidate, CollaborationAccessRole as CollaborationMemberRole } from "../../api/collaborationAccess";
|
||
import type { CollaborationEditRequest, CollaborationShareAccessMode, CollaborationShareExpiryPolicy, CollaborationShareLink } from "../../types/collaboration";
|
||
|
||
const props = defineProps<{ target: CollaborationAccessTarget; file: CollaborationFile }>();
|
||
const emit = defineEmits<{ close: []; changed: [] }>();
|
||
const api = createCollaborationAccessApi(props.target);
|
||
const directoryLabel = computed(() => props.target.scope === "ledger" ? "系统账号" : "项目成员");
|
||
const roleLabel = (role: CollaborationMemberRole) => ({ VIEWER: "查看者", EDITOR: "编辑者", MANAGER: "管理者" })[role];
|
||
const accessDialogVisible = ref(false);
|
||
const accessFile = ref<CollaborationFile | null>(null);
|
||
const accessPanel = ref<"overview" | "permissions">("overview");
|
||
const commonExportEnabled = ref(false);
|
||
const exportPermissionSaving = ref(false);
|
||
const editRequestEnabled = ref(false);
|
||
const editRequestPermissionSaving = ref(false);
|
||
const sheetStructureEditEnabled = ref(true);
|
||
const sheetStructurePermissionSaving = ref(false);
|
||
const editRequests = ref<CollaborationEditRequest[]>([]);
|
||
const editRequestSectionRef = ref<HTMLElement | null>(null);
|
||
const resolvingEditRequestId = ref<string | null>(null);
|
||
const memberInviteDialogVisible = ref(false);
|
||
const memberSearch = ref("");
|
||
const transferDialogVisible = ref(false);
|
||
const transferOwnerId = ref("");
|
||
const transferringOwnership = ref(false);
|
||
const memberLoading = ref(false);
|
||
const memberSaving = ref(false);
|
||
const members = ref<CollaborationMember[]>([]);
|
||
const candidates = ref<CollaborationCandidate[]>([]);
|
||
const memberForm = reactive<{ user_ids: string[]; role: CollaborationMemberRole }>({ user_ids: [], role: "EDITOR" });
|
||
const shareLoading = ref(false);
|
||
const shareSaving = ref(false);
|
||
const shareLink = ref<CollaborationShareLink | null>(null);
|
||
const shareForm = reactive<{
|
||
enabled: boolean;
|
||
access_mode: CollaborationShareAccessMode;
|
||
expiry_policy: CollaborationShareExpiryPolicy;
|
||
password_enabled: boolean;
|
||
}>({
|
||
enabled: false,
|
||
access_mode: "VIEW",
|
||
expiry_policy: "SEVEN_DAYS",
|
||
password_enabled: false,
|
||
});
|
||
const shareUrl = computed(() => {
|
||
if (!shareLink.value?.share_token || !shareForm.enabled) return "";
|
||
const apiBase = new URL(clientRuntime.apiBaseUrl(), window.location.origin);
|
||
const url = new URL(shareLink.value.share_path, apiBase.origin);
|
||
url.hash = shareLink.value.share_token;
|
||
return url.href;
|
||
});
|
||
const availableCandidates = computed(() => {
|
||
const existing = new Set(members.value.map((item) => item.user_id));
|
||
return candidates.value.filter((item) => !existing.has(item.user_id));
|
||
});
|
||
const filteredAvailableCandidates = computed(() => {
|
||
const query = memberSearch.value.trim().toLocaleLowerCase();
|
||
if (!query) return availableCandidates.value;
|
||
return availableCandidates.value.filter((item) =>
|
||
[item.full_name, item.email, item.role_in_study].some((value) => value.toLocaleLowerCase().includes(query)),
|
||
);
|
||
});
|
||
const selectedMemberCandidates = computed(() => {
|
||
const selected = new Set(memberForm.user_ids);
|
||
return availableCandidates.value.filter((item) => selected.has(item.user_id));
|
||
});
|
||
const transferCandidates = computed(() =>
|
||
candidates.value.filter((item) => item.user_id !== accessFile.value?.owner_id && item.can_be_manager),
|
||
);
|
||
const currentOwnerCandidate = computed(() =>
|
||
candidates.value.find((item) => item.user_id === accessFile.value?.owner_id) || null,
|
||
);
|
||
const selectedTransferCandidate = computed(() =>
|
||
transferCandidates.value.find((item) => item.user_id === transferOwnerId.value) || null,
|
||
);
|
||
const accessPanelTitle = computed(() => ({
|
||
overview: "访问与权限",
|
||
permissions: "权限设置",
|
||
})[accessPanel.value]);
|
||
const accessPermissionSummary = computed(() => {
|
||
let enabledCount = Number(commonExportEnabled.value) + Number(editRequestEnabled.value);
|
||
if (accessFile.value?.file_type === "cell") enabledCount += Number(sheetStructureEditEnabled.value);
|
||
return enabledCount > 0 ? `已开启 ${enabledCount} 项` : "全部关闭";
|
||
});
|
||
|
||
const collaboratorInitial = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
|
||
const collaboratorTone = (userId: string) => {
|
||
let hash = 0;
|
||
for (const character of userId) hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
||
return hash % 5;
|
||
};
|
||
const openAccessManagement = async (item: CollaborationFile) => {
|
||
accessFile.value = item;
|
||
accessPanel.value = "overview";
|
||
commonExportEnabled.value = item.allow_export;
|
||
editRequestEnabled.value = item.allow_edit_request;
|
||
sheetStructureEditEnabled.value = item.allow_sheet_structure_edit;
|
||
accessDialogVisible.value = true;
|
||
memberLoading.value = true;
|
||
shareLoading.value = true;
|
||
shareLink.value = null;
|
||
try {
|
||
const [memberResponse, candidateResponse, shareResponse, requestResponse] = await Promise.all([
|
||
api.members(item.id),
|
||
api.candidates(item.id),
|
||
api.share(item.id),
|
||
api.requests(item.id),
|
||
]);
|
||
if (accessFile.value?.id !== item.id) return;
|
||
members.value = memberResponse.data;
|
||
candidates.value = candidateResponse.data;
|
||
memberForm.user_ids = [];
|
||
editRequests.value = requestResponse.data;
|
||
applyShareLink(shareResponse.data);
|
||
} catch (error) {
|
||
closeIfAccessRevoked(error);
|
||
ElMessage.error(await getApiErrorMessage(error, "访问与权限信息加载失败"));
|
||
} finally {
|
||
memberLoading.value = false;
|
||
shareLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const refreshAccessMembers = async (item: CollaborationFile) => {
|
||
memberLoading.value = true;
|
||
try {
|
||
const [memberResponse, candidateResponse, fileResponse] = await Promise.all([
|
||
api.members(item.id),
|
||
api.candidates(item.id),
|
||
api.file(item.id),
|
||
]);
|
||
if (accessFile.value?.id !== item.id) return;
|
||
members.value = memberResponse.data;
|
||
candidates.value = candidateResponse.data;
|
||
accessFile.value = fileResponse.data;
|
||
memberForm.user_ids = [];
|
||
emit("changed");
|
||
if (!fileResponse.data.can_manage) accessDialogVisible.value = false;
|
||
} catch (error) {
|
||
closeIfAccessRevoked(error);
|
||
ElMessage.error(await getApiErrorMessage(error, "账号授权信息加载失败"));
|
||
} finally {
|
||
memberLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const closeIfAccessRevoked = (error: unknown) => {
|
||
const status = (error as { response?: { status?: number } })?.response?.status;
|
||
if (status !== 403 && status !== 404) return;
|
||
accessDialogVisible.value = false;
|
||
memberInviteDialogVisible.value = false;
|
||
transferDialogVisible.value = false;
|
||
emit("changed");
|
||
};
|
||
|
||
const closeAccessManagement = () => {
|
||
emit("close");
|
||
accessPanel.value = "overview";
|
||
accessFile.value = null;
|
||
members.value = [];
|
||
candidates.value = [];
|
||
memberForm.user_ids = [];
|
||
shareLink.value = null;
|
||
editRequests.value = [];
|
||
memberInviteDialogVisible.value = false;
|
||
memberSearch.value = "";
|
||
transferDialogVisible.value = false;
|
||
transferOwnerId.value = "";
|
||
};
|
||
|
||
const openPermissionSettings = () => {
|
||
accessPanel.value = "permissions";
|
||
};
|
||
|
||
const returnToAccessOverview = () => {
|
||
accessPanel.value = "overview";
|
||
};
|
||
|
||
const handleAccessPanelBack = () => {
|
||
returnToAccessOverview();
|
||
};
|
||
|
||
const openMemberInvite = () => {
|
||
memberForm.user_ids = [];
|
||
memberForm.role = "EDITOR";
|
||
memberSearch.value = "";
|
||
memberInviteDialogVisible.value = true;
|
||
};
|
||
|
||
const closeMemberInvite = () => {
|
||
memberForm.user_ids = [];
|
||
memberSearch.value = "";
|
||
};
|
||
|
||
const toggleMemberCandidate = (userId: string) => {
|
||
const candidate = availableCandidates.value.find((item) => item.user_id === userId);
|
||
if (!candidate || !isCandidateEligible(candidate)) return;
|
||
memberForm.user_ids = memberForm.user_ids.includes(userId)
|
||
? memberForm.user_ids.filter((id) => id !== userId)
|
||
: [...memberForm.user_ids, userId];
|
||
};
|
||
|
||
const isCandidateEligible = (candidate: CollaborationCandidate) =>
|
||
memberForm.role === "MANAGER" ? candidate.can_be_manager : memberForm.role === "VIEWER" || candidate.can_be_editor;
|
||
|
||
const handleMemberRoleChange = () => {
|
||
const eligibleIds = new Set(
|
||
availableCandidates.value.filter(isCandidateEligible).map((candidate) => candidate.user_id),
|
||
);
|
||
memberForm.user_ids = memberForm.user_ids.filter((userId) => eligibleIds.has(userId));
|
||
};
|
||
|
||
const applyShareLink = (value: CollaborationShareLink) => {
|
||
shareLink.value = value;
|
||
shareForm.enabled = value.enabled;
|
||
shareForm.access_mode = value.access_mode;
|
||
shareForm.expiry_policy = value.expiry_policy;
|
||
shareForm.password_enabled = value.has_password;
|
||
};
|
||
|
||
const handleCommonExportChange = async (value: boolean | string | number) => {
|
||
const target = accessFile.value;
|
||
if (!target || exportPermissionSaving.value) return;
|
||
const previous = target.allow_export;
|
||
const next = Boolean(value);
|
||
exportPermissionSaving.value = true;
|
||
try {
|
||
const { data } = await api.updateFile(target, { allow_export: next });
|
||
accessFile.value = data;
|
||
commonExportEnabled.value = data.allow_export;
|
||
emit("changed");
|
||
} catch (error) {
|
||
commonExportEnabled.value = previous;
|
||
ElMessage.error(await getApiErrorMessage(error, "内容操作权限保存失败"));
|
||
} finally {
|
||
exportPermissionSaving.value = false;
|
||
}
|
||
};
|
||
|
||
const handleEditRequestPermissionChange = async (value: boolean | string | number) => {
|
||
const target = accessFile.value;
|
||
if (!target || editRequestPermissionSaving.value) return;
|
||
const previous = target.allow_edit_request;
|
||
editRequestPermissionSaving.value = true;
|
||
try {
|
||
const { data } = await api.updateFile(target, {
|
||
allow_edit_request: Boolean(value),
|
||
});
|
||
accessFile.value = data;
|
||
editRequestEnabled.value = data.allow_edit_request;
|
||
emit("changed");
|
||
} catch (error) {
|
||
editRequestEnabled.value = previous;
|
||
ElMessage.error(await getApiErrorMessage(error, "编辑权限申请设置保存失败"));
|
||
} finally {
|
||
editRequestPermissionSaving.value = false;
|
||
}
|
||
};
|
||
|
||
const handleSheetStructurePermissionChange = async (value: boolean | string | number) => {
|
||
const target = accessFile.value;
|
||
if (!target || sheetStructurePermissionSaving.value) return;
|
||
const previous = target.allow_sheet_structure_edit;
|
||
sheetStructurePermissionSaving.value = true;
|
||
try {
|
||
const { data } = await api.updateFile(target, {
|
||
allow_sheet_structure_edit: Boolean(value),
|
||
});
|
||
accessFile.value = data;
|
||
sheetStructureEditEnabled.value = data.allow_sheet_structure_edit;
|
||
emit("changed");
|
||
} catch (error) {
|
||
sheetStructureEditEnabled.value = previous;
|
||
ElMessage.error(await getApiErrorMessage(error, "工作表结构权限保存失败"));
|
||
} finally {
|
||
sheetStructurePermissionSaving.value = false;
|
||
}
|
||
};
|
||
|
||
const resolveEditRequest = async (
|
||
request: CollaborationEditRequest,
|
||
status: "APPROVED" | "REJECTED",
|
||
) => {
|
||
const target = accessFile.value;
|
||
if (!target || resolvingEditRequestId.value) return;
|
||
resolvingEditRequestId.value = request.id;
|
||
try {
|
||
await api.resolveRequest(target.id, request.id, status);
|
||
editRequests.value = editRequests.value.filter((item) => item.id !== request.id);
|
||
if (props.target.scope === "project") notifyProjectNotificationsChanged();
|
||
if (status === "APPROVED") await refreshAccessMembers(target);
|
||
ElMessage.success(status === "APPROVED" ? "已授予编辑权限" : "已拒绝编辑权限申请");
|
||
} catch (error) {
|
||
ElMessage.error(await getApiErrorMessage(error, "编辑权限申请处理失败"));
|
||
} finally {
|
||
resolvingEditRequestId.value = null;
|
||
}
|
||
};
|
||
|
||
const openOwnershipTransfer = () => {
|
||
transferOwnerId.value = "";
|
||
transferDialogVisible.value = true;
|
||
};
|
||
|
||
const closeOwnershipTransfer = () => {
|
||
transferOwnerId.value = "";
|
||
};
|
||
|
||
const submitOwnershipTransfer = async () => {
|
||
const target = accessFile.value;
|
||
const candidate = selectedTransferCandidate.value;
|
||
if (!target || !candidate || transferringOwnership.value) return;
|
||
try {
|
||
transferringOwnership.value = true;
|
||
const { data } = await api.transfer(target.id, candidate.user_id);
|
||
accessFile.value = data;
|
||
emit("changed");
|
||
commonExportEnabled.value = data.allow_export;
|
||
editRequestEnabled.value = data.allow_edit_request;
|
||
sheetStructureEditEnabled.value = data.allow_sheet_structure_edit;
|
||
transferOwnerId.value = "";
|
||
transferDialogVisible.value = false;
|
||
accessPanel.value = "permissions";
|
||
await refreshAccessMembers(data);
|
||
ElMessage.success(`文档所有权已转让给 ${candidate.full_name}`);
|
||
} catch (error: any) {
|
||
ElMessage.error(await getApiErrorMessage(error, "文档所有权转让失败"));
|
||
} finally {
|
||
transferringOwnership.value = false;
|
||
}
|
||
};
|
||
|
||
const persistShareSettings = async (
|
||
passwordMode: "KEEP" | "SET" | "CLEAR" = "KEEP",
|
||
password?: string,
|
||
) => {
|
||
if (!accessFile.value || !shareLink.value || shareSaving.value) return false;
|
||
const previous = shareLink.value;
|
||
shareSaving.value = true;
|
||
try {
|
||
const response = await api.updateShare(accessFile.value.id, {
|
||
enabled: shareForm.enabled,
|
||
access_mode: shareForm.access_mode,
|
||
expiry_policy: shareForm.expiry_policy,
|
||
password_mode: passwordMode,
|
||
...(passwordMode === "SET" ? { password } : {}),
|
||
});
|
||
applyShareLink(response.data);
|
||
await refreshAccessMembers(accessFile.value);
|
||
return true;
|
||
} catch (error) {
|
||
applyShareLink(previous);
|
||
ElMessage.error(await getApiErrorMessage(error, "链接分享设置保存失败"));
|
||
return false;
|
||
} finally {
|
||
shareSaving.value = false;
|
||
}
|
||
};
|
||
|
||
const handleShareEnabledChange = async () => {
|
||
await persistShareSettings();
|
||
};
|
||
|
||
const promptSharePassword = async () => {
|
||
if (!shareLink.value || shareSaving.value) return;
|
||
try {
|
||
const result = await ElMessageBox.prompt("请输入 4–64 位链接密码", "设置链接密码", {
|
||
inputType: "password",
|
||
inputPlaceholder: "链接访问密码",
|
||
inputValidator: (value) => value.length >= 4 && value.length <= 64 ? true : "请输入 4–64 位密码",
|
||
confirmButtonText: "设置",
|
||
cancelButtonText: "取消",
|
||
closeOnClickModal: false,
|
||
});
|
||
shareForm.password_enabled = true;
|
||
await persistShareSettings("SET", result.value);
|
||
} catch (error: any) {
|
||
if (error !== "cancel" && error !== "close") {
|
||
ElMessage.error("链接密码设置失败");
|
||
}
|
||
shareForm.password_enabled = Boolean(shareLink.value?.has_password);
|
||
}
|
||
};
|
||
|
||
const handleSharePasswordToggle = async (value: boolean | string | number) => {
|
||
if (Boolean(value)) {
|
||
if (!shareLink.value?.has_password) await promptSharePassword();
|
||
return;
|
||
}
|
||
await persistShareSettings("CLEAR");
|
||
};
|
||
|
||
const copyText = async (value: string) => {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(value);
|
||
return;
|
||
}
|
||
const input = document.createElement("textarea");
|
||
input.value = value;
|
||
input.style.position = "fixed";
|
||
input.style.opacity = "0";
|
||
document.body.appendChild(input);
|
||
input.select();
|
||
const copied = document.execCommand("copy");
|
||
input.remove();
|
||
if (!copied) throw new Error("copy failed");
|
||
};
|
||
|
||
const copyShareUrl = async () => {
|
||
if (!shareUrl.value) return;
|
||
try {
|
||
await copyText(shareUrl.value);
|
||
ElMessage.success("共享链接已复制");
|
||
} catch {
|
||
ElMessage.error("复制失败,请手动复制链接");
|
||
}
|
||
};
|
||
|
||
const saveMember = async () => {
|
||
if (!accessFile.value || memberForm.user_ids.length === 0) return ElMessage.warning("请选择需要授权的系统账号");
|
||
const targetFile = accessFile.value;
|
||
const selectedUserIds = [...memberForm.user_ids];
|
||
memberSaving.value = true;
|
||
try {
|
||
const results = await Promise.allSettled(selectedUserIds.map((userId) => api.setMember(
|
||
targetFile.id,
|
||
{ user_id: userId, role: memberForm.role },
|
||
)));
|
||
const failed = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
||
const succeededCount = results.length - failed.length;
|
||
await refreshAccessMembers(targetFile);
|
||
if (succeededCount > 0) memberInviteDialogVisible.value = false;
|
||
if (failed.length === 0) {
|
||
ElMessage.success(`已授权 ${succeededCount} 个账号`);
|
||
} else if (succeededCount > 0) {
|
||
ElMessage.warning(`已授权 ${succeededCount} 个账号,${failed.length} 个账号授权失败`);
|
||
} else {
|
||
ElMessage.error(await getApiErrorMessage(failed[0].reason, "账号授权失败"));
|
||
}
|
||
} finally {
|
||
memberSaving.value = false;
|
||
}
|
||
};
|
||
|
||
const removeMember = async (member: CollaborationMember) => {
|
||
if (!accessFile.value) return;
|
||
const targetFile = accessFile.value;
|
||
try {
|
||
await api.removeMember(targetFile.id, member.user_id);
|
||
await refreshAccessMembers(targetFile);
|
||
} catch (error) {
|
||
ElMessage.error(await getApiErrorMessage(error, "移除账号授权失败"));
|
||
}
|
||
};
|
||
|
||
let opening: Promise<void> | null = null;
|
||
onMounted(() => { opening = openAccessManagement(props.file); });
|
||
onBeforeUnmount(() => { accessFile.value = null; });
|
||
defineExpose({
|
||
focusEditRequests: async () => {
|
||
await opening;
|
||
await nextTick();
|
||
editRequestSectionRef.value?.focus({ preventScroll: true });
|
||
editRequestSectionRef.value?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
},
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.collaborator-avatar.tone-0 { background: #4c7aa0; }
|
||
.collaborator-avatar.tone-1 { background: #6b8e7a; }
|
||
.collaborator-avatar.tone-2 { background: #8a6f9e; }
|
||
.collaborator-avatar.tone-3 { background: #b0785e; }
|
||
.collaborator-avatar.tone-4 { background: #6e7f9f; }
|
||
.access-dialog-header { display: flex; min-height: 32px; align-items: center; gap: 8px; padding-right: 42px; }
|
||
.access-dialog-header > strong { color: var(--ctms-text-main); font-size: 18px; font-weight: 700; }
|
||
.access-dialog-header__back {
|
||
display: inline-flex;
|
||
width: 32px;
|
||
height: 32px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0;
|
||
border: 0;
|
||
border-radius: 6px;
|
||
color: var(--ctms-text-regular);
|
||
background: transparent;
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
}
|
||
.access-dialog-header__back:hover { color: var(--ctms-primary); background: var(--ctms-primary-light); }
|
||
.access-management { display: grid; max-height: min(660px, calc(100vh - 132px)); gap: 12px; padding: 16px 18px 18px; overflow-y: auto; }
|
||
.access-section { overflow: hidden; border: 1px solid color-mix(in srgb, var(--ctms-border-color) 88%, transparent); border-radius: 12px; background: var(--ctms-bg-card); box-shadow: 0 1px 2px color-mix(in srgb, var(--ctms-text-main) 4%, transparent); }
|
||
.access-section__header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 14px; border-bottom: 1px solid color-mix(in srgb, var(--ctms-border-color) 76%, transparent); }
|
||
.access-section__title { display: flex; align-items: baseline; gap: 8px; }
|
||
.access-section__title strong { color: var(--ctms-text-main); font-size: 15px; }
|
||
.access-section__title span { color: var(--ctms-text-disabled); font-size: 12px; }
|
||
.access-section__title .access-section__pending-count {
|
||
padding: 3px 7px;
|
||
border-radius: 10px;
|
||
color: var(--ctms-warning);
|
||
background: color-mix(in srgb, var(--ctms-warning) 11%, var(--ctms-bg-card));
|
||
font-weight: 650;
|
||
}
|
||
.access-section__manage-button {
|
||
display: inline-flex;
|
||
min-width: 108px;
|
||
min-height: 32px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 7px;
|
||
padding: 0 12px;
|
||
border: 1px solid color-mix(in srgb, var(--ctms-primary) 26%, var(--ctms-border-color));
|
||
border-radius: 7px;
|
||
color: var(--ctms-primary);
|
||
background: color-mix(in srgb, var(--ctms-primary) 8%, var(--ctms-bg-card));
|
||
cursor: pointer;
|
||
font: inherit;
|
||
font-size: 13px;
|
||
font-weight: 650;
|
||
line-height: 1;
|
||
transition: border-color 0.16s ease, background-color 0.16s ease;
|
||
}
|
||
.access-section__manage-button:hover {
|
||
border-color: color-mix(in srgb, var(--ctms-primary) 48%, var(--ctms-border-color));
|
||
background: color-mix(in srgb, var(--ctms-primary) 13%, var(--ctms-bg-card));
|
||
}
|
||
.access-section__manage-button:focus-visible { outline: 2px solid color-mix(in srgb, var(--ctms-primary) 34%, transparent); outline-offset: 2px; }
|
||
.access-section__manage-button .el-icon { font-size: 15px; }
|
||
.access-member-list { max-height: 252px; overflow-y: auto; }
|
||
.access-member-row { display: grid; min-height: 60px; align-items: center; gap: 14px; padding: 9px 14px; grid-template-columns: minmax(0, 1fr) 76px 64px; transition: background-color 0.16s ease; }
|
||
.access-member-row + .access-member-row { border-top: 1px solid color-mix(in srgb, var(--ctms-border-color) 58%, transparent); }
|
||
.access-member-row:hover { background: color-mix(in srgb, var(--ctms-bg-muted) 74%, var(--ctms-bg-card)); }
|
||
.access-member-identity { display: flex; min-width: 0; align-items: center; gap: 11px; }
|
||
.access-member-identity :deep(.el-avatar) { flex: 0 0 auto; color: #fff; font-size: 13px; font-weight: 700; }
|
||
.access-member-identity > span { display: grid; min-width: 0; gap: 2px; }
|
||
.access-member-identity strong,
|
||
.access-member-identity small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.access-member-identity strong { color: var(--ctms-text-main); font-size: 14px; font-weight: 650; }
|
||
.access-member-identity small { color: var(--ctms-text-secondary); font-size: 12px; }
|
||
.access-member-role {
|
||
--el-tag-bg-color: color-mix(in srgb, var(--ctms-primary) 7%, var(--ctms-bg-muted));
|
||
--el-tag-border-color: color-mix(in srgb, var(--ctms-primary) 18%, var(--ctms-border-color));
|
||
--el-tag-text-color: var(--ctms-text-regular);
|
||
justify-self: start;
|
||
border-radius: 5px;
|
||
font-weight: 600;
|
||
}
|
||
.access-member-owner { justify-self: end; padding: 4px 7px; border-radius: 5px; color: var(--ctms-text-secondary); background: var(--ctms-bg-muted); font-size: 12px; }
|
||
.access-member-remove { justify-self: end; color: var(--ctms-text-secondary); font-size: 12px; }
|
||
.access-member-remove:hover { color: var(--ctms-danger); }
|
||
.access-member-list > :deep(.el-empty) { padding: 24px 0; }
|
||
.access-setting-main { display: flex; min-height: 56px; align-items: center; justify-content: space-between; gap: 18px; padding: 9px 14px 7px; }
|
||
.access-setting-heading { display: flex; align-items: center; gap: 10px; }
|
||
.access-setting-heading__icon { display: inline-flex; width: 30px; height: 30px; align-items: center; justify-content: center; border-radius: 8px; color: var(--ctms-primary); background: color-mix(in srgb, var(--ctms-primary) 9%, var(--ctms-bg-muted)); font-size: 15px; }
|
||
.access-setting-heading > strong { color: var(--ctms-text-main); font-size: 15px; }
|
||
.access-setting-main__control { display: flex; align-items: center; gap: 10px; }
|
||
.access-setting-status { display: inline-flex; min-height: 24px; align-items: center; gap: 6px; padding: 0 8px; border-radius: 12px; color: var(--ctms-text-secondary); background: var(--ctms-bg-muted); font-size: 12px; }
|
||
.access-setting-status i { width: 6px; height: 6px; border-radius: 50%; background: var(--ctms-text-disabled); }
|
||
.access-setting-status.is-active { color: var(--ctms-success); background: color-mix(in srgb, var(--ctms-success) 10%, var(--ctms-bg-card)); }
|
||
.access-setting-status.is-active i { background: var(--ctms-success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ctms-success) 12%, transparent); }
|
||
.access-setting-navigation { display: flex; width: calc(100% - 28px); min-height: 44px; align-items: center; justify-content: space-between; gap: 18px; margin: 0 14px 8px; padding: 7px 0; border: 0; color: var(--ctms-text-main); background: transparent; cursor: pointer; font: inherit; text-align: left; transition: color 0.16s ease; }
|
||
.access-setting-navigation:hover { color: var(--ctms-primary); }
|
||
.access-setting-navigation__copy { display: grid; gap: 3px; }
|
||
.access-setting-navigation__copy strong { font-size: 14px; }
|
||
.access-setting-navigation__copy small { color: var(--ctms-text-secondary); font-size: 12px; font-weight: 400; }
|
||
.access-setting-navigation > .el-icon { color: var(--ctms-text-secondary); font-size: 16px; }
|
||
.access-permission-row { display: flex; min-height: 72px; align-items: center; justify-content: space-between; gap: 18px; padding: 12px 16px; }
|
||
.access-permission-row strong { color: var(--ctms-text-main); font-size: 14px; }
|
||
.access-section--common > * + * { border-top: 1px solid var(--ctms-border-color); }
|
||
.edit-request-list {
|
||
padding: 2px 14px 8px;
|
||
border-bottom: 1px solid color-mix(in srgb, var(--ctms-border-color) 76%, transparent);
|
||
background: color-mix(in srgb, var(--ctms-warning) 4%, var(--ctms-bg-card));
|
||
outline: none;
|
||
scroll-margin-top: 12px;
|
||
}
|
||
.edit-request-list:focus-visible { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--ctms-primary) 28%, transparent); }
|
||
.edit-request-list__title { padding: 9px 0 4px; color: var(--ctms-text-secondary); font-size: 12px; font-weight: 650; }
|
||
.edit-request-item { display: flex; min-height: 66px; align-items: center; justify-content: space-between; gap: 16px; }
|
||
.edit-request-item + .edit-request-item { border-top: 1px solid color-mix(in srgb, var(--ctms-border-color) 62%, transparent); }
|
||
.edit-request-item .access-member-identity { flex: 1 1 auto; }
|
||
.edit-request-item .access-member-identity small:last-child { color: var(--ctms-text-disabled); }
|
||
.edit-request-item__actions { display: flex; flex: 0 0 auto; }
|
||
.access-settings-entry {
|
||
display: flex;
|
||
min-height: 56px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 16px;
|
||
border: 1px solid var(--ctms-border-color);
|
||
border-radius: 10px;
|
||
color: var(--ctms-text-main);
|
||
background: var(--ctms-bg-card);
|
||
cursor: pointer;
|
||
font: inherit;
|
||
font-weight: 650;
|
||
text-align: left;
|
||
}
|
||
.access-settings-entry:hover { color: var(--ctms-primary); border-color: var(--ctms-primary); background: var(--ctms-primary-light); }
|
||
.access-settings-entry .el-icon { color: var(--ctms-text-secondary); font-size: 16px; }
|
||
.access-settings-entry--transfer { width: 100%; border: 0; border-radius: 0; color: var(--ctms-primary); }
|
||
:global(.access-management-dialog.el-dialog) { max-width: calc(100vw - 32px); overflow: hidden; padding: 0; border: 1px solid color-mix(in srgb, var(--ctms-border-color) 88%, transparent); border-radius: 14px; box-shadow: 0 20px 56px color-mix(in srgb, var(--ctms-text-main) 18%, transparent); }
|
||
:global(.access-management-dialog.el-dialog .el-dialog__header) { margin: 0; padding: 17px 20px 15px; border-bottom: 1px solid var(--ctms-border-color); }
|
||
:global(.access-management-dialog.el-dialog .el-dialog__headerbtn) { top: 9px; right: 10px; width: 40px; height: 40px; border-radius: 8px; }
|
||
:global(.access-management-dialog.el-dialog .el-dialog__headerbtn:hover) { background: var(--ctms-bg-muted); }
|
||
:global(.access-management-dialog.el-dialog .el-dialog__body) { padding: 0; background: color-mix(in srgb, var(--ctms-bg-muted) 62%, var(--ctms-bg-card)); }
|
||
.member-picker-layout { display: grid; min-width: 0; min-height: 0; flex: 1; grid-template-columns: minmax(340px, 45%) minmax(0, 1fr); background: var(--ctms-bg-card); }
|
||
.member-picker-directory { display: flex; min-width: 0; min-height: 0; flex-direction: column; border-right: 1px solid var(--ctms-border-color); background: color-mix(in srgb, var(--ctms-bg-card) 96%, var(--ctms-bg-muted)); }
|
||
.member-picker-directory__toolbar { padding: 18px 22px 12px; }
|
||
.member-picker-search { width: 100%; }
|
||
.member-picker-search :deep(.el-input__wrapper) { min-height: 40px; border-radius: 8px; box-shadow: 0 0 0 1px var(--ctms-border-color) inset; }
|
||
.member-picker-search :deep(.el-input__wrapper.is-focus) { box-shadow: 0 0 0 1px var(--ctms-primary) inset, 0 0 0 3px color-mix(in srgb, var(--ctms-primary) 10%, transparent); }
|
||
.member-picker-groups { display: flex; min-height: 0; overflow: hidden; flex: 1; flex-direction: column; }
|
||
.member-picker-group { min-height: 0; }
|
||
.member-picker-group--contacts { display: flex; min-height: 0; flex: 1; flex-direction: column; }
|
||
.member-picker-group-title { display: flex; min-height: 42px; align-items: center; justify-content: space-between; padding: 7px 22px; color: var(--ctms-text-main); font-size: 14px; font-weight: 650; }
|
||
.member-picker-group-title__label { display: inline-flex; align-items: center; gap: 10px; }
|
||
.member-picker-group-title__label::before { width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 6px solid var(--ctms-text-secondary); content: ""; transform: rotate(90deg); }
|
||
.member-picker-group-title small { color: var(--ctms-text-disabled); font-size: 11px; font-weight: 500; }
|
||
.member-picker-list { min-height: 0; padding: 2px 12px 18px; overflow-y: auto; flex: 1; }
|
||
.member-picker-contact {
|
||
display: grid;
|
||
width: 100%;
|
||
min-height: 64px;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin: 2px 0;
|
||
padding: 9px 11px;
|
||
border: 1px solid transparent;
|
||
border-radius: 8px;
|
||
color: var(--ctms-text-main);
|
||
background: transparent;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
grid-template-columns: 22px 38px minmax(0, 1fr);
|
||
text-align: left;
|
||
transition: border-color 0.16s ease, background-color 0.16s ease, transform 0.16s ease;
|
||
}
|
||
.member-picker-contact:hover { border-color: color-mix(in srgb, var(--ctms-primary) 16%, var(--ctms-border-color)); background: color-mix(in srgb, var(--ctms-primary) 5%, var(--ctms-bg-card)); }
|
||
.member-picker-contact.is-selected { border-color: color-mix(in srgb, var(--ctms-primary) 26%, var(--ctms-border-color)); background: var(--ctms-primary-light); }
|
||
.member-picker-contact.is-ineligible { cursor: not-allowed; opacity: 0.62; }
|
||
.member-picker-contact.is-ineligible:hover { border-color: transparent; background: transparent; }
|
||
.member-picker-contact__choice { display: inline-flex; width: 18px; height: 18px; align-items: center; justify-content: center; border: 1px solid var(--ctms-border-color); border-radius: 5px; color: #fff; background: var(--ctms-bg-card); }
|
||
.member-picker-contact__choice.is-selected { border-color: var(--ctms-primary); background: var(--ctms-primary); }
|
||
.member-picker-contact__choice .el-icon { font-size: 13px; }
|
||
.member-picker-contact :deep(.el-avatar),
|
||
.member-picker-selected-contact :deep(.el-avatar) { flex: 0 0 auto; color: #fff; font-size: 14px; font-weight: 700; }
|
||
.member-picker-contact__identity { display: grid; min-width: 0; gap: 3px; }
|
||
.member-picker-contact__identity small { overflow: hidden; color: var(--ctms-text-secondary); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.member-picker-contact__identity .member-picker-contact__restriction { color: var(--ctms-danger); font-size: 11px; }
|
||
.member-picker-contact__name { display: flex; min-width: 0; align-items: center; gap: 7px; }
|
||
.member-picker-contact__name strong { min-width: 0; overflow: hidden; font-size: 14px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
|
||
.member-picker-contact__name :deep(.el-tag) { height: 20px; padding: 0 6px; flex: 0 0 auto; border-radius: 5px; font-size: 10px; line-height: 18px; }
|
||
.member-picker-selection { display: flex; min-width: 0; min-height: 0; flex-direction: column; background: var(--ctms-bg-card); }
|
||
.member-picker-selection__header { display: flex; min-height: 66px; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 22px; border-bottom: 1px solid var(--ctms-border-color); color: var(--ctms-text-main); font-size: 14px; }
|
||
.member-picker-selection__header strong { font-weight: 680; }
|
||
.member-picker-selection__header strong span { color: var(--ctms-text-secondary); font-weight: 500; }
|
||
.member-picker-role { width: 112px; flex: 0 0 auto; }
|
||
.member-picker-role :deep(.el-select__wrapper) { min-height: 36px; border-radius: 7px; box-shadow: none; }
|
||
.member-picker-selected-list { min-height: 0; padding: 10px 16px; overflow-y: auto; flex: 1; }
|
||
.member-picker-selected-contact { display: grid; min-height: 66px; align-items: center; gap: 11px; padding: 9px 8px 9px 10px; border-bottom: 1px solid color-mix(in srgb, var(--ctms-border-color) 66%, transparent); border-radius: 7px; grid-template-columns: 40px minmax(0, 1fr) 32px; transition: background-color 0.16s ease; }
|
||
.member-picker-selected-contact:hover { background: var(--ctms-bg-muted); }
|
||
.member-picker-selected-contact__identity { display: grid; min-width: 0; gap: 4px; }
|
||
.member-picker-selected-contact__name { display: flex; min-width: 0; align-items: center; gap: 7px; }
|
||
.member-picker-selected-contact strong,
|
||
.member-picker-selected-contact small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.member-picker-selected-contact strong { min-width: 0; color: var(--ctms-text-main); font-size: 14px; }
|
||
.member-picker-selected-contact small { color: var(--ctms-text-secondary); font-size: 12px; }
|
||
.member-picker-selected-contact__name :deep(.el-tag) { height: 20px; padding: 0 6px; flex: 0 0 auto; border-radius: 5px; font-size: 10px; line-height: 18px; }
|
||
.member-picker-selected-contact__remove { color: var(--ctms-text-disabled); opacity: 0; transition: opacity 0.16s ease, color 0.16s ease, background-color 0.16s ease; }
|
||
.member-picker-selected-contact:hover .member-picker-selected-contact__remove,
|
||
.member-picker-selected-contact:focus-within .member-picker-selected-contact__remove { opacity: 1; }
|
||
.member-picker-selected-contact__remove:hover { color: var(--ctms-danger); background: color-mix(in srgb, var(--ctms-danger) 8%, transparent); }
|
||
.member-picker-selection__empty { display: flex; min-height: 0; align-items: center; justify-content: center; flex: 1; flex-direction: column; gap: 7px; padding: 24px; color: var(--ctms-text-secondary); text-align: center; }
|
||
.member-picker-selection__empty-visual { position: relative; display: inline-flex; width: 82px; height: 82px; align-items: center; justify-content: center; margin-bottom: 8px; border-radius: 26px; color: color-mix(in srgb, var(--ctms-primary) 72%, #fff); background: color-mix(in srgb, var(--ctms-primary) 9%, var(--ctms-bg-muted)); font-size: 39px; }
|
||
.member-picker-selection__empty-visual > span { position: absolute; right: -3px; bottom: 4px; display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; border: 3px solid var(--ctms-bg-card); border-radius: 50%; color: #fff; background: var(--ctms-primary); font-size: 13px; }
|
||
.member-picker-selection__empty strong { color: var(--ctms-text-regular); font-size: 14px; }
|
||
.member-picker-selection__empty > span { color: var(--ctms-text-disabled); font-size: 12px; }
|
||
.member-picker-selection__footer { display: flex; min-height: 68px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 22px; border-top: 1px solid var(--ctms-border-color); }
|
||
.member-picker-selection__footer > span { color: var(--ctms-text-secondary); font-size: 12px; }
|
||
.member-picker-selection__footer > div { display: flex; flex: 0 0 auto; gap: 10px; }
|
||
.member-picker-selection__footer :deep(.el-button) { min-width: 84px; margin: 0; }
|
||
.collaboration-role-tag {
|
||
--el-tag-bg-color: color-mix(in srgb, var(--ctms-primary) 13%, var(--ctms-bg-muted));
|
||
--el-tag-border-color: color-mix(in srgb, var(--ctms-primary) 28%, var(--ctms-border-color));
|
||
--el-tag-text-color: color-mix(in srgb, var(--ctms-primary) 82%, var(--ctms-text-main));
|
||
font-weight: 650;
|
||
}
|
||
:global(.member-invite-dialog.el-dialog) { display: flex; width: min(880px, calc(100vw - 48px)); height: min(680px, 84vh); overflow: hidden; flex-direction: column; padding: 0; border: 1px solid color-mix(in srgb, var(--ctms-border-color) 90%, transparent); border-radius: 16px; box-shadow: 0 22px 64px rgba(31, 50, 72, 0.2); }
|
||
:global(.member-invite-dialog.el-dialog .el-dialog__header) { margin: 0; padding: 20px 24px; flex: 0 0 auto; border-bottom: 1px solid var(--ctms-border-color); }
|
||
:global(.member-invite-dialog.el-dialog .el-dialog__title) { color: var(--ctms-text-main); font-size: 19px; font-weight: 720; letter-spacing: 0.01em; }
|
||
:global(.member-invite-dialog.el-dialog .el-dialog__headerbtn) { top: 12px; right: 14px; width: 44px; height: 44px; border-radius: 8px; }
|
||
:global(.member-invite-dialog.el-dialog .el-dialog__headerbtn:hover) { background: var(--ctms-bg-muted); }
|
||
:global(.member-invite-dialog.el-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; padding: 0; }
|
||
.ownership-transfer-layout { display: grid; min-height: 430px; grid-template-columns: minmax(260px, 42%) minmax(0, 1fr); }
|
||
.ownership-contact-list { min-height: 0; max-height: 430px; padding: 12px; overflow-y: auto; border-right: 1px solid var(--ctms-border-color); }
|
||
.ownership-contact {
|
||
display: grid;
|
||
width: 100%;
|
||
min-height: 58px;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 10px;
|
||
border: 0;
|
||
border-radius: 8px;
|
||
color: var(--ctms-text-main);
|
||
background: transparent;
|
||
grid-template-columns: 22px 36px minmax(0, 1fr);
|
||
text-align: left;
|
||
}
|
||
.ownership-contact--candidate { cursor: pointer; font: inherit; }
|
||
.ownership-contact--candidate:hover,
|
||
.ownership-contact--candidate.is-selected { background: var(--ctms-primary-light); }
|
||
.ownership-contact--current { color: var(--ctms-text-secondary); }
|
||
.ownership-contact__choice { display: inline-flex; width: 18px; height: 18px; align-items: center; justify-content: center; border: 1px solid var(--ctms-border-color); border-radius: 5px; color: #fff; background: var(--ctms-bg-card); }
|
||
.ownership-contact__choice.is-selected { border-color: var(--ctms-primary); background: var(--ctms-primary); }
|
||
.ownership-contact__choice .el-icon { font-size: 13px; }
|
||
.ownership-contact :deep(.el-avatar) { flex: 0 0 auto; color: #fff; background: var(--ctms-primary); font-size: 14px; font-weight: 700; }
|
||
.ownership-contact--current :deep(.el-avatar) { background: var(--ctms-text-disabled); }
|
||
.ownership-contact__identity { display: grid; min-width: 0; gap: 2px; }
|
||
.ownership-contact__identity strong,
|
||
.ownership-contact__identity small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.ownership-contact__identity strong { font-size: 14px; font-weight: 650; }
|
||
.ownership-contact__identity small { color: var(--ctms-text-secondary); font-size: 12px; }
|
||
.ownership-contact__name { display: flex; min-width: 0; align-items: center; gap: 7px; }
|
||
.ownership-contact__name strong { min-width: 0; }
|
||
.ownership-contact__name :deep(.el-tag) { height: 20px; padding: 0 6px; flex: 0 0 auto; border-radius: 5px; font-size: 10px; line-height: 18px; }
|
||
.ownership-selection { display: flex; min-width: 0; min-height: 430px; align-items: center; justify-content: center; flex-direction: column; gap: 10px; padding: 28px; color: var(--ctms-text-secondary); text-align: center; }
|
||
.ownership-selection :deep(.el-avatar) { color: #fff; background: var(--ctms-primary); font-size: 28px; font-weight: 700; }
|
||
.ownership-selection__name { display: flex; max-width: 100%; align-items: center; justify-content: center; gap: 8px; }
|
||
.ownership-selection__name strong { overflow: hidden; color: var(--ctms-text-main); font-size: 18px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.ownership-selection__name :deep(.el-tag) { flex: 0 0 auto; }
|
||
.ownership-selection__email { max-width: 100%; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||
:global(.ownership-transfer-dialog.el-dialog) { overflow: hidden; padding: 0; border-radius: 14px; }
|
||
:global(.ownership-transfer-dialog.el-dialog .el-dialog__header) { margin: 0; padding: 18px 22px; border-bottom: 1px solid var(--ctms-border-color); }
|
||
:global(.ownership-transfer-dialog.el-dialog .el-dialog__title) { color: var(--ctms-text-main); font-size: 18px; font-weight: 700; }
|
||
:global(.ownership-transfer-dialog.el-dialog .el-dialog__headerbtn) { top: 11px; right: 12px; width: 42px; height: 42px; }
|
||
:global(.ownership-transfer-dialog.el-dialog .el-dialog__body) { padding: 0; }
|
||
:global(.ownership-transfer-dialog.el-dialog .el-dialog__footer) { padding: 14px 22px; border-top: 1px solid var(--ctms-border-color); }
|
||
.share-settings-body { padding: 4px 14px 10px; }
|
||
.share-settings-list { display: grid; align-items: start; gap: 14px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||
.share-setting-row { display: grid; min-width: 0; gap: 7px; }
|
||
.share-setting-row__label { display: flex; min-height: 28px; align-items: center; gap: 8px; color: var(--ctms-text-main); }
|
||
.share-setting-row__label > span { display: inline-flex; width: 26px; height: 26px; align-items: center; justify-content: center; border-radius: 7px; color: var(--ctms-primary); background: color-mix(in srgb, var(--ctms-primary) 8%, var(--ctms-bg-muted)); font-size: 13px; }
|
||
.share-setting-row__label strong { font-size: 13px; font-weight: 650; }
|
||
.share-setting-row :deep(.el-select__wrapper) { min-height: 36px; border-radius: 7px; box-shadow: 0 0 0 1px color-mix(in srgb, var(--ctms-border-color) 90%, transparent) inset; }
|
||
.share-password-control { display: flex; min-width: 0; min-height: 36px; align-items: center; justify-content: flex-start; gap: 9px; color: var(--ctms-text-secondary); font-size: 12px; }
|
||
.share-link-panel { display: grid; min-height: 44px; align-items: center; gap: 12px; padding-top: 11px; grid-template-columns: 126px minmax(0, 1fr); }
|
||
.share-link-panel__label { display: flex; align-items: center; gap: 7px; color: var(--ctms-text-secondary); font-size: 12px; font-weight: 600; }
|
||
.share-link-panel__label .el-icon { color: var(--ctms-primary); font-size: 14px; }
|
||
.share-link-row { display: flex; min-width: 0; gap: 8px; }
|
||
.share-link-row .el-input { min-width: 0; flex: 1; }
|
||
.share-link-row :deep(.el-input__wrapper) { min-height: 38px; border-radius: 7px; box-shadow: 0 0 0 1px color-mix(in srgb, var(--ctms-primary) 16%, var(--ctms-border-color)) inset; }
|
||
.share-link-row :deep(.el-input__inner) { color: var(--ctms-text-secondary); font-size: 12px; }
|
||
.share-link-row :deep(.el-button) { min-width: 90px; min-height: 38px; margin: 0; border-radius: 7px; }
|
||
|
||
:global(body.is-desktop-runtime .access-management-dialog.el-dialog),
|
||
:global(body.is-desktop-runtime .member-invite-dialog.el-dialog),
|
||
:global(body.is-desktop-runtime .ownership-transfer-dialog.el-dialog) { --el-dialog-padding-primary: 0; overflow: hidden !important; padding: 0 !important; }
|
||
@media (max-width: 900px) {
|
||
.share-settings-list, .share-link-panel { gap: 8px; grid-template-columns: 1fr; }
|
||
.share-password-control { justify-content: flex-start; }
|
||
.member-picker-layout { grid-template-columns: 1fr; }
|
||
.member-picker-directory { max-height: 52%; border-right: 0; border-bottom: 1px solid var(--ctms-border-color); }
|
||
.member-picker-selection { min-height: 0; }
|
||
.member-picker-selection__header { min-height: 58px; }
|
||
.ownership-transfer-layout { min-height: 0; grid-template-columns: 1fr; }
|
||
.ownership-contact-list { max-height: 220px; border-right: 0; border-bottom: 1px solid var(--ctms-border-color); }
|
||
.ownership-selection { min-height: 220px; }
|
||
}
|
||
</style>
|