feat(订阅式软件提供): 软件管理页新增 API 与密钥、订阅成员弹窗

Group.vue 每个产品新增「API 与密钥」弹窗(激活/查看 ApiKey/重置密钥,密钥仅一次性展示可复制)与「订阅成员」弹窗(选消费者公司 + 部门员工选择器批量添加、列表移除);subscription.ts 增加凭据与成员接口及类型;entityPickerPresets 增加 DeptEmployee 预设;MultiEntityPicker 增加 company 过滤属性。
This commit is contained in:
TermiNexus committed 2026-10-05 11:29:13 +08:00
1 parent 43998fee1d
commit 75cb2b6b97
4 files changed
+452 -1

No files matched your search

+10
View File
@@ -23,6 +23,16 @@ export const entityPickerPresets: Record<string, EntityPickerConfig> = {
Workgroup: { api: '/API/FormAPI/WorkgroupSearch', labelField: 'WorkgroupName', valueField: 'WorkgroupUUID', placeholder: '搜索工作组' },
// 权限:副文本展示权限码
Permission: { api: '/API/FormAPI/PermissionSearch', labelField: 'PermName', valueField: 'PermUUID', secondaryField: 'PermCode', placeholder: '搜索权限' },
// 部门员工关联(部门 + 用户-角色对):对齐后端 DEPT_EMPLOYEE_PICKER_MULTI 的取值与分组,
// 供「页面直接使用 MultiEntityPicker」的场景(如订阅成员选择);值取 UserRoleDepartmentUUID
DeptEmployee: {
api: '/API/FormAPI/DeptEmployeeSearch',
labelField: 'DeptEmployeeLabel',
secondaryField: 'RoleName',
valueField: 'UserRoleDepartmentUUID',
placeholder: '选择部门员工关联(按部门分组展示)',
treeGroup: { groupField: 'DeptUUID', treeField: 'tree', ungroupedTitle: '未分配部门' },
},
}
/**
+78
View File
@@ -59,6 +59,50 @@ export interface SubscriptionAppPartyPayload {
Consumers: string[]
}
/** 订阅软件接入凭据状态(不含密钥,密钥只存哈希无法还原) */
export interface SubscriptionCredentialState {
/** 0=未激活(首次不给 API Key)/ 1=已激活 */
Activated: number
ApiKey: string
CreateTime: string | null
ResetTime: string | null
}
/** 激活 / 重置后返回(Secret 明文仅此一次) */
export interface SubscriptionCredentialIssued extends SubscriptionCredentialState {
Secret: string
}
/** 订阅成员(某产品下的「部门-员工键」订阅记录) */
export interface SubscriptionMemberItem {
SubscriptionMemberUUID: string
SubscriptionGroupUUID: string
CompanyUUID: string
CompanyName: string
DeptUUID: string
DeptName: string
UserUUID: string
UserName: string
UserRoleDepartmentUUID: string
CreateTime: string | null
}
/** 订阅成员列表响应(含该产品的消费者公司清单,供页面选择) */
export interface SubscriptionMemberList {
GroupUUID: string
GroupName: string
Consumers: SubscriptionCompanyItem[]
list: SubscriptionMemberItem[]
total: number
}
/** 保存订阅成员的返回(逐条结果:成功 Added / 跳过 Skipped) */
export interface SubscriptionMemberSaveResult {
Added: SubscriptionMemberItem[]
Skipped: { Key: string; Reason: string }[]
total: number
}
/** 订阅式软件提供 API 客户端 */
export const subscriptionApi = {
/** 订阅组(产品)列表(含提供者/消费者公司) */
@@ -88,4 +132,38 @@ export const subscriptionApi = {
/** 保存应用级订阅方(整体重写) */
saveAppParties: (data: SubscriptionAppPartyPayload): Promise<SubscriptionAppParties> =>
post<SubscriptionAppParties>('/API/Subscription/AppParty/Save', data),
// ==================== 产品级接入凭据(提供者子服务器鉴权) ====================
/** 查看某软件的接入凭据状态(不含密钥) */
credential: (SubscriptionGroupUUID: string): Promise<SubscriptionCredentialState> =>
get<SubscriptionCredentialState>('/API/Subscription/Credential/Get', { SubscriptionGroupUUID }),
/** 首次激活:生成 API Key + 密钥(密钥明文仅此一次返回) */
activateCredential: (SubscriptionGroupUUID: string): Promise<SubscriptionCredentialIssued> =>
post<SubscriptionCredentialIssued>('/API/Subscription/Credential/Activate', { SubscriptionGroupUUID }),
/** 重置密钥(ApiKey 不变,新密钥明文仅此一次返回,旧密钥立即失效) */
resetCredentialSecret: (SubscriptionGroupUUID: string): Promise<SubscriptionCredentialIssued> =>
post<SubscriptionCredentialIssued>('/API/Subscription/Credential/ResetSecret', { SubscriptionGroupUUID }),
// ==================== 订阅成员(部门-员工键) ====================
/** 某软件的订阅成员列表(含该产品的消费者公司清单) */
members: (SubscriptionGroupUUID: string): Promise<SubscriptionMemberList> =>
get<SubscriptionMemberList>('/API/Subscription/Members', { SubscriptionGroupUUID }),
/** 新增订阅成员(部门-员工键,批量;部门必须归属该软件的消费者公司) */
saveMembers: (
SubscriptionGroupUUID: string,
UserRoleDepartmentUUIDs: string[],
): Promise<SubscriptionMemberSaveResult> =>
post<SubscriptionMemberSaveResult>('/API/Subscription/Member/Save', {
SubscriptionGroupUUID,
UserRoleDepartmentUUIDs,
}),
/** 删除一条订阅成员 */
deleteMember: (SubscriptionGroupUUID: string, SubscriptionMemberUUID: string): Promise<void> =>
post<void>('/API/Subscription/Member/Delete', { SubscriptionGroupUUID, SubscriptionMemberUUID }),
}
+7
View File
@@ -45,6 +45,8 @@ const props = withDefaults(defineProps<{
placeholder?: string
/** 租户过滤:非空时搜索/浏览均按此租户过滤(多选职务按记录所属租户) */
tenant?: string
/** 公司过滤:非空时搜索/浏览均附带 CompanyUUID(部门员工关联等需显式指定公司的场景) */
company?: string
/** 回显标签(逗号/顿号分隔,与 modelValue 对齐) */
labelText?: string
/** 优先展示的分组 key(如部门绑定的通用部门 UUID):该分组置顶展示,其它分组仍可选 */
@@ -54,6 +56,7 @@ const props = withDefaults(defineProps<{
disabled: false,
placeholder: '',
tenant: '',
company: '',
labelText: '',
preferredGroupKey: '',
})
@@ -104,6 +107,8 @@ function rebuildEcho(): void {
watch(() => props.modelValue, rebuildEcho)
watch(() => props.labelText, rebuildEcho)
// 公司过滤变化:重建候选选项(旧公司的候选不再适用)
watch(() => props.company, () => preload())
/** 合并选项(已选回显 + 搜索结果,按 value 去重) */
function mergeOptions(list: PickerOption[]): void {
@@ -116,6 +121,8 @@ function mergeOptions(list: PickerOption[]): void {
async function fetchOptions(keyword: string): Promise<{ list: PickerOption[]; hasMore: boolean; tree: TreeGroupNode[] }> {
const params: Record<string, unknown> = { keyword }
if (props.tenant) params.TenantUUID = props.tenant
// 显式公司过滤(空则不发送,后端回退当前角色公司)
if (props.company) params.CompanyUUID = props.company
// treeGroup 模式:请求附带 tree=1,后端返回通用部门树 + 职务归组
if (props.config.treeGroup) params.tree = '1'
const res = await get<unknown>(props.config.api, params)
+357 -1
View File
@@ -6,15 +6,19 @@
* **一家或多家**公司作为消费者。可选公司范围 = 当前管理员的全部可管理公司
* (跨租户管理员可选任意租户的公司)。
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { setPageTitle } from '@/utils/serverScope'
import MultiEntityPicker from '@/components/MultiEntityPicker.vue'
import { entityPickerPresets } from '@/api/entityPickerPresets'
import {
subscriptionApi,
type SubscriptionCompanyItem,
type SubscriptionCredentialState,
type SubscriptionGroupItem,
type SubscriptionGroupPayload,
type SubscriptionMemberList,
} from '@/api/subscription'
// 同一页面挂两个菜单:软件管理(/subscription/software)与 订阅组管理(/subscription/group)
@@ -159,6 +163,180 @@ async function removeGroup(row: SubscriptionGroupItem): Promise<void> {
}
}
// ==================== 产品级 API 与密钥 ====================
const credVisible = ref(false)
const credLoading = ref(false)
const credSaving = ref(false)
const credGroup = ref<SubscriptionGroupItem | null>(null)
const credState = ref<SubscriptionCredentialState | null>(null)
/** 一次性密钥明文(激活/重置后返回,刷新或关闭后不再展示) */
const credSecret = ref('')
function formatTime(v: string | null): string {
if (!v) return '—'
return v.replace('T', ' ').slice(0, 19)
}
async function copyText(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择文本复制')
}
}
async function loadCredential(): Promise<void> {
if (!credGroup.value) return
credLoading.value = true
try {
credState.value = await subscriptionApi.credential(credGroup.value.SubscriptionGroupUUID)
} catch {
/* 拦截器已提示 */
} finally {
credLoading.value = false
}
}
function openCredential(row: SubscriptionGroupItem): void {
credGroup.value = row
credState.value = null
credSecret.value = ''
credVisible.value = true
void loadCredential()
}
async function activateCredential(): Promise<void> {
if (!credGroup.value) return
credSaving.value = true
try {
const r = await subscriptionApi.activateCredential(credGroup.value.SubscriptionGroupUUID)
credSecret.value = r.Secret
ElMessage.success('已激活,请立即保存密钥(仅展示一次)')
await loadCredential()
} catch {
/* 拦截器已提示 */
} finally {
credSaving.value = false
}
}
async function resetCredentialSecret(): Promise<void> {
if (!credGroup.value) return
try {
await ElMessageBox.confirm(
'重置后旧密钥立即失效,使用旧密钥的子服务器会无法换取访问令牌。确认重置?',
'重置密钥',
{ type: 'warning', confirmButtonText: '重置', cancelButtonText: '取消' },
)
} catch {
return
}
credSaving.value = true
try {
const r = await subscriptionApi.resetCredentialSecret(credGroup.value.SubscriptionGroupUUID)
credSecret.value = r.Secret
ElMessage.success('已重置,请立即保存新密钥(仅展示一次)')
await loadCredential()
} catch {
/* 拦截器已提示 */
} finally {
credSaving.value = false
}
}
// ==================== 订阅成员(部门-员工键) ====================
const memberVisible = ref(false)
const memberLoading = ref(false)
const memberSaving = ref(false)
const memberGroup = ref<SubscriptionGroupItem | null>(null)
const memberData = ref<SubscriptionMemberList | null>(null)
/** 当前选择的消费者公司(部门员工选择器的公司范围) */
const memberCompanyUUID = ref('')
/** 待添加的「部门员工关联」键 */
const memberPickKeys = ref<string[]>([])
const memberConsumers = computed<SubscriptionCompanyItem[]>(() => memberData.value?.Consumers || [])
// 切换消费者公司后清空已选(旧公司的部门员工键不再适用)
watch(memberCompanyUUID, () => {
memberPickKeys.value = []
})
async function loadMembers(): Promise<void> {
if (!memberGroup.value) return
memberLoading.value = true
try {
memberData.value = await subscriptionApi.members(memberGroup.value.SubscriptionGroupUUID)
} catch {
/* 拦截器已提示 */
} finally {
memberLoading.value = false
}
}
function openMembers(row: SubscriptionGroupItem): void {
memberGroup.value = row
memberData.value = null
memberCompanyUUID.value = (row.Consumers || [])[0]?.CompanyUUID || ''
memberPickKeys.value = []
memberVisible.value = true
void loadMembers()
}
async function addMembers(): Promise<void> {
if (!memberGroup.value) return
if (!memberCompanyUUID.value) {
ElMessage.warning('请先选择消费者公司')
return
}
if (!memberPickKeys.value.length) {
ElMessage.warning('请先选择「部门员工关联」')
return
}
memberSaving.value = true
try {
const r = await subscriptionApi.saveMembers(
memberGroup.value.SubscriptionGroupUUID,
memberPickKeys.value,
)
const added = r.Added?.length || 0
const skipped = r.Skipped || []
if (added) ElMessage.success(`已新增 ${added} 位订阅成员`)
if (skipped.length) ElMessage.warning(skipped.map((s) => s.Reason).join(';'))
if (!added && !skipped.length) ElMessage.info('没有可新增的订阅成员')
memberPickKeys.value = []
await loadMembers()
} catch {
/* 拦截器已提示 */
} finally {
memberSaving.value = false
}
}
async function removeMember(row: Record<string, unknown>): Promise<void> {
if (!memberGroup.value) return
const memberUUID = String(row.SubscriptionMemberUUID || '')
const label = `${row.DeptName || ''} · ${row.UserName || ''}`
if (!memberUUID) return
try {
await ElMessageBox.confirm(
`确认移除「${label}」的订阅?`,
'移除确认',
{ type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' },
)
} catch {
return
}
try {
await subscriptionApi.deleteMember(memberGroup.value.SubscriptionGroupUUID, memberUUID)
ElMessage.success('已移除')
await loadMembers()
} catch {
/* 拦截器已提示 */
}
}
onMounted(async () => {
await Promise.all([load(), loadCompanies()])
})
@@ -192,6 +370,12 @@ onMounted(async () => {
{{ g.GroupStatus ? '启用' : '停用' }}
</el-tag>
<div class="spacer" />
<el-button link type="primary" @click="openCredential(g)">
<el-icon><Key /></el-icon>API 与密钥
</el-button>
<el-button link type="primary" @click="openMembers(g)">
<el-icon><UserFilled /></el-icon>订阅成员
</el-button>
<el-button link type="primary" @click="openEdit(g)">
<el-icon><Edit /></el-icon>编辑
</el-button>
@@ -306,6 +490,129 @@ onMounted(async () => {
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
</template>
</el-dialog>
<!-- 产品级 API 与密钥:提供者的子服务器用这对凭据换取访问令牌 -->
<el-dialog
v-model="credVisible"
:title="`API 与密钥 — ${credGroup?.GroupName || ''}`"
width="640px"
:close-on-click-modal="false"
>
<div v-loading="credLoading" class="cred-body">
<el-alert
type="info"
:closable="false"
show-icon
title="提供者的子服务器用这对凭据调用主服务器换取访问令牌,从而启用本软件(产品)的功能。"
/>
<template v-if="credState">
<!-- 未激活 -->
<div v-if="!credState.Activated" class="cred-empty">
<el-empty description="尚未激活 API 与密钥(首次不下发,需手动激活)" :image-size="80" />
<el-button type="primary" :loading="credSaving" @click="activateCredential">
<el-icon><Key /></el-icon>激活 API 与密钥
</el-button>
</div>
<!-- 已激活 -->
<template v-else>
<el-form label-width="96px" class="cred-form">
<el-form-item label="API Key">
<el-input :model-value="credState.ApiKey" readonly>
<template #append>
<el-button @click="copyText(credState.ApiKey)">复制</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="激活时间">{{ formatTime(credState.CreateTime) }}</el-form-item>
<el-form-item label="最近重置">{{ formatTime(credState.ResetTime) }}</el-form-item>
</el-form>
<el-alert v-if="credSecret" type="warning" :closable="false" show-icon class="cred-secret">
<template #title>密钥(仅展示一次,请立即保存)</template>
<div class="secret-row">
<el-input :model-value="credSecret" readonly />
<el-button type="primary" @click="copyText(credSecret)">复制</el-button>
</div>
</el-alert>
<div class="cred-foot">
<el-button type="danger" plain :loading="credSaving" @click="resetCredentialSecret">
重置密钥
</el-button>
<span class="field-tip">重置后旧密钥立即失效,ApiKey 不变</span>
</div>
</template>
</template>
</div>
<template #footer>
<el-button @click="credVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 订阅成员:以「部门-员工键」为单位,部门必须归属该产品的消费者公司 -->
<el-dialog
v-model="memberVisible"
:title="`订阅成员 — ${memberGroup?.GroupName || ''}`"
width="840px"
:close-on-click-modal="false"
destroy-on-close
>
<div v-loading="memberLoading" class="member-body">
<el-alert
v-if="!memberConsumers.length"
type="warning"
:closable="false"
show-icon
title="该产品还没有消费者公司,请先在「编辑」里指定消费者公司后再添加订阅成员。"
/>
<div class="member-add">
<el-select
v-model="memberCompanyUUID"
placeholder="选择消费者公司"
class="member-company"
:disabled="!memberConsumers.length"
>
<el-option
v-for="c in memberConsumers"
:key="c.CompanyUUID"
:label="c.CompanyName"
:value="c.CompanyUUID"
/>
</el-select>
<MultiEntityPicker
v-model="memberPickKeys"
:config="entityPickerPresets.DeptEmployee"
:company="memberCompanyUUID"
:disabled="!memberCompanyUUID"
placeholder="选择部门员工关联(按部门分组展示)"
class="member-picker"
/>
<el-button
type="primary"
:loading="memberSaving"
:disabled="!memberConsumers.length"
@click="addMembers"
>
<el-icon><Plus /></el-icon>添加
</el-button>
</div>
<div class="field-tip">订阅以「部门-员工键」为单位;所选部门必须归属该产品的消费者公司。</div>
<el-table :data="memberData?.list || []" size="small" class="member-table">
<el-table-column prop="CompanyName" label="消费者公司" min-width="140" show-overflow-tooltip />
<el-table-column prop="DeptName" label="部门" min-width="140" show-overflow-tooltip />
<el-table-column prop="UserName" label="员工" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="90" align="right">
<template #default="{ row }">
<el-button link type="danger" @click="removeMember(row)">移除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无订阅成员" :image-size="60" />
</template>
</el-table>
</div>
<template #footer>
<el-button @click="memberVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -387,4 +694,53 @@ onMounted(async () => {
font-size: 12px;
color: var(--el-text-color-secondary);
}
/* API 与密钥弹窗 */
.cred-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.cred-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 8px 0 4px;
}
.cred-form {
margin-top: 4px;
}
.cred-secret .secret-row {
display: flex;
gap: 8px;
margin-top: 6px;
}
.cred-foot {
display: flex;
align-items: center;
gap: 10px;
}
/* 订阅成员弹窗 */
.member-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.member-add {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.member-company {
width: 200px;
flex-shrink: 0;
}
.member-picker {
flex: 1;
min-width: 260px;
}
.member-table {
margin-top: 2px;
}
</style>