align(全局+asin): 每面板操作提示条(guides对齐旧版admin-interactions.js原文);asin域行内按钮统一实心small(对齐.btn-sm);商品类目树折叠钮靛蓝#6366f1改蓝白token

This commit is contained in:
2026-09-06 21:38:16 +08:00
parent 26e062d32c
commit 4742a2e3b5
10 changed files with 315 additions and 13 deletions
@@ -12,6 +12,7 @@ import {
shouldShowEmptyMenu,
} from '@/layout/empty-state'
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
import OperationGuide from '@/layout/OperationGuide.vue'
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
import {
LOGOUT_CONFIRM_CANCEL,
@@ -110,6 +111,7 @@ async function signOut() {
<el-breadcrumb v-if="breadcrumbs.length > 1" class="admin-breadcrumb" separator="/">
<el-breadcrumb-item v-for="crumb in breadcrumbs" :key="crumb.key">{{ crumb.name }}</el-breadcrumb-item>
</el-breadcrumb>
<OperationGuide />
<GlobalErrorContainer />
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
<el-empty
@@ -0,0 +1,82 @@
<script setup lang="ts">
/** 当前页面操作提示条:对齐旧版 admin-interactions.js 的 adminOperationGuide14 页有独立文案,余用通用 fallback)。
* 挂在 AdminLayout 内容区顶部,随 route.meta.menuKey 切换文案;折叠态存 localStorage。 */
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { OPERATION_GUIDE_STORAGE_KEY, operationGuideFor } from '@/layout/operation-guides'
const route = useRoute()
const guide = computed(() => operationGuideFor(route.meta.menuKey))
function readCollapsed(): boolean {
try {
return localStorage.getItem(OPERATION_GUIDE_STORAGE_KEY) === '1'
} catch {
return false
}
}
function persistCollapsed(value: boolean) {
try {
localStorage.setItem(OPERATION_GUIDE_STORAGE_KEY, value ? '1' : '0')
} catch {
// 忽略隐私模式写入失败
}
}
const collapsed = ref(readCollapsed())
function toggle() {
collapsed.value = !collapsed.value
persistCollapsed(collapsed.value)
}
watch(collapsed, (v) => persistCollapsed(v))
</script>
<template>
<aside v-if="guide" class="operation-guide" :class="{ 'is-collapsed': collapsed }" aria-label="当前页面操作提示">
<div class="og-head">
<span class="og-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M12 11v5"></path><path d="M12 8h.01"></path></svg>
当前页面操作提示
</span>
<button type="button" class="og-toggle" :aria-expanded="!collapsed" @click="toggle">
{{ collapsed ? '展开提示' : '收起提示' }}
</button>
</div>
<div v-show="!collapsed" class="og-body">
<p class="og-text">{{ guide.text }}</p>
<ol v-if="guide.steps.length" class="og-steps">
<li v-for="step in guide.steps" :key="step">{{ step }}</li>
</ol>
</div>
</aside>
</template>
<style scoped>
.operation-guide {
display: flex; flex-direction: column;
margin: 0 0 18px; padding: 12px 16px;
border: 1px solid #cddbe6; border-radius: 12px;
background: linear-gradient(180deg, #fbfdfe, #f4f8fc);
box-shadow: 0 10px 26px -24px rgba(39, 67, 94, 0.3);
color: var(--admin-text);
}
.operation-guide.is-collapsed { padding: 8px 16px; }
.og-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.og-title {
display: inline-flex; align-items: center; gap: 7px;
font-size: 13px; font-weight: 650; color: var(--admin-primary-strong);
}
.og-title svg { width: 15px; height: 15px; }
.og-toggle {
padding: 0; border: 0; background: none; cursor: pointer;
font: inherit; font-size: 12px; color: #6b8096;
}
.og-toggle:hover { color: var(--admin-primary-strong); }
.og-body { margin-top: 8px; padding-left: 22px; }
.og-text { margin: 0; color: #40586e; font-size: 13px; line-height: 1.7; }
.og-steps { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; }
.og-steps li {
padding: 2px 10px; border-radius: 999px;
background: #e9f1f8; border: 1px solid #d3e0ea;
color: #2f4a63; font-size: 12px; line-height: 1.7;
}
</style>
@@ -0,0 +1,86 @@
/**
* 每菜单页的操作提示(guides)文案,对齐旧版 backend/static/admin-interactions.js 的 guides 表原文
* (除 group-manage / duplicate-check 用通用 fallback)。key = 路由 meta.menuKey。
*/
export interface OperationGuideData {
text: string
steps: string[]
}
export const OPERATION_GUIDE_FALLBACK: OperationGuideData = {
text: '先使用筛选定位记录,再进行新增、编辑、导出等操作。涉及删除的数据会要求二次确认。',
steps: ['选择筛选条件', '处理记录', '核对反馈'],
}
/** 折叠状态 localStorage key(沿用旧版 shufuAdminGuideCollapsed 语义)。 */
export const OPERATION_GUIDE_STORAGE_KEY = 'shufuAdminGuideCollapsed'
export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
admin_users: {
text: '先用筛选定位账号,再编辑角色和菜单权限;删除账号会要求二次确认。',
steps: ['筛选账号', '编辑权限', '确认保存'],
},
admin_columns: {
text: '菜单会影响后台和软件端的可见范围。先填写名称并选择对应页面,再设置上级菜单;顺序直接拖动列表左侧手柄调整。',
steps: ['新增或调整菜单', '设置层级', '拖动排序'],
},
admin_group_manage: OPERATION_GUIDE_FALLBACK,
admin_dedupe_total_data: {
text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。',
steps: ['选择分组', '筛选或导入', '核对并导出'],
},
admin_invalid_asin_data: {
text: '维护不符合规则的 ASIN 或品牌。添加后可使用上方筛选快速回查。',
steps: ['填写 ASIN/品牌', '选择分组', '保存并回查'],
},
admin_query_asin: {
text: '查询 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN。',
steps: ['筛选店铺', '打开配置抽屉', '保存或导出'],
},
admin_product_categories: {
text: '类目树支持展开查看层级。搜索、编辑和删除都在同一列表中完成。',
steps: ['搜索类目', '展开层级', '新增或编辑'],
},
admin_skip_price_asin: {
text: '最低价 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN 与最低价。',
steps: ['筛选店铺', '打开配置抽屉', '保存或批量导入'],
},
admin_shop_keys: {
text: '紫鸟令牌属于敏感配置。白名单状态可悬停查看检测详情,编辑前请先核对账号名称。',
steps: ['新增或筛选密钥', '查看白名单状态', '编辑或删除'],
},
admin_shop_manage: {
text: '店铺信息按分组管理。长商城名会自动缩略,悬停即可查看完整内容。',
steps: ['选择分组', '维护店铺信息', '筛选核对结果'],
},
admin_shop_data_crawl_tasks: {
text: '店铺数据任务按状态和时间筛选。批量操作前请核对已选任务。',
steps: ['筛选任务', '检查状态', '执行批量操作'],
},
// admin_shop_data_duplicate_check(店铺撞款监控)为例外页:不展示全局提示条,不参与对齐。
admin_image_video_tasks: {
text: '可先使用筛选缩小任务范围,再查看任务状态、结果和权限范围。',
steps: ['设置筛选', '查看任务结果', '按需处理任务'],
},
admin_history: {
text: '生成记录可按用户和时间范围回溯,用于核对结果文件和执行时间。',
steps: ['设置时间范围', '筛选记录', '查看结果预览'],
},
admin_version: {
text: '上传版本后请核对版本号和下载链接,再通知用户更新。',
steps: ['上传压缩包', '检查版本记录', '维护历史版本'],
},
digital_human_version: {
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
steps: ['上传草稿', '确认更新日志', '发布或设为最新'],
},
}
/** 全部有独立/兜底提示的后台业务菜单 key。 */
export const OPERATION_GUIDE_MENU_KEYS = Object.keys(OPERATION_GUIDES)
/** 返回指定菜单 key 的提示数据;menuKey 不在已知集合时返回 null(登录/404 等页面不展示)。 */
export function operationGuideFor(menuKey: unknown): OperationGuideData | null {
if (typeof menuKey !== 'string' || !menuKey) return null
return OPERATION_GUIDES[menuKey] ?? null
}
@@ -202,8 +202,8 @@ onMounted(() => {
</el-table-column>
<el-table-column label="操作" min-width="150" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openEdit(row as InvalidAsinItem)">编辑</el-button>
<el-button text type="danger" size="small" @click="remove(row as InvalidAsinItem)">删除</el-button>
<el-button type="primary" size="small" @click="openEdit(row as InvalidAsinItem)">编辑</el-button>
<el-button type="danger" size="small" @click="remove(row as InvalidAsinItem)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -371,8 +371,8 @@ onMounted(() => {
</el-table-column>
<el-table-column label="操作" min-width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row as DedupeTotalItem)">编辑</el-button>
<el-button link type="danger" @click="removeRow(row as DedupeTotalItem)">删除</el-button>
<el-button size="small" type="primary" @click="openEdit(row as DedupeTotalItem)">编辑</el-button>
<el-button size="small" type="danger" @click="removeRow(row as DedupeTotalItem)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -371,9 +371,8 @@ onMounted(loadTree)
<el-table-column label="操作" min-width="140" fixed="right">
<template #default="{ row }">
<template v-if="!isCategoryLoadMoreNode(row.node)">
<el-button link size="small" type="primary" @click="openEdit(row.node as ProductCategoryNode)">编辑</el-button>
<el-button size="small" type="primary" @click="openEdit(row.node as ProductCategoryNode)">编辑</el-button>
<el-button
link
size="small"
type="danger"
:disabled="!canDelete(row.node as ProductCategoryNode)"
@@ -413,8 +412,8 @@ onMounted(loadTree)
</el-table-column>
<el-table-column label="操作" min-width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row as ProductCategoryNode)">编辑</el-button>
<el-button link type="danger" :disabled="!canDelete(row as ProductCategoryNode)" @click="removeCategory(row as ProductCategoryNode)">删除</el-button>
<el-button size="small" type="primary" @click="openEdit(row as ProductCategoryNode)">编辑</el-button>
<el-button size="small" type="danger" :disabled="!canDelete(row as ProductCategoryNode)" @click="removeCategory(row as ProductCategoryNode)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -462,8 +461,8 @@ onMounted(loadTree)
height: 20px;
border: 0;
border-radius: 5px;
background: #eef0fe;
color: #6366f1;
background: var(--admin-primary-soft, #e7f0f8);
color: var(--admin-primary-strong, #2f5d8b);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -475,7 +474,7 @@ onMounted(loadTree)
flex: none;
transition: background 0.12s ease, color 0.12s ease;
}
.tree-node-mark:not(.is-leaf):hover { background: #6366f1; color: #fff; }
.tree-node-mark:not(.is-leaf):hover { background: var(--admin-primary-strong, #2f5d8b); color: #fff; }
.tree-node-mark.is-leaf { background: transparent; cursor: default; }
.node-name { font-weight: 600; color: var(--el-text-color-primary); white-space: nowrap; }
.node-desc { color: var(--el-text-color-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 320px; display: inline-block; vertical-align: middle; }
@@ -381,7 +381,7 @@ onMounted(() => {
</el-table-column>
<el-table-column label="操作" min-width="90" fixed="right">
<template #default="{ row }">
<el-button v-if="row.isFirst" link type="primary" @click="openConfig(row.item as QueryAsinItem)">配置</el-button>
<el-button v-if="row.isFirst" size="small" type="primary" @click="openConfig(row.item as QueryAsinItem)">配置</el-button>
</template>
</el-table-column>
</el-table>
@@ -433,7 +433,7 @@ onMounted(() => {
</el-table-column>
<el-table-column label="操作" min-width="90" fixed="right">
<template #default="{ row }">
<el-button v-if="row.isFirst" link type="primary" @click="openConfig(row.item as SkipPriceItem)">配置</el-button>
<el-button v-if="row.isFirst" size="small" type="primary" @click="openConfig(row.item as SkipPriceItem)">配置</el-button>
</template>
</el-table-column>
</el-table>
@@ -0,0 +1,60 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
/** 对齐旧版 .btn-sm:asin 域 5 页表格行内操作(编辑/删除/配置)统一为实心小按钮(type primary/danger + size small),不再用 link/text;类目树折叠钮由靛蓝改为蓝白 token。 */
// 实心小按钮:同一 el-button 内同时含 size="small" 与 @click="<action>",且不含 link/text 修饰。
const SOLID_ACTION = (action: string) => new RegExp(`<el-button[^>]*size="small"[^>]*@click="${action}`)
const NO_MODIFIER = (action: string, mod: 'link' | 'text') =>
new RegExp(`<el-button[^>]*${mod}[^>]*@click="${action}`)
function assertRowButtonSolid(source: string, action: string) {
assert.match(source, SOLID_ACTION(action), `${action} 为 size=small 实心按钮`)
assert.doesNotMatch(source, NO_MODIFIER(action, 'link'), `${action} 不再 link`)
assert.doesNotMatch(source, NO_MODIFIER(action, 'text'), `${action} 不再 text`)
}
test('align_dedupe_registry_row_buttons_solid', () => {
const s = readSource('src/pages/asin/DedupeRegistryPage.vue')
assertRowButtonSolid(s, 'openEdit')
assertRowButtonSolid(s, 'removeRow')
})
test('align_invalid_asin_row_buttons_solid', () => {
const s = readSource('src/pages/asin/AsinInvalidPage.vue')
assertRowButtonSolid(s, 'openEdit')
assertRowButtonSolid(s, 'remove')
})
test('align_query_asin_config_solid', () => {
assertRowButtonSolid(readSource('src/pages/asin/QueryAsinPage.vue'), 'openConfig')
})
test('align_skip_price_config_solid', () => {
assertRowButtonSolid(readSource('src/pages/asin/SkipPricePage.vue'), 'openConfig')
})
test('align_product_category_tree_row_buttons_solid', () => {
const s = readSource('src/pages/asin/ProductCategoryPage.vue')
assertRowButtonSolid(s, 'openEdit')
assertRowButtonSolid(s, 'removeCategory')
})
test('align_product_category_no_indigo_left', () => {
const s = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.doesNotMatch(s, /#6366f1|#eef0fe|6366f1|eef0fe/, '靛蓝色点已清')
assert.match(s, /--admin-primary-soft/, '折叠钮改用蓝白主色 soft token')
})
test('align_product_category_search_and_tree_edits_both_solid', () => {
const s = readSource('src/pages/asin/ProductCategoryPage.vue')
const opens = s.match(/<el-button[^>]*size="small"[^>]*@click="openEdit/g) || []
assert.ok(opens.length >= 2, `树/搜索两处编辑均实心(${opens.length}`)
})
test('align_product_category_load_more_kept_link', () => {
// 类目"加载更多"是非行内操作的树加载行,保留 link 不误伤。
const s = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.match(s, /link size="small" type="primary"[^>]*loadMoreChildren/, '加载更多保留 link')
})
@@ -0,0 +1,73 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { adminPages } from '../src/router/routes.ts'
import {
OPERATION_GUIDES,
OPERATION_GUIDE_FALLBACK,
OPERATION_GUIDE_STORAGE_KEY,
operationGuideFor,
} from '../src/layout/operation-guides.ts'
/** 对齐旧版 admin-interactions.js guides 表:每业务菜单页顶部展示可折叠「当前页面操作提示」,文案沿用旧版原文;店铺撞款监控(例外)不展示。 */
const EXCEPTED_KEY = 'admin_shop_data_duplicate_check'
test('align_guide_users_matches_reference_text', () => {
const g = operationGuideFor('admin_users')
assert.ok(g)
assert.equal(g.text, '先用筛选定位账号,再编辑角色和菜单权限;删除账号会要求二次确认。')
assert.deepEqual(g.steps, ['筛选账号', '编辑权限', '确认保存'])
})
test('align_guide_history_and_version_reference_text', () => {
assert.match(operationGuideFor('admin_history')!.text, /生成记录可按用户和时间范围回溯/)
assert.deepEqual(operationGuideFor('admin_version')!.steps, ['上传压缩包', '检查版本记录', '维护历史版本'])
})
test('align_guide_group_manage_falls_back', () => {
// 旧版 group-manage 无独立 guide,用通用 fallback。
assert.deepEqual(operationGuideFor('admin_group_manage'), OPERATION_GUIDE_FALLBACK)
})
test('align_guide_duplicate_check_not_present', () => {
// 例外页:不展示提示条(不参与本轮对齐)。
assert.equal(operationGuideFor(EXCEPTED_KEY), null)
assert.equal(EXCEPTED_KEY in OPERATION_GUIDES, false)
})
test('align_guide_unknown_key_null', () => {
assert.equal(operationGuideFor(undefined), null)
assert.equal(operationGuideFor(null), null)
assert.equal(operationGuideFor('not-a-menu'), null)
assert.equal(operationGuideFor(''), null)
})
test('align_guide_every_router_page_has_guide_except_duplicate_check', () => {
// 路由注册表里的业务页除例外页外都应有独立/兜底提示。
const without = adminPages.filter((p) => p.menuKey !== EXCEPTED_KEY)
assert.ok(without.length >= 15, '业务页至少 15')
for (const page of without) {
assert.ok(operationGuideFor(page.menuKey), `缺少 guide: ${page.menuKey}`)
}
})
test('align_guide_storage_key_matches_legacy', () => {
assert.equal(OPERATION_GUIDE_STORAGE_KEY, 'shufuAdminGuideCollapsed')
})
test('align_guide_component_in_layout_headline', () => {
const layout = readSource('src/layout/AdminLayout.vue')
assert.match(layout, /OperationGuide/, '壳层引用 OperationGuide 组件')
const comp = readSource('src/layout/OperationGuide.vue')
assert.match(comp, /当前页面操作提示/, '标题对齐旧版 adminOperationGuide 语义')
assert.match(comp, /收起提示/, '折叠按钮文案')
assert.match(comp, /展开提示/, '展开按钮文案')
assert.match(comp, /operationGuideFor\(route\.meta\.menuKey\)/, '按菜单 key 查文案')
assert.match(comp, /OPERATION_GUIDE_STORAGE_KEY/, '折叠态读写同一 localStorage key')
})
test('align_guide_fallback_shape', () => {
assert.equal(OPERATION_GUIDE_FALLBACK.steps.length, 3)
assert.ok(OPERATION_GUIDE_FALLBACK.text.length > 10)
})