Files
ctms/frontend/src/components/collaboration/CollaborationAccessDialog.vue
T
Cheng Zhou c1dd8def2c 新增全局协作台账并统一工作台管理界面
新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。
复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。
补充在线文档字体构建支持、数据库表结构升级和回归测试。
不包含本地台账内容、账号授权记录或历史文件迁移数据。
2026-09-03 16:55:08 +08:00

1191 lines
62 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>