Files
crawler-plugin/admin-frontend-vue/src/pages/shop/ShopManagePage.vue
T
huangzd1997 3f6ad0c6ad feat(后台分组): 分组筛选与分组列仅超管可见,非超管一律隐藏
管理员/普通用户数据已由后端裁剪到本人可访问分组,分组维度对其无意义;
账号归属分组是超管职责,故只有超管需要按分组筛选与区分。

- 隐藏分组筛选+分组列:品牌数据库、查询ASIN、最低价ASIN、去重总数据、
  店铺管理、用户密钥
- 仅隐藏分组展示:店铺数据记录(筛选框+卡片行)、图片视频任务(所属分组行)、
  撞款检测(明细抽屉分组列+卡片分组标签)
- 弹窗分组选择保留展示但锁定:非超管仅1个可访问分组时自动选中并置灰
  (查询/最低价ASIN 另联动加载该分组店铺),多分组不锁以免限制用户
- 空数据行 colspan 随列显隐动态化
- 新增 tests/align-group-visibility.test.ts 回归守卫(5 条)
2026-09-13 14:00:19 +08:00

578 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 店铺管理页 · 像素复刻旧版 admin.html panel-shop-manage(自绘:面板头+新增店铺、筛选、密码列 ******+眼睛实时读明文、旧式表格/分页、原生 confirm 删除)。
* script 逻辑沿用现有 Vue 实现(凭证明文 dialog/编辑弹窗/分组筛选)。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
import {
deleteShopManage,
fetchShopCredential,
fetchShopManageGroups,
fetchShopManageList,
submitCreateShopManage,
submitUpdateShopManage,
} from './shop-manage-api.ts'
import { emptyShopManageForm } from './shop-manage-form-model.ts'
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)
const page = ref(1)
const pageSize = ref(10)
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const groups = ref<ShopGroupOption[]>([])
const filter = reactive({ shopName: '', groupId: null as number | null })
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
const form = reactive(emptyShopManageForm())
const credentialVisible = ref(false)
const credential = ref<ShopCredential | null>(null)
/** 密码列“眼睛”实时读取:仅展示已读明文,其余保持掩码;读取中占位。 */
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 || '******'
const plain = revealedPasswordCache.value[row.id]
return plain != null && plain !== '' ? plain : '读取中...'
}
async function togglePassword(row: ShopSummary) {
if (revealedRowId.value === row.id) {
revealedRowId.value = null
return
}
revealedRowId.value = row.id
if (revealedPasswordCache.value[row.id] == null) {
try {
const cred = await fetchShopCredential(row.id, row.shopName)
revealedPasswordCache.value[row.id] = cred.password
} catch (error) {
revealedRowId.value = null
ElMessage.error(error instanceof Error ? error.message : '读取密码失败')
}
}
}
async function loadGroups() {
try {
groups.value = await fetchShopManageGroups()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '分组加载失败')
}
}
async function load() {
loading.value = true
try {
const result = await fetchShopManageList({
page: page.value,
pageSize: pageSize.value,
groupId: filter.groupId,
shopName: filter.shopName.trim() || undefined,
})
rows.value = result.items
total.value = result.total
page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '店铺列表加载失败')
} finally {
loading.value = false
}
}
function apply() {
page.value = 1
load()
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
void load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
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 ?? lockedGroupId.value,
shopName: row.shopName,
mallName: row.mallName,
znUsername: row.znUsername,
account: row.account,
password: '',
})
dialogVisible.value = true
}
async function submit() {
if (
form.groupId == null ||
!(form.shopName || '').trim() ||
!(form.mallName || '').trim() ||
!(form.account || '').trim() ||
!(form.password || '').trim()
) {
ElMessage.warning('请完整填写分组、店铺名、店铺商城名、账号、密码')
return
}
saving.value = true
try {
const createdById = session.user?.id ?? 0
if (editingId.value == null) {
await submitCreateShopManage({ ...form }, createdById)
ElMessage.success('已新增店铺')
} else {
await submitUpdateShopManage(editingId.value, { ...form })
ElMessage.success('已保存')
}
dialogVisible.value = false
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
saving.value = false
}
}
async function showCredential(row: ShopSummary) {
try {
credential.value = await fetchShopCredential(row.id, row.shopName)
credentialVisible.value = true
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '读取凭证失败')
}
}
async function remove(row: ShopSummary) {
// 对齐旧版:原生 confirm。
if (!window.confirm(`确定删除店铺“${row.shopName}”吗?`)) return
try {
await deleteShopManage(row.id)
ElMessage.success('已删除')
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function copyCredential(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择复制')
}
}
onMounted(() => {
loadGroups()
load()
})
</script>
<template>
<div class="shop-view">
<section class="panel-box">
<div class="shop-head">
<h3>店铺列表</h3>
<button class="btn" type="button" @click="openCreate">新增店铺</button>
</div>
<div class="form-row shop-filter-row">
<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" />
</el-select>
</div>
<div class="form-group" style="min-width: 220px">
<label>店铺名</label>
<input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
</div>
<button class="btn" type="button" @click="apply">查询</button>
</div>
<div class="table-scroll shop-manage-table-scroll">
<table>
<thead>
<tr>
<th style="width: 58px">序号</th>
<th v-if="isSuperAdmin" style="width: 120px">分组</th>
<th style="width: 150px">店铺名</th>
<th style="width: 130px">店铺商城名</th>
<th style="width: 130px">自动化账号</th>
<th style="width: 130px">账号</th>
<th style="width: 170px">密码</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">修改时间</th>
<th style="width: 220px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="(row, index) in rows" :key="row.id">
<td>{{ (page - 1) * pageSize + index + 1 }}</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>
<td :title="row.account">{{ row.account }}</td>
<td>
<span class="pwd-cell">{{ shownPassword(row) }}</span>
<button
class="pwd-eye btn btn-sm btn-secondary"
type="button"
:aria-label="revealedRowId === row.id ? '隐藏密码' : '显示密码'"
@click="togglePassword(row)"
>
{{ revealedRowId === row.id ? '隐藏' : '显示' }}
</button>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button class="btn btn-sm btn-secondary" type="button" @click="showCredential(row)">凭证</button>
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">暂无店铺</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<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="选择分组" :disabled="lockedGroupId != null">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="店铺名" required>
<el-input v-model="form.shopName" placeholder="店铺名" />
</el-form-item>
<el-form-item label="店铺商城名" required>
<el-input v-model="form.mallName" placeholder="店铺商城名" />
</el-form-item>
<el-form-item label="账号" required>
<el-input v-model="form.account" placeholder="登录账号" />
</el-form-item>
<el-form-item label="密码" required>
<el-input v-model="form.password" type="password" show-password :placeholder="editingId == null ? '登录密码' : '后端必填,编辑请重新填写登录密码'" />
</el-form-item>
<el-form-item label="自动化账号">
<el-input v-model="form.znUsername" placeholder="可选,紫鸟自动化账号" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
</template>
</el-dialog>
<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 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="密码">
<code>{{ credential.password }}</code>
<el-button text type="primary" size="small" @click="copyCredential(credential.password)">复制</el-button>
</el-descriptions-item>
<el-descriptions-item label="自动化账号">{{ credential.znUsername || '—' }}</el-descriptions-item>
</el-descriptions>
<p class="dim">明文凭据仅在本窗口临时显示请勿外泄</p>
</el-dialog>
</div>
</template>
<style scoped>
/* 像素复刻旧版 admin.html panel-shop-manage(蓝白末层)。 */
.shop-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.shop-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.shop-manage-table-scroll > table {
min-width: 1200px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.pwd-cell {
font-family: Consolas, monospace;
font-size: 12.5px;
margin-right: 6px;
}
.pwd-eye {
min-height: 28px;
padding: 3px 10px;
font-size: 12px;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.dim {
color: #8293a5;
font-size: 12px;
}
</style>