1994 lines
65 KiB
Vue
1994 lines
65 KiB
Vue
<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.productId || '-' }}</span>
|
||
<a-button type="text" size="small" @click="copyText(String(c.productId) || '')">
|
||
<template #icon><CopyOutlined /></template>
|
||
</a-button>
|
||
</div>
|
||
<div class="cred-field">
|
||
<span class="cred-label">ClientSecret</span>
|
||
<span class="cred-value cred-secret">{{ c.clientSecret || '-' }}</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 && !miniprogramQrCode" 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">
|
||
<!-- 小程序码(优先展示) -->
|
||
<img
|
||
v-if="miniprogramQrCode"
|
||
:src="miniprogramQrCode"
|
||
class="qrcode-img miniprogram-qrcode"
|
||
alt="小程序码"
|
||
@click="onGenerateInviteQrCode"
|
||
/>
|
||
<!-- 普通二维码(降级方案) -->
|
||
<a-qrcode
|
||
v-else
|
||
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 v-if="qrCodeUsed" class="qrcode-used">
|
||
<CheckCircleOutlined style="color: #52c41a; margin-right: 4px" />
|
||
<span>{{ qrCodeUsedBy || '有人' }}已加入</span>
|
||
</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?.productId }}</span>
|
||
<a-button type="link" size="small" @click="copyText(String(newCredentialResult?.productId) || '')">复制</a-button>
|
||
</div>
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="ClientSecret">
|
||
<div class="secret-row">
|
||
<span class="secret-text">{{ newCredentialResult?.clientSecret }}</span>
|
||
<a-button type="link" size="small" @click="copyText(newCredentialResult?.clientSecret || '')">复制</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, getInviteStatus } 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 = ''
|
||
miniprogramQrCode.value = ''
|
||
inviteLink.value = ''
|
||
qrCodeExpired.value = false
|
||
qrCodeUsed.value = false
|
||
qrCodeUsedBy.value = ''
|
||
currentQrCodeToken.value = ''
|
||
clearInviteTimers()
|
||
} else {
|
||
clearInviteTimers()
|
||
}
|
||
})
|
||
|
||
const qrCodeLoading = ref(false)
|
||
const inviteUrl = ref('')
|
||
const miniprogramQrCode = ref('') // 小程序码(优先展示)
|
||
const qrCodeExpired = ref(false)
|
||
const qrcodeRef = ref() // a-qrcode 组件 ref
|
||
const qrCodeExpireTimer = ref<NodeJS.Timeout | null>(null)
|
||
const currentQrCodeToken = ref('') // 当前二维码的 token
|
||
const qrCodeUsed = ref(false) // 邀请是否已被使用
|
||
const qrCodeUsedBy = ref('') // 使用者名称
|
||
const qrCodePollTimer = ref<NodeJS.Timeout | null>(null) // 轮询定时器
|
||
|
||
// 链接邀请
|
||
const linkLoading = ref(false)
|
||
const inviteLink = ref('')
|
||
const linkExpireTimer = ref<NodeJS.Timeout | null>(null)
|
||
|
||
function clearInviteTimers() {
|
||
if (qrCodeExpireTimer.value) {
|
||
clearTimeout(qrCodeExpireTimer.value)
|
||
qrCodeExpireTimer.value = null
|
||
}
|
||
if (linkExpireTimer.value) {
|
||
clearTimeout(linkExpireTimer.value)
|
||
linkExpireTimer.value = null
|
||
}
|
||
if (qrCodePollTimer.value) {
|
||
clearInterval(qrCodePollTimer.value)
|
||
qrCodePollTimer.value = null
|
||
}
|
||
}
|
||
|
||
// 开始轮询检测邀请状态
|
||
function startQrCodePoll() {
|
||
stopQrCodePoll()
|
||
if (!currentQrCodeToken.value) return
|
||
|
||
// 每 3 秒轮询一次
|
||
qrCodePollTimer.value = setInterval(async () => {
|
||
if (!currentQrCodeToken.value || qrCodeUsed.value || qrCodeExpired.value) {
|
||
stopQrCodePoll()
|
||
return
|
||
}
|
||
|
||
try {
|
||
const res = await getInviteStatus(currentQrCodeToken.value)
|
||
if (res.data?.code === 0 && res.data.data) {
|
||
const status = res.data.data.status
|
||
if (status === 1) {
|
||
// 已被使用
|
||
qrCodeUsed.value = true
|
||
qrCodeUsedBy.value = res.data.data.usedBy || '已加入'
|
||
stopQrCodePoll()
|
||
|
||
// 刷新成员列表
|
||
await loadMembers()
|
||
|
||
// 显示提示
|
||
message.success(`${res.data.data.usedBy || '有人'}已通过邀请加入应用`)
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// 轮询失败不提示,继续轮询
|
||
}
|
||
}, 3000)
|
||
}
|
||
|
||
// 停止轮询
|
||
function stopQrCodePoll() {
|
||
if (qrCodePollTimer.value) {
|
||
clearInterval(qrCodePollTimer.value)
|
||
qrCodePollTimer.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
|
||
// 重置状态
|
||
qrCodeUsed.value = false
|
||
qrCodeUsedBy.value = ''
|
||
stopQrCodePoll()
|
||
|
||
try {
|
||
// 调用后端接口生成邀请 token(包含小程序码)
|
||
const res = await generateInviteQrCode({
|
||
appId: props.app.productId,
|
||
role: inviteForm.role,
|
||
})
|
||
|
||
if (res.data?.code === 0 && res.data.data) {
|
||
// 保存 token 用于轮询
|
||
currentQrCodeToken.value = res.data.data.token || ''
|
||
|
||
// 优先使用后端返回的小程序码
|
||
if (res.data.data.miniprogramQrCode) {
|
||
miniprogramQrCode.value = res.data.data.miniprogramQrCode
|
||
}
|
||
// 使用后端返回的邀请链接(用于生成普通二维码的降级方案)
|
||
inviteUrl.value = res.data.data.inviteUrl || ''
|
||
qrCodeExpired.value = false
|
||
|
||
// 设置24小时过期提示(实际过期由后端控制)
|
||
if (qrCodeExpireTimer.value) clearTimeout(qrCodeExpireTimer.value)
|
||
qrCodeExpireTimer.value = setTimeout(() => {
|
||
qrCodeExpired.value = true
|
||
stopQrCodePoll()
|
||
}, 24 * 60 * 60 * 1000)
|
||
|
||
// 开始轮询检测邀请状态
|
||
if (currentQrCodeToken.value) {
|
||
startQrCodePoll()
|
||
}
|
||
|
||
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()
|
||
currentQrCodeToken.value = token // 保存 token 用于轮询
|
||
miniprogramQrCode.value = '' // 降级方案没有小程序码
|
||
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
|
||
qrCodeUsed.value = false
|
||
qrCodeUsedBy.value = ''
|
||
if (qrCodeExpireTimer.value) clearTimeout(qrCodeExpireTimer.value)
|
||
qrCodeExpireTimer.value = setTimeout(() => {
|
||
qrCodeExpired.value = true
|
||
stopQrCodePoll()
|
||
}, 24 * 60 * 60 * 1000)
|
||
|
||
// 本地模式无法轮询,只能提示用户(但保持逻辑一致性)
|
||
startQrCodePoll()
|
||
|
||
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: String(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: String(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;
|
||
}
|
||
|
||
.miniprogram-qrcode {
|
||
width: 200px;
|
||
height: 200px;
|
||
cursor: pointer;
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.miniprogram-qrcode:hover {
|
||
transform: scale(1.02);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.qrcode-used {
|
||
color: #52c41a;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.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>
|