diff --git a/AGENTS.md b/AGENTS.md index 12229a8..39fa370 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ pnpm build ## 全量验收脚本 -- Client 全量验收唯一入口为 `../../baiyue-firstleg/scripts/client-test-env-acceptance.mjs`;二期兼容场景才单独执行 `client-phase2-test-env-acceptance.mjs`,不替代完整入口。 +- Client 全量验收唯一入口为 `../../baiyue-firstleg/scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `client-phase2-test-env-acceptance.mjs`,不替代完整入口。 - 入口通过测试环境 HTTP 验证注册/审核、客户隔离、订单、入库计费、文件、财务和权限链路,会创建或修改测试数据,必须获得当前对话明确授权。 - `pnpm test/typecheck` 和 `frontend-full-regression.ps1` 仅是本地质量检查,不代表 Client 服务已完成全量验收。 - HTTP 验收通过后,再使用开发验收账号通过浏览器逐页检查真实接口调用、客户可见字段、原型、权限、错误态、空态和窄屏布局。 diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 0b08982..239316c 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -13,10 +13,10 @@ - 服务端字段变化先核对 Controller、DTO 和 OpenAPI,再更新本应用的类型、API 适配层和测试;本包不反向定义服务端行为。 - 避免建立宽泛的万能类型或引入重量级依赖。 -## 兼容性与验证 +## 契约与验证 - 修改导出名称、字段类型、枚举值或常量语义前,搜索当前应用内全部消费者。 -- 优先采用兼容性新增;破坏性修改必须同步更新当前应用消费者。 +- 当前开发阶段按目标模型直接修改;破坏性修改必须同步更新当前应用全部消费者、测试和接口映射,不保留旧模型兼容分支。 - 本包没有独立质量脚本;变更后运行父应用声明的类型检查、测试或构建命令。 ## 一期/二期/三期契约索引 diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index 940e921..6e7d0a4 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -465,15 +465,15 @@ export function updateMerchantFirstLegOrder(id: number | string, payload: FirstL return apiRequest({ method: 'PUT', url: `/merchant/v1/orders/first-leg/${id}`, data: payload }) } -export function submitMerchantFirstLegOrder(id: number | string) { +export function submitMerchantFirstLegOrder(id: number | string, idempotencyKey: string) { return apiRequest({ method: 'POST', url: `/merchant/v1/orders/first-leg/${id}/submit`, - headers: { 'Idempotency-Key': createIdempotencyKey(`first-leg-submit-${id}`) }, + headers: { 'Idempotency-Key': idempotencyKey }, }) } -function createIdempotencyKey(scope: string) { +export function createIdempotencyKey(scope: string) { const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` return `${scope}-${randomPart}` } diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs index e1f7e59..fd36da0 100644 --- a/src/modules/first-leg/store/firstLegOrders.test.mjs +++ b/src/modules/first-leg/store/firstLegOrders.test.mjs @@ -49,7 +49,8 @@ test('maps transport and status fields instead of treating container mode as tra assert.match(storeSource, /status === 'SUBMITTED'\) return 'PENDING_REVIEW'/) assert.match(storeSource, /status === 'REJECTED'\) return 'PENDING_REVIEW'/) assert.match(storeSource, /status === 'APPROVED' \|\| status === 'ACCEPTED' \|\| status === 'PROCESSING'\) return 'WAIT_MEASURE'/) - assert.match(storeSource, /status === 'COMPLETED'\) return 'DELIVERED'/) + assert.match(storeSource, /status === 'COMPLETED'\) return 'LOADED'/) + assert.doesNotMatch(storeSource, /status === 'COMPLETED'\) return 'DELIVERED'/) }) test('prefers unified reads and only falls back to the real legacy API when C02 is unavailable', () => { @@ -148,10 +149,13 @@ test('does not expose the out-of-scope cancellation write', () => { test('sends an idempotency key for create and submit writes', () => { assert.match(apiSource, /'Idempotency-Key': createIdempotencyKey\('first-leg-create'\)/) - assert.match(apiSource, /'Idempotency-Key': createIdempotencyKey\(`first-leg-submit-\$\{id\}`\)/) + assert.match(apiSource, /submitMerchantFirstLegOrder\(id: number \| string, idempotencyKey: string\)/) + assert.match(apiSource, /'Idempotency-Key': idempotencyKey/) assert.match(apiSource, /createMerchantFirstLegOrderBatch\(items: MerchantBatchDraftItem\[\], idempotencyKey: string\)/) assert.match(apiSource, /headers: \{ 'Idempotency-Key': idempotencyKey \}/) assert.match(storeSource, /createMerchantFirstLegOrderBatch\(items, idempotencyKey\)/) + assert.match(storeSource, /pendingSubmitKeys\.get\(id\) \?\? createIdempotencyKey\(`first-leg-submit-\$\{id\}`\)/) + assert.match(storeSource, /submitMerchantFirstLegOrder\(id, idempotencyKey\)/) }) test('does not expose the contradictory backend rejected state in the client model', () => { diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index 7b06ff4..cf0e33b 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -3,6 +3,7 @@ import { defineStore } from 'pinia' import { createMerchantFirstLegOrderBatch, createMerchantFirstLegOrder, + createIdempotencyKey, getMerchantFirstLegOrder, getMerchantFirstLegOrders, getMerchantWarehouseDetail, @@ -49,7 +50,7 @@ function clientStatus(status: ApiFirstLegStatus): FirstLegOrder['status'] { // 后端联调任务曾出现 REJECTED,但一期已确认订单不设驳回状态;兼容旧数据时仍按待审核展示。 if (status === 'REJECTED') return 'PENDING_REVIEW' if (status === 'APPROVED' || status === 'ACCEPTED' || status === 'PROCESSING') return 'WAIT_MEASURE' - if (status === 'COMPLETED') return 'DELIVERED' + if (status === 'COMPLETED') return 'LOADED' if (status === 'CANCELLED') return 'PENDING_REVIEW' return 'PENDING_REVIEW' } @@ -442,6 +443,7 @@ function mapWarehouse(item: MerchantWarehouse): ClientWarehouse { export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () => { const orders = ref([]) + const pendingSubmitKeys = new Map() const warehouses = ref([]) const loading = ref(false) const loadedFromApi = ref(false) @@ -566,16 +568,23 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () return createMerchantFirstLegOrderBatch(items, idempotencyKey) } - async function submit(draft: FirstLegOrderDraft, customerName: string, id?: string, companyAuditStatus?: MerchantTenantStatus) { + async function submitSaved(id: string, companyAuditStatus?: MerchantTenantStatus) { if (companyAuditStatus && companyAuditStatus !== 'ACTIVE') throw new Error('企业资料尚未审核通过,请先完善企业资料并等待平台审核') + const idempotencyKey = pendingSubmitKeys.get(id) ?? createIdempotencyKey(`first-leg-submit-${id}`) + pendingSubmitKeys.set(id, idempotencyKey) + await submitMerchantFirstLegOrder(id, idempotencyKey) + pendingSubmitKeys.delete(id) + return loadUnifiedDetail(id) + } + + async function submit(draft: FirstLegOrderDraft, customerName: string, id?: string, companyAuditStatus?: MerchantTenantStatus) { const order = await saveDraft(draft, customerName, id) - await submitMerchantFirstLegOrder(order.id) - return loadUnifiedDetail(order.id) + return submitSaved(order.id, companyAuditStatus) } async function loadDraftDetail(id: string) { return loadLegacyDetail(id) } - return { orders, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, draftLoadError, activeCount, pendingCount, getById, load, loadDetail, loadDraftDetail, loadWarehouseDetail, loadWarehouseType, saveDraft, saveDraftBatch, submit } + return { orders, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, draftLoadError, activeCount, pendingCount, getById, load, loadDetail, loadDraftDetail, loadWarehouseDetail, loadWarehouseType, saveDraft, saveDraftBatch, submitSaved, submit } }) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index 18e3413..c9f9216 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -475,7 +475,8 @@ test('copies reactive order drafts into plain request data', () => { assert.match(source, /function cloneDraft\(draft: FirstLegOrderDraft\)/) assert.match(source, /items: draft\.items\.map\(\(item\) => \(\{ \.\.\.item \}\)\)/) assert.match(source, /store\.saveDraft\(cloneDraft\(form\)/) - assert.match(source, /store\.submit\(cloneDraft\(form\)/) + assert.match(source, /editingId\.value = savedOrder\.id/) + assert.match(source, /store\.submitSaved\(savedOrder\.id/) assert.doesNotMatch(source, /structuredClone\((?:form|order\.)/) }) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 7384f28..e83d6c1 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -1425,7 +1425,9 @@ async function submitOrder() { try { saving.value = true const knownCompanyAuditStatus = companyAuditLoaded.value ? tenantStore.company.auditStatus : undefined - const order = await store.submit(cloneDraft(form), tenantStore.company.companyName, editingId.value, knownCompanyAuditStatus) + const savedOrder = await store.saveDraft(cloneDraft(form), tenantStore.company.companyName, editingId.value) + editingId.value = savedOrder.id + const order = await store.submitSaved(savedOrder.id, knownCompanyAuditStatus) markEditorClean() editorOpen.value = false if (isCreatePage.value) await router.push({ name: 'first-leg-orders' }) diff --git a/src/modules/notifications/api/notifications.ts b/src/modules/notifications/api/notifications.ts index fe36814..5872680 100644 --- a/src/modules/notifications/api/notifications.ts +++ b/src/modules/notifications/api/notifications.ts @@ -12,6 +12,7 @@ interface NotificationWire { sentAt: string readAt?: string | null status: NotificationStatus + summary?: string body?: string sourceType?: string | null sourceId?: string | null @@ -26,7 +27,7 @@ interface NotificationPageWire { } function mapSummary(item: NotificationWire): NotificationSummary { - return { id: String(item.id), title: item.title, summary: item.body ? item.body.slice(0, 120) : '', type: item.category, sentAt: item.sentAt, status: item.status } + return { id: String(item.id), title: item.title, summary: item.summary ?? '', type: item.category, sentAt: item.sentAt, status: item.status } } export async function getMyNotifications(query: NotificationQuery = {}): Promise { diff --git a/src/modules/notifications/notifications.test.mjs b/src/modules/notifications/notifications.test.mjs index 8c5d2db..a90af13 100644 --- a/src/modules/notifications/notifications.test.mjs +++ b/src/modules/notifications/notifications.test.mjs @@ -22,6 +22,8 @@ test('does not accept browser-selected customer or user scope in the API boundar assert.match(source, /category: query\.type/) assert.match(source, /method: 'PUT'/) assert.doesNotMatch(source, /method: 'POST'/) + assert.match(source, /summary: item\.summary \?\? ''/) + assert.doesNotMatch(source, /item\.body\.slice/) }) test('keeps customer message actions first and covers the requested local states', async () => { @@ -35,4 +37,24 @@ test('keeps customer message actions first and covers the requested local states assert.match(source, /暂无消息/) assert.doesNotMatch(source, /清空已读|删除消息/) assert.match(source, /Promise\.all\(\[load\(\), notifications\.refreshUnreadCount\(\)\]\)/) + assert.match(source, /auth\.hasPermission\('merchant:notification:read'\)/) + assert.match(source, /auth\.hasPermission\('merchant:notification:read-all'\)/) + assert.match(source, /v-if="canMarkAllRead"/) + assert.match(source, /v-if="canMarkRead && row\.status === 'UNREAD'"/) +}) + +test('keeps detail readable without read permission and isolates auto-read failures', async () => { + const source = await readFile(new URL('views/NotificationDetailView.vue', moduleRoot), 'utf8') + assert.match(source, /auth\.hasPermission\('merchant:notification:read'\)/) + assert.match(source, /!canMarkRead\.value\)return/) + assert.match(source, /readErrorMessage\.value=/) + assert.match(source, /v-if="readErrorMessage"/) + assert.match(source, /detail\.value=null;errorMessage\.value=/) +}) + +test('maps all three notification authorities into the client session', async () => { + const source = await readFile(new URL('../tenant-management/contracts.ts', moduleRoot), 'utf8') + for (const permission of ['merchant:notification:view', 'merchant:notification:read', 'merchant:notification:read-all']) { + assert.match(source, new RegExp(`'${permission}': \\['${permission}'\\]`)) + } }) diff --git a/src/modules/notifications/views/NotificationDetailView.vue b/src/modules/notifications/views/NotificationDetailView.vue index c588830..2d44414 100644 --- a/src/modules/notifications/views/NotificationDetailView.vue +++ b/src/modules/notifications/views/NotificationDetailView.vue @@ -1,14 +1,17 @@ - + diff --git a/src/modules/notifications/views/NotificationsView.vue b/src/modules/notifications/views/NotificationsView.vue index 439146a..61fd61a 100644 --- a/src/modules/notifications/views/NotificationsView.vue +++ b/src/modules/notifications/views/NotificationsView.vue @@ -1,14 +1,18 @@ @@ -29,9 +33,9 @@ onMounted(() => { void Promise.all([load(), notifications.refreshUnreadCount()])

