fix: synchronize organization state after writes

This commit is contained in:
ttommybot
2026-07-24 10:22:43 +08:00
committed by lyingbug
parent 8cb43473e6
commit 014cf4e545
11 changed files with 861 additions and 244 deletions
@@ -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()
+406 -96
View File
@@ -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)
})
+52
View File
@@ -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'])
})
+90
View File
@@ -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\(/)
})