feat: processing flow enhancement + responsive UI
Phase 2 - Processing flow:
- Multi-task monitoring: store supports concurrent task tracking
- Task retry: POST /api/tasks/{id}/retry re-runs failed tasks
- Dashboard multi-task cards with progress, error details, retry/dismiss
- Log panel expanded from 10 to 50 lines with "view all" link
Phase 3 - UI/UX:
- Mobile sidebar drawer (< 768px) with hamburger menu
- Layout responsive styles (768px, 480px breakpoints)
- Tasks/Logs pages responsive (stat cards, filters, columns)
- File views responsive (header wrap, button sizing)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -234,6 +234,7 @@ async def ocr_batch(
|
|||||||
"""Run OCR on all images in input/."""
|
"""Run OCR on all images in input/."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task("批量OCR识别")
|
task = tm.create_task("批量OCR识别")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/ocr-batch", "body": {}}
|
||||||
|
|
||||||
image_exts = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'}
|
image_exts = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'}
|
||||||
files = _list_input_files(filter_ext=list(image_exts))
|
files = _list_input_files(filter_ext=list(image_exts))
|
||||||
@@ -296,6 +297,7 @@ async def process_excel(
|
|||||||
"""Convert OCR output Excel files to standardized purchase orders."""
|
"""Convert OCR output Excel files to standardized purchase orders."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task("Excel标准化处理")
|
task = tm.create_task("Excel标准化处理")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/excel", "body": body.dict()}
|
||||||
|
|
||||||
excel_exts = {'.xls', '.xlsx'}
|
excel_exts = {'.xls', '.xlsx'}
|
||||||
if body.files:
|
if body.files:
|
||||||
@@ -354,6 +356,7 @@ async def merge_orders(
|
|||||||
"""Merge selected purchase order files into one PosPal template."""
|
"""Merge selected purchase order files into one PosPal template."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task("合并采购单")
|
task = tm.create_task("合并采购单")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/merge", "body": body.dict()}
|
||||||
|
|
||||||
# If specific files provided, use them; otherwise merge all
|
# If specific files provided, use them; otherwise merge all
|
||||||
if body.filenames:
|
if body.filenames:
|
||||||
@@ -399,6 +402,7 @@ async def full_pipeline(
|
|||||||
"""Run the full pipeline: OCR -> Excel -> Result (NO merge)."""
|
"""Run the full pipeline: OCR -> Excel -> Result (NO merge)."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task("一键全流程处理")
|
task = tm.create_task("一键全流程处理")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/pipeline", "body": body.dict()}
|
||||||
|
|
||||||
async def _bg():
|
async def _bg():
|
||||||
def do_work():
|
def do_work():
|
||||||
@@ -501,6 +505,7 @@ async def ocr_single(
|
|||||||
"""OCR a single image file."""
|
"""OCR a single image file."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task(f"OCR: {body.filename}")
|
task = tm.create_task(f"OCR: {body.filename}")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/ocr-single", "body": body.dict()}
|
||||||
|
|
||||||
file_path = _input_dir / body.filename
|
file_path = _input_dir / body.filename
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
@@ -544,6 +549,7 @@ async def excel_single(
|
|||||||
"""Process a single Excel file to purchase order."""
|
"""Process a single Excel file to purchase order."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task(f"Excel处理: {body.filename}")
|
task = tm.create_task(f"Excel处理: {body.filename}")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/excel-single", "body": body.dict()}
|
||||||
|
|
||||||
file_path = _output_dir / body.filename
|
file_path = _output_dir / body.filename
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
@@ -582,6 +588,7 @@ async def pipeline_single(
|
|||||||
"""Full pipeline for a single image: OCR -> Excel -> Result (no merge)."""
|
"""Full pipeline for a single image: OCR -> Excel -> Result (no merge)."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task(f"全流程: {body.filename}")
|
task = tm.create_task(f"全流程: {body.filename}")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/pipeline-single", "body": body.dict()}
|
||||||
|
|
||||||
file_path = _input_dir / body.filename
|
file_path = _input_dir / body.filename
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
@@ -659,6 +666,7 @@ async def merge_batch(
|
|||||||
"""Merge selected purchase order files into one PosPal template."""
|
"""Merge selected purchase order files into one PosPal template."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task("批量合并采购单")
|
task = tm.create_task("批量合并采购单")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/merge-batch", "body": body.dict()}
|
||||||
|
|
||||||
file_paths = [_result_dir / f for f in body.filenames if (_result_dir / f).is_file()]
|
file_paths = [_result_dir / f for f in body.filenames if (_result_dir / f).is_file()]
|
||||||
if not file_paths:
|
if not file_paths:
|
||||||
|
|||||||
@@ -121,7 +121,34 @@ async def retry_task(
|
|||||||
"""Retry a failed task by re-invoking its processing endpoint.
|
"""Retry a failed task by re-invoking its processing endpoint.
|
||||||
|
|
||||||
Only tasks with status ``failed`` may be retried.
|
Only tasks with status ``failed`` may be retried.
|
||||||
|
For in-memory tasks with metadata, the original endpoint and request body
|
||||||
|
are used to faithfully reproduce the original call. For historical DB-only
|
||||||
|
tasks, the endpoint is looked up from ``_RETRY_ROUTE_MAP`` by task name.
|
||||||
"""
|
"""
|
||||||
|
tm = request.state.task_manager
|
||||||
|
|
||||||
|
# --- Strategy 1: in-memory task with metadata ---
|
||||||
|
new_task = tm.retry_task(task_id)
|
||||||
|
if new_task is not None:
|
||||||
|
meta = new_task.metadata or {}
|
||||||
|
endpoint = meta.get("endpoint")
|
||||||
|
body = meta.get("body", {})
|
||||||
|
if endpoint:
|
||||||
|
base_url = f"http://{request.url.hostname}:{request.url.port}"
|
||||||
|
url = f"{base_url}{endpoint}"
|
||||||
|
auth_header = request.headers.get("authorization")
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if auth_header:
|
||||||
|
headers["authorization"] = auth_header
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(url, json=body, headers=headers)
|
||||||
|
return {"task_id": new_task.id, "status": "retried", "original_response": resp.json()}
|
||||||
|
|
||||||
|
# Metadata present but no endpoint — fall through to DB strategy
|
||||||
|
# (the new task was already created; caller can track it)
|
||||||
|
return {"task_id": new_task.id, "status": "retried"}
|
||||||
|
|
||||||
|
# --- Strategy 2: DB-only historical task (no in-memory record) ---
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
task = await loop.run_in_executor(
|
task = await loop.run_in_executor(
|
||||||
None, lambda: db_schema.query_task_by_id(task_id),
|
None, lambda: db_schema.query_task_by_id(task_id),
|
||||||
@@ -142,18 +169,18 @@ async def retry_task(
|
|||||||
detail=f"未知的任务类型: {task_name}",
|
detail=f"未知的任务类型: {task_name}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build the internal URL to the processing endpoint.
|
# Create a new in-memory task to track the retry.
|
||||||
|
new_task = tm.create_task(task_name)
|
||||||
|
|
||||||
base_url = f"http://{request.url.hostname}:{request.url.port}"
|
base_url = f"http://{request.url.hostname}:{request.url.port}"
|
||||||
url = f"{base_url}{endpoint}"
|
url = f"{base_url}{endpoint}"
|
||||||
|
|
||||||
# Forward the Authorization header so the processing endpoint can
|
|
||||||
# authenticate the request.
|
|
||||||
auth_header = request.headers.get("authorization")
|
auth_header = request.headers.get("authorization")
|
||||||
headers: dict[str, str] = {}
|
headers = {}
|
||||||
if auth_header:
|
if auth_header:
|
||||||
headers["authorization"] = auth_header
|
headers["authorization"] = auth_header
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.post(url, headers=headers)
|
resp = await client.post(url, headers=headers)
|
||||||
|
|
||||||
return resp.json()
|
return {"task_id": new_task.id, "status": "retried", "original_response": resp.json()}
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ class Task:
|
|||||||
result_files: List[str] = field(default_factory=list)
|
result_files: List[str] = field(default_factory=list)
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
log_lines: List[str] = field(default_factory=list)
|
log_lines: List[str] = field(default_factory=list)
|
||||||
|
metadata: Optional[dict] = None
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
d = {
|
||||||
"task_id": self.id,
|
"task_id": self.id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"status": self.status.value,
|
"status": self.status.value,
|
||||||
@@ -40,6 +41,9 @@ class Task:
|
|||||||
"error": self.error,
|
"error": self.error,
|
||||||
"log_lines": self.log_lines[-100:],
|
"log_lines": self.log_lines[-100:],
|
||||||
}
|
}
|
||||||
|
if self.metadata:
|
||||||
|
d["metadata"] = self.metadata
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
class TaskManager:
|
class TaskManager:
|
||||||
@@ -135,6 +139,21 @@ class TaskManager:
|
|||||||
)
|
)
|
||||||
self._schedule(self._broadcast(task_id))
|
self._schedule(self._broadcast(task_id))
|
||||||
|
|
||||||
|
def retry_task(self, task_id: str) -> Optional[Task]:
|
||||||
|
"""Create a new task to retry a failed task with its original parameters.
|
||||||
|
|
||||||
|
Returns the new task if the original was failed and retryable, else None.
|
||||||
|
The caller is responsible for dispatching the actual work based on
|
||||||
|
``new_task.metadata``.
|
||||||
|
"""
|
||||||
|
original = self._tasks.get(task_id)
|
||||||
|
if not original or original.status != TaskStatus.FAILED:
|
||||||
|
return None
|
||||||
|
new_task = self.create_task(original.name)
|
||||||
|
if original.metadata:
|
||||||
|
new_task.metadata = dict(original.metadata)
|
||||||
|
return new_task
|
||||||
|
|
||||||
def set_failed(self, task_id: str, error: str):
|
def set_failed(self, task_id: str, error: str):
|
||||||
task = self._tasks.get(task_id)
|
task = self._tasks.get(task_id)
|
||||||
if not task:
|
if not task:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
|
||||||
export interface TaskInfo {
|
export interface TaskInfo {
|
||||||
@@ -13,44 +13,64 @@ export interface TaskInfo {
|
|||||||
log_lines: string[]
|
log_lines: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskConnection {
|
||||||
|
ws: WebSocket | null
|
||||||
|
reconnectAttempts: number
|
||||||
|
reconnectTimer: ReturnType<typeof setTimeout> | null
|
||||||
|
}
|
||||||
|
|
||||||
export const useProcessingStore = defineStore('processing', () => {
|
export const useProcessingStore = defineStore('processing', () => {
|
||||||
const currentTask = ref<TaskInfo | null>(null)
|
// --- Multi-task tracking ---
|
||||||
|
const activeTasks = ref(new Map<string, TaskInfo>())
|
||||||
|
|
||||||
|
const activeTaskList = computed(() =>
|
||||||
|
Array.from(activeTasks.value.values())
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentTask = computed<TaskInfo | null>(() =>
|
||||||
|
activeTaskList.value[0] ?? null
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Legacy compatibility ---
|
||||||
const tasks = ref<TaskInfo[]>([])
|
const tasks = ref<TaskInfo[]>([])
|
||||||
const logs = ref<string[]>([])
|
const logs = ref<string[]>([])
|
||||||
const taskSource = ref<string>('')
|
const taskSource = ref<string>('')
|
||||||
|
|
||||||
let ws: WebSocket | null = null
|
// --- Per-task WebSocket management ---
|
||||||
let reconnectAttempts = 0
|
const taskConnections = new Map<string, TaskConnection>()
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let currentTaskId: string | null = null
|
|
||||||
const MAX_RECONNECT = 5
|
const MAX_RECONNECT = 5
|
||||||
|
|
||||||
function connectWebSocket(taskId: string) {
|
function connectWebSocket(taskId: string) {
|
||||||
disconnectWebSocket()
|
disconnectTaskWS(taskId)
|
||||||
currentTaskId = taskId
|
taskConnections.set(taskId, { ws: null, reconnectAttempts: 0, reconnectTimer: null })
|
||||||
reconnectAttempts = 0
|
|
||||||
doConnect(taskId)
|
doConnect(taskId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function doConnect(taskId: string) {
|
function doConnect(taskId: string) {
|
||||||
|
const conn = taskConnections.get(taskId)
|
||||||
|
if (!conn) return
|
||||||
|
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const host = window.location.host
|
const host = window.location.host
|
||||||
const url = `${protocol}//${host}/ws/task/${taskId}?token=${token}`
|
const url = `${protocol}//${host}/ws/task/${taskId}?token=${token}`
|
||||||
|
|
||||||
ws = new WebSocket(url)
|
const socket = new WebSocket(url)
|
||||||
|
conn.ws = socket
|
||||||
|
|
||||||
ws.onopen = () => {
|
socket.onopen = () => {
|
||||||
reconnectAttempts = 0
|
conn.reconnectAttempts = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data)
|
const data = JSON.parse(event.data)
|
||||||
if (data.error) return // ignore error messages from ws
|
if (data.error) return
|
||||||
currentTask.value = data
|
|
||||||
logs.value = data.log_lines || []
|
|
||||||
|
|
||||||
|
// Update activeTasks map
|
||||||
|
activeTasks.value.set(data.task_id, data)
|
||||||
|
|
||||||
|
// Legacy: update tasks list
|
||||||
const idx = tasks.value.findIndex(t => t.task_id === data.task_id)
|
const idx = tasks.value.findIndex(t => t.task_id === data.task_id)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
tasks.value[idx] = data
|
tasks.value[idx] = data
|
||||||
@@ -58,30 +78,33 @@ export const useProcessingStore = defineStore('processing', () => {
|
|||||||
tasks.value.unshift(data)
|
tasks.value.unshift(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy: update logs for the current (most recent) task
|
||||||
|
if (currentTask.value?.task_id === data.task_id) {
|
||||||
|
logs.value = data.log_lines || []
|
||||||
|
}
|
||||||
|
|
||||||
if (data.status === 'completed' || data.status === 'failed') {
|
if (data.status === 'completed' || data.status === 'failed') {
|
||||||
setTimeout(() => disconnectWebSocket(), 2000)
|
setTimeout(() => disconnectTaskWS(data.task_id), 2000)
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = () => {
|
socket.onerror = () => {
|
||||||
// Error will be followed by onclose, which handles reconnection
|
// Error will be followed by onclose, which handles reconnection
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
socket.onclose = () => {
|
||||||
ws = null
|
conn.ws = null
|
||||||
// Auto-reconnect if task is still running and not manually disconnected
|
const task = activeTasks.value.get(taskId)
|
||||||
const task = currentTask.value
|
|
||||||
if (
|
if (
|
||||||
currentTaskId === taskId &&
|
|
||||||
task &&
|
task &&
|
||||||
(task.status === 'pending' || task.status === 'running') &&
|
(task.status === 'pending' || task.status === 'running') &&
|
||||||
reconnectAttempts < MAX_RECONNECT
|
conn.reconnectAttempts < MAX_RECONNECT
|
||||||
) {
|
) {
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000)
|
const delay = Math.min(1000 * Math.pow(2, conn.reconnectAttempts), 10000)
|
||||||
reconnectAttempts++
|
conn.reconnectAttempts++
|
||||||
reconnectTimer = setTimeout(() => {
|
conn.reconnectTimer = setTimeout(() => {
|
||||||
if (currentTaskId === taskId) {
|
if (taskConnections.has(taskId)) {
|
||||||
doConnect(taskId)
|
doConnect(taskId)
|
||||||
}
|
}
|
||||||
}, delay)
|
}, delay)
|
||||||
@@ -89,24 +112,58 @@ export const useProcessingStore = defineStore('processing', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function disconnectTaskWS(taskId: string) {
|
||||||
|
const conn = taskConnections.get(taskId)
|
||||||
|
if (!conn) return
|
||||||
|
conn.reconnectAttempts = MAX_RECONNECT // prevent reconnect
|
||||||
|
if (conn.reconnectTimer) {
|
||||||
|
clearTimeout(conn.reconnectTimer)
|
||||||
|
conn.reconnectTimer = null
|
||||||
|
}
|
||||||
|
if (conn.ws) {
|
||||||
|
conn.ws.close()
|
||||||
|
conn.ws = null
|
||||||
|
}
|
||||||
|
taskConnections.delete(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disconnect all task WebSockets (backward compat) */
|
||||||
function disconnectWebSocket() {
|
function disconnectWebSocket() {
|
||||||
currentTaskId = null
|
for (const taskId of Array.from(taskConnections.keys())) {
|
||||||
reconnectAttempts = MAX_RECONNECT // prevent reconnect
|
disconnectTaskWS(taskId)
|
||||||
if (reconnectTimer) {
|
|
||||||
clearTimeout(reconnectTimer)
|
|
||||||
reconnectTimer = null
|
|
||||||
}
|
}
|
||||||
if (ws) {
|
}
|
||||||
ws.close()
|
|
||||||
ws = null
|
function removeTask(taskId: string) {
|
||||||
|
disconnectTaskWS(taskId)
|
||||||
|
activeTasks.value.delete(taskId)
|
||||||
|
const idx = tasks.value.findIndex(t => t.task_id === taskId)
|
||||||
|
if (idx >= 0) tasks.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryTask(taskId: string) {
|
||||||
|
const res = await api.post(`/api/tasks/${taskId}/retry`)
|
||||||
|
const newTaskId: string = res.data.task_id
|
||||||
|
const taskInfo: TaskInfo = {
|
||||||
|
task_id: newTaskId,
|
||||||
|
name: res.data.message || '',
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
message: '',
|
||||||
|
result_files: [],
|
||||||
|
error: null,
|
||||||
|
log_lines: [],
|
||||||
}
|
}
|
||||||
|
activeTasks.value.set(newTaskId, taskInfo)
|
||||||
|
connectWebSocket(newTaskId)
|
||||||
|
return newTaskId
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startTask(endpoint: string, body?: any, source: string = 'processing') {
|
async function startTask(endpoint: string, body?: any, source: string = 'processing') {
|
||||||
const res = await api.post(endpoint, body || {})
|
const res = await api.post(endpoint, body || {})
|
||||||
const taskId = res.data.task_id
|
const taskId = res.data.task_id
|
||||||
taskSource.value = source
|
taskSource.value = source
|
||||||
currentTask.value = {
|
const taskInfo: TaskInfo = {
|
||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
name: res.data.message || '',
|
name: res.data.message || '',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
@@ -116,10 +173,23 @@ export const useProcessingStore = defineStore('processing', () => {
|
|||||||
error: null,
|
error: null,
|
||||||
log_lines: [],
|
log_lines: [],
|
||||||
}
|
}
|
||||||
|
activeTasks.value.set(taskId, taskInfo)
|
||||||
logs.value = []
|
logs.value = []
|
||||||
connectWebSocket(taskId)
|
connectWebSocket(taskId)
|
||||||
return taskId
|
return taskId
|
||||||
}
|
}
|
||||||
|
|
||||||
return { currentTask, tasks, logs, taskSource, connectWebSocket, disconnectWebSocket, startTask }
|
return {
|
||||||
|
activeTasks,
|
||||||
|
activeTaskList,
|
||||||
|
currentTask,
|
||||||
|
tasks,
|
||||||
|
logs,
|
||||||
|
taskSource,
|
||||||
|
connectWebSocket,
|
||||||
|
disconnectWebSocket,
|
||||||
|
startTask,
|
||||||
|
removeTask,
|
||||||
|
retryTask,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,27 +35,34 @@
|
|||||||
<div class="main-grid">
|
<div class="main-grid">
|
||||||
<!-- Left column: Progress + Logs -->
|
<!-- Left column: Progress + Logs -->
|
||||||
<div class="col-left">
|
<div class="col-left">
|
||||||
<!-- Progress -->
|
<!-- Active tasks list -->
|
||||||
<div class="card progress-card animate-in animate-in-delay-1">
|
<div class="card progress-card animate-in animate-in-delay-1">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>处理进度</h3>
|
<h3>处理进度</h3>
|
||||||
<el-tag v-if="currentTask" :type="statusType" size="small" effect="dark">
|
<el-tag v-if="visibleTasks.length > 0" size="small" effect="dark">
|
||||||
{{ statusText }}
|
{{ visibleTasks.length }} 个任务
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="currentTask" class="progress-area">
|
<div v-if="visibleTasks.length > 0" class="task-cards">
|
||||||
<div class="progress-bar-wrapper">
|
<div v-for="task in visibleTasks" :key="task.task_id" class="task-card-item">
|
||||||
<div class="progress-bar-track">
|
<div class="task-card-header">
|
||||||
<div
|
<span class="task-name">{{ task.name }}</span>
|
||||||
class="progress-bar-fill"
|
<el-tag :type="statusTagType(task.status)" size="small">{{ statusLabel(task.status) }}</el-tag>
|
||||||
:style="{ width: currentTask.progress + '%', background: statusColor }"
|
</div>
|
||||||
></div>
|
<el-progress v-if="task.status === 'running' || task.status === 'pending'" :percentage="task.progress" :stroke-width="8" />
|
||||||
|
<div v-if="task.message" class="task-message">{{ task.message }}</div>
|
||||||
|
<!-- Error display -->
|
||||||
|
<el-alert v-if="task.status === 'failed' && task.error" :title="task.error" type="error" show-icon :closable="false" class="task-error" />
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="task-card-actions">
|
||||||
|
<el-button v-if="task.status === 'failed'" type="warning" size="small" @click="handleRetry(task.task_id)">重试</el-button>
|
||||||
|
<el-button v-if="task.status === 'completed' || task.status === 'failed'" size="small" @click="handleDismiss(task.task_id)">关闭</el-button>
|
||||||
|
</div>
|
||||||
|
<!-- Log lines for this task -->
|
||||||
|
<div v-if="task.log_lines?.length" class="task-logs">
|
||||||
|
<div v-for="(log, i) in task.log_lines.slice(-50)" :key="i" class="log-line">{{ log }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="progress-meta">
|
|
||||||
<span class="progress-pct" :style="{ color: statusColor }">{{ currentTask.progress }}%</span>
|
|
||||||
<span class="progress-msg">{{ currentTask.message }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
@@ -71,7 +78,10 @@
|
|||||||
<div class="card log-card animate-in animate-in-delay-2">
|
<div class="card log-card animate-in animate-in-delay-2">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>处理日志</h3>
|
<h3>处理日志</h3>
|
||||||
<el-button size="small" link @click="clearLogs">清空</el-button>
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<el-button size="small" link @click="$router.push('/tasks')">查看全部日志</el-button>
|
||||||
|
<el-button size="small" link @click="clearLogs">清空</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="logBox" class="log-box">
|
<div ref="logBox" class="log-box">
|
||||||
<div v-if="logs.length === 0" class="empty-state small">
|
<div v-if="logs.length === 0" class="empty-state small">
|
||||||
@@ -207,41 +217,10 @@ const detailedStats = ref({
|
|||||||
total_processed: 0,
|
total_processed: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentTask = computed(() => {
|
const visibleTasks = computed(() =>
|
||||||
if (ps.taskSource !== 'sync') return ps.currentTask
|
ps.taskSource !== 'sync' ? ps.activeTaskList : []
|
||||||
return null
|
)
|
||||||
})
|
const logs = computed(() => ps.logs.slice(0, 50))
|
||||||
const logs = computed(() => ps.logs.slice(0, 10))
|
|
||||||
|
|
||||||
const statusType = computed(() => {
|
|
||||||
const m: Record<string, string> = {
|
|
||||||
pending: 'info',
|
|
||||||
running: 'warning',
|
|
||||||
completed: 'success',
|
|
||||||
failed: 'danger',
|
|
||||||
}
|
|
||||||
return m[currentTask.value?.status || ''] || 'info'
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusColor = computed(() => {
|
|
||||||
const m: Record<string, string> = {
|
|
||||||
pending: '#a1a1aa',
|
|
||||||
running: '#f97316',
|
|
||||||
completed: '#22c55e',
|
|
||||||
failed: '#ef4444',
|
|
||||||
}
|
|
||||||
return m[currentTask.value?.status || ''] || '#a1a1aa'
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusText = computed(() => {
|
|
||||||
const m: Record<string, string> = {
|
|
||||||
pending: '等待中',
|
|
||||||
running: '运行中',
|
|
||||||
completed: '已完成',
|
|
||||||
failed: '已失败',
|
|
||||||
}
|
|
||||||
return m[currentTask.value?.status || ''] || ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const stats = computed(() => [
|
const stats = computed(() => [
|
||||||
{
|
{
|
||||||
@@ -290,6 +269,29 @@ function clearLogs(): void {
|
|||||||
ps.logs.splice(0)
|
ps.logs.splice(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusTagType(status: string): string {
|
||||||
|
const map: Record<string, string> = { pending: 'info', running: '', completed: 'success', failed: 'danger' }
|
||||||
|
return map[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = { pending: '等待中', running: '运行中', completed: '已完成', failed: '失败' }
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRetry(taskId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ps.retryTask(taskId)
|
||||||
|
ElMessage.success('已重新提交任务')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('重试失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDismiss(taskId: string): void {
|
||||||
|
ps.removeTask(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshStats(): Promise<void> {
|
async function refreshStats(): Promise<void> {
|
||||||
statsLoading.value = true
|
statsLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -400,11 +402,11 @@ const runPipeline = () => doAction('/processing/pipeline')
|
|||||||
const runOcr = () => doAction('/processing/ocr-batch')
|
const runOcr = () => doAction('/processing/ocr-batch')
|
||||||
const runExcel = () => doAction('/processing/excel')
|
const runExcel = () => doAction('/processing/excel')
|
||||||
|
|
||||||
// Auto-refresh stats when task completes
|
// Auto-refresh stats when any task completes or fails
|
||||||
watch(
|
watch(
|
||||||
() => currentTask.value?.status,
|
() => visibleTasks.value.map(t => t.status),
|
||||||
(status) => {
|
(statuses) => {
|
||||||
if (status === 'completed' || status === 'failed') {
|
if (statuses.some(s => s === 'completed' || s === 'failed')) {
|
||||||
refreshStats()
|
refreshStats()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -693,6 +695,75 @@ onMounted(() => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Task cards ── */
|
||||||
|
.task-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-item {
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-item:hover {
|
||||||
|
border-color: #d4d4d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-message {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-error {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-logs {
|
||||||
|
margin-top: 10px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #09090b;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-logs .log-line {
|
||||||
|
color: #a1a1aa;
|
||||||
|
padding: 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Progress area ── */
|
/* ── Progress area ── */
|
||||||
.progress-card {
|
.progress-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-container class="layout">
|
<el-container class="layout">
|
||||||
<el-aside :width="isCollapse ? '72px' : '240px'" class="sidebar">
|
<el-aside v-show="!isMobile" :width="isCollapse ? '72px' : '240px'" class="sidebar">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="sidebar-logo" @click="isCollapse = !isCollapse">
|
<div class="sidebar-logo" @click="isCollapse = !isCollapse">
|
||||||
<div class="logo-mark">
|
<div class="logo-mark">
|
||||||
@@ -83,6 +83,9 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar-left">
|
<div class="topbar-left">
|
||||||
|
<button v-if="isMobile" class="hamburger-btn" @click="mobileDrawer = true">
|
||||||
|
<el-icon :size="22"><MenuIcon /></el-icon>
|
||||||
|
</button>
|
||||||
<h2 class="page-title">{{ pageTitle }}</h2>
|
<h2 class="page-title">{{ pageTitle }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
@@ -122,6 +125,82 @@
|
|||||||
</el-container>
|
</el-container>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
|
||||||
|
<!-- Mobile sidebar drawer -->
|
||||||
|
<el-drawer
|
||||||
|
v-model="mobileDrawer"
|
||||||
|
direction="ltr"
|
||||||
|
size="260px"
|
||||||
|
:with-header="false"
|
||||||
|
class="mobile-drawer"
|
||||||
|
>
|
||||||
|
<div class="drawer-sidebar">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<div class="logo-mark">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/>
|
||||||
|
<rect x="9" y="3" width="6" height="4" rx="1"/>
|
||||||
|
<path d="M9 14l2 2 4-4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="logo-text">益选 OCR</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<el-menu
|
||||||
|
:default-active="route.path"
|
||||||
|
:default-openeds="filesMenuOpen"
|
||||||
|
mode="vertical"
|
||||||
|
background-color="transparent"
|
||||||
|
text-color="var(--text-sidebar)"
|
||||||
|
active-text-color="#fafafa"
|
||||||
|
class="sidebar-nav"
|
||||||
|
router
|
||||||
|
@select="onMenuSelect"
|
||||||
|
>
|
||||||
|
<el-menu-item index="/">
|
||||||
|
<el-icon><HomeFilled /></el-icon>
|
||||||
|
<template #title>处理中心</template>
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-sub-menu index="/files">
|
||||||
|
<template #title>
|
||||||
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
<span>文件处理</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="/files/orders">采购单</el-menu-item>
|
||||||
|
<el-menu-item index="/files/tables">表格处理</el-menu-item>
|
||||||
|
<el-menu-item index="/files/images">图片处理</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
|
||||||
|
<el-menu-item index="/tasks">
|
||||||
|
<el-icon><Timer /></el-icon>
|
||||||
|
<template #title>任务历史</template>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/logs">
|
||||||
|
<el-icon><Notebook /></el-icon>
|
||||||
|
<template #title>日志中心</template>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/memory">
|
||||||
|
<el-icon><Memo /></el-icon>
|
||||||
|
<template #title>记忆库</template>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/barcodes">
|
||||||
|
<el-icon><Connection /></el-icon>
|
||||||
|
<template #title>条码映射</template>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/config">
|
||||||
|
<el-icon><Setting /></el-icon>
|
||||||
|
<template #title>系统配置</template>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/sync">
|
||||||
|
<el-icon><Cloudy /></el-icon>
|
||||||
|
<template #title>云端同步</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
<!-- Change password dialog -->
|
<!-- Change password dialog -->
|
||||||
<el-dialog v-model="showPwd" title="修改密码" width="420px" :close-on-click-modal="false">
|
<el-dialog v-model="showPwd" title="修改密码" width="420px" :close-on-click-modal="false">
|
||||||
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="70px">
|
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="70px">
|
||||||
@@ -143,12 +222,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, reactive, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
HomeFilled, Memo, Connection, Setting, Cloudy, Timer, Notebook, FolderOpened,
|
HomeFilled, Memo, Connection, Setting, Cloudy, Timer, Notebook, FolderOpened,
|
||||||
ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight
|
ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight, Menu as MenuIcon
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
@@ -158,6 +237,8 @@ const router = useRouter()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const isCollapse = ref(false)
|
const isCollapse = ref(false)
|
||||||
|
const isMobile = ref(window.innerWidth < 768)
|
||||||
|
const mobileDrawer = ref(false)
|
||||||
const showPwd = ref(false)
|
const showPwd = ref(false)
|
||||||
const pwdForm = reactive({ old_password: '', new_password: '', confirm_password: '' })
|
const pwdForm = reactive({ old_password: '', new_password: '', confirm_password: '' })
|
||||||
const pwdFormRef = ref<FormInstance>()
|
const pwdFormRef = ref<FormInstance>()
|
||||||
@@ -187,18 +268,35 @@ const isOnline = ref(navigator.onLine)
|
|||||||
function updateOnlineStatus() {
|
function updateOnlineStatus() {
|
||||||
isOnline.value = navigator.onLine
|
isOnline.value = navigator.onLine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track viewport for mobile drawer
|
||||||
|
function updateMobileState() {
|
||||||
|
isMobile.value = window.innerWidth < 768
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close drawer on route change
|
||||||
|
watch(() => route.path, () => {
|
||||||
|
mobileDrawer.value = false
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('online', updateOnlineStatus)
|
window.addEventListener('online', updateOnlineStatus)
|
||||||
window.addEventListener('offline', updateOnlineStatus)
|
window.addEventListener('offline', updateOnlineStatus)
|
||||||
|
window.addEventListener('resize', updateMobileState)
|
||||||
await authStore.fetchUser()
|
await authStore.fetchUser()
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('online', updateOnlineStatus)
|
window.removeEventListener('online', updateOnlineStatus)
|
||||||
window.removeEventListener('offline', updateOnlineStatus)
|
window.removeEventListener('offline', updateOnlineStatus)
|
||||||
|
window.removeEventListener('resize', updateMobileState)
|
||||||
})
|
})
|
||||||
|
|
||||||
const filesMenuOpen = ['/files']
|
const filesMenuOpen = ['/files']
|
||||||
|
|
||||||
|
function onMenuSelect() {
|
||||||
|
mobileDrawer.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const pageTitles: Record<string, string> = {
|
const pageTitles: Record<string, string> = {
|
||||||
'/': '处理中心',
|
'/': '处理中心',
|
||||||
'/files/orders': '采购单',
|
'/files/orders': '采购单',
|
||||||
@@ -416,6 +514,18 @@ async function changePassword() {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -423,6 +533,50 @@ async function changePassword() {
|
|||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Hamburger button (mobile) ── */
|
||||||
|
.hamburger-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile drawer ── */
|
||||||
|
.mobile-drawer :deep(.el-drawer__body) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-sidebar {
|
||||||
|
background: #09090b;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-sidebar .sidebar-logo {
|
||||||
|
padding: 20px 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-sidebar .sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-right: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.user-chip {
|
.user-chip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -506,4 +660,29 @@ async function changePassword() {
|
|||||||
.page-leave-to {
|
.page-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.user-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.content {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
height: 52px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -306,4 +306,35 @@ onUnmounted(cancelSearch)
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stats-row {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.card-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.card-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.stats-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -453,4 +453,35 @@ onUnmounted(cancelSearch)
|
|||||||
|
|
||||||
.log-line.err { color: #f87171; }
|
.log-line.err { color: #f87171; }
|
||||||
.log-line.ok { color: #34d399; }
|
.log-line.ok { color: #34d399; }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stats-row {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.card-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.card-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.stats-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -366,4 +366,20 @@ onMounted(loadData)
|
|||||||
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
|
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
|
||||||
.preview-table-wrap { flex:1;overflow:auto;min-height:0;border:1px solid var(--border-light);border-radius:8px }
|
.preview-table-wrap { flex:1;overflow:auto;min-height:0;border:1px solid var(--border-light);border-radius:8px }
|
||||||
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
|
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.header-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.header-actions .el-button {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -335,4 +335,20 @@ onMounted(loadData)
|
|||||||
.preview-image-wrap { flex:1; display:flex; align-items:center; justify-content:center; min-height:0 }
|
.preview-image-wrap { flex:1; display:flex; align-items:center; justify-content:center; min-height:0 }
|
||||||
.preview-table-wrap { flex:1; overflow:auto; min-height:0; border:1px solid var(--border-light); border-radius:8px }
|
.preview-table-wrap { flex:1; overflow:auto; min-height:0; border:1px solid var(--border-light); border-radius:8px }
|
||||||
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
|
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.header-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.header-actions .el-button {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -337,4 +337,20 @@ onMounted(loadData)
|
|||||||
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
|
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
|
||||||
.preview-table-wrap { flex:1;overflow:auto;min-height:0;border:1px solid var(--border-light);border-radius:8px }
|
.preview-table-wrap { flex:1;overflow:auto;min-height:0;border:1px solid var(--border-light);border-radius:8px }
|
||||||
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
|
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.header-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.header-actions .el-button {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user