fix: synchronize organization state after writes
This commit is contained in:
@@ -122,7 +122,7 @@ import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { shareAgent, listAgentShares, removeAgentShare } from '@/api/organization'
|
||||
import { listAgentShares } from '@/api/organization'
|
||||
import type { AgentShareResponse } from '@/api/organization'
|
||||
import type { CustomAgent } from '@/api/agent'
|
||||
import SpaceAvatar from '@/components/SpaceAvatar.vue'
|
||||
@@ -224,7 +224,7 @@ async function handleShare() {
|
||||
if (!selectedOrgId.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await shareAgent(props.agentId, {
|
||||
const result = await orgStore.shareAgent(props.agentId, {
|
||||
organization_id: selectedOrgId.value,
|
||||
permission: 'viewer'
|
||||
})
|
||||
@@ -246,7 +246,11 @@ async function handleShare() {
|
||||
|
||||
async function handleUnshare(share: AgentShareResponse) {
|
||||
try {
|
||||
const result = await removeAgentShare(props.agentId, share.id)
|
||||
const result = await orgStore.unshareAgent(
|
||||
props.agentId,
|
||||
share.id,
|
||||
share.organization_id
|
||||
)
|
||||
if (result.success) {
|
||||
MessagePlugin.success(t('organization.share.unshareSuccess'))
|
||||
await loadShares()
|
||||
|
||||
@@ -6,14 +6,25 @@ import type {
|
||||
SharedKnowledgeBase,
|
||||
SharedAgentInfo,
|
||||
OrganizationPreview,
|
||||
ResourceCountsByOrg
|
||||
ResourceCountsByOrg,
|
||||
SearchableOrganizationItem,
|
||||
UpdateOrganizationRequest,
|
||||
ShareKnowledgeBaseRequest,
|
||||
UpdateSharePermissionRequest,
|
||||
InviteMemberRequest,
|
||||
ReviewJoinRequestRequest,
|
||||
RequestRoleUpgradeRequest,
|
||||
ApiResponse,
|
||||
KnowledgeBaseShare,
|
||||
AgentShareResponse
|
||||
} from '@/api/organization'
|
||||
import {
|
||||
listMyOrganizations,
|
||||
createOrganization,
|
||||
updateOrganization,
|
||||
updateOrganization as updateOrganizationApi,
|
||||
deleteOrganization,
|
||||
joinOrganization,
|
||||
joinOrganization as joinOrganizationApi,
|
||||
joinOrganizationById as joinOrganizationByIdApi,
|
||||
previewOrganization,
|
||||
leaveOrganization,
|
||||
generateInviteCode,
|
||||
@@ -21,8 +32,19 @@ import {
|
||||
updateMemberRole,
|
||||
removeMember,
|
||||
listSharedKnowledgeBases,
|
||||
listSharedAgents
|
||||
listSharedAgents,
|
||||
searchSearchableOrganizations,
|
||||
shareKnowledgeBase as shareKnowledgeBaseApi,
|
||||
removeShare as removeShareApi,
|
||||
updateSharePermission as updateSharePermissionApi,
|
||||
shareAgent as shareAgentApi,
|
||||
removeAgentShare as removeAgentShareApi,
|
||||
inviteMember as inviteMemberApi,
|
||||
reviewJoinRequest as reviewJoinRequestApi,
|
||||
requestRoleUpgrade as requestRoleUpgradeApi
|
||||
} from '@/api/organization'
|
||||
import { createVersionedRequestCoordinator } from './versionedRequest'
|
||||
import { applyOrganizationResourceDelta, upsertById } from './organizationState'
|
||||
|
||||
export const useOrganizationStore = defineStore('organization', () => {
|
||||
// State
|
||||
@@ -31,20 +53,24 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
const currentMembers = ref<OrganizationMember[]>([])
|
||||
const sharedKnowledgeBases = ref<SharedKnowledgeBase[]>([])
|
||||
const sharedAgents = ref<SharedAgentInfo[]>([])
|
||||
const searchableOrganizations = ref<SearchableOrganizationItem[]>([])
|
||||
const previewData = ref<OrganizationPreview | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
/** 各空间内知识库/智能体数量(由 GET /organizations 的 resource_counts 填充,供列表侧栏使用) */
|
||||
const resourceCounts = ref<ResourceCountsByOrg | null>(null)
|
||||
/** 用于去重:同一时刻只允许一次 GET /organizations 请求 */
|
||||
let fetchOrganizationsPromise: Promise<void> | null = null
|
||||
let organizationsLoadedAt = 0
|
||||
/** 共享资源缓存 TTL,与 chatResources 对齐 */
|
||||
const SHARED_RESOURCE_TTL_MS = 60_000
|
||||
const SEARCHABLE_ORGANIZATION_TTL_MS = 5 * 60_000
|
||||
let sharedKbLoadedAt = 0
|
||||
let sharedAgentsLoadedAt = 0
|
||||
let fetchSharedKbPromise: Promise<SharedKnowledgeBase[]> | null = null
|
||||
let fetchSharedAgentsPromise: Promise<SharedAgentInfo[]> | null = null
|
||||
let searchableOrganizationsQuery = ''
|
||||
const searchableOrganizationCache = new Map<
|
||||
string,
|
||||
{ data: SearchableOrganizationItem[]; loadedAt: number }
|
||||
>()
|
||||
|
||||
// Computed
|
||||
const myOrganizations = computed(() => organizations.value)
|
||||
@@ -68,6 +94,28 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
* Fetch all organizations the user belongs to.
|
||||
* 去重 + 短期缓存,列表页与侧栏等多处共用。
|
||||
*/
|
||||
const organizationsRequest = createVersionedRequestCoordinator(
|
||||
async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return await listMyOrganizations()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
},
|
||||
(response) => {
|
||||
if (response.success && response.data) {
|
||||
organizations.value = response.data.organizations
|
||||
resourceCounts.value = response.data.resource_counts ?? null
|
||||
organizationsLoadedAt = Date.now()
|
||||
} else {
|
||||
resourceCounts.value = null
|
||||
error.value = response.message || 'Failed to fetch organizations'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function fetchOrganizations(options?: { force?: boolean }) {
|
||||
const force = options?.force ?? false
|
||||
if (
|
||||
@@ -77,29 +125,49 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (fetchOrganizationsPromise) return fetchOrganizationsPromise
|
||||
loading.value = true
|
||||
error.value = null
|
||||
fetchOrganizationsPromise = (async () => {
|
||||
try {
|
||||
const response = await listMyOrganizations()
|
||||
if (response.success && response.data) {
|
||||
organizations.value = response.data.organizations
|
||||
resourceCounts.value = response.data.resource_counts ?? null
|
||||
organizationsLoadedAt = Date.now()
|
||||
} else {
|
||||
resourceCounts.value = null
|
||||
error.value = response.message || 'Failed to fetch organizations'
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to fetch organizations'
|
||||
resourceCounts.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
fetchOrganizationsPromise = null
|
||||
}
|
||||
})()
|
||||
return fetchOrganizationsPromise
|
||||
return organizationsRequest.fetch(force)
|
||||
}
|
||||
|
||||
function patchOrganization(id: string, patch: Partial<Organization>) {
|
||||
organizationsRequest.invalidate()
|
||||
organizationsLoadedAt = 0
|
||||
organizations.value = organizations.value.map(org =>
|
||||
org.id === id ? { ...org, ...patch } : org
|
||||
)
|
||||
if (currentOrganization.value?.id === id) {
|
||||
currentOrganization.value = { ...currentOrganization.value, ...patch }
|
||||
}
|
||||
}
|
||||
|
||||
function upsertOrganization(organization: Organization) {
|
||||
organizationsRequest.invalidate()
|
||||
organizationsLoadedAt = 0
|
||||
organizations.value = upsertById(organizations.value, organization)
|
||||
if (currentOrganization.value?.id === organization.id) {
|
||||
currentOrganization.value = organization
|
||||
}
|
||||
}
|
||||
|
||||
function adjustOrganizationResourceCount(
|
||||
organizationId: string,
|
||||
resource: 'knowledge_bases' | 'agents',
|
||||
delta: number
|
||||
) {
|
||||
organizationsRequest.invalidate()
|
||||
organizationsLoadedAt = 0
|
||||
const result = applyOrganizationResourceDelta(
|
||||
organizations.value,
|
||||
resourceCounts.value,
|
||||
organizationId,
|
||||
resource,
|
||||
delta
|
||||
)
|
||||
organizations.value = result.organizations
|
||||
resourceCounts.value = result.resourceCounts
|
||||
if (currentOrganization.value?.id === organizationId) {
|
||||
currentOrganization.value =
|
||||
result.organizations.find(org => org.id === organizationId) ?? currentOrganization.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,10 +179,11 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
try {
|
||||
const response = await createOrganization({ name, description })
|
||||
if (response.success && response.data) {
|
||||
organizations.value.unshift(response.data)
|
||||
upsertOrganization(response.data)
|
||||
// 创建成功后重置缓存时间戳,确保后续 fetchOrganizations() 不会被 TTL 缓存跳过,
|
||||
// 从而刷新 resource_counts 等创建接口不返回的聚合字段。
|
||||
organizationsLoadedAt = 0
|
||||
void fetchOrganizations({ force: true })
|
||||
return response.data
|
||||
} else {
|
||||
error.value = response.message || 'Failed to create organization'
|
||||
@@ -131,19 +200,14 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
/**
|
||||
* Update an organization
|
||||
*/
|
||||
async function update(id: string, name?: string, description?: string) {
|
||||
async function updateOrganization(id: string, updates: UpdateOrganizationRequest) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await updateOrganization(id, { name, description })
|
||||
const response = await updateOrganizationApi(id, updates)
|
||||
if (response.success && response.data) {
|
||||
const index = organizations.value.findIndex(o => o.id === id)
|
||||
if (index !== -1) {
|
||||
organizations.value[index] = response.data
|
||||
}
|
||||
if (currentOrganization.value?.id === id) {
|
||||
currentOrganization.value = response.data
|
||||
}
|
||||
upsertOrganization(response.data)
|
||||
void fetchOrganizations({ force: true })
|
||||
return response.data
|
||||
} else {
|
||||
error.value = response.message || 'Failed to update organization'
|
||||
@@ -157,6 +221,10 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function update(id: string, name?: string, description?: string) {
|
||||
return updateOrganization(id, { name, description })
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an organization
|
||||
*/
|
||||
@@ -166,6 +234,8 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
try {
|
||||
const response = await deleteOrganization(id)
|
||||
if (response.success) {
|
||||
organizationsRequest.invalidate()
|
||||
organizationsLoadedAt = 0
|
||||
organizations.value = organizations.value.filter(o => o.id !== id)
|
||||
if (currentOrganization.value?.id === id) {
|
||||
currentOrganization.value = null
|
||||
@@ -214,13 +284,11 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await joinOrganization({ invite_code: inviteCode })
|
||||
const response = await joinOrganizationApi({ invite_code: inviteCode })
|
||||
if (response.success && response.data) {
|
||||
// Check if already in list
|
||||
const exists = organizations.value.some(o => o.id === response.data!.id)
|
||||
if (!exists) {
|
||||
organizations.value.unshift(response.data)
|
||||
}
|
||||
upsertOrganization(response.data)
|
||||
invalidateSearchableOrganizations(response.data.id)
|
||||
void fetchOrganizations({ force: true })
|
||||
return response.data
|
||||
} else {
|
||||
error.value = response.message || 'Failed to join organization'
|
||||
@@ -243,6 +311,8 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
try {
|
||||
const response = await leaveOrganization(id)
|
||||
if (response.success) {
|
||||
organizationsRequest.invalidate()
|
||||
organizationsLoadedAt = 0
|
||||
organizations.value = organizations.value.filter(o => o.id !== id)
|
||||
if (currentOrganization.value?.id === id) {
|
||||
currentOrganization.value = null
|
||||
@@ -269,13 +339,8 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
try {
|
||||
const response = await generateInviteCode(id)
|
||||
if (response.success && response.data) {
|
||||
const org = organizations.value.find(o => o.id === id)
|
||||
if (org) {
|
||||
org.invite_code = response.data.invite_code
|
||||
}
|
||||
if (currentOrganization.value?.id === id) {
|
||||
currentOrganization.value.invite_code = response.data.invite_code
|
||||
}
|
||||
patchOrganization(id, { invite_code: response.data.invite_code })
|
||||
void fetchOrganizations({ force: true })
|
||||
return response.data.invite_code
|
||||
} else {
|
||||
error.value = response.message || 'Failed to generate invite code'
|
||||
@@ -348,6 +413,11 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
const response = await removeMember(orgId, tenantId)
|
||||
if (response.success) {
|
||||
currentMembers.value = currentMembers.value.filter(m => m.tenant_id !== tenantId)
|
||||
const organization = organizations.value.find(org => org.id === orgId)
|
||||
patchOrganization(orgId, {
|
||||
member_count: Math.max(0, (organization?.member_count ?? 0) - 1)
|
||||
})
|
||||
void fetchOrganizations({ force: true })
|
||||
return true
|
||||
} else {
|
||||
error.value = response.message || 'Failed to remove member'
|
||||
@@ -365,6 +435,26 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
* Fetch shared knowledge bases.
|
||||
* 去重 + 短期缓存,避免对话页等多处并发重复请求。
|
||||
*/
|
||||
const sharedKnowledgeBasesRequest = createVersionedRequestCoordinator(
|
||||
async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return await listSharedKnowledgeBases()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
},
|
||||
(response) => {
|
||||
if (response.success && response.data) {
|
||||
sharedKnowledgeBases.value = response.data.filter(s => s.knowledge_base != null)
|
||||
sharedKbLoadedAt = Date.now()
|
||||
} else {
|
||||
error.value = response.message || 'Failed to fetch shared knowledge bases'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function fetchSharedKnowledgeBases(options?: { force?: boolean }) {
|
||||
const force = options?.force ?? false
|
||||
if (
|
||||
@@ -374,35 +464,24 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
) {
|
||||
return sharedKnowledgeBases.value
|
||||
}
|
||||
if (fetchSharedKbPromise) return fetchSharedKbPromise
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
fetchSharedKbPromise = (async () => {
|
||||
try {
|
||||
const response = await listSharedKnowledgeBases()
|
||||
if (response.success && response.data) {
|
||||
sharedKnowledgeBases.value = response.data.filter(s => s.knowledge_base != null)
|
||||
sharedKbLoadedAt = Date.now()
|
||||
return sharedKnowledgeBases.value
|
||||
}
|
||||
error.value = response.message || 'Failed to fetch shared knowledge bases'
|
||||
return []
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to fetch shared knowledge bases'
|
||||
return []
|
||||
} finally {
|
||||
loading.value = false
|
||||
fetchSharedKbPromise = null
|
||||
}
|
||||
})()
|
||||
return fetchSharedKbPromise
|
||||
await sharedKnowledgeBasesRequest.fetch(force)
|
||||
return sharedKnowledgeBases.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch shared agents (shared to me through organizations).
|
||||
* 去重 + 短期缓存。
|
||||
*/
|
||||
const sharedAgentsRequest = createVersionedRequestCoordinator(
|
||||
listSharedAgents,
|
||||
(response) => {
|
||||
if (response.success && response.data) {
|
||||
sharedAgents.value = response.data.filter(s => s.agent != null)
|
||||
sharedAgentsLoadedAt = Date.now()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function fetchSharedAgents(options?: { force?: boolean }) {
|
||||
const force = options?.force ?? false
|
||||
if (
|
||||
@@ -412,31 +491,244 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
) {
|
||||
return sharedAgents.value
|
||||
}
|
||||
if (fetchSharedAgentsPromise) return fetchSharedAgentsPromise
|
||||
await sharedAgentsRequest.fetch(force)
|
||||
return sharedAgents.value
|
||||
}
|
||||
|
||||
fetchSharedAgentsPromise = (async () => {
|
||||
try {
|
||||
const response = await listSharedAgents()
|
||||
if (response.success && response.data) {
|
||||
sharedAgents.value = response.data.filter(s => s.agent != null)
|
||||
sharedAgentsLoadedAt = Date.now()
|
||||
return sharedAgents.value
|
||||
}
|
||||
return []
|
||||
} catch {
|
||||
return []
|
||||
} finally {
|
||||
fetchSharedAgentsPromise = null
|
||||
interface InvalidateOrganizationDataOptions {
|
||||
organizations?: boolean
|
||||
sharedKnowledgeBases?: boolean
|
||||
sharedAgents?: boolean
|
||||
searchableOrganizations?: boolean
|
||||
excludeSearchableOrganizationId?: string
|
||||
}
|
||||
|
||||
function invalidateSearchableOrganizations(organizationId?: string) {
|
||||
searchableOrganizationCache.clear()
|
||||
if (organizationId) {
|
||||
searchableOrganizations.value = searchableOrganizations.value.filter(
|
||||
organization => organization.id !== organizationId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateOrganizationData(options: InvalidateOrganizationDataOptions) {
|
||||
if (options.organizations) {
|
||||
organizationsRequest.invalidate()
|
||||
organizationsLoadedAt = 0
|
||||
}
|
||||
if (options.sharedKnowledgeBases) {
|
||||
sharedKnowledgeBasesRequest.invalidate()
|
||||
sharedKbLoadedAt = 0
|
||||
}
|
||||
if (options.sharedAgents) {
|
||||
sharedAgentsRequest.invalidate()
|
||||
sharedAgentsLoadedAt = 0
|
||||
}
|
||||
if (options.searchableOrganizations) {
|
||||
invalidateSearchableOrganizations(options.excludeSearchableOrganizationId)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSearchableOrganizations(
|
||||
query: string,
|
||||
options?: { force?: boolean; limit?: number }
|
||||
) {
|
||||
const normalizedQuery = query.trim()
|
||||
searchableOrganizationsQuery = normalizedQuery
|
||||
const cached = searchableOrganizationCache.get(normalizedQuery)
|
||||
if (
|
||||
!options?.force &&
|
||||
cached &&
|
||||
Date.now() - cached.loadedAt < SEARCHABLE_ORGANIZATION_TTL_MS
|
||||
) {
|
||||
searchableOrganizations.value = cached.data
|
||||
return cached.data
|
||||
}
|
||||
|
||||
const response = await searchSearchableOrganizations(normalizedQuery, options?.limit ?? 20)
|
||||
if (response.success && response.data) {
|
||||
const data = response.data.data.filter(organization => !organization.is_already_member)
|
||||
searchableOrganizationCache.set(normalizedQuery, { data, loadedAt: Date.now() })
|
||||
if (searchableOrganizationsQuery === normalizedQuery) {
|
||||
searchableOrganizations.value = data
|
||||
}
|
||||
})()
|
||||
return fetchSharedAgentsPromise
|
||||
return data
|
||||
}
|
||||
if (searchableOrganizationsQuery === normalizedQuery) {
|
||||
searchableOrganizations.value = []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function clearSearchableOrganizations() {
|
||||
searchableOrganizationsQuery = ''
|
||||
searchableOrganizations.value = []
|
||||
}
|
||||
|
||||
async function joinById(
|
||||
organizationId: string,
|
||||
message?: string,
|
||||
role?: 'admin' | 'editor' | 'viewer',
|
||||
options?: { requiresApproval?: boolean }
|
||||
) {
|
||||
const response = await joinOrganizationByIdApi(organizationId, message, role)
|
||||
if (response.success) {
|
||||
invalidateOrganizationData({
|
||||
searchableOrganizations: true,
|
||||
excludeSearchableOrganizationId: organizationId
|
||||
})
|
||||
if (!options?.requiresApproval && response.data) {
|
||||
upsertOrganization(response.data)
|
||||
void fetchOrganizations({ force: true })
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function shareKnowledgeBase(
|
||||
knowledgeBaseId: string,
|
||||
request: ShareKnowledgeBaseRequest
|
||||
): Promise<ApiResponse<KnowledgeBaseShare>> {
|
||||
const response = await shareKnowledgeBaseApi(knowledgeBaseId, request)
|
||||
if (response.success) {
|
||||
adjustOrganizationResourceCount(request.organization_id, 'knowledge_bases', 1)
|
||||
invalidateOrganizationData({ sharedKnowledgeBases: true })
|
||||
void Promise.all([
|
||||
fetchOrganizations({ force: true }),
|
||||
fetchSharedKnowledgeBases({ force: true })
|
||||
])
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function unshareKnowledgeBase(
|
||||
knowledgeBaseId: string,
|
||||
shareId: string,
|
||||
organizationId: string
|
||||
): Promise<ApiResponse<void>> {
|
||||
const response = await removeShareApi(knowledgeBaseId, shareId)
|
||||
if (response.success) {
|
||||
adjustOrganizationResourceCount(organizationId, 'knowledge_bases', -1)
|
||||
invalidateOrganizationData({ sharedKnowledgeBases: true })
|
||||
void Promise.all([
|
||||
fetchOrganizations({ force: true }),
|
||||
fetchSharedKnowledgeBases({ force: true })
|
||||
])
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function changeKnowledgeBaseSharePermission(
|
||||
knowledgeBaseId: string,
|
||||
shareId: string,
|
||||
request: UpdateSharePermissionRequest
|
||||
): Promise<ApiResponse<void>> {
|
||||
const response = await updateSharePermissionApi(knowledgeBaseId, shareId, request)
|
||||
if (response.success) {
|
||||
invalidateOrganizationData({ sharedKnowledgeBases: true })
|
||||
void fetchSharedKnowledgeBases({ force: true })
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function shareAgent(
|
||||
agentId: string,
|
||||
request: ShareKnowledgeBaseRequest
|
||||
): Promise<ApiResponse<AgentShareResponse>> {
|
||||
const response = await shareAgentApi(agentId, request)
|
||||
if (response.success) {
|
||||
adjustOrganizationResourceCount(request.organization_id, 'agents', 1)
|
||||
invalidateOrganizationData({ sharedAgents: true, sharedKnowledgeBases: true })
|
||||
void Promise.all([
|
||||
fetchOrganizations({ force: true }),
|
||||
fetchSharedAgents({ force: true }),
|
||||
fetchSharedKnowledgeBases({ force: true })
|
||||
])
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function unshareAgent(
|
||||
agentId: string,
|
||||
shareId: string,
|
||||
organizationId: string
|
||||
): Promise<ApiResponse<void>> {
|
||||
const response = await removeAgentShareApi(agentId, shareId)
|
||||
if (response.success) {
|
||||
adjustOrganizationResourceCount(organizationId, 'agents', -1)
|
||||
invalidateOrganizationData({ sharedAgents: true, sharedKnowledgeBases: true })
|
||||
void Promise.all([
|
||||
fetchOrganizations({ force: true }),
|
||||
fetchSharedAgents({ force: true }),
|
||||
fetchSharedKnowledgeBases({ force: true })
|
||||
])
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function inviteOrganizationMember(
|
||||
organizationId: string,
|
||||
request: InviteMemberRequest
|
||||
): Promise<ApiResponse<void>> {
|
||||
const response = await inviteMemberApi(organizationId, request)
|
||||
if (response.success) {
|
||||
const organization = organizations.value.find(org => org.id === organizationId)
|
||||
patchOrganization(organizationId, {
|
||||
member_count: (organization?.member_count ?? 0) + 1
|
||||
})
|
||||
void fetchOrganizations({ force: true })
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function reviewOrganizationJoinRequest(
|
||||
organizationId: string,
|
||||
requestId: string,
|
||||
request: ReviewJoinRequestRequest
|
||||
): Promise<ApiResponse<void>> {
|
||||
const response = await reviewJoinRequestApi(organizationId, requestId, request)
|
||||
if (response.success) {
|
||||
const organization = organizations.value.find(org => org.id === organizationId)
|
||||
patchOrganization(organizationId, {
|
||||
member_count: (organization?.member_count ?? 0) + (request.approved ? 1 : 0),
|
||||
pending_join_request_count: Math.max(
|
||||
0,
|
||||
(organization?.pending_join_request_count ?? 0) - 1
|
||||
)
|
||||
})
|
||||
void fetchOrganizations({ force: true })
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function requestOrganizationRoleUpgrade(
|
||||
organizationId: string,
|
||||
request: RequestRoleUpgradeRequest
|
||||
) {
|
||||
const response = await requestRoleUpgradeApi(organizationId, request)
|
||||
if (response.success) {
|
||||
patchOrganization(organizationId, { has_pending_upgrade: true })
|
||||
void fetchOrganizations({ force: true })
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current organization for detail view
|
||||
*/
|
||||
function setCurrentOrganization(org: Organization | null) {
|
||||
currentOrganization.value = org
|
||||
if (org) {
|
||||
upsertOrganization(org)
|
||||
void fetchOrganizations({ force: true })
|
||||
} else {
|
||||
currentOrganization.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearCurrentOrganizationContext() {
|
||||
currentOrganization.value = null
|
||||
currentMembers.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -477,15 +769,18 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
currentMembers.value = []
|
||||
sharedKnowledgeBases.value = []
|
||||
sharedAgents.value = []
|
||||
searchableOrganizations.value = []
|
||||
resourceCounts.value = null
|
||||
previewData.value = null
|
||||
error.value = null
|
||||
sharedKbLoadedAt = 0
|
||||
sharedAgentsLoadedAt = 0
|
||||
fetchSharedKbPromise = null
|
||||
fetchSharedAgentsPromise = null
|
||||
organizationsLoadedAt = 0
|
||||
fetchOrganizationsPromise = null
|
||||
searchableOrganizationsQuery = ''
|
||||
searchableOrganizationCache.clear()
|
||||
organizationsRequest.invalidate()
|
||||
sharedKnowledgeBasesRequest.invalidate()
|
||||
sharedAgentsRequest.invalidate()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -495,6 +790,7 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
currentMembers,
|
||||
sharedKnowledgeBases,
|
||||
sharedAgents,
|
||||
searchableOrganizations,
|
||||
resourceCounts,
|
||||
previewData,
|
||||
loading,
|
||||
@@ -509,6 +805,7 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
// Actions
|
||||
fetchOrganizations,
|
||||
create,
|
||||
updateOrganization,
|
||||
update,
|
||||
remove,
|
||||
preview,
|
||||
@@ -520,7 +817,20 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
kickMember,
|
||||
fetchSharedKnowledgeBases,
|
||||
fetchSharedAgents,
|
||||
fetchSearchableOrganizations,
|
||||
clearSearchableOrganizations,
|
||||
invalidateOrganizationData,
|
||||
joinById,
|
||||
shareKnowledgeBase,
|
||||
unshareKnowledgeBase,
|
||||
changeKnowledgeBaseSharePermission,
|
||||
shareAgent,
|
||||
unshareAgent,
|
||||
inviteOrganizationMember,
|
||||
reviewOrganizationJoinRequest,
|
||||
requestOrganizationRoleUpgrade,
|
||||
setCurrentOrganization,
|
||||
clearCurrentOrganizationContext,
|
||||
getKBPermission,
|
||||
canEditKB,
|
||||
canManageKB,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
applyOrganizationResourceDelta,
|
||||
upsertById
|
||||
} from './organizationState.ts'
|
||||
|
||||
test('upsert adds a newly joined organization to the front', () => {
|
||||
const result = upsertById(
|
||||
[{ id: 'existing', name: 'Existing' }],
|
||||
{ id: 'joined', name: 'Joined' }
|
||||
)
|
||||
|
||||
assert.deepEqual(result.map(organization => organization.id), ['joined', 'existing'])
|
||||
})
|
||||
|
||||
test('upsert replaces edited organization data without duplicating the card', () => {
|
||||
const result = upsertById(
|
||||
[{ id: 'space-1', name: 'Old name' }],
|
||||
{ id: 'space-1', name: 'New name' }
|
||||
)
|
||||
|
||||
assert.deepEqual(result, [{ id: 'space-1', name: 'New name' }])
|
||||
})
|
||||
|
||||
test('knowledge base sharing updates card and resource counts immediately', () => {
|
||||
const result = applyOrganizationResourceDelta(
|
||||
[{ id: 'space-1', share_count: 2 }],
|
||||
{
|
||||
knowledge_bases: { by_organization: { 'space-1': 2 } },
|
||||
agents: { by_organization: { 'space-1': 1 } }
|
||||
},
|
||||
'space-1',
|
||||
'knowledge_bases',
|
||||
1
|
||||
)
|
||||
|
||||
assert.equal(result.organizations[0].share_count, 3)
|
||||
assert.equal(result.resourceCounts?.knowledge_bases.by_organization['space-1'], 3)
|
||||
})
|
||||
|
||||
test('resource counts never become negative when a share is removed', () => {
|
||||
const result = applyOrganizationResourceDelta(
|
||||
[{ id: 'space-1', agent_share_count: 0 }],
|
||||
{
|
||||
knowledge_bases: { by_organization: {} },
|
||||
agents: { by_organization: { 'space-1': 0 } }
|
||||
},
|
||||
'space-1',
|
||||
'agents',
|
||||
-1
|
||||
)
|
||||
|
||||
assert.equal(result.organizations[0].agent_share_count, 0)
|
||||
assert.equal(result.resourceCounts?.agents.by_organization['space-1'], 0)
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
export function upsertById<T extends { id: string }>(items: T[], item: T): T[] {
|
||||
const exists = items.some(existing => existing.id === item.id)
|
||||
return exists
|
||||
? items.map(existing => existing.id === item.id ? item : existing)
|
||||
: [item, ...items]
|
||||
}
|
||||
|
||||
interface OrganizationResourceCounts {
|
||||
knowledge_bases: { by_organization: Record<string, number> }
|
||||
agents: { by_organization: Record<string, number> }
|
||||
}
|
||||
|
||||
interface CountedOrganization {
|
||||
id: string
|
||||
share_count?: number
|
||||
agent_share_count?: number
|
||||
}
|
||||
|
||||
export function applyOrganizationResourceDelta<T extends CountedOrganization>(
|
||||
organizations: T[],
|
||||
resourceCounts: OrganizationResourceCounts | null,
|
||||
organizationId: string,
|
||||
resource: 'knowledge_bases' | 'agents',
|
||||
delta: number
|
||||
): { organizations: T[]; resourceCounts: OrganizationResourceCounts | null } {
|
||||
const field = resource === 'knowledge_bases' ? 'share_count' : 'agent_share_count'
|
||||
const organizationsAfterUpdate = organizations.map(organization => {
|
||||
if (organization.id !== organizationId) return organization
|
||||
return {
|
||||
...organization,
|
||||
[field]: Math.max(0, (organization[field] ?? 0) + delta)
|
||||
}
|
||||
})
|
||||
|
||||
if (!resourceCounts) {
|
||||
return { organizations: organizationsAfterUpdate, resourceCounts }
|
||||
}
|
||||
|
||||
const counts = resourceCounts[resource].by_organization
|
||||
return {
|
||||
organizations: organizationsAfterUpdate,
|
||||
resourceCounts: {
|
||||
...resourceCounts,
|
||||
[resource]: {
|
||||
by_organization: {
|
||||
...counts,
|
||||
[organizationId]: Math.max(0, (counts[organizationId] ?? 0) + delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createVersionedRequestCoordinator } from './versionedRequest.ts'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
test('force queues a fresh request behind an existing request', async () => {
|
||||
const first = deferred<string>()
|
||||
const second = deferred<string>()
|
||||
const requests = [first, second]
|
||||
const applied: string[] = []
|
||||
let calls = 0
|
||||
const coordinator = createVersionedRequestCoordinator(
|
||||
() => requests[calls++].promise,
|
||||
value => applied.push(value)
|
||||
)
|
||||
|
||||
const initial = coordinator.fetch()
|
||||
const forced = coordinator.fetch(true)
|
||||
assert.equal(calls, 1)
|
||||
|
||||
first.resolve('old')
|
||||
await initial
|
||||
await new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
assert.equal(calls, 2)
|
||||
|
||||
second.resolve('fresh')
|
||||
await forced
|
||||
assert.deepEqual(applied, ['old', 'fresh'])
|
||||
})
|
||||
|
||||
test('an invalidated older response cannot overwrite newer state', async () => {
|
||||
const first = deferred<string>()
|
||||
const second = deferred<string>()
|
||||
const requests = [first, second]
|
||||
const applied: string[] = []
|
||||
let calls = 0
|
||||
const coordinator = createVersionedRequestCoordinator(
|
||||
() => requests[calls++].promise,
|
||||
value => applied.push(value)
|
||||
)
|
||||
|
||||
const initial = coordinator.fetch()
|
||||
coordinator.invalidate()
|
||||
const forced = coordinator.fetch(true)
|
||||
|
||||
first.resolve('stale')
|
||||
await initial
|
||||
await new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
second.resolve('fresh')
|
||||
await forced
|
||||
|
||||
assert.deepEqual(applied, ['fresh'])
|
||||
})
|
||||
|
||||
test('a newer force request extends an active force queue after invalidation', async () => {
|
||||
const first = deferred<string>()
|
||||
const second = deferred<string>()
|
||||
const third = deferred<string>()
|
||||
const requests = [first, second, third]
|
||||
const applied: string[] = []
|
||||
let calls = 0
|
||||
const coordinator = createVersionedRequestCoordinator(
|
||||
() => requests[calls++].promise,
|
||||
value => applied.push(value)
|
||||
)
|
||||
|
||||
void coordinator.fetch()
|
||||
const firstForce = coordinator.fetch(true)
|
||||
first.resolve('first')
|
||||
await new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
|
||||
coordinator.invalidate()
|
||||
const secondForce = coordinator.fetch(true)
|
||||
second.resolve('stale-second')
|
||||
await new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
assert.equal(calls, 3)
|
||||
|
||||
third.resolve('fresh-third')
|
||||
await Promise.all([firstForce, secondForce])
|
||||
assert.deepEqual(applied, ['first', 'fresh-third'])
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
export interface VersionedRequestCoordinator<T> {
|
||||
fetch: (force?: boolean) => Promise<void>
|
||||
invalidate: () => void
|
||||
hasInFlightRequest: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates cached reads that also need a reliable "force refresh" mode.
|
||||
*
|
||||
* A forced read requested while another read is running is queued after the
|
||||
* current request. Invalidating the coordinator also prevents an older
|
||||
* response from overwriting state changed by a newer write.
|
||||
*/
|
||||
export function createVersionedRequestCoordinator<T>(
|
||||
request: () => Promise<T>,
|
||||
apply: (value: T) => void
|
||||
): VersionedRequestCoordinator<T> {
|
||||
let revision = 0
|
||||
let startedSequence = 0
|
||||
let completedSequence = 0
|
||||
let forceTargetSequence = 0
|
||||
let inFlight: Promise<void> | null = null
|
||||
let forceQueue: Promise<void> | null = null
|
||||
|
||||
const start = (): Promise<void> => {
|
||||
const requestRevision = revision
|
||||
const sequence = ++startedSequence
|
||||
const current = (async () => {
|
||||
const value = await request()
|
||||
if (requestRevision === revision) {
|
||||
apply(value)
|
||||
}
|
||||
})()
|
||||
|
||||
inFlight = current
|
||||
void current.then(
|
||||
() => {
|
||||
completedSequence = Math.max(completedSequence, sequence)
|
||||
if (inFlight === current) inFlight = null
|
||||
},
|
||||
() => {
|
||||
completedSequence = Math.max(completedSequence, sequence)
|
||||
if (inFlight === current) inFlight = null
|
||||
}
|
||||
)
|
||||
return current
|
||||
}
|
||||
|
||||
const fetch = (force = false): Promise<void> => {
|
||||
if (!inFlight) return start()
|
||||
if (!force) return inFlight
|
||||
|
||||
// A force call always requires at least one request newer than the one
|
||||
// that was in flight when force was requested.
|
||||
forceTargetSequence = Math.max(forceTargetSequence, startedSequence + 1)
|
||||
if (!forceQueue) {
|
||||
const queued = (async () => {
|
||||
while (completedSequence < forceTargetSequence) {
|
||||
if (inFlight) {
|
||||
try {
|
||||
await inFlight
|
||||
} catch {
|
||||
// The queued forced request must still run after a failed read.
|
||||
}
|
||||
} else {
|
||||
await start()
|
||||
}
|
||||
}
|
||||
})()
|
||||
forceQueue = queued
|
||||
void queued.then(
|
||||
() => {
|
||||
if (forceQueue === queued) forceQueue = null
|
||||
},
|
||||
() => {
|
||||
if (forceQueue === queued) forceQueue = null
|
||||
}
|
||||
)
|
||||
}
|
||||
return forceQueue
|
||||
}
|
||||
|
||||
return {
|
||||
fetch,
|
||||
invalidate: () => {
|
||||
revision += 1
|
||||
},
|
||||
hasInFlightRequest: () => inFlight !== null
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ import { ref, computed, watch } from 'vue'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { shareKnowledgeBase, listKBShares, removeShare, updateSharePermission } from '@/api/organization'
|
||||
import { listKBShares } from '@/api/organization'
|
||||
import type { KnowledgeBaseShare } from '@/api/organization'
|
||||
import SpaceAvatar from '@/components/SpaceAvatar.vue'
|
||||
import ShareToSpaceOrgSelect from '@/components/ShareToSpaceOrgSelect.vue'
|
||||
@@ -261,7 +261,7 @@ async function handleShare() {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await shareKnowledgeBase(props.kbId, {
|
||||
const result = await orgStore.shareKnowledgeBase(props.kbId, {
|
||||
organization_id: selectedOrgId.value,
|
||||
permission: selectedPermission.value
|
||||
})
|
||||
@@ -286,7 +286,7 @@ async function handleUpdatePermission(share: KnowledgeBaseShare, newPermission:
|
||||
if (share.permission === newPermission) return
|
||||
|
||||
try {
|
||||
const result = await updateSharePermission(props.kbId, share.id, {
|
||||
const result = await orgStore.changeKnowledgeBaseSharePermission(props.kbId, share.id, {
|
||||
permission: newPermission as 'viewer' | 'editor'
|
||||
})
|
||||
if (result.success) {
|
||||
@@ -303,7 +303,11 @@ async function handleUpdatePermission(share: KnowledgeBaseShare, newPermission:
|
||||
|
||||
async function handleUnshare(share: KnowledgeBaseShare) {
|
||||
try {
|
||||
const result = await removeShare(props.kbId, share.id)
|
||||
const result = await orgStore.unshareKnowledgeBase(
|
||||
props.kbId,
|
||||
share.id,
|
||||
share.organization_id
|
||||
)
|
||||
if (result.success) {
|
||||
MessagePlugin.success(t('organization.share.unshareSuccess'))
|
||||
await loadShares()
|
||||
|
||||
@@ -99,10 +99,10 @@
|
||||
<span class="card-title" :title="org.name">{{ org.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<t-popup v-model="org.showMore" overlayClassName="card-more-popup"
|
||||
<t-popup v-model="organizationMenuVisibility[org.id]" overlayClassName="card-more-popup"
|
||||
:on-visible-change="(visible: boolean) => onVisibleChange(visible, org)" trigger="click"
|
||||
destroy-on-close placement="bottom-right">
|
||||
<div class="more-wrap" @click.stop :class="{ 'active-more': org.showMore }">
|
||||
<div class="more-wrap" @click.stop :class="{ 'active-more': organizationMenuVisibility[org.id] }">
|
||||
<img class="more-icon" src="@/assets/img/more.png" alt="" />
|
||||
</div>
|
||||
<template #content>
|
||||
@@ -201,7 +201,7 @@
|
||||
|
||||
<!-- Organization Settings Modal (用于创建和编辑组织) -->
|
||||
<OrganizationSettingsModal :visible="showSettingsModal" :org-id="settingsOrgId" :mode="settingsMode"
|
||||
@update:visible="showSettingsModal = $event" @saved="handleSettingsSaved" />
|
||||
@update:visible="showSettingsModal = $event" />
|
||||
|
||||
<!-- Delete Confirm Dialog -->
|
||||
<t-dialog v-model:visible="deleteVisible" dialogClassName="del-org-dialog" :closeBtn="false" :cancelBtn="null"
|
||||
@@ -474,22 +474,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
|
||||
import { ref, reactive, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Organization, OrganizationPreview, SearchableOrganizationItem } from '@/api/organization'
|
||||
import { previewOrganization, joinOrganization, submitJoinRequest, searchSearchableOrganizations, joinOrganizationById } from '@/api/organization'
|
||||
import { previewOrganization, submitJoinRequest } from '@/api/organization'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import OrganizationSettingsModal from './OrganizationSettingsModal.vue'
|
||||
import SpaceAvatar from '@/components/SpaceAvatar.vue'
|
||||
import ListSpaceSidebar from '@/components/ListSpaceSidebar.vue'
|
||||
import { shouldShowOrgRelationTag } from '@/utils/card-list-badge'
|
||||
|
||||
interface OrgWithUI extends Organization {
|
||||
showMore?: boolean
|
||||
}
|
||||
type OrgWithUI = Organization
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
@@ -534,12 +532,11 @@ const invitePreviewError = ref('')
|
||||
// 加入方式:邀请码 / 搜索空间
|
||||
const joinStep = ref<'invite' | 'search'>('invite')
|
||||
const searchQuery = ref('')
|
||||
const searchableList = ref<SearchableOrganizationItem[]>([])
|
||||
const searchableList = computed(() => orgStore.searchableOrganizations)
|
||||
const searchLoading = ref(false)
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// 搜索结果缓存:避免重复点击时重复请求导致高度跳动
|
||||
const searchCache = ref<{ query: string; data: SearchableOrganizationItem[]; timestamp: number } | null>(null)
|
||||
const CACHE_DURATION = 5 * 60 * 1000 // 缓存5分钟
|
||||
const organizationMenuVisibility = reactive<Record<string, boolean>>({})
|
||||
|
||||
// Tab 内容容器 ref,用于高度过渡
|
||||
const tabContentWrapperRef = ref<HTMLElement | null>(null)
|
||||
@@ -693,7 +690,7 @@ const handleOrganizationDialogEvent = ((event: CustomEvent<{ type: 'create' | 'j
|
||||
invitePreviewLoading.value = false
|
||||
joinStep.value = 'invite'
|
||||
searchQuery.value = ''
|
||||
searchableList.value = []
|
||||
orgStore.clearSearchableOrganizations()
|
||||
// 注意:不清空缓存,保留搜索结果以便下次快速显示
|
||||
showInvitePreview.value = true
|
||||
}
|
||||
@@ -704,7 +701,7 @@ const spaceSelection = ref<'all' | 'created' | 'joined'>('all')
|
||||
|
||||
// Computed
|
||||
const loading = computed(() => orgStore.loading)
|
||||
const organizations = ref<OrgWithUI[]>([])
|
||||
const organizations = computed<OrgWithUI[]>(() => orgStore.organizations)
|
||||
|
||||
const createdCount = computed(() => organizations.value.filter(o => o.is_owner).length)
|
||||
const joinedCount = computed(() => organizations.value.filter(o => !o.is_owner).length)
|
||||
@@ -758,15 +755,6 @@ const emptyStateDesc = computed(() => {
|
||||
return t('organization.emptyDesc')
|
||||
})
|
||||
|
||||
// Watch store changes and update local organizations
|
||||
watch(
|
||||
() => orgStore.organizations,
|
||||
(newOrgs) => {
|
||||
organizations.value = newOrgs.map(org => ({ ...org, showMore: false }))
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Methods
|
||||
function getRoleTheme(role: string) {
|
||||
switch (role) {
|
||||
@@ -778,7 +766,7 @@ function getRoleTheme(role: string) {
|
||||
|
||||
const onVisibleChange = (visible: boolean, org: OrgWithUI) => {
|
||||
if (!visible) {
|
||||
org.showMore = false
|
||||
organizationMenuVisibility[org.id] = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,13 +794,13 @@ function handleJoinOrganization() {
|
||||
invitePreviewLoading.value = false
|
||||
joinStep.value = 'invite'
|
||||
searchQuery.value = ''
|
||||
searchableList.value = []
|
||||
orgStore.clearSearchableOrganizations()
|
||||
showInvitePreview.value = true
|
||||
}
|
||||
|
||||
function handleCardClick(org: OrgWithUI) {
|
||||
// 如果弹窗正在显示,不触发设置
|
||||
if (org.showMore) {
|
||||
if (organizationMenuVisibility[org.id]) {
|
||||
return
|
||||
}
|
||||
settingsOrgId.value = org.id
|
||||
@@ -820,20 +808,15 @@ function handleCardClick(org: OrgWithUI) {
|
||||
showSettingsModal.value = true
|
||||
}
|
||||
|
||||
function handleSettingsSaved() {
|
||||
orgStore.fetchOrganizations()
|
||||
}
|
||||
|
||||
|
||||
function handleSettings(org: OrgWithUI) {
|
||||
org.showMore = false
|
||||
organizationMenuVisibility[org.id] = false
|
||||
settingsOrgId.value = org.id
|
||||
settingsMode.value = 'edit'
|
||||
showSettingsModal.value = true
|
||||
}
|
||||
|
||||
function handleLeave(org: OrgWithUI) {
|
||||
org.showMore = false
|
||||
organizationMenuVisibility[org.id] = false
|
||||
leavingOrg.value = org
|
||||
leaveVisible.value = true
|
||||
}
|
||||
@@ -851,7 +834,7 @@ async function confirmLeave() {
|
||||
}
|
||||
|
||||
function handleDelete(org: OrgWithUI) {
|
||||
org.showMore = false
|
||||
organizationMenuVisibility[org.id] = false
|
||||
deletingOrg.value = org
|
||||
deleteVisible.value = true
|
||||
}
|
||||
@@ -936,18 +919,16 @@ async function confirmJoinOrganization() {
|
||||
}
|
||||
} else {
|
||||
// 直接加入
|
||||
const result = await joinOrganization({ invite_code: inviteCode.value })
|
||||
if (result.success) {
|
||||
const result = await orgStore.join(inviteCode.value)
|
||||
if (result) {
|
||||
MessagePlugin.success(t('organization.invite.joinSuccess'))
|
||||
showInvitePreview.value = false
|
||||
inviteCode.value = ''
|
||||
invitePreviewData.value = null
|
||||
// 清除 URL 中的 invite_code 参数
|
||||
router.replace({ path: route.path, query: {} })
|
||||
// 刷新组织列表
|
||||
orgStore.fetchOrganizations()
|
||||
} else {
|
||||
MessagePlugin.error(result.message || t('organization.invite.joinFailed'))
|
||||
MessagePlugin.error(orgStore.error || t('organization.invite.joinFailed'))
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -977,7 +958,7 @@ function closeInvitePreview() {
|
||||
invitePreviewError.value = ''
|
||||
joinStep.value = 'invite'
|
||||
searchQuery.value = ''
|
||||
searchableList.value = []
|
||||
orgStore.clearSearchableOrganizations()
|
||||
inviteRequestRole.value = 'viewer'
|
||||
inviteRequestMessage.value = ''
|
||||
router.replace({ path: route.path, query: {} })
|
||||
@@ -994,60 +975,21 @@ function backFromPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索标签点击:如果有缓存,先显示缓存,避免高度跳动
|
||||
// 搜索缓存由 Store 统一管理,切换标签时直接读取缓存或请求最新结果
|
||||
function handleSearchTabClick() {
|
||||
joinStep.value = 'search'
|
||||
|
||||
// 检查是否有有效的缓存
|
||||
const currentQuery = searchQuery.value.trim()
|
||||
if (searchCache.value &&
|
||||
searchCache.value.query === currentQuery &&
|
||||
Date.now() - searchCache.value.timestamp < CACHE_DURATION) {
|
||||
// 先显示缓存结果(已过滤已加入空间),避免高度跳动
|
||||
searchableList.value = searchCache.value.data
|
||||
// 然后在后台刷新(可选,如果需要最新数据)
|
||||
// doSearchSearchable()
|
||||
} else {
|
||||
// 没有缓存或缓存过期,执行搜索
|
||||
doSearchSearchable()
|
||||
}
|
||||
void doSearchSearchable()
|
||||
}
|
||||
|
||||
// 搜索可加入空间
|
||||
async function doSearchSearchable() {
|
||||
const currentQuery = searchQuery.value.trim()
|
||||
|
||||
// 检查缓存
|
||||
if (searchCache.value &&
|
||||
searchCache.value.query === currentQuery &&
|
||||
Date.now() - searchCache.value.timestamp < CACHE_DURATION) {
|
||||
// 使用缓存(已是过滤后的列表),不重新请求
|
||||
searchableList.value = searchCache.value.data
|
||||
return
|
||||
}
|
||||
|
||||
searchLoading.value = true
|
||||
try {
|
||||
const res = await searchSearchableOrganizations(currentQuery, 20)
|
||||
if (res.success && res.data) {
|
||||
const raw = res.data.data || []
|
||||
// 不展示已加入的空间
|
||||
const data = raw.filter((org: SearchableOrganizationItem) => !org.is_already_member)
|
||||
searchableList.value = data
|
||||
// 更新缓存(存过滤后的列表)
|
||||
searchCache.value = {
|
||||
query: currentQuery,
|
||||
data: data,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
} else {
|
||||
searchableList.value = []
|
||||
// 清空缓存
|
||||
searchCache.value = null
|
||||
}
|
||||
await orgStore.fetchSearchableOrganizations(currentQuery, { limit: 20 })
|
||||
} catch (e) {
|
||||
searchableList.value = []
|
||||
searchCache.value = null
|
||||
orgStore.clearSearchableOrganizations()
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
@@ -1142,17 +1084,21 @@ async function joinBySearchOrg() {
|
||||
// 如果需要审核,传递角色和消息;否则直接加入
|
||||
const message = invitePreviewData.value.require_approval ? inviteRequestMessage.value?.trim() || undefined : undefined
|
||||
const role = invitePreviewData.value.require_approval ? inviteRequestRole.value : undefined
|
||||
const result = await joinOrganizationById(invitePreviewData.value.id, message, role)
|
||||
const result = await orgStore.joinById(
|
||||
invitePreviewData.value.id,
|
||||
message,
|
||||
role,
|
||||
{ requiresApproval: invitePreviewData.value.require_approval }
|
||||
)
|
||||
if (result.success) {
|
||||
if (invitePreviewData.value.require_approval) {
|
||||
MessagePlugin.success(t('organization.invite.requestSubmitted'))
|
||||
} else {
|
||||
MessagePlugin.success(t('organization.invite.joinSuccess'))
|
||||
orgStore.fetchOrganizations()
|
||||
}
|
||||
showInvitePreview.value = false
|
||||
invitePreviewData.value = null
|
||||
searchableList.value = []
|
||||
orgStore.clearSearchableOrganizations()
|
||||
searchQuery.value = ''
|
||||
joinStep.value = 'invite'
|
||||
inviteRequestRole.value = 'viewer'
|
||||
@@ -1169,7 +1115,7 @@ async function joinBySearchOrg() {
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
orgStore.fetchOrganizations()
|
||||
void orgStore.fetchOrganizations()
|
||||
window.addEventListener('openOrganizationDialog', handleOrganizationDialogEvent)
|
||||
|
||||
// 检查 URL 中是否有邀请码
|
||||
|
||||
@@ -4,12 +4,13 @@ import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./OrganizationSettingsModal.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('reviewing join requests refreshes local and cached global organization data', () => {
|
||||
test('reviewing join requests goes through the store and refreshes modal data', () => {
|
||||
assert.match(
|
||||
source,
|
||||
/const refreshOrganizationAfterReview = async \(\) => \{\s*await Promise\.all\(\[\s*fetchOrgDetail\(\),\s*orgStore\.fetchOrganizations\(\{ force: true \}\)\s*\]\)\s*\}/
|
||||
/const refreshOrganizationAfterReview = async \(\) => \{\s*await Promise\.all\(\[\s*fetchOrgDetail\(\),\s*fetchMembers\(\)\s*\]\)\s*\}/
|
||||
)
|
||||
|
||||
assert.match(source, /orgStore\.reviewOrganizationJoinRequest\(/)
|
||||
const refreshCalls = source.match(/await refreshOrganizationAfterReview\(\)/g) ?? []
|
||||
assert.equal(refreshCalls.length, 2)
|
||||
})
|
||||
|
||||
@@ -573,7 +573,7 @@
|
||||
<div class="join-request-actions">
|
||||
<t-popup :visible="approvePopupRequestId === row.id"
|
||||
placement="left-start" destroy-on-close overlay-class-name="org-approve-request-popup-overlay"
|
||||
@visible-change="(visible) => handleApprovePopupVisibleChange(visible, row)">
|
||||
@visible-change="(visible: boolean) => handleApprovePopupVisibleChange(visible, row)">
|
||||
<t-tooltip :content="$t('organization.settings.approve')" placement="top">
|
||||
<t-button theme="primary" variant="text" shape="square" size="small"
|
||||
:loading="reviewingRequestId === row.id" @click.stop="openApprovePopup(row)">
|
||||
@@ -824,21 +824,10 @@ import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
getOrganization,
|
||||
listMembers,
|
||||
updateOrganization,
|
||||
updateMemberRole,
|
||||
removeMember,
|
||||
generateInviteCode,
|
||||
listOrgShares,
|
||||
listOrgAgentShares,
|
||||
listJoinRequests,
|
||||
reviewJoinRequest,
|
||||
removeShare,
|
||||
removeAgentShare,
|
||||
requestRoleUpgrade,
|
||||
searchTenantsForInvite,
|
||||
inviteMember,
|
||||
type Organization,
|
||||
type OrganizationMember,
|
||||
type KnowledgeBaseShare,
|
||||
type AgentShareResponse,
|
||||
@@ -874,8 +863,8 @@ const emit = defineEmits<{
|
||||
|
||||
// State
|
||||
const currentSection = ref('basic')
|
||||
const orgInfo = ref<Organization | null>(null)
|
||||
const members = ref<OrganizationMember[]>([])
|
||||
const orgInfo = computed(() => orgStore.currentOrganization)
|
||||
const members = computed(() => orgStore.currentMembers)
|
||||
const sharedKnowledgeBases = ref<KnowledgeBaseShare[]>([])
|
||||
const sharedAgents = ref<AgentShareResponse[]>([])
|
||||
const joinRequests = ref<JoinRequestResponse[]>([])
|
||||
@@ -1318,7 +1307,7 @@ const fetchOrgDetail = async () => {
|
||||
try {
|
||||
const res = await getOrganization(props.orgId)
|
||||
if (res.success && res.data) {
|
||||
orgInfo.value = res.data
|
||||
orgStore.setCurrentOrganization(res.data)
|
||||
const validity = res.data.invite_code_validity_days
|
||||
const memberLimit = res.data.member_limit
|
||||
formData.value = {
|
||||
@@ -1344,10 +1333,7 @@ const fetchMembers = async () => {
|
||||
if (!props.orgId) return
|
||||
membersLoading.value = true
|
||||
try {
|
||||
const res = await listMembers(props.orgId)
|
||||
if (res.success && res.data) {
|
||||
members.value = res.data.members || []
|
||||
}
|
||||
await orgStore.fetchMembers(props.orgId)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch members:', error)
|
||||
} finally {
|
||||
@@ -1445,7 +1431,7 @@ const fetchJoinRequests = async () => {
|
||||
const refreshOrganizationAfterReview = async () => {
|
||||
await Promise.all([
|
||||
fetchOrgDetail(),
|
||||
orgStore.fetchOrganizations({ force: true })
|
||||
fetchMembers()
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1458,7 +1444,11 @@ const handleApproveRequest = async (req: JoinRequestResponse, assignRole: 'viewe
|
||||
if (!props.orgId) return false
|
||||
reviewingRequestId.value = req.id
|
||||
try {
|
||||
const res = await reviewJoinRequest(props.orgId, req.id, { approved: true, role: assignRole })
|
||||
const res = await orgStore.reviewOrganizationJoinRequest(
|
||||
props.orgId,
|
||||
req.id,
|
||||
{ approved: true, role: assignRole }
|
||||
)
|
||||
if (res.success) {
|
||||
MessagePlugin.success(t('organization.settings.approveSuccess'))
|
||||
joinRequests.value = joinRequests.value.filter(r => r.id !== req.id)
|
||||
@@ -1479,7 +1469,11 @@ const handleRejectRequest = async (req: JoinRequestResponse) => {
|
||||
if (!props.orgId) return
|
||||
reviewingRequestId.value = req.id
|
||||
try {
|
||||
const res = await reviewJoinRequest(props.orgId, req.id, { approved: false })
|
||||
const res = await orgStore.reviewOrganizationJoinRequest(
|
||||
props.orgId,
|
||||
req.id,
|
||||
{ approved: false }
|
||||
)
|
||||
if (res.success) {
|
||||
MessagePlugin.success(t('organization.settings.rejectSuccess'))
|
||||
joinRequests.value = joinRequests.value.filter(r => r.id !== req.id)
|
||||
@@ -1519,7 +1513,7 @@ const handleSave = async () => {
|
||||
} else {
|
||||
// 编辑模式
|
||||
if (!props.orgId) return
|
||||
const res = await updateOrganization(props.orgId, {
|
||||
const result = await orgStore.updateOrganization(props.orgId, {
|
||||
name: formData.value.name.trim(),
|
||||
description: formData.value.description.trim(),
|
||||
avatar: formData.value.avatar || undefined,
|
||||
@@ -1528,12 +1522,12 @@ const handleSave = async () => {
|
||||
invite_code_validity_days: formData.value.invite_code_validity_days,
|
||||
member_limit: formData.value.member_limit
|
||||
})
|
||||
if (res.success) {
|
||||
if (result) {
|
||||
MessagePlugin.success(t('common.saveSuccess'))
|
||||
emit('saved')
|
||||
handleClose()
|
||||
} else {
|
||||
MessagePlugin.error(res.message || t('common.saveFailed'))
|
||||
MessagePlugin.error(orgStore.error || t('common.saveFailed'))
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -1546,13 +1540,15 @@ const handleSave = async () => {
|
||||
const handleRoleChange = async (member: OrganizationMember, newRole: string) => {
|
||||
if (!props.orgId) return
|
||||
try {
|
||||
const res = await updateMemberRole(props.orgId, member.tenant_id, {
|
||||
role: newRole as 'admin' | 'editor' | 'viewer'
|
||||
})
|
||||
if (res.success) {
|
||||
const success = await orgStore.changeMemberRole(
|
||||
props.orgId,
|
||||
member.tenant_id,
|
||||
newRole as 'admin' | 'editor' | 'viewer'
|
||||
)
|
||||
if (success) {
|
||||
MessagePlugin.success(t('organization.roleUpdated'))
|
||||
} else {
|
||||
MessagePlugin.error(res.message || t('organization.roleUpdateFailed'))
|
||||
MessagePlugin.error(orgStore.error || t('organization.roleUpdateFailed'))
|
||||
fetchMembers()
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -1565,12 +1561,11 @@ const confirmRemoveMember = async (member: OrganizationMember) => {
|
||||
if (!props.orgId) return
|
||||
|
||||
try {
|
||||
const res = await removeMember(props.orgId, member.tenant_id)
|
||||
if (res.success) {
|
||||
const success = await orgStore.kickMember(props.orgId, member.tenant_id)
|
||||
if (success) {
|
||||
MessagePlugin.success(t('organization.memberRemoved'))
|
||||
fetchMembers()
|
||||
} else {
|
||||
MessagePlugin.error(res.message || t('organization.memberRemoveFailed'))
|
||||
MessagePlugin.error(orgStore.error || t('organization.memberRemoveFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
MessagePlugin.error(error?.message || t('organization.memberRemoveFailed'))
|
||||
@@ -1590,7 +1585,7 @@ const handleSubmitUpgrade = async () => {
|
||||
|
||||
upgradeSubmitting.value = true
|
||||
try {
|
||||
const res = await requestRoleUpgrade(props.orgId, {
|
||||
const res = await orgStore.requestOrganizationRoleUpgrade(props.orgId, {
|
||||
requested_role: upgradeForm.value.requested_role,
|
||||
message: upgradeForm.value.message
|
||||
})
|
||||
@@ -1647,7 +1642,7 @@ const handleAddMember = async () => {
|
||||
|
||||
addMemberSubmitting.value = true
|
||||
try {
|
||||
const res = await inviteMember(props.orgId, {
|
||||
const res = await orgStore.inviteOrganizationMember(props.orgId, {
|
||||
tenant_id: selectedTenantId.value,
|
||||
representative_user_id: candidate?.representative_user_id,
|
||||
role: addMemberRole.value,
|
||||
@@ -1721,13 +1716,13 @@ const refreshInviteCode = async () => {
|
||||
if (!props.orgId) return
|
||||
refreshingCode.value = true
|
||||
try {
|
||||
const res = await generateInviteCode(props.orgId) as any
|
||||
if (res.success) {
|
||||
inviteCode.value = res.invite_code || (res as any).data?.invite_code
|
||||
const code = await orgStore.refreshInviteCode(props.orgId)
|
||||
if (code) {
|
||||
inviteCode.value = code
|
||||
MessagePlugin.success(t('organization.inviteCodeRefreshed'))
|
||||
await fetchOrgDetail()
|
||||
} else {
|
||||
MessagePlugin.error(res.message || t('organization.inviteCodeRefreshFailed'))
|
||||
MessagePlugin.error(orgStore.error || t('organization.inviteCodeRefreshFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
MessagePlugin.error(error?.message || t('organization.inviteCodeRefreshFailed'))
|
||||
@@ -1739,12 +1734,14 @@ const refreshInviteCode = async () => {
|
||||
const handleValidityChange = async (value: number) => {
|
||||
if (!props.orgId) return
|
||||
try {
|
||||
const res = await updateOrganization(props.orgId, { invite_code_validity_days: value })
|
||||
if (res.success) {
|
||||
const result = await orgStore.updateOrganization(props.orgId, {
|
||||
invite_code_validity_days: value
|
||||
})
|
||||
if (result) {
|
||||
MessagePlugin.success(t('common.saveSuccess'))
|
||||
} else {
|
||||
formData.value.invite_code_validity_days = orgInfo.value?.invite_code_validity_days ?? 7
|
||||
MessagePlugin.error(res.message || t('common.saveFailed'))
|
||||
MessagePlugin.error(orgStore.error || t('common.saveFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
formData.value.invite_code_validity_days = orgInfo.value?.invite_code_validity_days ?? 7
|
||||
@@ -1756,15 +1753,15 @@ const handleValidityChange = async (value: number) => {
|
||||
const handleApprovalToggle = async (value: boolean) => {
|
||||
if (!props.orgId) return
|
||||
try {
|
||||
const res = await updateOrganization(props.orgId, {
|
||||
const result = await orgStore.updateOrganization(props.orgId, {
|
||||
require_approval: value
|
||||
})
|
||||
if (res.success) {
|
||||
if (result) {
|
||||
MessagePlugin.success(t('common.saveSuccess'))
|
||||
} else {
|
||||
// 回滚
|
||||
formData.value.require_approval = !value
|
||||
MessagePlugin.error(res.message || t('common.saveFailed'))
|
||||
MessagePlugin.error(orgStore.error || t('common.saveFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 回滚
|
||||
@@ -1777,14 +1774,14 @@ const handleApprovalToggle = async (value: boolean) => {
|
||||
const handleSearchableToggle = async (value: boolean) => {
|
||||
if (!props.orgId) return
|
||||
try {
|
||||
const res = await updateOrganization(props.orgId, {
|
||||
const result = await orgStore.updateOrganization(props.orgId, {
|
||||
searchable: value
|
||||
})
|
||||
if (res.success) {
|
||||
if (result) {
|
||||
MessagePlugin.success(t('common.saveSuccess'))
|
||||
} else {
|
||||
formData.value.searchable = !value
|
||||
MessagePlugin.error(res.message || t('common.saveFailed'))
|
||||
MessagePlugin.error(orgStore.error || t('common.saveFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
formData.value.searchable = !value
|
||||
@@ -1800,7 +1797,11 @@ const handleShareClick = (share: KnowledgeBaseShare) => {
|
||||
const handleRemoveShare = async (share: KnowledgeBaseShare) => {
|
||||
if (!props.orgId) return
|
||||
try {
|
||||
const res = await removeShare(share.knowledge_base_id, share.id)
|
||||
const res = await orgStore.unshareKnowledgeBase(
|
||||
share.knowledge_base_id,
|
||||
share.id,
|
||||
props.orgId
|
||||
)
|
||||
if (res.success) {
|
||||
MessagePlugin.success(t('organization.settings.removeShareSuccess'))
|
||||
sharedKnowledgeBases.value = sharedKnowledgeBases.value.filter(s => s.id !== share.id)
|
||||
@@ -1815,7 +1816,7 @@ const handleRemoveShare = async (share: KnowledgeBaseShare) => {
|
||||
const handleRemoveAgentShare = async (share: AgentShareResponse) => {
|
||||
if (!props.orgId) return
|
||||
try {
|
||||
const res = await removeAgentShare(share.agent_id, share.id)
|
||||
const res = await orgStore.unshareAgent(share.agent_id, share.id, props.orgId)
|
||||
if (res.success) {
|
||||
MessagePlugin.success(t('organization.settings.removeShareSuccess'))
|
||||
sharedAgents.value = sharedAgents.value.filter(s => s.id !== share.id)
|
||||
@@ -1865,8 +1866,7 @@ watch(() => props.visible, (newVal) => {
|
||||
if (props.mode === 'create') {
|
||||
// 创建模式:重置表单
|
||||
formData.value = { name: '', description: '', avatar: '', require_approval: false, searchable: false, invite_code_validity_days: 7, member_limit: 50 }
|
||||
orgInfo.value = null
|
||||
members.value = []
|
||||
orgStore.clearCurrentOrganizationContext()
|
||||
sharedKnowledgeBases.value = []
|
||||
inviteCode.value = ''
|
||||
inviteCodeExpiresAt.value = null
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const listSource = readFileSync(new URL('./OrganizationList.vue', import.meta.url), 'utf8')
|
||||
const settingsSource = readFileSync(new URL('./OrganizationSettingsModal.vue', import.meta.url), 'utf8')
|
||||
const kbShareSource = readFileSync(
|
||||
new URL('../knowledge/settings/KBShareSettings.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const agentShareSource = readFileSync(
|
||||
new URL('../../components/AgentShareSettings.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const storeSource = readFileSync(
|
||||
new URL('../../stores/organization.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('organization list directly derives cards from the store', () => {
|
||||
assert.match(
|
||||
listSource,
|
||||
/const organizations = computed<OrgWithUI\[\]>\(\(\) => orgStore\.organizations\)/
|
||||
)
|
||||
assert.doesNotMatch(listSource, /organizations\.value = newOrgs\.map/)
|
||||
})
|
||||
|
||||
test('join flows and searchable cache are coordinated by the store', () => {
|
||||
assert.match(listSource, /orgStore\.join\(inviteCode\.value\)/)
|
||||
assert.match(listSource, /orgStore\.joinById\(/)
|
||||
assert.match(listSource, /orgStore\.fetchSearchableOrganizations\(/)
|
||||
assert.doesNotMatch(listSource, /const searchCache = ref/)
|
||||
})
|
||||
|
||||
test('organization settings writes use store actions', () => {
|
||||
for (const action of [
|
||||
'updateOrganization',
|
||||
'reviewOrganizationJoinRequest',
|
||||
'inviteOrganizationMember',
|
||||
'requestOrganizationRoleUpgrade',
|
||||
'unshareKnowledgeBase',
|
||||
'unshareAgent'
|
||||
]) {
|
||||
assert.match(settingsSource, new RegExp(`orgStore\\.${action}\\(`))
|
||||
}
|
||||
})
|
||||
|
||||
test('knowledge base and agent share editors use store actions', () => {
|
||||
assert.match(kbShareSource, /orgStore\.shareKnowledgeBase\(/)
|
||||
assert.match(kbShareSource, /orgStore\.unshareKnowledgeBase\(/)
|
||||
assert.match(kbShareSource, /orgStore\.changeKnowledgeBaseSharePermission\(/)
|
||||
assert.match(agentShareSource, /orgStore\.shareAgent\(/)
|
||||
assert.match(agentShareSource, /orgStore\.unshareAgent\(/)
|
||||
})
|
||||
|
||||
test('store invalidates all affected organization caches after sharing', () => {
|
||||
assert.match(
|
||||
storeSource,
|
||||
/invalidateOrganizationData\(\{ sharedKnowledgeBases: true \}\)/
|
||||
)
|
||||
assert.match(
|
||||
storeSource,
|
||||
/invalidateOrganizationData\(\{ sharedAgents: true, sharedKnowledgeBases: true \}\)/
|
||||
)
|
||||
assert.match(storeSource, /adjustOrganizationResourceCount\(/)
|
||||
})
|
||||
Reference in New Issue
Block a user