优化
This commit is contained in:
@@ -465,15 +465,15 @@ export function updateMerchantFirstLegOrder(id: number | string, payload: FirstL
|
||||
return apiRequest<void>({ 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<void>({
|
||||
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}`
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<FirstLegOrder[]>([])
|
||||
const pendingSubmitKeys = new Map<string, string>()
|
||||
const warehouses = ref<ClientWarehouse[]>([])
|
||||
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 }
|
||||
})
|
||||
|
||||
@@ -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\.)/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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<NotificationPage> {
|
||||
|
||||
@@ -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}'\\]`))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Bell, RefreshCcw } from '@lucide/vue'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { useNotificationsStore } from '../public'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { getMyNotification, markMyNotificationRead } from '../api/notifications'
|
||||
import type { NotificationDetail } from '../contracts'
|
||||
const route = useRoute(); const router = useRouter(); const notifications = useNotificationsStore(); const detail = ref<NotificationDetail | null>(null); const loading = ref(false); const errorMessage = ref('')
|
||||
async function load(){loading.value=true;errorMessage.value='';try{detail.value=await getMyNotification(String(route.params.notificationId));if(detail.value.status==='UNREAD'){await markMyNotificationRead(detail.value.id);detail.value={...detail.value,status:'READ'};await notifications.refreshUnreadCount()}}catch(error){errorMessage.value=error instanceof ApiError?error.message:'消息加载失败,请稍后重试'}finally{loading.value=false}}
|
||||
const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const notifications = useNotificationsStore(); const detail = ref<NotificationDetail | null>(null); const loading = ref(false); const errorMessage = ref(''); const readErrorMessage = ref('')
|
||||
const canMarkRead = computed(() => auth.hasPermission('merchant:notification:read'))
|
||||
async function markReadIfAllowed(){if(!detail.value||detail.value.status!=='UNREAD'||!canMarkRead.value)return;try{await markMyNotificationRead(detail.value.id);detail.value={...detail.value,status:'READ'};await notifications.refreshUnreadCount()}catch(error){readErrorMessage.value=error instanceof ApiError?error.message:'消息已加载,但自动标记已读失败'}}
|
||||
async function load(){loading.value=true;errorMessage.value='';readErrorMessage.value='';try{detail.value=await getMyNotification(String(route.params.notificationId));await markReadIfAllowed()}catch(error){detail.value=null;errorMessage.value=error instanceof ApiError?error.message:'消息加载失败,请稍后重试'}finally{loading.value=false}}
|
||||
onMounted(()=>{void load()})
|
||||
</script>
|
||||
<template><div class="notification-detail"><el-button link @click="router.push({ name: 'notifications-list' })"><ArrowLeft :size="16" />返回我的消息</el-button><header class="page-heading"><div><p class="eyebrow">消息详情</p><h1>{{ detail?.title || '消息详情' }}</h1></div><Bell :size="30" /></header><div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load()"><RefreshCcw :size="15" />重试</el-button></div><article v-else v-loading="loading" class="content-card"><p class="meta">{{ detail?.sentAt }} · {{ detail?.type }}</p><p class="content">{{ detail?.content || '暂无消息内容' }}</p></article></div></template>
|
||||
<template><div class="notification-detail"><el-button link @click="router.push({ name: 'notifications-list' })"><ArrowLeft :size="16" />返回我的消息</el-button><header class="page-heading"><div><p class="eyebrow">消息详情</p><h1>{{ detail?.title || '消息详情' }}</h1></div><Bell :size="30" /></header><div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load()"><RefreshCcw :size="15" />重试</el-button></div><article v-else v-loading="loading" class="content-card"><el-alert v-if="readErrorMessage" type="warning" :closable="false" :title="readErrorMessage" show-icon /><p class="meta">{{ detail?.sentAt }} · {{ detail?.type }}</p><p class="content">{{ detail?.content || '暂无消息内容' }}</p></article></div></template>
|
||||
<style scoped>.notification-detail{display:flex;flex-direction:column;gap:16px}.page-heading{display:flex;justify-content:space-between;color:var(--navy)}.page-heading h1{margin:2px 0 5px;font-size:24px}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700}.content-card{min-height:240px;padding:24px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.meta{color:var(--muted);font-size:12px}.content{white-space:pre-wrap;line-height:1.8;color:var(--navy)}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}@media(max-width:720px){.content-card{padding:16px}.page-heading h1{font-size:21px}}</style>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Bell, RefreshCcw } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { useNotificationsStore } from '../public'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { getMyNotifications, markAllMyNotificationsRead, markMyNotificationRead } from '../api/notifications'
|
||||
import type { NotificationPage, NotificationStatus, NotificationSummary, NotificationType } from '../contracts'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const notifications = useNotificationsStore()
|
||||
const canMarkRead = computed(() => auth.hasPermission('merchant:notification:read'))
|
||||
const canMarkAllRead = computed(() => auth.hasPermission('merchant:notification:read-all'))
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const page = ref<NotificationPage>({ records: [], total: 0, page: 1, size: 20, pages: 0 })
|
||||
@@ -19,8 +23,8 @@ async function load(nextPage = page.value.page) {
|
||||
loading.value = true; errorMessage.value = ''
|
||||
try { page.value = await getMyNotifications({ ...filters, page: nextPage, size: page.value.size }) } catch (error) { errorMessage.value = messageOf(error); page.value = { records: [], total: 0, page: nextPage, size: 20, pages: 0 } } finally { loading.value = false }
|
||||
}
|
||||
async function markRead(row: NotificationSummary) { try { await markMyNotificationRead(row.id); await Promise.all([load(), notifications.refreshUnreadCount()]) } catch (error) { errorMessage.value = messageOf(error) } }
|
||||
async function markAllRead() { try { await markAllMyNotificationsRead(); await Promise.all([load(), notifications.refreshUnreadCount()]) } catch (error) { errorMessage.value = messageOf(error) } }
|
||||
async function markRead(row: NotificationSummary) { if (!canMarkRead.value) return; try { await markMyNotificationRead(row.id); await Promise.all([load(), notifications.refreshUnreadCount()]) } catch (error) { errorMessage.value = messageOf(error) } }
|
||||
async function markAllRead() { if (!canMarkAllRead.value) return; try { await markAllMyNotificationsRead(); await Promise.all([load(), notifications.refreshUnreadCount()]) } catch (error) { errorMessage.value = messageOf(error) } }
|
||||
function openDetail(row: NotificationSummary) { void router.push({ name: 'notification-detail', params: { notificationId: row.id } }) }
|
||||
onMounted(() => { void Promise.all([load(), notifications.refreshUnreadCount()]) })
|
||||
</script>
|
||||
@@ -29,9 +33,9 @@ onMounted(() => { void Promise.all([load(), notifications.refreshUnreadCount()])
|
||||
<div class="notification-page">
|
||||
<header class="page-heading"><div><p class="eyebrow">消息中心</p><h1>我的消息</h1><p>仅显示当前登录企业和成员可见的站内消息。</p></div><Bell :size="34" stroke-width="1.5" aria-hidden="true" /></header>
|
||||
<section class="content-card">
|
||||
<form class="filter-bar" @submit.prevent="load(1)"><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="消息状态"><el-option label="未读" value="UNREAD" /><el-option label="已读" value="READ" /></el-select><el-input v-model="filters.type" clearable maxlength="64" placeholder="消息分类" aria-label="消息分类" /><el-button type="primary" native-type="submit" :loading="loading">查询</el-button><el-button :disabled="loading" @click="filters.status = undefined; filters.type = undefined; load(1)">重置</el-button><el-button class="mark-all" :disabled="loading" @click="markAllRead">全部标为已读</el-button></form>
|
||||
<form class="filter-bar" @submit.prevent="load(1)"><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="消息状态"><el-option label="未读" value="UNREAD" /><el-option label="已读" value="READ" /></el-select><el-input v-model="filters.type" clearable maxlength="64" placeholder="消息分类" aria-label="消息分类" /><el-button type="primary" native-type="submit" :loading="loading">查询</el-button><el-button :disabled="loading" @click="filters.status = undefined; filters.type = undefined; load(1)">重置</el-button><el-button v-if="canMarkAllRead" class="mark-all" :disabled="loading" @click="markAllRead">全部标为已读</el-button></form>
|
||||
<div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load(page.page)"><RefreshCcw :size="15" />重试</el-button></div>
|
||||
<el-table v-else v-loading="loading" :data="page.records" row-key="id" aria-label="我的消息列表"><el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row as NotificationSummary)">查看</el-button><el-button v-if="row.status === 'UNREAD'" link type="primary" @click="markRead(row as NotificationSummary)">标为已读</el-button></template></el-table-column><el-table-column prop="title" label="消息标题" min-width="180" /><el-table-column prop="summary" label="内容摘要" min-width="260" /><el-table-column prop="type" label="分类" width="140" /><el-table-column prop="sentAt" label="发送时间" width="180" /><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'UNREAD' ? 'danger' : 'info'">{{ row.status === 'UNREAD' ? '未读' : '已读' }}</el-tag></template></el-table-column><template #empty><div class="table-empty"><Bell :size="32" /><strong>暂无消息</strong><span>当前没有可查看的站内消息。</span></div></template></el-table>
|
||||
<el-table v-else v-loading="loading" :data="page.records" row-key="id" aria-label="我的消息列表"><el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row as NotificationSummary)">查看</el-button><el-button v-if="canMarkRead && row.status === 'UNREAD'" link type="primary" @click="markRead(row as NotificationSummary)">标为已读</el-button></template></el-table-column><el-table-column prop="title" label="消息标题" min-width="180" /><el-table-column prop="summary" label="内容摘要" min-width="260" /><el-table-column prop="type" label="分类" width="140" /><el-table-column prop="sentAt" label="发送时间" width="180" /><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'UNREAD' ? 'danger' : 'info'">{{ row.status === 'UNREAD' ? '未读' : '已读' }}</el-tag></template></el-table-column><template #empty><div class="table-empty"><Bell :size="32" /><strong>暂无消息</strong><span>当前没有可查看的站内消息。</span></div></template></el-table>
|
||||
<div v-if="!errorMessage && page.total > 0" class="pagination-row"><span>共 {{ page.total }} 条</span><el-pagination :current-page="page.page" :page-size="page.size" layout="prev, pager, next" :total="page.total" @current-change="load" /></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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'/)
|
||||
|
||||
@@ -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<Record<string, read
|
||||
'company:profile:edit': ['merchant:profile:edit'],
|
||||
'company:qualification:upload': ['merchant:qualification:upload'],
|
||||
'company:member:list': ['merchant:member:list'],
|
||||
'company:member:write': ['merchant:member:create', 'merchant:member:edit', 'merchant:member:grant-role', 'merchant:member:status'],
|
||||
'company:member:create': ['merchant:member:create'],
|
||||
'company:member:edit': ['merchant:member:edit'],
|
||||
'company:member:grant-role': ['merchant:member:grant-role'],
|
||||
'company:member:status': ['merchant:member:status'],
|
||||
'company:member:reset-password': ['merchant:member:reset-password'],
|
||||
'company:role:list': ['merchant:role:list'],
|
||||
'company:role:write': ['merchant:role:create', 'merchant:role:edit', 'merchant:role:status', 'merchant:role:delete'],
|
||||
'company:role:create': ['merchant:role:create'],
|
||||
'company:role:edit': ['merchant:role:edit'],
|
||||
'company:role:status': ['merchant:role:status'],
|
||||
'company:role:delete': ['merchant:role:delete'],
|
||||
'company:permission:list': ['merchant:role:list'],
|
||||
'company:permission:assign': ['merchant:role:grant'],
|
||||
'finance:bill:view': ['merchant:finance:statement:list', 'merchant:finance:statement:view'],
|
||||
'merchant:notification:view': ['merchant:notification:view'],
|
||||
'merchant:notification:read': ['merchant:notification:read'],
|
||||
'merchant:notification:read-all': ['merchant:notification:read-all'],
|
||||
}
|
||||
|
||||
export const FRONTEND_PERMISSIONS_REQUIRING_ALL_BACKEND_CODES = new Set<string>([
|
||||
|
||||
@@ -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() {
|
||||
<el-tab-pane name="basic">
|
||||
<template #label><span class="tab-label"><Building2 :size="16" />企业基本信息</span></template>
|
||||
<div class="client-card info-panel">
|
||||
<header class="info-panel-header"><h3>企业基本信息</h3><el-button v-if="auth.hasPermission('company:profile:edit')" type="primary" @click="startEdit"><Pencil :size="16" />编辑企业资料</el-button></header>
|
||||
<header class="info-panel-header"><h3>企业基本信息</h3><el-button v-if="auth.hasPermission('company:profile:edit') && store.company.auditStatus !== 'ARCHIVED'" type="primary" @click="startEdit"><Pencil :size="16" />编辑企业资料</el-button></header>
|
||||
<dl class="profile-grid">
|
||||
<div><dt>公司名称</dt><dd>{{ store.company.companyName || '-' }}</dd></div>
|
||||
<div><dt>统一社会信用代码</dt><dd>{{ store.company.creditCode || '-' }}</dd></div>
|
||||
|
||||
@@ -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) {
|
||||
<QueryToolbar v-model="memberFilters" ariaLabel="企业成员筛选" :fields="memberQueryFields" search-text="查询" @search="searchMembers" />
|
||||
|
||||
<div class="client-card member-table-card">
|
||||
<div v-if="auth.hasPermission('company:member:write')" class="table-actions"><el-button type="primary" @click="openCreate"><Plus :size="17" />新增成员</el-button></div>
|
||||
<div v-if="canCreateMember" class="table-actions"><el-button type="primary" @click="openCreate"><Plus :size="17" />新增成员</el-button></div>
|
||||
<div class="desktop-member-table">
|
||||
<DataTable v-model:page="memberPage" v-model:page-size="memberPageSize" :data="pagedMembers" :columns="memberTableColumns" :total="filteredMembers.length" row-key="id">
|
||||
<template #actions="{ row }"><el-button v-if="auth.hasPermission('company:member:write')" link type="primary" @click="openEdit(row as TenantMember)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:member:reset-password')" link type="primary" @click="resetPassword(row as TenantMember)"><KeyRound :size="15" />重置密码</el-button><el-button v-if="auth.hasPermission('company:member:write') && !(row as TenantMember).isAdministrator" link :type="(row as TenantMember).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as TenantMember)">{{ (row as TenantMember).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></template>
|
||||
<template #actions="{ row }"><el-button v-if="canEditMember" link type="primary" @click="openEdit(row as TenantMember)"><Pencil :size="15" />编辑</el-button><el-button v-if="canResetMemberPassword" link type="primary" @click="resetPassword(row as TenantMember)"><KeyRound :size="15" />重置密码</el-button><el-button v-if="canChangeMemberStatus && !(row as TenantMember).isAdministrator" link :type="(row as TenantMember).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as TenantMember)">{{ (row as TenantMember).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!canEditMember && !canResetMemberPassword && !canChangeMemberStatus" class="readonly-action">仅查看</span></template>
|
||||
<template #member="{ row }"><div class="member-identity"><span>{{ (row as TenantMember).name.slice(0, 1) }}</span><div><strong>{{ (row as TenantMember).name }}</strong><small>{{ (row as TenantMember).account }}</small></div></div></template>
|
||||
<template #contact="{ row }"><div class="member-contact"><span>{{ (row as TenantMember).phone }}</span><small>{{ (row as TenantMember).email || '-' }}</small></div></template>
|
||||
<template #roles="{ row }"><div class="role-tags"><el-tag v-for="name in store.roleNames((row as TenantMember).roleIds)" :key="name" effect="plain">{{ name }}</el-tag><span v-if="store.roleNames((row as TenantMember).roleIds).length === 0" class="readonly-action">未分配</span></div></template>
|
||||
@@ -182,7 +187,7 @@ async function resetPassword(member: TenantMember) {
|
||||
<article v-for="member in pagedMembers" :key="member.id">
|
||||
<header><div class="member-identity"><span>{{ member.name.slice(0, 1) }}</span><div><strong>{{ member.name }}</strong><small>{{ member.account }}</small></div></div><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ member.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></header>
|
||||
<dl><div><dt>手机</dt><dd>{{ member.phone }}</dd></div><div><dt>最近登录</dt><dd>{{ member.lastLoginAt }}</dd></div><div class="full"><dt>角色</dt><dd>{{ store.roleNames(member.roleIds).join('、') }}</dd></div></dl>
|
||||
<footer><el-button v-if="auth.hasPermission('company:member:write')" link type="primary" @click="openEdit(member)">编辑</el-button><el-button v-if="auth.hasPermission('company:member:reset-password')" link type="primary" @click="resetPassword(member)">重置密码</el-button><el-button v-if="auth.hasPermission('company:member:write') && !member.isAdministrator" link :type="member.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(member)">{{ member.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></footer>
|
||||
<footer><el-button v-if="canEditMember" link type="primary" @click="openEdit(member)">编辑</el-button><el-button v-if="canResetMemberPassword" link type="primary" @click="resetPassword(member)">重置密码</el-button><el-button v-if="canChangeMemberStatus && !member.isAdministrator" link :type="member.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(member)">{{ member.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!canEditMember && !canResetMemberPassword && !canChangeMemberStatus" class="readonly-action">仅查看</span></footer>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -195,7 +200,7 @@ async function resetPassword(member: TenantMember) {
|
||||
<el-form-item label="登录账号" prop="account" required><el-input v-model="form.account" :disabled="Boolean(editingId)" maxlength="64" placeholder="4–64 位字母、数字或 _ . @ -" /></el-form-item>
|
||||
<el-form-item v-if="!editingId" label="初始密码" prop="password" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="8–64 位,同时包含字母和数字" /></el-form-item>
|
||||
<div class="member-form-grid"><el-form-item label="联系电话" prop="phone"><el-input v-model="form.phone" maxlength="32" placeholder="例如 13800138000(选填)" /></el-form-item><el-form-item label="邮箱" prop="email"><el-input v-model="form.email" maxlength="128" placeholder="例如 name@example.com(选填)" /></el-form-item></div>
|
||||
<el-form-item label="成员角色" prop="roleIds" required><el-select v-model="form.roleIds" multiple placeholder="请选择角色"><el-option v-for="role in store.roles.filter((item) => item.status === 'ACTIVE')" :key="role.id" :label="role.name" :value="role.id" /></el-select></el-form-item>
|
||||
<el-form-item label="成员角色" prop="roleIds" required><el-select v-model="form.roleIds" multiple :disabled="Boolean(editingId) && !canGrantMemberRole" placeholder="请选择角色"><el-option v-for="role in store.roles.filter((item) => item.status === 'ACTIVE')" :key="role.id" :label="role.name" :value="role.id" /></el-select></el-form-item>
|
||||
<div v-if="!editingId" class="member-form-tip"><KeyRound :size="17" /><span>请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。</span></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-actions"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMember">保存</el-button></div></template>
|
||||
|
||||
@@ -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) {
|
||||
<QueryToolbar v-model="roleFilters" ariaLabel="企业角色筛选" :fields="roleQueryFields" search-text="查询" @search="searchRoles" />
|
||||
|
||||
<div class="client-card role-table-card">
|
||||
<div v-if="auth.hasPermission('company:role:write')" class="table-actions"><el-button type="primary" @click="openCreate"><Plus :size="17" />新增角色</el-button></div>
|
||||
<div v-if="canCreateRole" class="table-actions"><el-button type="primary" @click="openCreate"><Plus :size="17" />新增角色</el-button></div>
|
||||
<DataTable v-model:page="rolePage" v-model:page-size="rolePageSize" :data="pagedRoles" :columns="roleTableColumns" :total="filteredRoles.length" row-key="id">
|
||||
<template #actions="{ row }"><el-button v-if="auth.hasPermission('company:permission:list')" link type="primary" :disabled="actionRoleId === (row as TenantRole).id" @click="assignPermissions(row as TenantRole)">{{ auth.hasPermission('company:permission:assign') && !(row as TenantRole).builtin ? '分配权限' : '查看权限' }}</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link type="primary" :disabled="actionRoleId === (row as TenantRole).id" @click="openEdit(row as TenantRole)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link :type="(row as TenantRole).status === 'ACTIVE' ? 'warning' : 'success'" :loading="actionRoleId === (row as TenantRole).id" @click="toggleStatus(row as TenantRole)">{{ (row as TenantRole).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link type="danger" :disabled="actionRoleId === (row as TenantRole).id" @click="removeRole(row as TenantRole)"><Trash2 :size="15" />删除</el-button></template>
|
||||
<template #actions="{ row }"><el-button v-if="auth.hasPermission('company:permission:list')" link type="primary" :disabled="actionRoleId === (row as TenantRole).id" @click="assignPermissions(row as TenantRole)">{{ auth.hasPermission('company:permission:assign') && !(row as TenantRole).builtin ? '分配权限' : '查看权限' }}</el-button><el-button v-if="canEditRole && !(row as TenantRole).builtin" link type="primary" :disabled="actionRoleId === (row as TenantRole).id" @click="openEdit(row as TenantRole)"><Pencil :size="15" />编辑</el-button><el-button v-if="canChangeRoleStatus && !(row as TenantRole).builtin" link :type="(row as TenantRole).status === 'ACTIVE' ? 'warning' : 'success'" :loading="actionRoleId === (row as TenantRole).id" @click="toggleStatus(row as TenantRole)">{{ (row as TenantRole).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="canDeleteRole && !(row as TenantRole).builtin" link type="danger" :disabled="actionRoleId === (row as TenantRole).id" @click="removeRole(row as TenantRole)"><Trash2 :size="15" />删除</el-button></template>
|
||||
<template #role="{ row }"><div class="role-identity"><span class="role-icon"><LockKeyhole v-if="(row as TenantRole).builtin" :size="20" /><ShieldCheck v-else :size="20" /></span><div><strong>{{ (row as TenantRole).name }}</strong><code>{{ (row as TenantRole).code }}</code></div></div></template>
|
||||
<template #memberCount="{ row }"><span>{{ store.roleMemberCount((row as TenantRole).id) }}</span></template>
|
||||
<template #permissionCount="{ row }"><span>{{ (row as TenantRole).permissionCodes.length }}</span></template>
|
||||
|
||||
@@ -123,12 +123,20 @@ test('implements member list, role assignment, password reset and status control
|
||||
assert.doesNotMatch(membersSource, /<DataTable[^>]*:loading="loading"/)
|
||||
assert.match(membersSource, /fixed: 'left'/)
|
||||
assert.match(membersSource, /class="table-actions"/)
|
||||
assert.doesNotMatch(membersSource, /template #actions><el-button v-if="auth\.hasPermission\('company:member:write'\)/)
|
||||
assert.match(membersSource, /canCreateMember.*company:member:create/)
|
||||
assert.match(membersSource, /canEditMember.*company:member:edit/)
|
||||
assert.match(membersSource, /canGrantMemberRole.*company:member:grant-role/)
|
||||
assert.match(membersSource, /canChangeMemberStatus.*company:member:status/)
|
||||
assert.doesNotMatch(membersSource, /company:member:write/)
|
||||
assert.doesNotMatch(membersSource, /member-metrics/)
|
||||
})
|
||||
|
||||
test('protects built-in roles and exposes role permission assignment', () => {
|
||||
assert.match(rolesSource, /auth\.hasPermission\('company:role:write'\).*\.builtin/)
|
||||
assert.match(rolesSource, /canCreateRole.*company:role:create/)
|
||||
assert.match(rolesSource, /canEditRole.*company:role:edit/)
|
||||
assert.match(rolesSource, /canChangeRoleStatus.*company:role:status/)
|
||||
assert.match(rolesSource, /canDeleteRole.*company:role:delete/)
|
||||
assert.doesNotMatch(rolesSource, /company:role:write/)
|
||||
assert.match(rolesSource, /function assignPermissions\(role: TenantRole\)/)
|
||||
assert.match(storeSource, /if \(role\.builtin\) throw new Error\('内置角色不能删除'\)/)
|
||||
assert.match(storeSource, /if \(members\.value\.some\(\(member\) => member\.roleIds\.includes\(id\)\)\)/)
|
||||
@@ -152,15 +160,19 @@ test('protects built-in roles and exposes role permission assignment', () => {
|
||||
assert.doesNotMatch(rolesSource, /<DataTable[^>]*:loading="loading"/)
|
||||
assert.match(rolesSource, /fixed: 'left'/)
|
||||
assert.match(rolesSource, /class="table-actions"/)
|
||||
assert.doesNotMatch(rolesSource, /template #actions><el-button v-if="auth\.hasPermission\('company:role:write'\)/)
|
||||
assert.match(rolesSource, /canEditRole && !\(row as TenantRole\)\.builtin/)
|
||||
assert.doesNotMatch(rolesSource, /role-guidance/)
|
||||
})
|
||||
|
||||
test('shows company audit status and applies button permissions', () => {
|
||||
assert.match(profileSource, /companyApproved/)
|
||||
assert.match(profileSource, /ARCHIVED: \{ title: '企业已归档'/)
|
||||
assert.match(profileSource, /auditStatus !== 'ARCHIVED'/)
|
||||
assert.match(profileSource, /企业资料正在等待平台审核/)
|
||||
assert.match(profileSource, /auth\.hasPermission\('company:profile:edit'\)/)
|
||||
assert.match(membersSource, /auth\.hasPermission\('company:member:write'\)/)
|
||||
assert.match(membersSource, /company:member:create/)
|
||||
assert.match(membersSource, /company:member:edit/)
|
||||
assert.match(membersSource, /company:member:status/)
|
||||
assert.match(permissionsSource, /auth\.hasPermission\('company:permission:assign'\)/)
|
||||
assert.match(storeSource, /auditStatus: 'PROFILE_REQUIRED'/)
|
||||
assert.match(storeSource, /loadCompanyContext/)
|
||||
|
||||
Reference in New Issue
Block a user