feat(后台分组): 分组筛选与分组列仅超管可见,非超管一律隐藏
管理员/普通用户数据已由后端裁剪到本人可访问分组,分组维度对其无意义; 账号归属分组是超管职责,故只有超管需要按分组筛选与区分。 - 隐藏分组筛选+分组列:品牌数据库、查询ASIN、最低价ASIN、去重总数据、 店铺管理、用户密钥 - 仅隐藏分组展示:店铺数据记录(筛选框+卡片行)、图片视频任务(所属分组行)、 撞款检测(明细抽屉分组列+卡片分组标签) - 弹窗分组选择保留展示但锁定:非超管仅1个可访问分组时自动选中并置灰 (查询/最低价ASIN 另联动加载该分组店铺),多分组不锁以免限制用户 - 空数据行 colspan 随列显隐动态化 - 新增 tests/align-group-visibility.test.ts 回归守卫(5 条)
This commit is contained in:
@@ -11,6 +11,11 @@ import {
|
||||
type AdminUserSecretRow,
|
||||
type GroupOption,
|
||||
} from '@/api/user-secrets'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<AdminUserSecretRow[]>([])
|
||||
@@ -200,7 +205,7 @@ onMounted(load)
|
||||
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" v-if="groupOptions.length" style="min-width: 170px">
|
||||
<div class="form-group" v-if="isSuperAdmin && groupOptions.length" style="min-width: 170px">
|
||||
<label>分组</label>
|
||||
<select v-model="groupFilter">
|
||||
<option :value="null">全部分组</option>
|
||||
@@ -222,7 +227,7 @@ onMounted(load)
|
||||
<tr>
|
||||
<th style="width: 58px">序号</th>
|
||||
<th style="width: 260px">用户</th>
|
||||
<th style="width: 130px">分组</th>
|
||||
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
|
||||
<th style="width: 230px">货源查询密钥</th>
|
||||
<th style="width: 230px">外观专利密钥</th>
|
||||
<th style="width: 230px">代理设置</th>
|
||||
@@ -237,7 +242,7 @@ onMounted(load)
|
||||
<td>
|
||||
<span class="user-name">{{ row.username || '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<td v-if="isSuperAdmin">
|
||||
<span class="creator-name">{{ groupTextOf(row) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@@ -286,10 +291,10 @@ onMounted(load)
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="8" class="empty-tip">加载中...</td>
|
||||
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="8" class="empty-tip">{{ keyword || statusFilter || groupFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
||||
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">{{ keyword || statusFilter || groupFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -183,9 +183,9 @@ onMounted(() => {
|
||||
<label>品牌</label>
|
||||
<input v-model="filter.brand" type="text" placeholder="输入品牌" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 180px">
|
||||
<div v-if="isSuperAdmin" class="form-group" style="min-width: 180px">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组" :disabled="!isSuperAdmin">
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
@@ -199,7 +199,7 @@ onMounted(() => {
|
||||
<th style="width: 70px">ID</th>
|
||||
<th style="width: 170px">ASIN</th>
|
||||
<th style="width: 150px">品牌</th>
|
||||
<th style="width: 140px">分组</th>
|
||||
<th v-if="isSuperAdmin" style="width: 140px">分组</th>
|
||||
<th style="width: 110px">来源</th>
|
||||
<th style="width: 170px">创建时间</th>
|
||||
<th style="width: 150px">操作</th>
|
||||
@@ -211,7 +211,7 @@ onMounted(() => {
|
||||
<td>{{ row.id }}</td>
|
||||
<td class="mono-cell">{{ row.dataValue }}</td>
|
||||
<td>{{ row.brand || '—' }}</td>
|
||||
<td>{{ row.groupName || '未分组' }}</td>
|
||||
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
|
||||
<td>
|
||||
<span class="source-text" :class="{ 'is-auto': row.recordSource !== 'MANUAL' }">
|
||||
{{ recordSourceLabel(row.recordSource) }}
|
||||
@@ -225,10 +225,10 @@ onMounted(() => {
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="7" class="empty-tip">加载中...</td>
|
||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="7" class="empty-tip">暂无数据</td>
|
||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -24,6 +24,11 @@ import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './de
|
||||
import type { DedupeImportProgress } from './dedupe-import-model.ts'
|
||||
import { toExportUrl } from './dedupe-total-export.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<DedupeTotalItem[]>([])
|
||||
@@ -32,6 +37,11 @@ const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const filter = reactive(createDedupeTotalFilterState())
|
||||
const groups = ref<DedupeGroupOption[]>([])
|
||||
/** 非超管仅有一个可访问分组时,编辑/导入弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||
const lockedGroupId = computed<number | null>(() => {
|
||||
if (isSuperAdmin.value) return null
|
||||
return groups.value.length === 1 ? groups.value[0].id : null
|
||||
})
|
||||
const jumpPage = ref('')
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
@@ -115,7 +125,7 @@ function changeSize(size: number) {
|
||||
function openEdit(row: DedupeTotalItem): void {
|
||||
editTarget.value = row
|
||||
editValue.value = row.dataValue
|
||||
editGroupId.value = row.groupId
|
||||
editGroupId.value = row.groupId ?? lockedGroupId.value
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
@@ -157,7 +167,7 @@ async function removeRow(row: DedupeTotalItem): Promise<void> {
|
||||
}
|
||||
|
||||
function resetImport(): void {
|
||||
importGroupId.value = null
|
||||
importGroupId.value = lockedGroupId.value
|
||||
importFile.value = null
|
||||
importProgress.value = ''
|
||||
importRunning.value = false
|
||||
@@ -306,7 +316,7 @@ onMounted(() => {
|
||||
<label>用户名(模糊搜索)</label>
|
||||
<input v-model="filter.username" type="text" placeholder="输入用户名" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div v-if="isSuperAdmin" class="form-group">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
|
||||
@@ -348,7 +358,7 @@ onMounted(() => {
|
||||
<th>ASIN</th>
|
||||
<th>国家</th>
|
||||
<th>用户名</th>
|
||||
<th>分组</th>
|
||||
<th v-if="isSuperAdmin">分组</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
@@ -360,7 +370,7 @@ onMounted(() => {
|
||||
<td class="mono-cell">{{ row.dataValue }}</td>
|
||||
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||
<td>{{ row.username || '-' }}</td>
|
||||
<td>{{ row.groupName || '未分组' }}</td>
|
||||
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
|
||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||
@@ -369,10 +379,10 @@ onMounted(() => {
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="7" class="empty-tip">加载中...</td>
|
||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="7" class="empty-tip">暂无总数据</td>
|
||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">暂无总数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -390,7 +400,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>分组</label>
|
||||
<select v-model="editGroupId">
|
||||
<select v-model="editGroupId" :disabled="lockedGroupId != null">
|
||||
<option disabled :value="null">请选择分组</option>
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
@@ -411,7 +421,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>分组</label>
|
||||
<select v-model="importGroupId" :disabled="importRunning">
|
||||
<select v-model="importGroupId" :disabled="importRunning || lockedGroupId != null">
|
||||
<option disabled :value="null">请选择分组</option>
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
|
||||
@@ -16,6 +16,11 @@ import { isAllowedExcelImportFile } from './import-progress-model.ts'
|
||||
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
||||
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<QueryAsinItem[]>([])
|
||||
@@ -23,6 +28,11 @@ const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const groups = ref<ShopGroupOption[]>([])
|
||||
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||
const lockedGroupId = computed<number | null>(() => {
|
||||
if (isSuperAdmin.value) return null
|
||||
return groups.value.length === 1 ? groups.value[0].id : null
|
||||
})
|
||||
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
@@ -115,7 +125,7 @@ const creating = ref(false)
|
||||
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
|
||||
|
||||
function openCreate(): void {
|
||||
createGroupId.value = null
|
||||
createGroupId.value = lockedGroupId.value
|
||||
createShopName.value = ''
|
||||
createCountry.value = ''
|
||||
createAsin.value = ''
|
||||
@@ -123,6 +133,8 @@ function openCreate(): void {
|
||||
createMsgOk.value = false
|
||||
shopNames.value = []
|
||||
createVisible.value = true
|
||||
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
|
||||
if (createGroupId.value != null) void onCreateGroupChange()
|
||||
}
|
||||
|
||||
async function onCreateGroupChange(): Promise<void> {
|
||||
@@ -189,7 +201,7 @@ const importRunning = ref(false)
|
||||
const importProgress = ref('')
|
||||
|
||||
function resetImport(): void {
|
||||
importGroupId.value = null
|
||||
importGroupId.value = lockedGroupId.value
|
||||
importFile.value = null
|
||||
importProgress.value = ''
|
||||
importRunning.value = false
|
||||
@@ -348,7 +360,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="form-row query-filter-row">
|
||||
<div class="form-group">
|
||||
<div v-if="isSuperAdmin" class="form-group">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
@@ -378,7 +390,7 @@ onMounted(() => {
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 6%">序号</th>
|
||||
<th style="width: 13%">分组</th>
|
||||
<th v-if="isSuperAdmin" style="width: 13%">分组</th>
|
||||
<th style="width: 15%">店铺名</th>
|
||||
<th style="width: 24%">ASIN</th>
|
||||
<th style="width: 12%">国家</th>
|
||||
@@ -390,7 +402,7 @@ onMounted(() => {
|
||||
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
|
||||
<template v-if="row.isFirst">
|
||||
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
|
||||
<td :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
|
||||
<td v-if="isSuperAdmin" :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
|
||||
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
|
||||
</template>
|
||||
<td>
|
||||
@@ -409,10 +421,10 @@ onMounted(() => {
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="6" class="empty-tip">加载中...</td>
|
||||
<td :colspan="isSuperAdmin ? 6 : 5" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="6" class="empty-tip">暂无数据</td>
|
||||
<td :colspan="isSuperAdmin ? 6 : 5" class="empty-tip">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -423,7 +435,7 @@ onMounted(() => {
|
||||
<el-dialog v-model="createVisible" title="新增查询 ASIN" width="560px" :close-on-click-modal="false">
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="分组">
|
||||
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" @change="onCreateGroupChange">
|
||||
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" :disabled="lockedGroupId != null" @change="onCreateGroupChange">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -475,7 +487,7 @@ onMounted(() => {
|
||||
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入添加查询 ASIN' : '删除导入查询 ASIN'" width="520px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="分组(Excel 未提供时的兜底,可选)">
|
||||
<el-select filterable v-model="importGroupId" placeholder="可不选" clearable style="width: 100%">
|
||||
<el-select filterable v-model="importGroupId" placeholder="可不选" clearable style="width: 100%" :disabled="lockedGroupId != null">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@@ -15,6 +15,11 @@ import { fetchShopNamesByGroup } from './query-asin-api.ts'
|
||||
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
||||
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<SkipPriceItem[]>([])
|
||||
@@ -22,6 +27,11 @@ const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const groups = ref<ShopGroupOption[]>([])
|
||||
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||
const lockedGroupId = computed<number | null>(() => {
|
||||
if (isSuperAdmin.value) return null
|
||||
return groups.value.length === 1 ? groups.value[0].id : null
|
||||
})
|
||||
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
@@ -57,7 +67,7 @@ const creating = ref(false)
|
||||
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
|
||||
|
||||
function openCreate(): void {
|
||||
createGroupId.value = null
|
||||
createGroupId.value = lockedGroupId.value
|
||||
createShopName.value = ''
|
||||
createCountry.value = ''
|
||||
createAsin.value = ''
|
||||
@@ -66,6 +76,8 @@ function openCreate(): void {
|
||||
createMsgOk.value = false
|
||||
shopNames.value = []
|
||||
createVisible.value = true
|
||||
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
|
||||
if (createGroupId.value != null) void onCreateGroupChange()
|
||||
}
|
||||
|
||||
async function onCreateGroupChange(): Promise<void> {
|
||||
@@ -238,7 +250,7 @@ const importRunning = ref(false)
|
||||
const importProgress = ref('')
|
||||
|
||||
function resetImport(): void {
|
||||
importGroupId.value = null
|
||||
importGroupId.value = lockedGroupId.value
|
||||
importFile.value = null
|
||||
importProgress.value = ''
|
||||
importRunning.value = false
|
||||
@@ -392,7 +404,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="form-row skip-filter-row">
|
||||
<div class="form-group">
|
||||
<div v-if="isSuperAdmin" class="form-group">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
@@ -430,7 +442,7 @@ onMounted(() => {
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 6%">序号</th>
|
||||
<th style="width: 13%">分组</th>
|
||||
<th v-if="isSuperAdmin" style="width: 13%">分组</th>
|
||||
<th style="width: 15%">店铺名</th>
|
||||
<th style="width: 24%">ASIN</th>
|
||||
<th style="width: 12%">国家</th>
|
||||
@@ -443,7 +455,7 @@ onMounted(() => {
|
||||
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
|
||||
<template v-if="row.isFirst">
|
||||
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
|
||||
<td :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
|
||||
<td v-if="isSuperAdmin" :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
|
||||
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
|
||||
</template>
|
||||
<td>
|
||||
@@ -463,10 +475,10 @@ onMounted(() => {
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="7" class="empty-tip">加载中...</td>
|
||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="7" class="empty-tip">暂无数据</td>
|
||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -477,7 +489,7 @@ onMounted(() => {
|
||||
<el-dialog v-model="createVisible" title="新增 ASIN" width="560px" :close-on-click-modal="false">
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="分组">
|
||||
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" @change="onCreateGroupChange">
|
||||
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" :disabled="lockedGroupId != null" @change="onCreateGroupChange">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -535,7 +547,7 @@ onMounted(() => {
|
||||
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入最低价 ASIN' : '删除导入最低价 ASIN'" width="520px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="分组(必选)">
|
||||
<el-select filterable v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
|
||||
<el-select filterable v-model="importGroupId" placeholder="请选择分组" style="width: 100%" :disabled="lockedGroupId != null">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@@ -18,6 +18,8 @@ import type { ShopCredential, ShopGroupOption, ShopSummary } from './shop-dto.ts
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
const loading = ref(false)
|
||||
const rows = ref<ShopSummary[]>([])
|
||||
const total = ref(0)
|
||||
@@ -42,6 +44,11 @@ const revealedPasswordCache = ref<Record<number, string>>({})
|
||||
const revealedRowId = ref<number | null>(null)
|
||||
|
||||
const groupOptions = computed(() => groups.value)
|
||||
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||
const lockedGroupId = computed<number | null>(() => {
|
||||
if (isSuperAdmin.value) return null
|
||||
return groups.value.length === 1 ? groups.value[0].id : null
|
||||
})
|
||||
|
||||
function shownPassword(row: ShopSummary): string {
|
||||
if (revealedRowId.value !== row.id) return row.passwordMasked || '******'
|
||||
@@ -123,13 +130,14 @@ function goJump() {
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
Object.assign(form, emptyShopManageForm())
|
||||
if (lockedGroupId.value != null) form.groupId = lockedGroupId.value
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: ShopSummary) {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
groupId: row.groupId,
|
||||
groupId: row.groupId ?? lockedGroupId.value,
|
||||
shopName: row.shopName,
|
||||
mallName: row.mallName,
|
||||
znUsername: row.znUsername,
|
||||
@@ -214,7 +222,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="form-row shop-filter-row">
|
||||
<div class="form-group" style="min-width: 180px">
|
||||
<div v-if="isSuperAdmin" class="form-group" style="min-width: 180px">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
@@ -232,7 +240,7 @@ onMounted(() => {
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 58px">序号</th>
|
||||
<th style="width: 120px">分组</th>
|
||||
<th v-if="isSuperAdmin" style="width: 120px">分组</th>
|
||||
<th style="width: 150px">店铺名</th>
|
||||
<th style="width: 130px">店铺商城名</th>
|
||||
<th style="width: 130px">自动化账号</th>
|
||||
@@ -247,7 +255,7 @@ onMounted(() => {
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="(row, index) in rows" :key="row.id">
|
||||
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
||||
<td data-text :title="row.groupName || '—'">{{ row.groupName || '—' }}</td>
|
||||
<td v-if="isSuperAdmin" data-text :title="row.groupName || '—'">{{ row.groupName || '—' }}</td>
|
||||
<td :title="row.shopName">{{ row.shopName }}</td>
|
||||
<td :title="row.mallName || '—'">{{ row.mallName || '—' }}</td>
|
||||
<td :title="row.znUsername || '—'">{{ row.znUsername || '—' }}</td>
|
||||
@@ -273,10 +281,10 @@ onMounted(() => {
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="10" class="empty-tip">加载中...</td>
|
||||
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="10" class="empty-tip">暂无店铺</td>
|
||||
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">暂无店铺</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -287,7 +295,7 @@ onMounted(() => {
|
||||
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增店铺' : '编辑店铺'" width="600px">
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="所属分组" required>
|
||||
<el-select filterable v-model="form.groupId" placeholder="选择分组">
|
||||
<el-select filterable v-model="form.groupId" placeholder="选择分组" :disabled="lockedGroupId != null">
|
||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -316,7 +324,7 @@ onMounted(() => {
|
||||
<el-dialog v-model="credentialVisible" title="店铺明文凭据" width="560px">
|
||||
<el-descriptions v-if="credential" :column="1" border>
|
||||
<el-descriptions-item label="店铺名">{{ credential.shopName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分组">{{ credential.groupName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="isSuperAdmin" label="分组">{{ credential.groupName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺商城名">{{ credential.mallName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账号">{{ credential.account }}</el-descriptions-item>
|
||||
<el-descriptions-item label="密码">
|
||||
|
||||
@@ -588,7 +588,7 @@ onMounted(() => {
|
||||
<div class="dup-body">
|
||||
<div v-for="block in shopBlocks(item)" :key="block.shopName" class="store-col">
|
||||
<div class="st-name">{{ block.shopName }}<span class="st-count">{{ block.count }} 条</span></div>
|
||||
<div v-if="block.groupName" class="st-grp">分组:{{ block.groupName }}</div>
|
||||
<div v-if="isSuper && block.groupName" class="st-grp">分组:{{ block.groupName }}</div>
|
||||
<template v-for="site in block.sites" :key="site.country">
|
||||
<div class="c-site"><span class="m-dot" :style="{ background: siteColor(site.country) }"></span>{{ asinCountryLabel(site.country) }} · {{ site.times.length }} 次</div>
|
||||
<div class="c-times"><span v-for="(time, index) in site.times" :key="index" class="c-time">{{ time }}</span></div>
|
||||
@@ -666,12 +666,12 @@ onMounted(() => {
|
||||
</div>
|
||||
<table v-if="drawerAggRows.length" class="d-table">
|
||||
<thead>
|
||||
<tr><th>店铺</th><th>分组</th><th>站点</th><th>上架时间(按时间升序)</th><th>次数</th></tr>
|
||||
<tr><th>店铺</th><th v-if="isSuper">分组</th><th>站点</th><th>上架时间(按时间升序)</th><th>次数</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in drawerRows" :key="row.shopName + '|' + row.country">
|
||||
<td class="cell-strong">{{ row.shopName }}</td>
|
||||
<td><span class="d-stgrp">{{ row.groupName || '—' }}</span></td>
|
||||
<td v-if="isSuper"><span class="d-stgrp">{{ row.groupName || '—' }}</span></td>
|
||||
<td><span class="site-chip" :style="{ background: siteColor(row.country) + '1F', color: siteColor(row.country) }">{{ asinCountryLabel(row.country || '') }}</span></td>
|
||||
<td><template v-for="(time, index) in row.times" :key="index"><span class="d-time">{{ time }}</span></template></td>
|
||||
<td class="num-cell">{{ row.count }}</td>
|
||||
|
||||
@@ -337,7 +337,7 @@ onMounted(load)
|
||||
|
||||
<div class="iv-card-info">
|
||||
<div class="iv-info-row"><label>用户名</label><span>{{ task.username || '-' }}</span></div>
|
||||
<div class="iv-info-row"><label>所属分组</label><span>{{ task.groupName || '-' }}</span></div>
|
||||
<div v-if="session.isSuperAdmin" class="iv-info-row"><label>所属分组</label><span>{{ task.groupName || '-' }}</span></div>
|
||||
<div class="iv-info-row"><label>任务模式</label><span>{{ task.mode || '-' }}</span></div>
|
||||
<div class="iv-info-row"><label>生成时间</label><span>{{ formatDateTime(imageVideoGeneratedAt(task)) }}</span></div>
|
||||
<div class="iv-info-row">
|
||||
|
||||
@@ -305,7 +305,7 @@ onMounted(load)
|
||||
<label>店铺</label>
|
||||
<input v-model="filter.shopName" type="text" placeholder="模糊搜索店铺名" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 150px">
|
||||
<div v-if="session.isSuperAdmin" class="form-group" style="min-width: 150px">
|
||||
<label>分组</label>
|
||||
<input v-model="filter.groupName" type="text" placeholder="模糊搜索分组名" @keyup.enter="apply" />
|
||||
</div>
|
||||
@@ -361,7 +361,7 @@ onMounted(load)
|
||||
<span class="shop-card-task-no" :title="`任务 ${row.taskId || row.taskNo || row.resultId}`">任务 {{ row.taskId || row.taskNo || row.resultId }}</span>
|
||||
<span class="st-pill" :class="`is-${statusType(row.status)}`">{{ statusLabel(row.status) }}</span>
|
||||
</div>
|
||||
<div class="sd-card-row" v-if="row.groupName">
|
||||
<div v-if="session.isSuperAdmin && row.groupName" class="sd-card-row">
|
||||
<span class="sd-card-label">分组</span>
|
||||
<span class="sd-card-value">{{ row.groupName }}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* 分组维度仅超管可见:非超管(管理员/普通用户)数据已由后端裁剪到本人分组,
|
||||
* 后台各页的分组筛选与分组列一律不展示,只有超管需要按分组筛选/区分。
|
||||
* 参考实现见撞款检测页(DuplicateCheckPage.vue 分组筛选用 isSuper 门控)。
|
||||
*/
|
||||
|
||||
/** 页面 → 分组筛选门控片段(正则不做宽松匹配,确保门控真实存在)。 */
|
||||
const FILTER_GATES: Array<[string, RegExp]> = [
|
||||
['src/pages/asin/AsinInvalidPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||
['src/pages/asin/QueryAsinPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||
['src/pages/asin/SkipPricePage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||
['src/pages/asin/DedupeRegistryPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||
['src/pages/shop/ShopManagePage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||
['src/pages/tasks/ShopDataTasksPage.vue', /v-if="session\.isSuperAdmin"[\s\S]{0,160}模糊搜索分组名/],
|
||||
['src/pages/account/UserSecretsPage.vue', /v-if="isSuperAdmin[\s\S]{0,160}<label>分组<\/label>/],
|
||||
]
|
||||
|
||||
/** 页面 → 分组列表头门控片段。 */
|
||||
const COLUMN_GATES: Array<[string, RegExp]> = [
|
||||
['src/pages/asin/AsinInvalidPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||
['src/pages/asin/QueryAsinPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||
['src/pages/asin/SkipPricePage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||
['src/pages/asin/DedupeRegistryPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||
['src/pages/shop/ShopManagePage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||
['src/pages/tasks/DuplicateCheckPage.vue', /<th v-if="isSuper"[^>]*>分组<\/th>/],
|
||||
['src/pages/account/UserSecretsPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||
]
|
||||
|
||||
test('align_group_visibility_filter_gated_by_super_admin', () => {
|
||||
for (const [file, pattern] of FILTER_GATES) {
|
||||
assert.match(readSource(file), pattern, `${file} 分组筛选未按超管门控`)
|
||||
}
|
||||
})
|
||||
|
||||
test('align_group_visibility_column_gated_by_super_admin', () => {
|
||||
for (const [file, pattern] of COLUMN_GATES) {
|
||||
assert.match(readSource(file), pattern, `${file} 分组列未按超管门控`)
|
||||
}
|
||||
})
|
||||
|
||||
test('align_group_visibility_no_ungated_group_column_header', () => {
|
||||
// 不变量:以上页面的“分组”数据列表头必须带 v-if 门控,防止后续改动误放开。
|
||||
// DuplicateCheckPage 的分组说明抽屉内另有一张分组统计表,该抽屉由已门控的按钮打开,
|
||||
// 非超管无法触达,故不纳入本不变量(单独在 detail_cards 用例中校验按钮门控)。
|
||||
const ungated = /<th(?![^>]*v-if)[^>]*>分组<\/th>/
|
||||
for (const [file] of COLUMN_GATES) {
|
||||
if (file.endsWith('DuplicateCheckPage.vue')) continue
|
||||
assert.doesNotMatch(readSource(file), ungated, `${file} 存在未门控的分组列表头`)
|
||||
}
|
||||
})
|
||||
|
||||
test('align_group_visibility_detail_cards_gated', () => {
|
||||
// 明细卡片/信息行里的分组展示同样只给超管。
|
||||
assert.match(
|
||||
readSource('src/pages/tasks/ImageVideoTasksPage.vue'),
|
||||
/v-if="session\.isSuperAdmin"[\s\S]{0,120}所属分组/,
|
||||
'图片视频任务页「所属分组」未门控',
|
||||
)
|
||||
assert.match(
|
||||
readSource('src/pages/tasks/ShopDataTasksPage.vue'),
|
||||
/v-if="session\.isSuperAdmin && row\.groupName"/,
|
||||
'店铺数据记录页卡片分组行未门控',
|
||||
)
|
||||
assert.match(
|
||||
readSource('src/pages/tasks/DuplicateCheckPage.vue'),
|
||||
/v-if="isSuper && block\.groupName"/,
|
||||
'撞款检测页卡片分组标签未门控',
|
||||
)
|
||||
// 分组说明抽屉整体为分组维度内容:入口按钮与分组范围切换条均已按超管门控。
|
||||
const dup = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
||||
assert.match(dup, /<button v-if="isSuper"[^>]*@click="groupInfoVisible = true"/, '分组说明入口未门控')
|
||||
assert.match(dup, /<div v-if="isSuper" class="scope-bar"/, '分组撞款范围条未门控')
|
||||
})
|
||||
|
||||
test('align_group_visibility_dialogs_keep_locked_selector', () => {
|
||||
// 弹窗分组选择保留展示但非超管锁定(单分组时),不隐藏。
|
||||
const lockedPages = [
|
||||
'src/pages/asin/QueryAsinPage.vue',
|
||||
'src/pages/asin/SkipPricePage.vue',
|
||||
'src/pages/asin/DedupeRegistryPage.vue',
|
||||
'src/pages/shop/ShopManagePage.vue',
|
||||
]
|
||||
for (const file of lockedPages) {
|
||||
const src = readSource(file)
|
||||
assert.match(src, /lockedGroupId/, `${file} 缺少弹窗分组锁定计算`)
|
||||
assert.match(src, /:disabled="lockedGroupId != null"/, `${file} 弹窗分组未锁定`)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user