feat(web): 前端 SPA 化 + 工具页任务面板统一与历史批量删除

- SPA 化:22 个 MPA html 入口与 *-main.ts 合并为 index.html + vue-router(URL 无 .html 后缀),
  页面跳转全部 router-link,/new_web_source/xxx.html 旧路径归一为 /xxx
- 任务面板统一:共享 TaskCenterPanel/TaskItemCard/TaskStatCards/HistoryTaskLayer,
  16 个工具页右侧统一为统计卡 + 当前任务 + 历史任务弹层(任务ID/开始/结束/状态必展示)
- 历史记录支持单条删除 + 批量勾选删除(确认框/全选/失败提示)
- Java 7 模块(dedupe/convert/split/productrisk/shopmatch/pricetrack/deletebrand)
  history 接口补齐任务时间字段(VO+Service,复用 biz_file_task 列)
- 图片工作台/API 层(brand/permission/user)既有未提交改动一并提交
This commit is contained in:
2026-09-08 10:02:55 +08:00
parent abcfa5bef7
commit e248b6e43a
125 changed files with 7482 additions and 4304 deletions
@@ -30,6 +30,8 @@ export interface BrandTaskItem {
desc?: string
file_paths?: string[]
created_at?: string
/** 最后更新(终态时即完成时间,作为结束时间兜底) */
updated_at?: string
progress_total?: number
progress_current?: number
error_message?: string
@@ -12,6 +12,16 @@ export interface ConvertResultItem {
success: boolean;
error?: string;
downloadUrl?: string;
/** 所属任务IDbiz_file_task.id */
taskId?: number;
/** 任务状态:PENDING / RUNNING / SUCCESS / FAILED */
taskStatus?: string;
/** 任务创建时间 */
createdAt?: string;
/** 任务开始时间 */
startedAt?: string;
/** 任务结束时间(未结束为空) */
finishedAt?: string;
}
export interface ConvertRunVo {
@@ -12,6 +12,16 @@ export interface DedupeResultItem {
success: boolean;
error?: string;
downloadUrl?: string;
/** 所属任务IDbiz_file_task.id */
taskId?: number;
/** 任务状态:PENDING / RUNNING / SUCCESS / FAILED */
taskStatus?: string;
/** 任务创建时间 */
createdAt?: string;
/** 任务开始时间 */
startedAt?: string;
/** 任务结束时间(未结束为空) */
finishedAt?: string;
}
export interface DedupeRunVo {
@@ -54,7 +54,8 @@ function normalizeColumnKeys(items: PermissionMenuItem[] | undefined) {
return Array.from(keys)
}
export async function getCurrentUserAppColumnKeys() {
/** 拉取当前用户 app 列权限(菜单项 + 本地缓存写入);权限接口失败时抛出 */
async function fetchAppColumnPermissions() {
const uid = getCurrentUserId()
const cacheKey = getAppPermissionCacheKey(uid)
@@ -81,5 +82,15 @@ export async function getCurrentUserAppColumnKeys() {
window.localStorage.setItem(cacheKey, JSON.stringify(items))
} catch (_error) {}
return normalizeColumnKeys(items)
return items
}
/** 归一化后的列权限键(菜单入口/卡片显隐用,见 AmazonConsolePage 等) */
export async function getCurrentUserAppColumnKeys() {
return normalizeColumnKeys(await fetchAppColumnPermissions())
}
/** 原始权限菜单项(页面需要自行计算权限状态时用,见 DesktopHomePage */
export async function getCurrentUserAppColumnRaw() {
return fetchAppColumnPermissions()
}
@@ -20,6 +20,16 @@ export interface SplitResultItem {
downloadUrl?: string;
entryCount?: number;
entries?: SplitArchiveEntry[];
/** 所属任务IDbiz_file_task.id */
taskId?: number;
/** 任务状态:PENDING / RUNNING / SUCCESS / FAILED */
taskStatus?: string;
/** 任务创建时间 */
createdAt?: string;
/** 任务开始时间 */
startedAt?: string;
/** 任务结束时间(未结束为空) */
finishedAt?: string;
}
export interface SplitRunVo {
+31
View File
@@ -1,3 +1,5 @@
import { post } from './http.ts'
export function getCurrentUserId() {
const raw =
typeof window === "undefined"
@@ -9,3 +11,32 @@ export function getCurrentUserId() {
}
return value;
}
/** 桌面端登录接口响应 data 段 */
export interface LoginData {
token?: string
userId?: number | string
username?: string
[key: string]: unknown
}
export interface LoginResponse {
success: boolean
message?: string
msg?: string
data?: LoginData
}
/**
* 桌面端登录(原页面裸 window.fetch('/newApi/login') 收敛到 shared 层:
* 页面不得直连 /newApi,见 shared-api-allowlist 测试约定)
*/
export function loginWithDevice(account: string, password: string, deviceId: string) {
return post<LoginResponse>(
'/newApi/login',
{ username: account, password, deviceId },
{
headers: { 'X-Device-Id': deviceId, 'X-Requested-With': 'XMLHttpRequest' },
},
)
}
+35 -13
View File
@@ -1,16 +1,14 @@
/**
* 页面登录态引导
*
* 桌面客户端由 Flask 在页面 <head> 注入 localStorage.uid;前端 Web 独立部署后没有注入
* 直接访问子页面(书签/刷新)时 uid 缺失会导致用户信息取用失败
* 页面入口先执行 ensureAuth 再挂载:
* SPA 化后由 src/main.ts 的路由守卫调用;本模块只做检查与 uid/username 恢复
* 不主动跳转(跳转统一由 router.beforeEach 返回 { name: 'login' }
* - localStorage.uid 已存在(桌面注入 / 登录页已写入)→ 直接放行,零额外请求;
* - 无 uid 但有 JWT → 调 /newApi/check_login 恢复 uid/username
* - 无 token 或校验失败 → 清本地登录态并跳转登录页。
* - 无 token 或校验失败 → 返回 false,由守卫跳转登录页。
*/
import { get } from '@/shared/api/http'
import { resolvePageHref } from '@/shared/page-prefix'
const AUTH_TOKEN_KEY = 'aiimage_auth_token'
const UID_KEY = 'uid'
@@ -20,11 +18,7 @@ interface CheckLoginData {
username?: string
}
function redirectToLogin() {
window.location.replace(resolvePageHref('/new_web_source/login.html'))
}
/** 确保当前页面具备登录态;返回是否可继续挂载应用 */
/** 确保当前页面具备登录态;返回是否可继续进入页面 */
export async function ensureAuth(): Promise<boolean> {
if (typeof window === 'undefined') return true
try {
@@ -34,7 +28,6 @@ export async function ensureAuth(): Promise<boolean> {
}
const token = window.localStorage.getItem(AUTH_TOKEN_KEY) || ''
if (!token) {
redirectToLogin()
return false
}
const response = await get<{ success: boolean; data?: CheckLoginData }>('/newApi/check_login')
@@ -47,13 +40,42 @@ export async function ensureAuth(): Promise<boolean> {
}
return true
} catch {
// token 失效:清掉本地登录态后回登录页
// token 失效:清掉本地登录态(跳转交给路由守卫)
try {
window.localStorage.removeItem(AUTH_TOKEN_KEY)
} catch {
/* 忽略 */
}
redirectToLogin()
return false
}
}
/**
* 尽力恢复登录用户快照(ensureAuth 的"软"版本):有 token 时调 /newApi/check_login
* 回写 uid/username,无 token 或失败静默返回 null、不跳转登录页(首页等不强制登录的场景用)。
*/
export async function restoreLoginUser(): Promise<{ username?: string } | null> {
if (typeof window === 'undefined') return null
try {
const rawUid = window.localStorage.getItem(UID_KEY) || ''
if (rawUid && Number(rawUid) > 0) {
// 已有 uid(桌面端注入或登录页已写)无需再查
return null
}
const token = window.localStorage.getItem(AUTH_TOKEN_KEY) || ''
if (!token) {
return null
}
const response = await get<{ success: boolean; data?: CheckLoginData }>('/newApi/check_login')
if (response?.success !== true || !response.data?.userId) {
return null
}
window.localStorage.setItem(UID_KEY, String(response.data.userId))
if (response.data.username) {
window.localStorage.setItem('username', String(response.data.username))
}
return { username: response.data.username }
} catch {
return null
}
}
@@ -16,7 +16,12 @@ export interface VariantTaskListItem {
task_id: string;
status?: number | string;
task_type?: string;
/** 最近更新时间(后台有则返回) */
update_time?: string;
/** 任务创建时间(后台有则返回) */
create_time?: string;
/** 任务完成时间(后台有则返回) */
finish_time?: string;
res_type?: number | string;
res_mes?: string;
file_url?: string[] | string;
@@ -121,6 +126,8 @@ export interface PywebviewApi {
not_found?: boolean;
error?: string;
}>;
/** 用系统默认浏览器打开链接(桌面端下载教程包等走外部浏览器,可看到下载进度) */
open_external_url?: (url: string) => Promise<{ success: boolean; error?: string }>;
/** 版本更新(桌面专属):下载更新包并启动 update.exe 后退出主程序(原 Flask /api/update/do 桥化) */
do_update_app?: (fileUrl: string) => Promise<{ success: boolean; error?: string }>;
launch_yaoayanui?: () => Promise<{
@@ -0,0 +1,254 @@
<template>
<div class="history-task-layer">
<button type="button" class="history-btn" @click="open = true">
历史任务<template v-if="count > 0">{{ count }}</template>
</button>
<el-drawer v-model="open" :title="historyTitle" size="640px" class="task-history-drawer">
<div class="history-body">
<!-- 批量删除工具条传入 onBatchDelete 时显示 -->
<div v-if="onBatchDelete" class="history-toolbar">
<label class="select-all">
<input v-model="allSelected" type="checkbox" title="全选/取消全选" />
<span>全选</span>
</label>
<span class="selected-count" v-if="selectedKeys.size > 0">已选 {{ selectedKeys.size }} </span>
<button type="button" class="btn-delete" :disabled="!selectedKeys.size || batchDeleting"
@click="confirmBatchDelete">
{{ batchDeleting ? '删除中...' : `批量删除${selectedKeys.size ? `${selectedKeys.size}` : ''}` }}
</button>
</div>
<div v-if="!items.length" class="empty-tasks">{{ emptyText }}</div>
<ul v-else class="history-list">
<li v-for="item in items" :key="item.key" class="history-item-row" :class="{ 'is-selected': isSelected(item.key) }">
<label v-if="onBatchDelete" class="row-check" @click.stop>
<input v-model="selectedSet" type="checkbox" :value="item.key" :disabled="batchDeleting" />
</label>
<div class="history-item-card">
<TaskItemCard :item="item">
<template #extra="scope">
<slot name="history-item-extra" v-bind="scope" />
</template>
<template #actions="scope">
<slot name="history-item-actions" v-bind="scope" />
</template>
</TaskItemCard>
</div>
</li>
</ul>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessageBox } from 'element-plus'
import TaskItemCard from './TaskItemCard.vue'
import type { TaskItemView } from './types'
const props = withDefaults(defineProps<{
/** 历史任务数量(按钮角标) */
count: number
/** 弹层标题 */
historyTitle?: string
/** 历史任务列表 */
items: TaskItemView[]
/** 空列表提示 */
emptyText?: string
/** 批量删除回调(传入后历史列表支持勾选批量删除;不传则不显示复选框) */
onBatchDelete?: (items: TaskItemView[]) => Promise<void> | void
}>(), {
historyTitle: '历史任务',
emptyText: '暂无历史任务',
})
const open = ref(false)
const batchDeleting = ref(false)
/** 勾选集合:以 TaskItemView.key 为维度 */
const selectedSet = ref<Set<string>>(new Set())
const selectedKeys = computed(() => selectedSet.value)
const allSelected = computed({
get: () => Boolean(props.items.length) && selectedSet.value.size === props.items.length,
set: (checked: boolean) => {
const next = new Set<string>()
if (checked) {
props.items.forEach((item) => next.add(item.key))
}
selectedSet.value = next
},
})
watch(open, (value) => {
if (!value) {
selectedSet.value = new Set()
}
})
function isSelected(key: string) {
return selectedSet.value.has(key)
}
async function confirmBatchDelete() {
if (!props.onBatchDelete || !selectedSet.value.size || batchDeleting.value) return
const target = props.items.filter((item) => selectedSet.value.has(item.key))
try {
await ElMessageBox.confirm(
`确定批量删除选中的 ${target.length} 条历史记录吗?删除后不可恢复。`,
'批量删除历史记录',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' },
)
} catch {
return
}
batchDeleting.value = true
try {
await props.onBatchDelete(target)
selectedSet.value = new Set()
} finally {
batchDeleting.value = false
}
}
</script>
<style scoped>
.history-task-layer {
display: inline-flex;
}
.history-btn {
padding: 5px 12px;
border: 1px solid #3e4a62;
border-radius: 6px;
background: #222;
color: #c8d2e2;
font-size: 12px;
cursor: pointer;
transition: all .2s;
white-space: nowrap;
}
.history-btn:hover {
background: #2b3447;
color: #f5f8fc;
}
.history-body {
padding: 4px 2px 12px;
}
.history-toolbar {
display: flex;
align-items: center;
gap: 14px;
padding: 0 4px 12px;
border-bottom: 1px solid #2e3a52;
margin-bottom: 12px;
}
.select-all {
display: inline-flex;
align-items: center;
gap: 6px;
color: #c8d2e2;
font-size: 13px;
cursor: pointer;
user-select: none;
}
.selected-count {
color: #a0acbe;
font-size: 12px;
}
.btn-delete {
margin-left: auto;
padding: 6px 14px;
border: none;
border-radius: 6px;
cursor: pointer;
background: rgba(231, 76, 60, .18);
color: #ff8f8f;
font-size: 12px;
transition: background .2s;
}
.btn-delete:hover:not(:disabled) {
background: rgba(231, 76, 60, .32);
}
.btn-delete:disabled {
opacity: .5;
cursor: not-allowed;
}
.empty-tasks {
color: #5e6878;
font-size: 13px;
padding: 18px;
text-align: center;
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
}
.history-item-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.row-check {
flex: 0 0 auto;
padding-top: 14px;
cursor: pointer;
}
.row-check input {
width: 15px;
height: 15px;
cursor: pointer;
}
.history-item-card {
flex: 1;
min-width: 0;
}
.history-item-row.is-selected .history-item-card {
border-radius: 8px;
outline: 2px solid rgba(52, 152, 219, .35);
outline-offset: -1px;
}
</style>
<style>
/* el-drawer 深色主题(非 scopedElement Plus 组件挂在 body 下) */
.task-history-drawer .el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
border-bottom: 1px solid #2e3a52;
color: #f5f8fc;
font-size: 15px;
font-weight: 600;
}
.task-history-drawer .el-drawer__close-btn {
color: #a0acbe;
}
.task-history-drawer .el-drawer__body {
background: #151a25;
padding: 12px 16px;
}
.task-history-drawer .el-drawer {
background: #151a25;
}
</style>
@@ -0,0 +1,139 @@
<template>
<div class="task-center-panel">
<div class="panel-header">
<span class="panel-header__title">{{ title }}</span>
<HistoryTaskLayer
:count="historyItems.length"
:history-title="historyTitle"
:items="historyItems"
:empty-text="historyEmptyText"
:on-batch-delete="onBatchDelete"
>
<template #history-item-extra="scope">
<slot name="history-item-extra" v-bind="scope" />
</template>
<template #history-item-actions="scope">
<slot name="history-item-actions" v-bind="scope" />
</template>
</HistoryTaskLayer>
</div>
<div class="task-list-wrap">
<TaskStatCards :cards="cards" />
<!-- 统计卡与当前任务列表之间的可选区块"匹配结果"表格由各页面按需提供 -->
<slot name="cards-extra" />
<div class="result-list-wrap">
<div class="result-list-header">
<span>{{ currentTitle }}</span>
</div>
<div v-if="!currentItems.length" class="empty-tasks">{{ currentEmptyText }}</div>
<ul v-else class="task-list">
<TaskItemCard v-for="item in currentItems" :key="item.key" :item="item">
<template #extra="scope">
<slot name="item-extra" v-bind="scope" />
</template>
<template #actions="scope">
<slot name="item-actions" v-bind="scope" />
</template>
</TaskItemCard>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import HistoryTaskLayer from './HistoryTaskLayer.vue'
import TaskItemCard from './TaskItemCard.vue'
import TaskStatCards from './TaskStatCards.vue'
import type { TaskItemView, TaskStatCard } from './types'
withDefaults(defineProps<{
/** 面板标题(右侧 panel-header */
title: string
/** 统计卡 */
cards?: TaskStatCard[]
/** 当前任务列表 */
currentItems?: TaskItemView[]
/** 历史任务列表(进入"历史任务"弹层) */
historyItems?: TaskItemView[]
/** 当前任务区块标题 */
currentTitle?: string
/** 历史弹层标题 */
historyTitle?: string
/** 空态文案 */
currentEmptyText?: string
historyEmptyText?: string
/** 历史记录批量删除回调(传入后历史弹层支持勾选批量删除) */
onBatchDelete?: (items: TaskItemView[]) => Promise<void> | void
}>(), {
cards: () => [],
currentItems: () => [],
historyItems: () => [],
currentTitle: '当前任务',
historyTitle: '历史任务',
currentEmptyText: '暂无当前任务',
historyEmptyText: '暂无历史任务',
})
</script>
<style scoped>
.task-center-panel {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #2e3a52;
}
.panel-header__title {
font-size: 15px;
font-weight: 600;
color: #c8d2e2;
}
.task-list-wrap {
flex: 1;
padding: 16px 20px;
overflow: auto;
}
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 8px;
background: #1c2333;
min-height: 180px;
}
.result-list-header {
display: flex;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #2e3a52;
color: #c8d2e2;
font-size: 14px;
}
.empty-tasks {
color: #5e6878;
font-size: 13px;
padding: 18px;
text-align: center;
}
.task-list {
list-style: none;
margin: 0;
padding: 12px;
}
</style>
@@ -0,0 +1,133 @@
<template>
<li class="task-item">
<div class="left">
<span class="id" :title="item.title">{{ item.title || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div class="files">开始时间{{ item.startedAt || '-' }}</div>
<div class="files">结束时间{{ item.finishedAt || '-' }}</div>
<div v-for="line in item.extraLines || []" :key="line" class="files">{{ line }}</div>
<slot name="extra" :item="item" />
<div v-if="item.progress" class="file-progress">
<div v-if="item.progress.stage || item.progress.countLabel" class="file-progress-meta">
<span>{{ item.progress.stage || '处理中' }}</span>
<span v-if="item.progress.countLabel">{{ item.progress.countLabel }}</span>
<span v-else>总进度 {{ item.progress.percent }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${item.progress.percent}%` }"></div>
</div>
</div>
</div>
<div class="task-right">
<span class="status" :class="item.statusClass">{{ item.statusText }}</span>
<slot name="actions" :item="item" />
</div>
</li>
</template>
<script setup lang="ts">
import type { TaskItemView } from './types'
defineProps<{
item: TaskItemView
}>()
</script>
<style scoped>
.task-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid #2e3a52;
border-radius: 8px;
margin-bottom: 8px;
background: #222;
}
.left {
flex: 1;
min-width: 0;
}
.id {
color: #f5f8fc;
font-size: 13px;
font-weight: 600;
display: block;
margin-bottom: 4px;
word-break: break-all;
}
.files {
color: #5e6878;
font-size: 12px;
line-height: 1.5;
word-break: break-all;
}
.task-right {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
flex-shrink: 0;
}
.status {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
}
.status.success {
background: rgba(46, 204, 113, .18);
color: #2ecc71;
}
.status.failed {
background: rgba(231, 76, 60, .18);
color: #ff6b6b;
}
.status.running {
background: rgba(52, 152, 219, .18);
color: #3498db;
}
.status.pending {
background: rgba(149, 165, 166, .18);
color: #a0acbe;
}
.file-progress {
margin-top: 8px;
max-width: 520px;
}
.file-progress-meta {
display: flex;
justify-content: space-between;
gap: 12px;
color: #d8c278;
font-size: 12px;
}
.file-progress-track {
margin-top: 5px;
height: 8px;
border-radius: 999px;
overflow: hidden;
background: #333f55;
border: 1px solid #3b3b3b;
}
.file-progress-bar {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #4aa3ff, #f0c75e);
transition: width .25s ease;
}
</style>
@@ -0,0 +1,50 @@
<template>
<div class="task-stat-cards">
<div v-for="card in cards" :key="card.label" class="summary-card">
<span class="summary-label">{{ card.label }}</span>
<strong>{{ card.value }}</strong>
</div>
</div>
</template>
<script setup lang="ts">
import type { TaskStatCard } from './types'
defineProps<{
cards: TaskStatCard[]
}>()
</script>
<style scoped>
.task-stat-cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-card {
padding: 14px 16px;
border: 1px solid #2e3a52;
border-radius: 8px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
color: #f5f8fc;
font-size: 22px;
}
.summary-label {
color: #5e6878;
font-size: 12px;
}
@media (max-width: 1100px) {
.task-stat-cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -0,0 +1,46 @@
/**
* 工具详情页右侧任务面板的统一视图模型。
*
* 各页面(BrandXxxTab)把各自业务任务对象适配成该结构后,交给 TaskCenterPanel 渲染,
* 保证所有菜单详情右侧的任务卡片样式一致(任务ID / 开始时间 / 结束时间 / 任务状态)。
*/
export type TaskStatusClass = 'success' | 'failed' | 'running' | 'pending'
export interface TaskStatCard {
/** 统计卡标签,如:运行中任务 */
label: string
/** 数值 */
value: number
}
export interface TaskProgressView {
/** 0-100 */
percent: number
/** 阶段文案,如:LLM 回流中 */
stage?: string
/** 计数文案,如:12/50 */
countLabel?: string
}
export interface TaskItemView {
/** 列表 key(同任务的当前/历史用同一 key 即可) */
key: string
/** 主标题:文件名 / 店铺名 / 任务描述 */
title: string
/** 任务 ID(无则显示 -) */
taskId: string | number | null | undefined
/** 开始时间(已格式化),无则显示 - */
startedAt: string
/** 结束时间(已格式化),无则显示 - */
finishedAt: string
/** 状态文案:执行中 / 已完成 / 失败 / 等待中… */
statusText: string
statusClass: TaskStatusClass
/** 附加信息行(行数、错误等),可空 */
extraLines?: string[]
/** 进度条(可空) */
progress?: TaskProgressView | null
/** 页面业务原始对象(插槽操作按钮联动下载/删除等使用) */
source?: unknown
}
+10 -12
View File
@@ -1,20 +1,18 @@
/**
* 页面路径环境适配
*
* 生产环境(桌面客户端 Flask)功能页由 /new_web_source/ 路由提供;
* vite dev 下页面直接挂在根路径。旧静态页(/brand、/web_source/*)仅生产存在
* SPA 化(2026-09-08)后所有页面统一为无 .html 后缀的路径(/home、/dedupe …),
* 生产(https://api.aishufu.top)与 vite dev 前缀一致,无需再区分环境
* 旧 MPA 链接形式 /new_web_source/xxx.html 由本模块归一为 /xxx。
*/
/** 当前是否运行在桌面客户端生产环境(页面路径以 /new_web_source/ 开头) */
export function isDesktopClientPage(): boolean {
return typeof window !== 'undefined' && window.location.pathname.startsWith('/new_web_source')
}
/** 链接本地化:dev 预览去掉 /new_web_source 前缀;/brand、/web_source/* 等旧静态页仅桌面端可用 */
/** 归一页面链接:/new_web_source/xxx.html → /xxx;其余原样返回 */
export function resolvePageHref(rawHref?: string): string {
if (!rawHref) return ''
if (rawHref.startsWith('/new_web_source/')) {
return `${isDesktopClientPage() ? '/new_web_source' : ''}${rawHref.slice('/new_web_source'.length)}`
}
return isDesktopClientPage() ? rawHref : ''
return rawHref.replace(/^\/new_web_source\/(.+)\.html$/, '/$1')
}
/** 兼容旧引用:SPA 下生产/开发路径一致,恒为 false(页面不再按路径区分桌面/Web) */
export function isDesktopClientPage(): boolean {
return false
}