消息中心

我的消息

仅显示当前登录企业和成员可见的站内消息。

-
查询重置全部标为已读 +
查询重置全部标为已读 - +
共 {{ page.total }} 条
diff --git a/src/modules/tenant-management/api/merchantIam.test.mjs b/src/modules/tenant-management/api/merchantIam.test.mjs index ad4bfd3..f8739db 100644 --- a/src/modules/tenant-management/api/merchantIam.test.mjs +++ b/src/modules/tenant-management/api/merchantIam.test.mjs @@ -70,6 +70,15 @@ test('starts empty and only loads server role and member records', () => { test('shares the merchant permission map and preserves resources not rendered by the role editor', () => { assert.match(contractsSource, /export const FRONTEND_TO_MERCHANT_PERMISSION_CODES/) assert.match(contractsSource, /'order:first-leg:cancel': \['merchant:order:cancel'\]/) + assert.match(contractsSource, /'company:member:create': \['merchant:member:create'\]/) + assert.match(contractsSource, /'company:member:edit': \['merchant:member:edit'\]/) + assert.match(contractsSource, /'company:member:grant-role': \['merchant:member:grant-role'\]/) + assert.match(contractsSource, /'company:member:status': \['merchant:member:status'\]/) + assert.match(contractsSource, /'company:role:create': \['merchant:role:create'\]/) + assert.match(contractsSource, /'company:role:edit': \['merchant:role:edit'\]/) + assert.match(contractsSource, /'company:role:status': \['merchant:role:status'\]/) + assert.match(contractsSource, /'company:role:delete': \['merchant:role:delete'\]/) + assert.doesNotMatch(contractsSource, /company:(?:member|role):write/) assert.match(storeSource, /from '@\/modules\/tenant-management\/contracts'/) assert.match(authSource, /FRONTEND_TO_MERCHANT_PERMISSION_CODES/) assert.match(authSource, /from '@\/modules\/tenant-management\/public'/) diff --git a/src/modules/tenant-management/contracts.ts b/src/modules/tenant-management/contracts.ts index 23eeb37..08a8694 100644 --- a/src/modules/tenant-management/contracts.ts +++ b/src/modules/tenant-management/contracts.ts @@ -1,4 +1,4 @@ -export type MerchantTenantStatus = 'PROFILE_REQUIRED' | 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED' +export type MerchantTenantStatus = 'PROFILE_REQUIRED' | 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED' | 'ARCHIVED' export interface TenantPermissionItem { code: string @@ -56,7 +56,10 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ label: '企业成员', items: [ { code: 'company:member:list', label: '查看成员', description: '查看本企业成员列表' }, - { code: 'company:member:write', label: '维护成员', description: '新增、编辑、启停企业成员' }, + { code: 'company:member:create', label: '新增成员', description: '新增企业成员' }, + { code: 'company:member:edit', label: '编辑成员', description: '编辑企业成员资料' }, + { code: 'company:member:grant-role', label: '分配成员角色', description: '调整企业成员角色' }, + { code: 'company:member:status', label: '启停成员', description: '启用或停用企业成员' }, { code: 'company:member:reset-password', label: '重置密码', description: '重置企业成员登录密码' }, ], }, @@ -65,7 +68,10 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ label: '角色与权限', items: [ { code: 'company:role:list', label: '查看角色', description: '查看本企业角色列表' }, - { code: 'company:role:write', label: '维护角色', description: '新增、编辑、启停企业角色' }, + { code: 'company:role:create', label: '新增角色', description: '新增企业角色' }, + { code: 'company:role:edit', label: '编辑角色', description: '编辑企业角色' }, + { code: 'company:role:status', label: '启停角色', description: '启用或停用企业角色' }, + { code: 'company:role:delete', label: '删除角色', description: '删除未分配成员的企业角色' }, { code: 'company:permission:list', label: '查看权限', description: '查看角色拥有的菜单和操作权限' }, { code: 'company:permission:assign', label: '分配权限', description: '为本企业角色分配菜单及操作权限' }, ], @@ -83,13 +89,22 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly([ diff --git a/src/modules/tenant-management/views/CompanyProfileView.vue b/src/modules/tenant-management/views/CompanyProfileView.vue index a28664b..c064036 100644 --- a/src/modules/tenant-management/views/CompanyProfileView.vue +++ b/src/modules/tenant-management/views/CompanyProfileView.vue @@ -25,6 +25,7 @@ const auditStatusCopy = computed(() => ({ ACTIVE: { title: '企业资料已审核,下单资格已开通', detail: '您可以创建、保存并提交头程订单。' }, REJECTED: { title: '企业资料审核未通过', detail: '请根据平台反馈替换对应资质,必填文件齐全后可重新提交审核。' }, DISABLED: { title: '企业账号已被平台停用', detail: '当前不能提交订单,请联系平台客服核实。' }, + ARCHIVED: { title: '企业已归档', detail: '该企业已结束使用,资料和历史业务仅供查看,不能再提交或修改。' }, })[store.company.auditStatus]) const activeTab = ref('basic') const editing = ref(false) @@ -114,6 +115,7 @@ const qualificationUploadDisabledReason = computed(() => { if (store.company.auditStatus === 'PENDING') return '企业资料正在审核,暂不能再次提交资质文件。' if (store.company.auditStatus === 'ACTIVE') return '当前企业已审核通过,资质替换请联系平台管理员处理。' if (store.company.auditStatus === 'DISABLED') return '企业账号已停用,暂不能提交资质文件。' + if (store.company.auditStatus === 'ARCHIVED') return '企业已归档,不能再提交资质文件。' return '' }) const selectedQualificationCount = computed(() => qualificationDrafts.filter((item) => Boolean(item.file)).length) @@ -183,6 +185,7 @@ onMounted(() => void loadCompanyContext()) onBeforeUnmount(() => qualificationDrafts.forEach(releaseQualificationPreview)) function startEdit() { + if (store.company.auditStatus === 'ARCHIVED') return Object.assign(draft, store.company) profileFormRef.value?.clearValidate() editing.value = true @@ -487,7 +490,7 @@ function closeQualificationPreview() {
-

企业基本信息

编辑企业资料
+

企业基本信息

编辑企业资料
公司名称
{{ store.company.companyName || '-' }}
统一社会信用代码
{{ store.company.creditCode || '-' }}
diff --git a/src/modules/tenant-management/views/MembersView.vue b/src/modules/tenant-management/views/MembersView.vue index 17926eb..b49c681 100644 --- a/src/modules/tenant-management/views/MembersView.vue +++ b/src/modules/tenant-management/views/MembersView.vue @@ -54,6 +54,11 @@ const filteredMembers = computed(() => store.members.filter((member) => { return matchesKeyword && (!appliedMemberFilters.value.status || member.status === appliedMemberFilters.value.status) })) const pagedMembers = computed(() => filteredMembers.value.slice((memberPage.value - 1) * memberPageSize.value, memberPage.value * memberPageSize.value)) +const canCreateMember = computed(() => auth.hasPermission('company:member:create')) +const canEditMember = computed(() => auth.hasPermission('company:member:edit')) +const canGrantMemberRole = computed(() => auth.hasPermission('company:member:grant-role')) +const canChangeMemberStatus = computed(() => auth.hasPermission('company:member:status')) +const canResetMemberPassword = computed(() => auth.hasPermission('company:member:reset-password')) async function loadMembers() { loading.value = true @@ -166,10 +171,10 @@ async function resetPassword(member: TenantMember) {
-
新增成员
+
新增成员
- + @@ -182,7 +187,7 @@ async function resetPassword(member: TenantMember) {
{{ member.name.slice(0, 1) }}
{{ member.name }}{{ member.account }}
{{ member.status === 'ACTIVE' ? '正常' : '已停用' }}
手机
{{ member.phone }}
最近登录
{{ member.lastLoginAt }}
角色
{{ store.roleNames(member.roleIds).join('、') }}
-
编辑重置密码{{ member.status === 'ACTIVE' ? '停用' : '启用' }}仅查看
+
编辑重置密码{{ member.status === 'ACTIVE' ? '停用' : '启用' }}仅查看
@@ -195,7 +200,7 @@ async function resetPassword(member: TenantMember) {
- +
请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。
diff --git a/src/modules/tenant-management/views/RolesView.vue b/src/modules/tenant-management/views/RolesView.vue index 3c5111b..b8e4b10 100644 --- a/src/modules/tenant-management/views/RolesView.vue +++ b/src/modules/tenant-management/views/RolesView.vue @@ -43,6 +43,10 @@ const filteredRoles = computed(() => { return store.roles.filter((role) => !key || [role.name, role.code, role.description].some((value) => value.toLowerCase().includes(key))) }) const pagedRoles = computed(() => filteredRoles.value.slice((rolePage.value - 1) * rolePageSize.value, rolePage.value * rolePageSize.value)) +const canCreateRole = computed(() => auth.hasPermission('company:role:create')) +const canEditRole = computed(() => auth.hasPermission('company:role:edit')) +const canChangeRoleStatus = computed(() => auth.hasPermission('company:role:status')) +const canDeleteRole = computed(() => auth.hasPermission('company:role:delete')) function resetForm() { editingId.value = '' @@ -147,9 +151,9 @@ function assignPermissions(role: TenantRole) {
-
新增角色
+
新增角色
- + diff --git a/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs b/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs index 598fd6b..be90722 100644 --- a/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs +++ b/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs @@ -123,12 +123,20 @@ test('implements member list, role assignment, password reset and status control assert.doesNotMatch(membersSource, /]*:loading="loading"/) assert.match(membersSource, /fixed: 'left'/) assert.match(membersSource, /class="table-actions"/) - assert.doesNotMatch(membersSource, /template #actions>