diff --git a/src/api/entityPickerPresets.ts b/src/api/entityPickerPresets.ts index 020e97e..1bc3a93 100644 --- a/src/api/entityPickerPresets.ts +++ b/src/api/entityPickerPresets.ts @@ -23,6 +23,16 @@ export const entityPickerPresets: Record = { 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: '未分配部门' }, + }, } /** diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 7e6273b..e8a9be5 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -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 => post('/API/Subscription/AppParty/Save', data), + + // ==================== 产品级接入凭据(提供者子服务器鉴权) ==================== + + /** 查看某软件的接入凭据状态(不含密钥) */ + credential: (SubscriptionGroupUUID: string): Promise => + get('/API/Subscription/Credential/Get', { SubscriptionGroupUUID }), + + /** 首次激活:生成 API Key + 密钥(密钥明文仅此一次返回) */ + activateCredential: (SubscriptionGroupUUID: string): Promise => + post('/API/Subscription/Credential/Activate', { SubscriptionGroupUUID }), + + /** 重置密钥(ApiKey 不变,新密钥明文仅此一次返回,旧密钥立即失效) */ + resetCredentialSecret: (SubscriptionGroupUUID: string): Promise => + post('/API/Subscription/Credential/ResetSecret', { SubscriptionGroupUUID }), + + // ==================== 订阅成员(部门-员工键) ==================== + + /** 某软件的订阅成员列表(含该产品的消费者公司清单) */ + members: (SubscriptionGroupUUID: string): Promise => + get('/API/Subscription/Members', { SubscriptionGroupUUID }), + + /** 新增订阅成员(部门-员工键,批量;部门必须归属该软件的消费者公司) */ + saveMembers: ( + SubscriptionGroupUUID: string, + UserRoleDepartmentUUIDs: string[], + ): Promise => + post('/API/Subscription/Member/Save', { + SubscriptionGroupUUID, + UserRoleDepartmentUUIDs, + }), + + /** 删除一条订阅成员 */ + deleteMember: (SubscriptionGroupUUID: string, SubscriptionMemberUUID: string): Promise => + post('/API/Subscription/Member/Delete', { SubscriptionGroupUUID, SubscriptionMemberUUID }), } diff --git a/src/components/MultiEntityPicker.vue b/src/components/MultiEntityPicker.vue index 7f284c6..7ff202f 100644 --- a/src/components/MultiEntityPicker.vue +++ b/src/components/MultiEntityPicker.vue @@ -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 = { 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(props.config.api, params) diff --git a/src/views/subscription/Group.vue b/src/views/subscription/Group.vue index e623305..8f7d37a 100644 --- a/src/views/subscription/Group.vue +++ b/src/views/subscription/Group.vue @@ -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 { } } +// ==================== 产品级 API 与密钥 ==================== +const credVisible = ref(false) +const credLoading = ref(false) +const credSaving = ref(false) +const credGroup = ref(null) +const credState = ref(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 { + try { + await navigator.clipboard.writeText(text) + ElMessage.success('已复制') + } catch { + ElMessage.warning('复制失败,请手动选择文本复制') + } +} + +async function loadCredential(): Promise { + 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 { + 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 { + 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(null) +const memberData = ref(null) +/** 当前选择的消费者公司(部门员工选择器的公司范围) */ +const memberCompanyUUID = ref('') +/** 待添加的「部门员工关联」键 */ +const memberPickKeys = ref([]) + +const memberConsumers = computed(() => memberData.value?.Consumers || []) + +// 切换消费者公司后清空已选(旧公司的部门员工键不再适用) +watch(memberCompanyUUID, () => { + memberPickKeys.value = [] +}) + +async function loadMembers(): Promise { + 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 { + 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): Promise { + 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 ? '启用' : '停用' }}
+ + API 与密钥 + + + 订阅成员 + 编辑 @@ -306,6 +490,129 @@ onMounted(async () => { 保存 + + + +
+ + +
+ +
+ + + +
+ +
+ + + + + + 添加 + +
+
订阅以「部门-员工键」为单位;所选部门必须归属该产品的消费者公司。
+ + + + + + + + + +
+ +
@@ -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; +}