Files
crawler-plugin/frontend-vue/src/pages/amazon/AmazonConsolePage.vue
T
huangzd1997 9ffd68bae5 feat(教程管理): 后台可上传教程包,工具台按最新上传下载
- admin-vue 新增「记录与版本 → 教程管理」页(menuKey admin_tutorial):
  zip 浏览器直传 MinIO(presign → PUT 带进度 → confirm 落库)+ 列表/下载/删除,
  最新一条标「当前生效」;补操作指引与 align-tutorial 守卫测试。
- frontend-vue 工具台「立即下载教程」进页面取 /api/tutorial/latest(最新上传优先),
  接口异常或无记录回退历史固定直链,按钮不失效。
- Java 侧 tutorial 模块与 V119 迁移已在 dc6e8924 提交并上线(2026-09-13)。

vue-tsc + 两端单测(工具台 695 / 后台 1617)通过;生产已实测
presign→PUT→confirm→latest 切换→删除回落全链路。
2026-09-13 23:48:24 +08:00

799 lines
20 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.
<template>
<div
class="page-shell module-page amazon-console"
:style="{ '--scroll-color': currentGroup?.color ?? '#2dd4bf' }"
>
<AmazonTopBar :active-cat="activeCat" @change="onCatChange" />
<main class="console-body">
<!-- Hero仅前端工具分类显示对齐 gemini-code 主页 -->
<section v-if="currentGroup && currentGroup.key === 'front'" class="hero">
<div class="hero-left">
<span class="hero-pill">运营辅助 · 数据安全 · 高效跟价</span>
<h1 class="hero-title">让亚马逊运营更清晰高效</h1>
<p class="hero-sub">
从采集去重查品到上架跟价20 个工具按前端 · 运营 · 后勤三线组织每条工具一句话讲清用途标准流程一步不缺
</p>
<div class="hero-stats">
<div class="stat">
<strong style="color: #2dd4bf">20</strong>
<span>个工具</span>
</div>
<div class="stat">
<strong style="color: #60a5fa">3</strong>
<span>大分类</span>
</div>
<div class="stat">
<strong style="color: #f59e0b">8</strong>
<span>前端标准流程步骤</span>
</div>
</div>
</div>
<div class="hero-card">
<div>
<div class="hero-card-head">
<div class="hero-card-dots">
<i class="dot dot--teal"></i>
<i class="dot dot--blue"></i>
<i class="dot dot--brand"></i>
</div>
<span class="hero-card-caption">数富AI · 运营工作台</span>
</div>
<h2 class="hero-card-title">让每一次运营动作<br />都有清晰的工作流</h2>
<div class="hero-card-divider"></div>
<p class="hero-card-sub">内置完整使用教程从软件配置到运营上架</p>
</div>
<div class="hero-card-btn-row">
<span class="hero-card-sub">每一步流程清晰可查</span>
<button class="hero-dl" type="button" @click="onDownloadClick">
<span>📥</span>
<span>立即下载教程</span>
</button>
</div>
</div>
</section>
<!-- 当前分类标准工作流 + 卡片墙分类切换式 gemini-code -->
<section v-if="currentGroup" class="tool-group" :id="`group-${currentGroup.key}`">
<section v-if="visibleWorkflow(currentGroup)?.length" class="workflow-panel">
<div class="workflow-head">
<span class="workflow-title">{{ currentGroup.workflowTitle }}</span>
<span class="workflow-badge">{{ currentGroup.workflowBadge }}</span>
<span class="workflow-hint">{{ currentGroup.workflowHint }}</span>
</div>
<ol class="workflow-steps">
<template v-for="(step, index) in visibleWorkflow(currentGroup)" :key="`${currentGroup.key}-${index}`">
<li
class="workflow-step"
:class="{ 'workflow-step--disabled': !step.toolId || !stepHref(step.toolId) }"
:style="{ '--gc': currentGroup.color }"
@click="onWorkflowStepClick(step)"
@mouseenter="prefetchRouteChunk(router, stepHref(step.toolId))"
>
<span class="step-circle" :style="circleStyle(currentGroup)">{{ index + 1 }}</span>
<span class="step-name">{{ step.name }}</span>
<span class="step-desc">{{ step.desc }}</span>
</li>
<span v-if="index < visibleWorkflow(currentGroup).length - 1" class="step-line" :style="lineStyle()"></span>
</template>
</ol>
</section>
<header class="group-head">
<i class="group-dot" :style="{ background: currentGroup.color }"></i>
<span class="group-name">{{ currentGroup.name }}</span>
<span class="group-desc">{{ currentGroup.desc }}</span>
<span class="group-count">{{ currentGroup.tools.length }} 个工具</span>
</header>
<div class="card-grid">
<template v-for="tool in currentGroup.tools" :key="tool.id">
<router-link v-if="resolveHref(tool.href)" :to="resolveHref(tool.href)!" class="tool-card" :style="cardStyle(currentGroup)" @mouseenter="prefetchToolChunk(tool)">
<div class="tool-card-top">
<ToolIcon :name="tool.id" />
<span v-if="tool.tag" class="tool-tag" :style="tagStyle(currentGroup)">{{ tool.tag }}</span>
</div>
<div class="tool-card-name">{{ tool.name }}</div>
<div class="tool-card-desc">{{ tool.desc }}</div>
<div class="tool-card-go">打开工具 </div>
</router-link>
<button v-else type="button" class="tool-card tool-card--disabled" :style="cardStyle(currentGroup)" @click="onPendingClick(tool)">
<div class="tool-card-top">
<ToolIcon :name="tool.id" />
<span v-if="tool.tag" class="tool-tag" :style="tagStyle(currentGroup)">{{ tool.tag }}</span>
</div>
<div class="tool-card-name">{{ tool.name }}</div>
<div class="tool-card-desc">{{ tool.desc }}</div>
<div class="tool-card-go">{{ tool.href ? '仅桌面客户端可用' : '暂未开通' }}</div>
</button>
</template>
</div>
</section>
</main>
<AmazonFooterBar />
<div class="toast" :class="{ show: Boolean(statusText), error: statusType === 'error' }">{{ statusText }}</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AmazonTopBar from '@/pages/amazon/components/AmazonTopBar.vue'
import AmazonFooterBar from '@/pages/amazon/components/AmazonFooterBar.vue'
import ToolIcon from '@/pages/amazon/components/ToolIcon.vue'
import { TUTORIAL_DOWNLOAD_URL, resolveTutorialDownloadUrl } from '@/pages/amazon/download-btn'
import { filterGroupsByPermission, filterWorkflowByPermission, TOOL_GROUPS, TOOL_MAP } from '@/pages/amazon/tool-catalog'
import type { ToolGroup, ToolInfo, VisibleGroup, WorkflowStep } from '@/pages/amazon/tool-catalog'
import { resolvePageHref } from '@/shared/page-prefix'
import { prefetchRouteChunk } from '@/shared/route-prefetch'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
const router = useRouter()
const allowedKeys = ref<string[]>([])
const visibleGroups = ref<VisibleGroup[]>(TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] })))
/** 权限接口异常时为 true:展示全部工具与时间线,避免页面空白 */
const permissionUnavailable = ref(false)
const statusText = ref('')
const statusType = ref<'normal' | 'error'>('normal')
const activeCat = ref<'front' | 'ops' | 'logi'>('front')
let statusTimer: number | undefined
const currentGroup = computed(() => visibleGroups.value.find((g) => g.key === activeCat.value) ?? visibleGroups.value[0])
/** 链接归一:/new_web_source/xxx.html → /xxxSPA 路径,见 shared/page-prefix */
function resolveHref(rawHref?: string): string {
return resolvePageHref(rawHref)
}
function visibleWorkflow(group: VisibleGroup) {
if (permissionUnavailable.value) return group.workflow ?? []
return group.workflow ? filterWorkflowByPermission(group.workflow, allowedKeys.value) : []
}
function stepHref(toolId?: string) {
if (!toolId) return ''
return resolveHref(TOOL_MAP.get(toolId)?.href ?? '')
}
/* 卡片/tag:weak 弱底 + 分类色文字(同 gemini-code,无核心特判) */
function tagStyle(group: ToolGroup) {
return { background: group.weak, color: group.color }
}
function circleStyle(group: ToolGroup) {
return { background: group.weak, color: group.color }
}
function lineStyle() {
return { background: '#4C5A75' }
}
function cardStyle(group: ToolGroup) {
return { '--gc': group.color } as Record<string, string>
}
function onWorkflowStepClick(step: WorkflowStep) {
if (!step.toolId) {
return
}
const target = TOOL_MAP.get(step.toolId)
const href = resolveHref(target?.href ?? '')
if (href) {
router.push(href)
return
}
showSoon(target?.href ? `${step.name}仅桌面客户端可用` : `${step.name}暂未开通,敬请期待`)
}
/** 卡片 hover 预取目标页 chunk:弱网下点击跳转不再等 chunk 下载 */
function prefetchToolChunk(tool: ToolInfo) {
prefetchRouteChunk(router, resolveHref(tool.href))
}
function onPendingClick(tool: ToolInfo) {
if (!tool.href) {
showSoon(`${tool.name}暂未开通,敬请期待`)
return
}
showSoon(`${tool.name}仅桌面客户端可用`)
}
function showSoon(name: string) {
toast(`${name}暂未开通,敬请期待`, 'normal')
}
function toast(msg: string, type: 'normal' | 'error' = 'normal') {
statusText.value = msg
statusType.value = type
if (statusTimer) {
window.clearTimeout(statusTimer)
}
statusTimer = window.setTimeout(() => {
statusText.value = ''
}, 2500)
}
/** 教程包直链:进页面时解析一次(后台「教程管理」最新上传优先),失败自动回退固定直链 */
const tutorialUrl = ref(TUTORIAL_DOWNLOAD_URL)
async function refreshTutorialUrl() {
tutorialUrl.value = await resolveTutorialDownloadUrl()
}
async function onDownloadClick() {
// 教程客户端压缩包:最新上传的包优先(/api/tutorial/latest),兜底走固定直链(见 download-btn.ts
const api = getPywebviewApi()
if (api?.open_external_url) {
// 桌面端:交系统默认浏览器下载,可看到下载进度
try {
const res = await api.open_external_url(tutorialUrl.value)
if (!res?.success) toast(`下载失败:${res?.error ?? '请重试'}`, 'error')
} catch (_error) {
toast('下载失败,请重试', 'error')
}
return
}
// 网页端:新窗口打开直链下载
window.open(tutorialUrl.value, '_blank', 'noopener')
}
function onCatChange(key: 'front' | 'ops' | 'logi') {
activeCat.value = key
history.replaceState(null, '', `#group-${key}`)
}
function parseHashGroup() {
const hash = window.location.hash.replace('#', '')
const m = /^group-(front|ops|logi)$/.exec(hash)
if (m) activeCat.value = m[1] as 'front' | 'ops' | 'logi'
}
onMounted(async () => {
// 教程包直链异步解析(不阻塞页面渲染;失败自动回退固定直链)
void refreshTutorialUrl()
try {
allowedKeys.value = await getCurrentUserAppColumnKeys()
} catch (_error) {
// 权限接口异常时展示全部工具,避免页面空白;正常时按菜单权限过滤
permissionUnavailable.value = true
}
visibleGroups.value = permissionUnavailable.value
? TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] }))
: filterGroupsByPermission(TOOL_GROUPS, allowedKeys.value)
parseHashGroup()
})
onBeforeUnmount(() => {
if (statusTimer !== undefined) {
window.clearTimeout(statusTimer)
statusTimer = undefined
}
})
</script>
<style scoped>
.amazon-console {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
background-color: #1a1f2e;
/* 深色底 + 青色网点纹理(对齐 gemini-code body */
background-image: radial-gradient(rgba(45, 212, 191, 0.08) 1px, transparent 1px);
background-size: 48px 48px;
font-family: "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif;
}
.console-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 14px 28px 28px;
background: transparent;
/* 滚动条滑块随分类色(保持各分类有独立主色) */
scrollbar-color: var(--scroll-color, #2dd4bf) #1a1f2e;
}
.console-body::-webkit-scrollbar {
width: 10px;
}
.console-body::-webkit-scrollbar-track {
background: #1a1f2e;
}
.console-body::-webkit-scrollbar-thumb {
min-height: 40px;
background: var(--scroll-color, #2dd4bf);
border-radius: 5px;
}
.console-body::-webkit-scrollbar-thumb:hover {
filter: brightness(1.2);
}
.console-body::-webkit-scrollbar-button {
display: none;
}
/* ===== Hero(仅 front 显示;PANEL 底 + LINE 边框 + 12px 圆角,对齐 gemini-code ===== */
.hero {
max-width: 1440px;
margin: 0 auto 20px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
padding: 26px 30px;
border: 1px solid #3e4a62;
border-radius: 12px;
background: #232a3b;
}
.hero-left {
flex: 1 1 auto;
min-width: 0;
}
.hero-pill {
display: inline-block;
margin-bottom: 12px;
padding: 4px 12px;
border: 1px solid rgba(45, 212, 191, 0.2);
border-radius: 20px;
background: #0f2e28;
color: #2dd4bf;
font-size: 11px;
font-weight: 700;
}
.hero-title {
margin: 0 0 8px;
color: #f5f8fc;
font-size: 26px;
font-weight: 700;
}
.hero-sub {
max-width: 650px;
margin: 0 0 20px;
color: #c8d2e2;
font-size: 12px;
line-height: 1.6;
}
.hero-stats {
display: flex;
gap: 40px;
}
.stat strong {
display: block;
font-size: 22px;
font-weight: 700;
line-height: 1.1;
}
.stat span {
display: block;
margin-top: 4px;
color: #a0acbe;
font-size: 11px;
}
/* Hero 右侧工作台卡(370 宽 / 10px 圆角,对齐 gemini-code hero-right-card */
.hero-card {
width: 370px;
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 22px;
border: 1px solid #3e4a62;
border-radius: 10px;
background: #2b3447;
}
.hero-card-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.hero-card-dots {
display: flex;
gap: 5px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.dot--teal {
background: #2dd4bf;
}
.dot--blue {
background: #60a5fa;
}
.dot--brand {
background: #f59e0b;
}
.hero-card-btn-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.hero-card-btn-row .hero-card-sub {
margin: 0;
}
.hero-card-caption {
color: #a0acbe;
font-size: 13px;
}
.hero-card-title {
margin: 0 0 12px;
color: #f5f8fc;
font-size: 20px;
font-weight: 700;
line-height: 1.4;
}
.hero-card-divider {
height: 1px;
margin-bottom: 12px;
background: #3e4a62;
}
.hero-card-sub {
margin: 0;
color: #c8d2e2;
font-size: 13px;
line-height: 1.5;
}
/* 下载按钮(渐变文字按钮,对齐 gemini-code download-btn;下载教学客户端 zip 直链) */
.hero-card-btn-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.hero-card-btn-row .hero-card-sub {
margin: 0;
}
.hero-dl {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 20px;
border: none;
border-radius: 6px;
background: linear-gradient(135deg, #f59e0b, #d97706);
color: #ffffff;
font-size: 13px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 2px 10px rgba(245, 158, 11, 0.4);
}
.hero-dl:hover {
filter: brightness(1.15);
transform: translateY(-1px);
}
/* ===== 标准工作流时间线(PANEL 底 + LINE 边框 + 圆形编号,对齐 gemini-code ===== */
.workflow-panel {
max-width: 1440px;
margin: 0 auto 22px;
padding: 18px 24px;
border: 1px solid #3e4a62;
border-radius: 10px;
background: #232a3b;
}
.workflow-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.workflow-title {
color: #f5f8fc;
font-size: 16px;
font-weight: 700;
}
.workflow-badge {
padding: 3px 10px;
border-radius: 4px;
background: #3a2a10;
color: #f59e0b;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.workflow-hint {
margin-left: auto;
color: #a0acbe;
font-size: 12px;
white-space: nowrap;
}
.workflow-steps {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0;
padding: 0;
list-style: none;
}
.workflow-step {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
cursor: pointer;
transition: transform 0.15s;
}
.workflow-step:not(.workflow-step--disabled):hover {
transform: translateY(-2px);
}
.workflow-step--disabled {
cursor: default;
opacity: 0.7;
}
/* 悬停:编号圆填充分类色白字、名称变亮(已确认保留的高亮反馈) */
.workflow-step:not(.workflow-step--disabled):hover .step-circle {
background: var(--gc, #2dd4bf) !important;
color: #ffffff !important;
}
.workflow-step:not(.workflow-step--disabled):hover .step-name {
color: #f5f8fc;
}
.step-circle {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
margin-bottom: 6px;
font-size: 13px;
font-weight: 700;
}
.step-name {
margin-bottom: 2px;
color: #c8d2e2;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.step-desc {
color: #a0acbe;
font-size: 11px;
text-align: center;
}
/* 步骤连接线:圆编号列之间贯穿的横线(垂直居中于编号行,对齐 gemini-code timeline-line */
.step-line {
flex: 1 1 auto;
min-width: 14px;
height: 2px;
margin: 0 4px;
transform: translateY(-16px);
}
/* ===== 分类标题 + 卡片墙 ===== */
.tool-group {
max-width: 1440px;
margin: 0 auto;
}
.group-head {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 14px;
}
.group-dot {
width: 10px;
height: 10px;
border-radius: 2px;
}
.group-name {
color: #f5f8fc;
font-size: 18px;
font-weight: 700;
}
.group-desc {
color: #a0acbe;
font-size: 12px;
}
.group-count {
margin-left: auto;
color: #a0acbe;
font-size: 12px;
}
/* 卡片墙(CARD 底 + 2px #2A3344 边框 + 10px 圆角 min-height 165,对齐 gemini-code tool-card */
.card-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 28px;
}
.tool-card {
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
min-height: 165px;
padding: 16px;
border: 2px solid #2a3344;
border-radius: 10px;
background: #2b3447;
text-align: left;
text-decoration: none;
font: inherit;
cursor: pointer;
transition: all 0.2s;
}
/* 悬浮:边框高亮为分类色 + 背景变亮 + 上浮阴影(对齐 gemini-code hover 并按要求加边框高亮) */
.tool-card:hover {
background: #323d52;
border-color: var(--gc, #2dd4bf);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
.tool-card-top {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.tool-tag {
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
/* 悬浮:角标变分类色实底白字(保留的亮反馈) */
.tool-card:hover .tool-tag {
background: var(--gc, #2dd4bf) !important;
color: #ffffff !important;
}
.tool-card-name {
margin-bottom: 6px;
color: #f5f8fc;
font-size: 15px;
font-weight: 700;
}
.tool-card-desc {
flex: 1;
color: #a0acbe;
font-size: 11px;
line-height: 1.4;
}
.tool-card-go {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
color: #a0acbe;
font-size: 11px;
transition: color 0.2s;
}
.tool-card:hover .tool-card-go {
color: var(--gc, #2dd4bf);
font-weight: 700;
}
.tool-card--disabled {
cursor: default;
opacity: 0.55;
}
.tool-card--disabled:hover {
border-color: #2a3344;
background: #2b3447;
transform: none;
box-shadow: none;
}
.tool-card--disabled:hover .tool-card-go {
color: #a0acbe;
font-weight: 400;
}
/* ===== toast(右下角 + 品牌色左边条,对齐 gemini-code toast ===== */
.toast {
position: fixed;
bottom: 60px;
right: 30px;
z-index: 1000;
max-width: min(420px, calc(100vw - 60px));
padding: 12px 20px;
border-radius: 6px;
border-left: 4px solid #f59e0b;
background: #3d4a63;
color: #f5f8fc;
font-size: 13px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.toast.show {
opacity: 1;
}
.toast.error {
border-left-color: #f87171;
background: rgba(176, 42, 55, 0.92);
}
@media (max-width: 1100px) {
.card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.hero {
flex-direction: column;
align-items: stretch;
}
.hero-card {
width: 100%;
}
.console-body {
padding: 14px 16px 24px;
}
}
</style>