Files
2026-04-08 17:10:58 +08:00

1888 lines
62 KiB
Vue
Raw Permalink 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>
<a-drawer
:open="open"
:title="null"
:width="720"
:body-style="{ padding: 0 }"
:header-style="{ display: 'none' }"
destroy-on-close
@close="$emit('update:open', false)"
>
<!-- 头部 -->
<div class="detail-header">
<div class="header-icon-wrap">
<img v-if="app?.icon" :src="app.icon" class="header-icon-img" />
<div v-else class="header-icon-placeholder" :style="{ background: iconBgColor(app?.productName) }">
{{ (app?.productName || 'A').charAt(0).toUpperCase() }}
</div>
</div>
<div class="header-info">
<div class="header-title">{{ app?.productName || '-' }}</div>
<div class="header-sub">
<span v-if="app?.productCode" class="header-code">{{ app.productCode }}</span>
<span class="header-status" :class="`status-${app?.status}`">{{ statusText(app?.status) }}</span>
</div>
</div>
<div class="header-actions">
<template v-if="app" v-for="entry in getAppEntries(app)" :key="entry.type">
<a-button
v-if="entry.available"
:type="entry.isPrimary ? 'primary' : 'default'"
size="small"
@click="handleEntryClick(entry)"
>
<template #icon><component :is="entry.icon" /></template>
{{ entry.label }}
</a-button>
</template>
</div>
<a-button type="text" class="close-btn" @click="$emit('update:open', false)">
<template #icon><CloseOutlined /></template>
</a-button>
</div>
<!-- Tab 导航 -->
<a-tabs v-model:active-key="activeTab" class="detail-tabs">
<!-- ====== 成员 Tab ====== -->
<a-tab-pane key="members" tab="成员">
<div class="tab-content">
<div class="tab-toolbar">
<span class="tab-count"> {{ members.length }} 位成员</span>
<a-button type="primary" size="small" @click="showInvite = true">
<template #icon><PlusOutlined /></template>
邀请成员
</a-button>
</div>
<a-spin :spinning="membersLoading">
<div v-if="members.length === 0 && !membersLoading" class="empty-wrap">
<a-empty description="暂无成员" />
</div>
<div v-else class="member-list">
<div v-for="m in members" :key="m.id" class="member-item" :class="{ 'pending': m.inviteStatus === 1 }">
<a-avatar :src="m.userAvatar || m.avatar" size="36" class="member-avatar">
{{ (m.nickname || m.username || '?').charAt(0).toUpperCase() }}
</a-avatar>
<div class="member-info">
<div class="member-name">
{{ m.nickname || m.username || '-' }}
<a-tag v-if="m.inviteStatus === 1" color="warning" size="small">待确认</a-tag>
<a-tag v-else-if="m.inviteStatus === 2" color="red" size="small">已拒绝</a-tag>
</div>
<div class="member-sub">
{{ m.phone || '' }}
<span v-if="m.inviteStatus === 1 && m.inviteExpireTime" class="expire-hint">
(有效期至: {{ m.inviteExpireTime }})
</span>
</div>
</div>
<div class="member-right">
<a-select
:value="m.role"
size="small"
style="width: 110px"
:disabled="m.role === 'owner' || m.inviteStatus !== 0"
@change="(val: string) => handleRoleChange(m, val)"
>
<a-select-option value="admin">管理员</a-select-option>
<a-select-option value="developer">开发者</a-select-option>
<a-select-option value="viewer">只读</a-select-option>
</a-select>
<a-popconfirm
v-if="m.role !== 'owner'"
:title="m.inviteStatus === 1 ? '确认取消该邀请?' : '确认移除该成员?'"
@confirm="handleRemoveMember(m)"
>
<a-button type="text" danger size="small">
{{ m.inviteStatus === 1 ? '取消邀请' : '移除' }}
</a-button>
</a-popconfirm>
</div>
</div>
</div>
</a-spin>
</div>
</a-tab-pane>
<!-- ====== 密钥 Tab ====== -->
<a-tab-pane key="credentials" tab="密钥凭证">
<div class="tab-content">
<div class="tab-toolbar">
<span class="tab-count"> {{ credentials.length }} 个凭证</span>
<a-button type="primary" size="small" @click="showAddCredential = true">
<template #icon><PlusOutlined /></template>
新增凭证
</a-button>
</div>
<a-spin :spinning="credentialsLoading">
<div v-if="credentials.length === 0 && !credentialsLoading" class="empty-wrap">
<a-empty description="暂无凭证" />
</div>
<div v-else class="credential-list">
<div v-for="c in credentials" :key="c.id" class="credential-item">
<div class="credential-header">
<span class="credential-name">{{ c.name || '未命名凭证' }}</span>
<a-tag :color="c.type === 'server' ? 'blue' : c.type === 'webhook' ? 'purple' : 'green'">
{{ c.type || 'server' }}
</a-tag>
<a-tag :color="c.status === 0 ? 'success' : 'error'">
{{ c.status === 0 ? '正常' : '已冻结' }}
</a-tag>
<div class="credential-actions">
<a-tooltip title="重置密钥">
<a-popconfirm title="重置后旧密钥立即失效,确认?" @confirm="handleResetSecret(c)">
<a-button type="text" size="small"><template #icon><ReloadOutlined /></template></a-button>
</a-popconfirm>
</a-tooltip>
<a-tooltip :title="c.status === 0 ? '冻结' : '启用'">
<a-button type="text" size="small" @click="handleToggleCredentialStatus(c)">
<template #icon>
<StopOutlined v-if="c.status === 0" style="color: #ff4d4f" />
<CheckCircleOutlined v-else style="color: #52c41a" />
</template>
</a-button>
</a-tooltip>
<a-popconfirm title="确认删除该凭证?" @confirm="handleRemoveCredential(c)">
<a-button type="text" danger size="small"><template #icon><DeleteOutlined /></template></a-button>
</a-popconfirm>
</div>
</div>
<div class="credential-fields">
<div class="cred-field">
<span class="cred-label">AppID</span>
<span class="cred-value">{{ c.appId || '-' }}</span>
<a-button type="text" size="small" @click="copyText(c.appId || '')">
<template #icon><CopyOutlined /></template>
</a-button>
</div>
<div class="cred-field">
<span class="cred-label">AppSecret</span>
<span class="cred-value cred-secret">{{ c.appSecret || '-' }}</span>
</div>
<div v-if="c.remark" class="cred-field">
<span class="cred-label">备注</span>
<span class="cred-value">{{ c.remark }}</span>
</div>
</div>
</div>
</div>
</a-spin>
</div>
</a-tab-pane>
<!-- ====== 配置 Tab ====== -->
<a-tab-pane key="config" tab="应用配置">
<div class="tab-content">
<a-button type="primary" @click="goToConfig">
<template #icon><SettingOutlined /></template>
配置应用参数
</a-button>
<div class="config-tip">
配置应用的 API回调支付Git 等信息
</div>
</div>
</a-tab-pane>
<!-- ====== 版本 Tab ====== -->
<a-tab-pane key="versions" tab="版本管理">
<div class="tab-content">
<div class="tab-toolbar">
<span class="tab-count"> {{ versions.length }} 个版本</span>
<a-button type="primary" size="small" @click="showAddVersion = true">
<template #icon><PlusOutlined /></template>
新增版本
</a-button>
</div>
<a-spin :spinning="versionsLoading">
<div v-if="versions.length === 0 && !versionsLoading" class="empty-wrap">
<a-empty description="暂无版本记录" />
</div>
<div v-else class="version-list">
<div v-for="v in versions" :key="v.id" class="version-item">
<div class="version-left">
<div class="version-no">
{{ v.versionNo }}
<a-tag v-if="v.isCurrent" color="green" style="margin-left: 6px">当前</a-tag>
</div>
<div class="version-name">{{ v.versionName || '' }}</div>
<div v-if="v.changelog" class="version-changelog">{{ v.changelog }}</div>
<div class="version-meta">
<span>{{ envText(v.env) }}</span>
<span class="meta-dot">·</span>
<span>{{ formatDateTime(v.publishTime || v.createTime) }}</span>
</div>
</div>
<div class="version-right">
<a-tag :color="versionStatusColor(v.status)">{{ versionStatusText(v.status) }}</a-tag>
<div class="version-actions">
<a-popconfirm
v-if="!v.isCurrent && v.status !== 0"
title="发布此版本为当前运行版本?"
@confirm="handlePublish(v)"
>
<a-button type="link" size="small">发布</a-button>
</a-popconfirm>
<a-popconfirm
v-if="!v.isCurrent && v.status === 1"
title="回滚到此版本?"
@confirm="handleRollback(v)"
>
<a-button type="link" size="small">回滚</a-button>
</a-popconfirm>
</div>
</div>
</div>
</div>
</a-spin>
</div>
</a-tab-pane>
<!-- ====== 设置 Tab ====== -->
<a-tab-pane key="settings" tab="应用设置">
<div class="tab-content">
<a-spin :spinning="settingsLoading">
<a-form :model="settingsForm" layout="vertical">
<!-- 应用图标 -->
<a-form-item label="应用图标">
<div class="icon-upload-row">
<!-- 预览 / 点击触发上传 -->
<a-upload
name="file"
accept=".jpg,.jpeg,.png,.svg,.bmp"
:show-upload-list="false"
:before-upload="handleIconBeforeUpload"
:custom-request="handleIconUpload"
>
<div class="icon-preview-wrap icon-preview-clickable" :class="{ 'is-uploading': iconUploading }">
<img v-if="settingsForm.icon" :src="settingsForm.icon" class="icon-preview-img" />
<div v-else class="icon-preview-placeholder" :style="{ background: iconBgColor(settingsForm.productName) }">
{{ (settingsForm.productName || 'A').charAt(0).toUpperCase() }}
</div>
<!-- 遮罩 hover 提示 -->
<div class="icon-preview-mask">
<LoadingOutlined v-if="iconUploading" />
<UploadOutlined v-else />
<span>{{ iconUploading ? '上传中...' : '点击上传' }}</span>
</div>
</div>
</a-upload>
<!-- URL 输入可直接粘贴 -->
<div class="icon-url-input">
<a-input v-model:value="settingsForm.icon" placeholder="或粘贴图标 URL" allow-clear />
<div class="icon-tip">
支持 JPEG / PNG / SVG / BMP · 2 MB 以内 · 尺寸 240×240 px
</div>
</div>
</div>
</a-form-item>
<!-- 应用名称 -->
<a-form-item label="应用名称" required>
<a-input v-model:value="settingsForm.productName" placeholder="请输入应用名称" show-count :maxlength="50" />
</a-form-item>
<!-- 应用标识只读创建后不允许修改 -->
<a-form-item label="应用标识Code">
<a-input :value="settingsForm.productCode" disabled>
<template #suffix>
<a-tooltip title="应用标识唯一且不可修改">
<LockOutlined style="color:#bbb" />
</a-tooltip>
</template>
</a-input>
<div class="icon-tip">应用标识全局唯一创建后不可修改</div>
</a-form-item>
<!-- 应用描述 -->
<a-form-item label="应用描述">
<a-textarea
v-model:value="settingsForm.description"
placeholder="简短描述应用用途,便于团队成员了解"
:rows="3"
show-count
:maxlength="200"
/>
</a-form-item>
<!-- 管理后台主页 -->
<a-form-item label="管理后台主页">
<a-input
v-model:value="settingsForm.adminUrl"
placeholder="如https://admin.example.com"
allow-clear
>
<template #prefix><LinkOutlined style="color:#bbb" /></template>
</a-input>
</a-form-item>
<!-- 保存按钮 -->
<a-form-item>
<a-button type="primary" :loading="settingsSaving" @click="handleSaveSettings">保存修改</a-button>
</a-form-item>
</a-form>
<!-- 危险操作区 -->
<a-divider />
<div class="danger-zone">
<div class="danger-title">危险操作</div>
<div class="danger-item">
<div class="danger-item-info">
<div class="danger-item-name">转移应用所有权</div>
<div class="danger-item-desc">将应用所有者转让给其他成员转让后你将失去 owner 权限</div>
</div>
<a-button danger @click="showTransfer = true">转移所有权</a-button>
</div>
<a-divider style="margin: 12px 0" />
<div class="danger-item">
<div class="danger-item-info">
<div class="danger-item-name">删除应用</div>
<div class="danger-item-desc">永久删除应用及其所有配置成员凭证和版本记录此操作不可撤销</div>
</div>
<a-popconfirm
title=""
:open="showDeleteApp"
@confirm="handleDeleteApp"
@cancel="showDeleteApp = false"
>
<template #description>
<div class="delete-confirm-content">
<a-alert
message="删除后无法恢复,所有数据将被永久清除"
type="error"
show-icon
style="margin-bottom: 12px"
/>
<div class="delete-confirm-label">
请输入应用名称 <b>{{ app?.productName }}</b> 以确认删除
</div>
<a-input
v-model:value="deleteConfirmText"
:placeholder="app?.productName || ''"
style="margin-top: 6px"
@click.stop
@press-enter="handleDeleteApp"
/>
</div>
</template>
<a-button danger type="primary" ghost @click.stop="showDeleteApp = true">删除应用</a-button>
</a-popconfirm>
</div>
</div>
</a-spin>
</div>
</a-tab-pane>
<!-- ====== 动态 Tab ====== -->
<a-tab-pane key="events" tab="操作动态">
<div class="tab-content">
<a-spin :spinning="eventsLoading">
<div v-if="events.length === 0 && !eventsLoading" class="empty-wrap">
<a-empty description="暂无动态记录" />
</div>
<a-timeline v-else class="event-timeline">
<a-timeline-item
v-for="e in events"
:key="e.id"
:color="eventColor(e.eventType)"
>
<div class="event-item">
<div class="event-title">{{ e.title }}</div>
<div v-if="e.content" class="event-content">{{ e.content }}</div>
<div class="event-meta">
<span v-if="e.operator">{{ e.operator }}</span>
<span class="meta-dot">·</span>
<span>{{ formatDateTime(e.createTime) }}</span>
</div>
</div>
</a-timeline-item>
</a-timeline>
</a-spin>
</div>
</a-tab-pane>
</a-tabs>
</a-drawer>
<!-- 小程序扫码弹窗 -->
<QrCodeModal
v-model:open="qrOpen"
:qrcode-url="qrApp?.qrcode"
:app-name="qrApp?.productName"
:title="qrApp ? (APP_TYPE_NAME[qrApp.appType ?? 10] || '小程序') + '二维码' : ''"
:tip="qrApp ? getScanTip(qrApp.appType ?? 20) : ''"
/>
<!-- 邀请成员弹窗 - 三种方式 -->
<a-modal
v-model:open="showInvite"
title="邀请成员"
:footer="null"
width="560px"
@cancel="showInvite = false"
>
<div class="invite-modal-content">
<!-- 邀请方式切换 -->
<a-tabs v-model:active-key="inviteTab" class="invite-tabs">
<!-- 方式1手机号邀请 -->
<a-tab-pane key="phone" tab="手机号邀请">
<div class="invite-panel">
<a-alert
message="输入对方注册手机号,系统将发送邀请通知"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-form :model="inviteForm" layout="vertical">
<a-form-item label="对方手机号" required>
<a-input
v-model:value="inviteForm.phone"
placeholder="请输入11位手机号"
allow-clear
:maxlength="11"
>
<template #prefix><MobileOutlined style="color: #bbb" /></template>
</a-input>
</a-form-item>
<a-form-item label="邀请角色">
<a-select v-model:value="inviteForm.role" :options="inviteRoleOptions" />
</a-form-item>
<a-form-item>
<a-button
type="primary"
:loading="inviteLoading"
:disabled="!isValidPhone(inviteForm.phone)"
block
@click="handleInviteByPhone"
>
发送邀请
</a-button>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
<!-- 方式2二维码邀请 -->
<a-tab-pane key="qrcode" tab="扫码邀请">
<div class="invite-panel qrcode-panel">
<a-alert
message="让对方使用微信或浏览器扫描二维码加入"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<div class="qrcode-section">
<div v-if="!inviteUrl" class="qrcode-generate">
<a-form :model="inviteForm" layout="vertical">
<a-form-item label="邀请角色">
<a-select v-model:value="inviteForm.role" :options="inviteRoleOptions" />
</a-form-item>
<a-button type="primary" :loading="qrCodeLoading" block @click="onGenerateInviteQrCode">
生成邀请二维码
</a-button>
</a-form>
</div>
<div v-else class="qrcode-display">
<div class="qrcode-wrapper">
<a-qrcode ref="qrcodeRef" :value="inviteUrl" :size="200" class="qrcode-img" />
<div v-if="qrCodeExpired" class="qrcode-overlay">
<div class="qrcode-expired">
<ClockCircleOutlined style="font-size: 24px; margin-bottom: 8px" />
<div>二维码已过期</div>
<a-button type="link" size="small" @click="onGenerateInviteQrCode">重新生成</a-button>
</div>
</div>
</div>
<div class="qrcode-info">
<div class="qrcode-role">角色{{ roleText(inviteForm.role) }}</div>
<div class="qrcode-expire">有效期24小时</div>
</div>
<a-space>
<a-button @click="downloadQrCode">
<template #icon><DownloadOutlined /></template>
下载二维码
</a-button>
<a-button type="primary" @click="onGenerateInviteQrCode">
<template #icon><ReloadOutlined /></template>
重新生成
</a-button>
</a-space>
</div>
</div>
</div>
</a-tab-pane>
<!-- 方式3链接邀请 -->
<a-tab-pane key="link" tab="链接邀请">
<div class="invite-panel">
<a-alert
message="复制邀请链接发送给对方,点击链接即可加入"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<div class="link-section">
<a-form :model="inviteForm" layout="vertical">
<a-form-item label="邀请角色">
<a-select v-model:value="inviteForm.role" :options="inviteRoleOptions" />
</a-form-item>
</a-form>
<div v-if="!inviteLink" class="link-generate">
<a-button type="primary" :loading="linkLoading" block @click="onGenerateInviteLink">
生成邀请链接
</a-button>
</div>
<div v-else class="link-display">
<div class="link-input-group">
<a-input :value="inviteLink" readonly class="link-input">
<template #suffix>
<a-button type="link" size="small" @click="copyInviteLink">
<template #icon><CopyOutlined /></template>
复制
</a-button>
</template>
</a-input>
</div>
<div class="link-info">
<a-tag color="blue">{{ roleText(inviteForm.role) }}</a-tag>
<span class="link-expire">有效期 7 </span>
</div>
<a-space style="margin-top: 12px">
<a-button @click="onGenerateInviteLink">
<template #icon><ReloadOutlined /></template>
重新生成
</a-button>
</a-space>
</div>
</div>
</div>
</a-tab-pane>
</a-tabs>
</div>
</a-modal>
<!-- 新增凭证弹窗 -->
<a-modal
v-model:open="showAddCredential"
title="新增密钥凭证"
:confirm-loading="addCredentialLoading"
@ok="handleAddCredential"
@cancel="showAddCredential = false"
>
<a-alert
message="凭证创建后 AppSecret 仅展示一次,请及时保存"
type="warning"
show-icon
style="margin-bottom: 16px"
/>
<a-form :model="credentialForm" layout="vertical">
<a-form-item label="凭证名称" required>
<a-input v-model:value="credentialForm.name" placeholder="如:生产环境密钥" />
</a-form-item>
<a-form-item label="凭证类型">
<a-select v-model:value="credentialForm.type">
<a-select-option value="server">服务端server</a-select-option>
<a-select-option value="client">客户端client</a-select-option>
<a-select-option value="webhook">Webhook</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="credentialForm.remark" placeholder="可选备注" />
</a-form-item>
</a-form>
</a-modal>
<!-- 新建凭证成功弹窗展示明文 secret -->
<a-modal
v-model:open="showSecretResult"
title="凭证创建成功"
:footer="null"
:closable="false"
:mask-closable="false"
width="520px"
>
<a-alert
message="AppSecret 仅此一次展示,请立即复制保存,关闭后无法再次查看!"
type="error"
show-icon
style="margin-bottom: 16px"
/>
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="AppID">
<div class="secret-row">
<span>{{ newCredentialResult?.appId }}</span>
<a-button type="link" size="small" @click="copyText(newCredentialResult?.appId || '')">复制</a-button>
</div>
</a-descriptions-item>
<a-descriptions-item label="AppSecret">
<div class="secret-row">
<span class="secret-text">{{ newCredentialResult?.appSecret }}</span>
<a-button type="link" size="small" @click="copyText(newCredentialResult?.appSecret || '')">复制</a-button>
</div>
</a-descriptions-item>
</a-descriptions>
<div style="margin-top: 16px; text-align: right">
<a-button type="primary" @click="showSecretResult = false">我已保存关闭</a-button>
</div>
</a-modal>
<!-- 重置密钥成功弹窗 -->
<a-modal
v-model:open="showResetResult"
title="密钥重置成功"
:footer="null"
:closable="false"
:mask-closable="false"
width="520px"
>
<a-alert
message="新 AppSecret 仅此一次展示,请立即复制保存!"
type="error"
show-icon
style="margin-bottom: 16px"
/>
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="AppSecret">
<div class="secret-row">
<span class="secret-text">{{ resetSecretResult?.appSecret }}</span>
<a-button type="link" size="small" @click="copyText(resetSecretResult?.appSecret || '')">复制</a-button>
</div>
</a-descriptions-item>
</a-descriptions>
<div style="margin-top: 16px; text-align: right">
<a-button type="primary" @click="showResetResult = false">我已保存关闭</a-button>
</div>
</a-modal>
<!-- 转移所有权弹窗 -->
<a-modal
v-model:open="showTransfer"
title="转移应用所有权"
:confirm-loading="transferLoading"
ok-text="确认转移"
ok-type="danger"
@ok="handleTransfer"
@cancel="showTransfer = false"
>
<a-alert
message="转移后你将失去 owner 权限,此操作不可撤销,请谨慎操作!"
type="warning"
show-icon
style="margin-bottom: 16px"
/>
<a-form :model="transferForm" layout="vertical">
<a-form-item label="转让给(选择成员)" required>
<a-select
v-model:value="transferForm.userId"
placeholder="请选择要转让的成员"
style="width: 100%"
:options="transferableMembers"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 新增版本弹窗 -->
<a-modal
v-model:open="showAddVersion"
title="新增版本"
:confirm-loading="addVersionLoading"
@ok="handleAddVersion"
@cancel="showAddVersion = false"
>
<a-form :model="versionForm" layout="vertical" style="margin-top: 8px">
<a-form-item label="版本号" required>
<a-input v-model:value="versionForm.versionNo" placeholder="如1.0.0" />
</a-form-item>
<a-form-item label="版本名称">
<a-input v-model:value="versionForm.versionName" placeholder="如:正式版 1.0" />
</a-form-item>
<a-form-item label="环境">
<a-select v-model:value="versionForm.env">
<a-select-option value="production">生产production</a-select-option>
<a-select-option value="staging">预发staging</a-select-option>
<a-select-option value="development">开发development</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="更新说明">
<a-textarea v-model:value="versionForm.changelog" :rows="3" placeholder="本次版本更新内容..." />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import {
CloseOutlined,
PlusOutlined,
ReloadOutlined,
CopyOutlined,
DeleteOutlined,
StopOutlined,
CheckCircleOutlined,
LinkOutlined,
UploadOutlined,
LoadingOutlined,
LockOutlined,
SettingOutlined,
MobileOutlined,
ClockCircleOutlined,
DownloadOutlined,
QrcodeOutlined,
GlobalOutlined,
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import type { AppProduct } from '@/api/cms/cmsWebsite/model'
import type { AppUser } from '@/api/app/appUser/model'
import type { AppCredential } from '@/api/app/appCredential/model'
import type { AppVersion } from '@/api/app/appVersion/model'
import type { AppEvent } from '@/api/app/appEvent/model'
import { APP_TYPE_NAME } from '@/api/app/appProduct/model'
import { getAppEntries, executeEntry, getScanTip } from '@/utils/appEntry'
import type { AppEntry } from '@/utils/appEntry'
import QrCodeModal from '@/components/QrCodeModal.vue'
import {
listAppUser,
inviteAppUser,
updateAppUserRole,
removeAppUser,
} from '@/api/app/appUser'
import {
listAppCredential,
createAppCredential,
resetAppCredentialSecret,
updateAppCredentialStatus,
removeAppCredential,
} from '@/api/app/appCredential'
import {
listAppVersion,
addAppVersion,
publishAppVersion,
rollbackAppVersion,
} from '@/api/app/appVersion'
import { listAppEvent, addAppEvent } from '@/api/app/appEvent'
import { removeAppProduct, updateAppProduct } from '@/api/app/appProduct'
import { uploadFile } from '@/api/system/file'
import { generateInviteQrCode, generateInviteLink } from '@/api/app/invite'
const props = defineProps<{
open: boolean
app: AppProduct | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'updated': [app: AppProduct]
'deleted': []
}>()
const activeTab = ref('members')
// =================== 应用图标上传 ===================
const iconUploading = ref(false)
const ALLOWED_ICON_TYPES = ['image/jpeg', 'image/png', 'image/svg+xml', 'image/bmp']
const MAX_ICON_SIZE = 2 * 1024 * 1024 // 2 MB
const MIN_ICON_DIM = 240 // px
/** beforeUpload格式 + 大小 + 分辨率校验 */
function handleIconBeforeUpload(file: File): Promise<boolean> {
return new Promise((resolve, reject) => {
// 格式校验
if (!ALLOWED_ICON_TYPES.includes(file.type)) {
message.error('仅支持 JPEG / PNG / SVG / BMP 格式')
return reject(false)
}
// 大小校验
if (file.size > MAX_ICON_SIZE) {
message.error('图标文件不能超过 2 MB')
return reject(false)
}
// SVG 不检测像素尺寸(矢量图)
if (file.type === 'image/svg+xml') return resolve(true)
// 分辨率校验
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
URL.revokeObjectURL(url)
if (img.width < MIN_ICON_DIM || img.height < MIN_ICON_DIM) {
message.error(`图标尺寸不能小于 ${MIN_ICON_DIM}×${MIN_ICON_DIM} px当前 ${img.width}×${img.height}`)
return reject(false)
}
resolve(true)
}
img.onerror = () => { URL.revokeObjectURL(url); resolve(true) }
img.src = url
})
}
/** customRequest上传后把 url 写入 settingsForm.icon */
async function handleIconUpload({ file }: { file: File }) {
iconUploading.value = true
try {
const result = await uploadFile(file)
settingsForm.icon = result.url || ''
message.success('图标上传成功')
} catch (e: any) {
message.error(e.message || '图标上传失败')
} finally {
iconUploading.value = false
}
}
// =================== 应用设置 ===================
const settingsLoading = ref(false)
const settingsSaving = ref(false)
const settingsForm = reactive({
productId: undefined as number | undefined,
productName: '',
productCode: '',
icon: '',
description: '',
adminUrl: '',
})
function initSettings() {
if (!props.app) return
settingsForm.productId = props.app.productId
settingsForm.productName = props.app.productName || ''
settingsForm.productCode = props.app.productCode || ''
settingsForm.icon = props.app.icon || ''
settingsForm.description = props.app.description || ''
settingsForm.adminUrl = props.app.adminUrl || ''
}
async function handleSaveSettings() {
if (!settingsForm.productName) { message.warning('应用名称不能为空'); return }
settingsSaving.value = true
try {
// productCode 唯一且不可修改,提交时明确排除
const { productCode: _omit, ...submitData } = settingsForm
await updateAppProduct(submitData as AppProduct)
message.success('保存成功')
logEvent('settings', '修改了应用设置', `应用名称:${submitData.productName}`)
emit('updated', { ...props.app, ...submitData } as AppProduct)
} catch (e: any) {
message.error(e.message || '保存失败')
} finally {
settingsSaving.value = false
}
}
// =================== 删除应用 ===================
const showDeleteApp = ref(false)
const deleteAppLoading = ref(false)
const deleteConfirmText = ref('')
async function handleDeleteApp() {
if (!props.app?.productId) return
if (deleteConfirmText.value !== props.app.productName) {
message.warning('请输入正确的应用名称以确认删除')
return
}
deleteAppLoading.value = true
try {
await removeAppProduct(props.app.productId!)
message.success('应用已删除')
emit('update:open', false)
emit('deleted')
} catch (e: any) {
message.error(e.message || '删除失败')
} finally {
deleteAppLoading.value = false
}
}
// =================== 转移所有权 ===================
const showTransfer = ref(false)
const transferLoading = ref(false)
const transferForm = reactive({ userId: undefined as number | undefined })
const transferableMembers = computed(() =>
members.value
.filter(m => m.role !== 'owner')
.map(m => ({
label: m.nickname || m.username || `用户 ${m.userId}`,
value: m.userId,
}))
)
async function handleTransfer() {
if (!transferForm.userId) { message.warning('请选择转让对象'); return }
transferLoading.value = true
try {
// 1. 将目标成员设为 owner
const target = members.value.find(m => m.userId === transferForm.userId)
if (!target) throw new Error('成员不存在')
await updateAppUserRole(target.id!, 'owner')
// 2. 将当前 owner 降为 admin
const currentOwner = members.value.find(m => m.role === 'owner')
if (currentOwner) await updateAppUserRole(currentOwner.id!, 'admin')
// 3. 同步更新 cms_website.user_id确保应用归属正确
if (props.app?.productId) {
await updateAppProduct({
productId: props.app.productId,
userId: transferForm.userId,
} as AppProduct)
// 通知父组件刷新列表userId 已变更)
emit('updated', { ...props.app, userId: transferForm.userId } as AppProduct)
}
message.success('所有权转移成功')
logEvent('transfer', '转移了应用所有权')
showTransfer.value = false
transferForm.userId = undefined
await loadMembers()
} catch (e: any) {
message.error(e.message || '转移失败')
} finally {
transferLoading.value = false
}
}
// =================== 成员 ===================
const members = ref<AppUser[]>([])
const membersLoading = ref(false)
const showInvite = ref(false)
// 邀请弹窗相关
const inviteTab = ref('phone')
const inviteLoading = ref(false)
const inviteForm = reactive({ phone: '', role: 'developer' })
// 邀请权限控制:获取当前用户在应用中的角色
const currentUserId = computed(() => {
if (import.meta.client) {
return Number(localStorage.getItem('UserId')) || 0
}
return 0
})
const myRoleInApp = computed(() => {
const myMember = members.value.find(m => m.userId === currentUserId.value)
return myMember?.role || ''
})
// 是否可以邀请管理员(仅 owner 和 admin 可邀请任意角色)
const canInviteAdmin = computed(() => {
return myRoleInApp.value === 'owner' || myRoleInApp.value === 'admin'
})
// 根据权限获取可邀请的角色列表
const inviteRoleOptions = computed(() => {
const options = [
{ value: 'developer', label: '开发者' }
]
if (canInviteAdmin.value) {
options.unshift({ value: 'admin', label: '管理员' })
options.push({ value: 'viewer', label: '只读' })
}
return options
})
// 监听弹窗打开,重置状态并检查权限
watch(showInvite, (val) => {
if (val) {
inviteTab.value = 'phone'
inviteForm.phone = ''
// 默认角色:开发者可邀请 developer管理员可邀请任意
inviteForm.role = canInviteAdmin.value ? 'developer' : 'developer'
inviteUrl.value = ''
inviteLink.value = ''
qrCodeExpired.value = false
clearInviteTimers()
} else {
clearInviteTimers()
}
})
const qrCodeLoading = ref(false)
const inviteUrl = ref('')
const qrCodeExpired = ref(false)
const qrcodeRef = ref() // a-qrcode 组件 ref
const qrCodeExpireTimer = ref<NodeJS.Timeout | null>(null)
// 链接邀请
const linkLoading = ref(false)
const inviteLink = ref('')
const linkExpireTimer = ref<NodeJS.Timeout | null>(null)
// 监听弹窗打开,重置状态
watch(showInvite, (val) => {
if (val) {
inviteTab.value = 'phone'
inviteForm.phone = ''
inviteForm.role = 'developer'
inviteUrl.value = ''
inviteLink.value = ''
qrCodeExpired.value = false
clearInviteTimers()
} else {
clearInviteTimers()
}
})
function clearInviteTimers() {
if (qrCodeExpireTimer.value) {
clearTimeout(qrCodeExpireTimer.value)
qrCodeExpireTimer.value = null
}
if (linkExpireTimer.value) {
clearTimeout(linkExpireTimer.value)
linkExpireTimer.value = null
}
}
function isValidPhone(phone: string): boolean {
return /^1[3-9]\d{9}$/.test(phone)
}
function roleText(role: string): string {
const map: Record<string, string> = {
owner: '所有者',
admin: '管理员',
developer: '开发者',
viewer: '只读',
}
return map[role] || role
}
async function loadMembers() {
if (!props.app?.productId) return
membersLoading.value = true
try {
const res = await listAppUser({ appId: props.app.productId })
// 按角色权限排序owner > admin > developer > viewer
const roleOrder: Record<string, number> = { owner: 0, admin: 1, developer: 2, viewer: 3 }
members.value = (res || []).sort((a, b) => {
const orderA = roleOrder[a.role || ''] ?? 99
const orderB = roleOrder[b.role || ''] ?? 99
return orderA - orderB
})
} catch (e: any) {
message.error(e.message || '加载成员失败')
} finally {
membersLoading.value = false
}
}
// ========== 方式1手机号邀请 ==========
async function handleInviteByPhone() {
const phone = inviteForm.phone?.trim()
if (!isValidPhone(phone)) {
message.warning('请输入有效的11位手机号')
return
}
inviteLoading.value = true
try {
await inviteAppUser({
appId: props.app!.productId!,
phone,
role: inviteForm.role,
})
message.success('邀请已发送')
logEvent('member_invite', '邀请了新成员', `方式:手机号 角色:${inviteForm.role}`)
showInvite.value = false
inviteForm.phone = ''
await loadMembers()
} catch (e: any) {
message.error(e.message || '邀请失败')
} finally {
inviteLoading.value = false
}
}
// ========== 方式2二维码邀请 ==========
async function onGenerateInviteQrCode() {
if (!props.app?.productId) return
qrCodeLoading.value = true
try {
// 调用后端接口生成邀请 token不再依赖后端返回二维码图片
const res = await generateInviteQrCode({
appId: props.app.productId,
role: inviteForm.role,
})
if (res.data?.code === 0 && res.data.data) {
// 使用后端返回的邀请链接,前端使用 <a-qrcode> 生成二维码
inviteUrl.value = res.data.data.inviteUrl || res.data.data.qrCodeUrl
qrCodeExpired.value = false
// 设置24小时过期提示实际过期由后端控制
if (qrCodeExpireTimer.value) clearTimeout(qrCodeExpireTimer.value)
qrCodeExpireTimer.value = setTimeout(() => {
qrCodeExpired.value = true
}, 24 * 60 * 60 * 1000)
message.success('二维码生成成功')
} else {
throw new Error(res.data?.message || '生成失败')
}
} catch (e: any) {
// 如果后端接口不存在,使用本地生成邀请链接作为降级方案
generateFallbackInviteUrl()
} finally {
qrCodeLoading.value = false
}
}
// 降级方案:本地生成邀请链接
function generateFallbackInviteUrl() {
if (!props.app?.productId) return
// 生成邀请链接(本地模式)
const baseUrl = window.location.origin
const token = generateInviteToken()
inviteUrl.value = `${baseUrl}/invite/accept?token=${token}&appId=${props.app.productId}&role=${inviteForm.role}`
// 存储 token 到 localStorage实际应该由后端存储
const inviteData = {
appId: props.app.productId,
role: inviteForm.role,
createTime: Date.now(),
expireTime: Date.now() + 24 * 60 * 60 * 1000,
}
localStorage.setItem(`invite_token_${token}`, JSON.stringify(inviteData))
qrCodeExpired.value = false
if (qrCodeExpireTimer.value) clearTimeout(qrCodeExpireTimer.value)
qrCodeExpireTimer.value = setTimeout(() => {
qrCodeExpired.value = true
}, 24 * 60 * 60 * 1000)
message.success('二维码已生成(本地模式)')
}
function generateInviteToken(): string {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
}
// 下载二维码
function downloadQrCode() {
if (!inviteUrl.value) return
// 尝试从 a-qrcode 组件获取 canvas 进行下载
const qrcodeEl = qrcodeRef.value?.$el as HTMLElement
if (qrcodeEl) {
const canvas = qrcodeEl.querySelector('canvas')
if (canvas) {
const link = document.createElement('a')
link.href = canvas.toDataURL('image/png')
link.download = `invite-${props.app?.productCode || 'app'}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
return
}
}
// 降级:下载邀请链接文本
const link = document.createElement('a')
link.href = inviteUrl.value
link.download = `invite-${props.app?.productCode || 'app'}-link.txt`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// ========== 方式3链接邀请 ==========
async function onGenerateInviteLink() {
if (!props.app?.productId) return
linkLoading.value = true
try {
// 调用后端接口生成邀请链接
const res = await generateInviteLink({
appId: props.app.productId,
role: inviteForm.role,
})
if (res.data?.code === 0 && res.data.data?.inviteUrl) {
// 将 websopy.com 替换为 websopy.websoft.top临时方案直到正式域名上线
inviteLink.value = res.data.data.inviteUrl.replace('https://websopy.com', 'https://websopy.websoft.top')
// 设置7天过期提示
if (linkExpireTimer.value) clearTimeout(linkExpireTimer.value)
linkExpireTimer.value = setTimeout(() => {
inviteLink.value = ''
}, 7 * 24 * 60 * 60 * 1000)
message.success('邀请链接已生成')
} else {
throw new Error(res.data?.message || '生成失败')
}
} catch (e: any) {
// 降级方案:前端生成链接
generateFallbackLink()
} finally {
linkLoading.value = false
}
}
function generateFallbackLink() {
if (!props.app?.productId) return
const baseUrl = window.location.origin
const token = generateInviteToken()
const inviteUrl = `${baseUrl}/invite/accept?token=${token}&appId=${props.app.productId}&role=${inviteForm.role}`
inviteLink.value = inviteUrl
// 存储 token
const inviteData = {
appId: props.app.productId,
role: inviteForm.role,
createTime: Date.now(),
expireTime: Date.now() + 7 * 24 * 60 * 60 * 1000,
}
localStorage.setItem(`invite_token_${token}`, JSON.stringify(inviteData))
if (linkExpireTimer.value) clearTimeout(linkExpireTimer.value)
linkExpireTimer.value = setTimeout(() => {
inviteLink.value = ''
}, 7 * 24 * 60 * 60 * 1000)
message.success('邀请链接已生成(本地模式)')
}
function copyInviteLink() {
if (!inviteLink.value) return
navigator.clipboard.writeText(inviteLink.value).then(() => {
message.success('链接已复制到剪贴板')
})
}
async function handleRoleChange(m: AppUser, role: string) {
try {
await updateAppUserRole(m.id!, role)
message.success('角色已更新')
logEvent('member_role', '修改了成员角色', `${m.nickname || m.username}${role}`)
m.role = role
} catch (e: any) {
message.error(e.message || '更新失败')
}
}
async function handleRemoveMember(m: AppUser) {
try {
await removeAppUser(m.id!)
message.success('已移除')
logEvent('member_remove', '移除了成员', m.nickname || m.username || String(m.userId))
await loadMembers()
} catch (e: any) {
message.error(e.message || '移除失败')
}
}
// =================== 密钥 ===================
const credentials = ref<AppCredential[]>([])
const credentialsLoading = ref(false)
const showAddCredential = ref(false)
const addCredentialLoading = ref(false)
const credentialForm = reactive({ name: '', type: 'server', remark: '' })
const showSecretResult = ref(false)
const newCredentialResult = ref<AppCredential | null>(null)
const showResetResult = ref(false)
const resetSecretResult = ref<AppCredential | null>(null)
async function loadCredentials() {
if (!props.app?.productId) return
credentialsLoading.value = true
try {
const res = await listAppCredential({ appId: props.app.productId })
credentials.value = res || []
} catch (e: any) {
message.error(e.message || '加载凭证失败')
} finally {
credentialsLoading.value = false
}
}
async function handleAddCredential() {
if (!credentialForm.name) { message.warning('请填写凭证名称'); return }
addCredentialLoading.value = true
try {
const result = await createAppCredential({
appId: props.app!.productId,
name: credentialForm.name,
type: credentialForm.type,
remark: credentialForm.remark,
})
const credentialName = credentialForm.name
showAddCredential.value = false
credentialForm.name = ''
credentialForm.remark = ''
newCredentialResult.value = result.data || null
logEvent('credential_create', '创建了访问凭证', credentialName || undefined)
showSecretResult.value = true
await loadCredentials()
} catch (e: any) {
message.error(e.message || '创建失败')
} finally {
addCredentialLoading.value = false
}
}
async function handleResetSecret(c: AppCredential) {
try {
const result = await resetAppCredentialSecret(c.id!)
resetSecretResult.value = result.data || null
logEvent('credential_reset', '重置了凭证密钥', c.name || undefined)
showResetResult.value = true
await loadCredentials()
} catch (e: any) {
message.error(e.message || '重置失败')
}
}
async function handleToggleCredentialStatus(c: AppCredential) {
const newStatus = c.status === 0 ? 1 : 0
try {
await updateAppCredentialStatus(c.id!, newStatus)
message.success(newStatus === 0 ? '已启用' : '已冻结')
logEvent('credential_status', newStatus === 0 ? '启用了访问凭证' : '冻结了访问凭证', c.name || undefined)
c.status = newStatus
} catch (e: any) {
message.error(e.message || '操作失败')
}
}
async function handleRemoveCredential(c: AppCredential) {
try {
await removeAppCredential(c.id!)
message.success('已删除')
logEvent('credential_delete', '删除了访问凭证', c.name || undefined)
await loadCredentials()
} catch (e: any) {
message.error(e.message || '删除失败')
}
}
// =================== 版本 ===================
const versions = ref<AppVersion[]>([])
const versionsLoading = ref(false)
const showAddVersion = ref(false)
const addVersionLoading = ref(false)
const versionForm = reactive({ versionNo: '', versionName: '', env: 'production', changelog: '' })
async function loadVersions() {
if (!props.app?.productId) return
versionsLoading.value = true
try {
const res = await listAppVersion({ appId: props.app.productId })
versions.value = res || []
} catch (e: any) {
message.error(e.message || '加载版本失败')
} finally {
versionsLoading.value = false
}
}
async function handleAddVersion() {
if (!versionForm.versionNo) { message.warning('请填写版本号'); return }
addVersionLoading.value = true
try {
await addAppVersion({
appId: props.app!.productId,
...versionForm,
})
message.success('创建成功')
showAddVersion.value = false
versionForm.versionNo = ''
versionForm.versionName = ''
versionForm.changelog = ''
await loadVersions()
} catch (e: any) {
message.error(e.message || '创建失败')
} finally {
addVersionLoading.value = false
}
}
async function handlePublish(v: AppVersion) {
try {
await publishAppVersion(v.id!)
message.success('发布成功')
logEvent('version_publish', '发布了新版本', v.versionNo || undefined)
await loadVersions()
} catch (e: any) {
message.error(e.message || '发布失败')
}
}
async function handleRollback(v: AppVersion) {
try {
await rollbackAppVersion(v.id!)
message.success('回滚成功')
logEvent('version_rollback', '回滚了版本', v.versionNo || undefined)
await loadVersions()
} catch (e: any) {
message.error(e.message || '回滚失败')
}
}
// =================== 动态 ===================
const events = ref<AppEvent[]>([])
const eventsLoading = ref(false)
async function loadEvents() {
if (!props.app?.productId) return
eventsLoading.value = true
try {
const res = await listAppEvent({ appId: props.app.productId, limit: 50 })
events.value = res || []
} catch (e: any) {
message.error(e.message || '加载动态失败')
} finally {
eventsLoading.value = false
}
}
// =================== 监听 Tab 变化 + 抽屉打开 ===================
watch(() => props.open, (val) => {
if (val) {
activeTab.value = 'members'
initSettings()
loadMembers()
}
})
watch(activeTab, (tab) => {
if (!props.app?.productId) return
if (tab === 'members') loadMembers()
else if (tab === 'credentials') loadCredentials()
else if (tab === 'versions') loadVersions()
else if (tab === 'events') loadEvents()
else if (tab === 'settings') initSettings()
})
// =================== 工具函数 ===================
/** 记录操作动态(静默,不影响主流程) */
function logEvent(eventType: string, title: string, content?: string) {
if (!props.app?.productId) return
addAppEvent({
appId: props.app.productId,
eventType,
title,
content: content || undefined,
})
}
/** 跳转到配置页面 */
function goToConfig() {
navigateTo(`/developer/config/${props.app?.productId}`)
}
function copyText(text: string) {
if (!text) return
navigator.clipboard.writeText(text).then(() => message.success('已复制'))
}
function statusText(status?: number) {
const map: Record<number, string> = { 0: '未开通', 1: '已启用', 2: '维护中', 3: '已关闭', 4: '欠费停机', 5: '违规关停' }
return typeof status === 'number' ? (map[status] ?? '未知') : '-'
}
function envText(env?: string) {
const map: Record<string, string> = { production: '生产', staging: '预发', development: '开发' }
return env ? (map[env] ?? env) : '-'
}
function versionStatusText(status?: number) {
const map: Record<number, string> = { 0: '构建中', 1: '已发布', 2: '已回滚', 3: '构建失败' }
return typeof status === 'number' ? (map[status] ?? '未知') : '-'
}
function versionStatusColor(status?: number) {
const map: Record<number, string> = { 0: 'processing', 1: 'success', 2: 'warning', 3: 'error' }
return typeof status === 'number' ? (map[status] ?? 'default') : 'default'
}
function eventColor(eventType?: string) {
const map: Record<string, string> = {
created: 'blue', published: 'green', updated: 'cyan',
domain_bound: 'purple', member_added: 'orange', status_changed: 'red',
}
return eventType ? (map[eventType] ?? 'gray') : 'gray'
}
function formatDateTime(dateStr?: string) {
if (!dateStr) return '-'
return dateStr.slice(0, 16).replace('T', ' ')
}
const PALETTE = ['#4e6ef2', '#f4a261', '#e76f51', '#2a9d8f', '#e9c46a', '#457b9d', '#a8dadc', '#f77f00']
function iconBgColor(name?: string) {
if (!name) return PALETTE[0]
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffffffff
return PALETTE[Math.abs(h) % PALETTE.length]
}
// ====== 入口处理 ======
const qrOpen = ref(false)
const qrApp = ref<AppProduct | null>(null)
function handleEntryClick(entry: AppEntry) {
if (!props.app) return
if (entry.type === 'scan-qr') {
qrApp.value = props.app
qrOpen.value = true
return
}
executeEntry(entry)
}
</script>
<style scoped>
/* 头部 */
.detail-header {
display: flex;
align-items: center;
gap: 14px;
padding: 20px 24px 16px;
border-bottom: 1px solid #f0f0f0;
position: relative;
}
.header-icon-wrap {
flex-shrink: 0;
width: 52px;
height: 52px;
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.header-icon-img { width: 100%; height: 100%; object-fit: cover; }
.header-icon-placeholder {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
font-size: 22px; font-weight: 700; color: #fff;
}
.header-info { flex: 1; min-width: 0; }
.header-title { font-size: 18px; font-weight: 600; color: #1a1a1a; }
.header-sub { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.header-code { font-size: 12px; color: #999; background: #f5f5f5; padding: 1px 8px; border-radius: 4px; }
.header-status {
font-size: 12px; padding: 1px 8px; border-radius: 12px; font-weight: 500;
}
.status-1 { background: #e6f7ee; color: #389e0d; }
.status-0 { background: #f5f5f5; color: #999; }
.status-2 { background: #fff7e6; color: #d46b08; }
.status-3, .status-5 { background: #fff1f0; color: #cf1322; }
.header-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex-shrink: 0;
}
.close-btn { position: absolute; top: 16px; right: 16px; }
/* Tabs */
.detail-tabs :deep(.ant-tabs-nav) { padding: 0 24px; margin-bottom: 0; }
/* Tab 内容 */
.tab-content { padding: 16px 24px; min-height: 300px; }
.tab-toolbar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px;
}
.tab-count { font-size: 13px; color: #999; }
.empty-wrap { display: flex; justify-content: center; padding: 40px 0; }
/* 成员列表 */
.member-list { display: flex; flex-direction: column; gap: 2px; }
.member-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; border-radius: 8px;
transition: background 0.15s;
}
.member-item:hover { background: #fafafa; }
.member-item.pending {
background: #fffbe6;
border: 1px dashed #ffd666;
}
.member-item.pending:hover {
background: #fff7d0;
}
.member-avatar { flex-shrink: 0; background: #4e6ef2; }
.member-info { flex: 1; min-width: 0; }
.member-name {
font-size: 14px;
font-weight: 500;
color: #1a1a1a;
display: flex;
align-items: center;
gap: 8px;
}
.member-sub {
font-size: 12px;
color: #999;
display: flex;
align-items: center;
gap: 8px;
}
.member-sub .expire-hint {
color: #faad14;
}
.member-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
/* 密钥列表 */
.credential-list { display: flex; flex-direction: column; gap: 12px; }
.credential-item {
border: 1px solid #f0f0f0; border-radius: 8px; padding: 14px 16px;
background: #fafcff;
}
.credential-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
}
.credential-name { font-weight: 600; font-size: 14px; flex: 1; }
.credential-actions { margin-left: auto; display: flex; gap: 4px; }
.credential-fields { display: flex; flex-direction: column; gap: 6px; }
.cred-field { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.cred-label { color: #999; width: 70px; flex-shrink: 0; }
.cred-value { font-family: 'Courier New', monospace; color: #333; flex: 1; }
.cred-secret { color: #888; letter-spacing: 1px; }
/* 版本列表 */
.version-list { display: flex; flex-direction: column; gap: 2px; }
.version-item {
display: flex; align-items: flex-start; gap: 12px;
padding: 12px 12px; border-radius: 8px;
border-bottom: 1px solid #f5f5f5;
}
.version-item:last-child { border-bottom: none; }
.version-left { flex: 1; }
.version-no { font-size: 15px; font-weight: 600; color: #1a1a1a; display: flex; align-items: center; }
.version-name { font-size: 13px; color: #666; margin-top: 2px; }
.version-changelog { font-size: 12px; color: #999; margin-top: 4px; max-width: 380px; white-space: pre-wrap; }
.version-meta { font-size: 12px; color: #bbb; margin-top: 4px; display: flex; gap: 4px; }
.version-right { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex-shrink: 0; }
.version-actions { display: flex; gap: 4px; }
/* 动态时间线 */
.event-timeline { padding: 8px 0; }
.event-item { padding-bottom: 4px; }
.event-title { font-size: 14px; font-weight: 500; color: #1a1a1a; }
.event-content { font-size: 13px; color: #666; margin-top: 2px; }
.event-meta { font-size: 12px; color: #bbb; margin-top: 4px; display: flex; gap: 4px; }
/* 密钥明文展示 */
.secret-row { display: flex; align-items: center; gap: 8px; }
.secret-text { font-family: 'Courier New', monospace; font-size: 13px; color: #e03; word-break: break-all; }
.meta-dot { color: #ddd; }
/* 配置提示 */
.config-tip {
font-size: 13px;
color: #999;
margin-top: 12px;
}
/* 图标上传行 */
.icon-upload-row {
display: flex;
align-items: flex-start;
gap: 16px;
}
.icon-preview-wrap {
flex-shrink: 0;
width: 80px;
height: 80px;
border-radius: 0; /* 无圆角 */
overflow: hidden;
border: 1px dashed #d9d9d9;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: #fafafa;
transition: border-color 0.2s;
}
.icon-preview-clickable {
cursor: pointer;
}
.icon-preview-clickable:hover {
border-color: #1677ff;
}
.icon-preview-clickable:hover .icon-preview-mask {
opacity: 1;
}
.icon-preview-img { width: 100%; height: 100%; object-fit: cover; }
.icon-preview-placeholder {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
font-size: 28px; font-weight: 700; color: #fff;
}
.icon-preview-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: #fff;
font-size: 12px;
opacity: 0;
transition: opacity 0.2s;
}
.icon-preview-mask .anticon { font-size: 18px; }
.is-uploading .icon-preview-mask { opacity: 1; }
.icon-url-input { flex: 1; }
.icon-tip { font-size: 12px; color: #bbb; margin-top: 6px; }
/* 危险区 */
.danger-zone {
border: 1px solid #ffd6d6;
border-radius: 8px;
padding: 16px 20px;
background: #fffafa;
}
.danger-title {
font-size: 13px;
font-weight: 600;
color: #cf1322;
margin-bottom: 12px;
}
.danger-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.danger-item-name { font-size: 14px; font-weight: 500; color: #1a1a1a; }
.danger-item-desc { font-size: 12px; color: #999; margin-top: 2px; }
/* 删除确认弹窗 */
.delete-confirm-content {
max-width: 320px;
}
.delete-confirm-label {
font-size: 13px;
color: #333;
line-height: 1.6;
}
/* 邀请弹窗样式 */
.invite-modal-content {
padding: 8px 0;
}
.invite-tabs :deep(.ant-tabs-nav) {
margin-bottom: 16px;
}
.invite-panel {
padding: 0 4px;
}
.qrcode-panel {
text-align: center;
}
.qrcode-section {
display: flex;
flex-direction: column;
align-items: center;
}
.qrcode-wrapper {
position: relative;
width: 200px;
height: 200px;
margin: 16px auto;
border-radius: 8px;
background: #fff;
}
.qrcode-img {
background: #fff;
border-radius: 8px;
}
.qrcode-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
}
.qrcode-expired {
text-align: center;
color: #999;
}
.qrcode-info {
margin: 12px 0 16px;
font-size: 13px;
color: #666;
}
.qrcode-role {
margin-bottom: 4px;
}
.qrcode-expire {
color: #999;
font-size: 12px;
}
.link-section {
padding: 0 4px;
}
.link-input-group {
margin-bottom: 12px;
}
.link-input :deep(.ant-input) {
background: #f5f5f5;
}
.link-info {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.link-expire {
font-size: 12px;
color: #999;
}
.link-display {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>