Files
crawler-plugin/admin-frontend-vue/src/pages/shop/ShopManagePage.vue
T

574 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'
const session = useAdminSessionStore()
const loading = ref(false)
const rows = ref<ShopSummary[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
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)
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,
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 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())
dialogVisible.value = true
}
function openEdit(row: ShopSummary) {
editingId.value = row.id
Object.assign(form, {
groupId: row.groupId,
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 class="form-group" style="min-width: 180px">
<label>分组</label>
<select v-model="filter.groupId">
<option :value="null">全部分组</option>
<option v-for="group in groupOptions" :key="group.id" :value="group.id">{{ group.groupName }}</option>
</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 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 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="10" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="10" class="empty-tip">暂无店铺</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<span class="page-total"> {{ total }} </span>
<button type="button" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button>
<span class="page-cur"> {{ page }} / {{ totalPages }} </span>
<button type="button" :disabled="page >= totalPages" @click="changePage(page + 1)">下一页</button>
<span class="page-jump">
跳至
<input v-model="jumpPage" type="text" inputmode="numeric" @keyup.enter="goJump" />
<button type="button" @click="goJump">跳转</button>
</span>
</div>
</section>
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增店铺' : '编辑店铺'" width="600px">
<el-form label-width="120px">
<el-form-item label="所属分组" required>
<el-select v-model="form.groupId" placeholder="选择分组">
<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 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 {
white-space: nowrap;
}
.ops-cell .btn + .btn {
margin-left: 8px;
}
.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>