fix(brand): 品牌检测补上传链+新增 /api/brand/run 端点+文件夹展开桥

- BrandBrandTab 对齐其它工具页:选文件/文件夹后先 upload_file_to_java 再提交(服务器可读路径),修复本地路径直传导致的「请先上传待处理文件/没有有效的xlsx文件路径」
- runBrandNow/createBrandTask 改为 files 契约(fileUrl=临时路径 localPath)+ 传 userId
- Java 新增 POST /api/brand/run(等价 /tasks 并返回待爬取数据),修复前端「立即运行」No static resource
- 选择文件夹改走新版桌面客户端桥 expand_brand_folder(本机展开);旧客户端提示改用文件多选
- 生产 .env 已另配 AIIMAGE_BRAND_CHECK_TOKEN(上游 16890 鉴权),非代码变更
This commit is contained in:
2026-09-08 13:18:23 +08:00
parent f2906e8d6f
commit 7b6f62f1bc
5 changed files with 86 additions and 32 deletions
@@ -63,6 +63,17 @@ public class BrandTaskController {
return ApiResponse.success(brandTaskService.createTaskAndBuildPayload(userId, request));
}
@PostMapping("/run")
@Operation(
summary = "立即执行品牌检查(新工具台'立即运行')",
description = "等价于 /tasks:创建任务并返回待爬取数据。前端已通过 /api/files/upload 上传文件(fileUrl=服务器临时路径)。"
)
public ApiResponse<BrandCrawlPayloadVo> run(
@Parameter(description = "用户 ID", required = true) @RequestParam Long userId,
@Valid @RequestBody BrandTaskCreateRequest request) {
return ApiResponse.success(brandTaskService.createTaskAndBuildPayload(userId, request));
}
@GetMapping("/tasks")
@Operation(
summary = "获取品牌任务列表",
@@ -123,21 +123,18 @@ import {
cancelBrandTask,
createBrandTask,
deleteBrandTask,
expandBrandFolder,
getBrandTaskDownloadUrl,
getBrandTasks,
runBrandNow,
type BrandTaskItem,
} from '@/shared/api/brand'
import { checkSelectedFiles, EXCEL_EXTENSIONS } from '@/shared/dispatch-guard.ts'
import { passGuard } from '@/shared/dispatch-guard-ui'
import type { UploadFileVo } from '@/shared/api/upload.ts'
import type { UploadedFileRef } from '@/shared/api/types/upload.ts'
import type { BrandExpandFolderItem } from '@/shared/api/types/modules/brand'
/** 扩展文件夹接口响应(向后端真实字段 paths 兼容,shared 类型仅声明 items 时以本接口为准) */
interface BrandExpandFolderPathsResponse {
success: boolean
paths?: string[]
error?: string
}
const selectedPaths = ref<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([])
const strategy = ref<'Terms' | 'Simple'>('Terms')
const runMode = ref<'immediate' | 'queue'>('immediate')
const submitting = ref(false)
@@ -151,6 +148,10 @@ const hasUid = computed(() => {
const numeric = Number(raw.trim())
return Number.isFinite(numeric) && numeric > 0
})
// 展示用:已上传文件列表的展示名(优先相对路径,其次原文件名)
const selectedPaths = computed(() =>
uploadedFiles.value.map((f) => f.relativePath || f.originalFilename || f.fileKey),
)
const displayPaths = computed(() => selectedPaths.value.slice(0, 8))
function baseName(path: string) {
@@ -275,10 +276,23 @@ function formatDateTime(value?: string) {
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function normalizeSelected(paths: string[]) {
selectedPaths.value = paths
.filter((path) => /\.xlsx$/i.test(path))
.filter((path, index, array) => array.indexOf(path) === index)
/** 上传本地 xlsx 到 Java 临时目录,成功后回填 fileUrl(服务器本地路径,Java 直接读取) */
async function uploadBrandPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
const api = getPywebviewApi()
if (!api?.upload_file_to_java) {
throw new Error('当前环境未提供文件上传能力')
}
const files: UploadFileVo[] = []
for (const item of paths) {
const filePath = typeof item === 'string' ? item : item.absolutePath
const relativePath = typeof item === 'string' ? undefined : item.relativePath
const uploaded = await api.upload_file_to_java(filePath, relativePath)
if (!uploaded?.success || !uploaded.data) {
throw new Error(uploaded?.error || uploaded?.message || `上传失败:${filePath}`)
}
files.push(uploaded.data)
}
return files
}
async function selectFiles() {
@@ -287,13 +301,15 @@ async function selectFiles() {
ElMessage.warning('当前环境不支持文件选择,请在本机客户端中打开')
return
}
const paths = await bridge.select_brand_xlsx_files()
if (!paths?.length) return
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_EXTENSIONS })))) return
try {
const paths = await bridge.select_brand_xlsx_files()
if (!paths?.length) return
normalizeSelected(paths)
ElMessage.success(`已选择 ${selectedPaths.value.length} 个 Excel 文件`)
const files = await uploadBrandPathsToJava(paths)
uploadedFiles.value = files
ElMessage.success(`已选择并上传 ${files.length} 个 Excel 文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '文件选择失败')
ElMessage.error(error instanceof Error ? error.message : '文件上传失败')
}
}
@@ -303,16 +319,23 @@ async function selectFolder() {
ElMessage.warning('当前环境不支持文件夹选择,请在本机客户端中打开')
return
}
const folder = await bridge.select_brand_folder()
if (!folder) return
// 新版桌面客户端桥在本机展开文件夹(服务器无法访问用户本机目录)
if (!bridge.expand_brand_folder) {
ElMessage.warning('当前桌面客户端版本不支持文件夹展开,请使用"选择文件"(可多选)')
return
}
try {
const folder = await bridge.select_brand_folder()
if (!folder) return
const res = (await expandBrandFolder(folder)) as BrandExpandFolderPathsResponse
if (!res.success || !res.paths?.length) {
const res = await bridge.expand_brand_folder(folder)
if (!res.success || !res.items?.length) {
ElMessage.warning(res.error || '该文件夹下没有 xlsx 文件')
return
}
normalizeSelected(res.paths)
ElMessage.success(`已选择文件夹内 ${selectedPaths.value.length} 个 Excel 文件`)
if (!(await passGuard(checkSelectedFiles(res.items.map((i) => i.absolutePath), { allowedExtensions: EXCEL_EXTENSIONS })))) return
const files = await uploadBrandPathsToJava(res.items)
uploadedFiles.value = files
ElMessage.success(`已选择并上传文件夹内 ${files.length} 个 Excel 文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '文件夹选择失败')
}
@@ -327,28 +350,35 @@ async function submitRun() {
ElMessage.warning('未获取到登录用户,请先在本机客户端登录后再试')
return
}
if (!selectedPaths.value.length) {
if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择待检 Excel 文件')
return
}
submitting.value = true
try {
// 上传后以服务器可读的 localPath 作为 fileUrlJava resolveSourceFile 直接读临时文件)
const files: UploadedFileRef[] = uploadedFiles.value.map((f) => ({
fileKey: f.fileKey,
originalFilename: f.originalFilename,
relativePath: f.relativePath,
fileUrl: f.localPath,
}))
if (runMode.value === 'immediate') {
const res = await runBrandNow(selectedPaths.value, strategy.value)
const res = await runBrandNow(files, strategy.value)
if (res.success && res.task_id) {
ElMessage.success(`已创建任务 ${res.task_id},客户端开始检测`)
} else {
throw new Error((res as { error?: string }).error || '运行失败')
}
} else {
const res = await createBrandTask(selectedPaths.value, strategy.value)
const res = await createBrandTask(files, strategy.value)
if (res.success && res.task_id) {
ElMessage.success(`任务 ${res.task_id} 已添加到队列`)
} else {
throw new Error((res as { error?: string }).error || '添加失败')
}
}
selectedPaths.value = []
uploadedFiles.value = []
await loadTasks()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '提交失败')
@@ -1,4 +1,5 @@
import { requestDeleteJson, requestGetJson, requestPostJson } from '../../http.ts'
import type { UploadedFileRef } from '../../upload.ts'
function getCurrentUserId() {
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
@@ -82,12 +83,18 @@ export function expandBrandFolderRecursive(folder: string) {
return requestPostJson<BrandExpandFolderResponse>(`${API_PREFIX}/api/brand/expand-folder-recursive`, { folder })
}
export function runBrandNow(paths: string[], strategy: string) {
return requestPostJson<BrandTaskMutationResponse>(`${API_PREFIX}/api/brand/run`, { paths, strategy })
export function runBrandNow(files: UploadedFileRef[], strategy: string) {
return requestPostJson<BrandTaskMutationResponse>(
`${API_PREFIX}/api/brand/run?userId=${encodeURIComponent(String(getCurrentUserId()))}`,
{ files, strategy, taskType: 1 },
)
}
export function createBrandTask(paths: string[], strategy: string) {
return requestPostJson<BrandTaskMutationResponse>(`${API_PREFIX}/api/brand/tasks?userId=${encodeURIComponent(String(getCurrentUserId()))}`, { paths, strategy })
export function createBrandTask(files: UploadedFileRef[], strategy: string) {
return requestPostJson<BrandTaskMutationResponse>(
`${API_PREFIX}/api/brand/tasks?userId=${encodeURIComponent(String(getCurrentUserId()))}`,
{ files, strategy, taskType: 2 },
)
}
export function getBrandTasks() {
@@ -2,4 +2,6 @@ export interface UploadedFileRef {
fileKey: string;
originalFilename?: string;
relativePath?: string;
/** 服务器本地临时文件路径(Java 可读),品牌检测等模块用它作为源文件地址 */
fileUrl?: string;
}
@@ -86,6 +86,10 @@ export interface PywebviewApi {
select_folder?: () => Promise<string | null>;
select_brand_xlsx_files?: () => Promise<string[]>;
select_brand_folder?: () => Promise<string | null>;
/** 本机展开文件夹下的 xlsx 文件(供品牌检测"选择文件夹");新版桌面客户端提供 */
expand_brand_folder?: (
folder: string,
) => Promise<{ success: boolean; items?: Array<{ absolutePath: string; relativePath: string }>; error?: string }>;
upload_file_to_java?: (
filePath: string,
relativePath?: string,