feat: 拆分设置页并支持远程源文件清理

This commit is contained in:
2026-05-19 09:22:42 +08:00
parent f2bef1f2f7
commit 596fec5192
8 changed files with 413 additions and 249 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ Vite 会把 `/api` 和 `/health` 代理到 `http://localhost:9081`。
`Configure.json` 主要包含: `Configure.json` 主要包含:
- `MySQL_DBInfo`:MySQL 连接信息 - `MySQL_DBInfo`:MySQL 连接信息
- `RemoteData`:FTP/SFTP 远程数据源配置,用于递归下载目录后自动处理 - `RemoteData`:FTP/SFTP 远程数据源配置,用于递归下载目录后自动处理,可选择处理成功后删除远程源文件
- `SheetFilter`:Excel Sheet 过滤规则 - `SheetFilter`:Excel Sheet 过滤规则
- `ExtractField`:字段抽取映射配置 - `ExtractField`:字段抽取映射配置
+7
View File
@@ -98,6 +98,13 @@ def _run_remote_processing(
processor = DataProcessor(state.config, work_dir, logger) processor = DataProcessor(state.config, work_dir, logger)
result = processor.process() result = processor.process()
status = "completed" if result.get("success") else "failed" status = "completed" if result.get("success") else "failed"
if status == "completed" and remote_config.auto_delete_source:
try:
deleted_count = downloader.delete_source_files(download_result.remote_files)
logger.success(f"远程源文件清理完成,共删除 {deleted_count} 个文件,目录已保留")
except Exception as exc:
logger.warning(f"远程源文件清理失败,数据处理结果已保留: {exc}")
state.history_manager.update( state.history_manager.update(
task_id, task_id,
status=status, status=status,
+4
View File
@@ -34,6 +34,7 @@ class RemoteDataConfig:
remote_dir: str = "/" remote_dir: str = "/"
passive: bool = True passive: bool = True
timeout: int = 30 timeout: int = 30
auto_delete_source: bool = False
def normalized(self) -> "RemoteDataConfig": def normalized(self) -> "RemoteDataConfig":
protocol = self.protocol.lower().strip() protocol = self.protocol.lower().strip()
@@ -51,6 +52,7 @@ class RemoteDataConfig:
remote_dir=(self.remote_dir or "/").strip() or "/", remote_dir=(self.remote_dir or "/").strip() or "/",
passive=bool(self.passive), passive=bool(self.passive),
timeout=max(int(self.timeout or 30), 1), timeout=max(int(self.timeout or 30), 1),
auto_delete_source=bool(self.auto_delete_source),
) )
def to_dict(self, include_password: bool = False) -> Dict[str, Any]: def to_dict(self, include_password: bool = False) -> Dict[str, Any]:
@@ -63,6 +65,7 @@ class RemoteDataConfig:
"remote_dir": self.remote_dir, "remote_dir": self.remote_dir,
"passive": self.passive, "passive": self.passive,
"timeout": self.timeout, "timeout": self.timeout,
"auto_delete_source": self.auto_delete_source,
} }
if include_password: if include_password:
data["passwd"] = self.passwd data["passwd"] = self.passwd
@@ -91,6 +94,7 @@ class RemoteDataConfig:
remote_dir=str(data.get("remote_dir", "/")), remote_dir=str(data.get("remote_dir", "/")),
passive=bool(data.get("passive", True)), passive=bool(data.get("passive", True)),
timeout=timeout, timeout=timeout,
auto_delete_source=bool(data.get("auto_delete_source", False)),
).normalized() ).normalized()
+88 -2
View File
@@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
import stat import stat
from dataclasses import dataclass from dataclasses import dataclass, field
from ftplib import FTP from ftplib import FTP
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable, Iterable
from app.config import RemoteDataConfig from app.config import RemoteDataConfig
@@ -16,6 +16,7 @@ LogFn = Callable[[str], None]
class RemoteDownloadResult: class RemoteDownloadResult:
file_count: int = 0 file_count: int = 0
total_bytes: int = 0 total_bytes: int = 0
remote_files: list[str] = field(default_factory=list)
class RemoteDownloadError(RuntimeError): class RemoteDownloadError(RuntimeError):
@@ -48,6 +49,18 @@ class RemoteDataDownloader:
return self._download_ftp(destination) return self._download_ftp(destination)
return self._download_sftp(destination) return self._download_sftp(destination)
def delete_source_files(self, remote_files: Iterable[str] | None = None) -> int:
self._validate_config()
source_files = list(remote_files or [])
if source_files:
if self.config.protocol == "ftp":
return self._delete_ftp_file_paths(source_files)
return self._delete_sftp_file_paths(source_files)
if self.config.protocol == "ftp":
return self._delete_ftp_source_files()
return self._delete_sftp_source_files()
def _validate_config(self) -> None: def _validate_config(self) -> None:
if self.config.protocol not in {"ftp", "sftp"}: if self.config.protocol not in {"ftp", "sftp"}:
raise RemoteDownloadError("远程协议只支持 FTP 或 SFTP") raise RemoteDownloadError("远程协议只支持 FTP 或 SFTP")
@@ -149,6 +162,38 @@ class RemoteDataDownloader:
ftp.retrbinary(f"RETR {remote_path}", file.write) ftp.retrbinary(f"RETR {remote_path}", file.write)
result.file_count += 1 result.file_count += 1
result.total_bytes += expected_size or local_path.stat().st_size result.total_bytes += expected_size or local_path.stat().st_size
result.remote_files.append(remote_path)
def _delete_ftp_source_files(self) -> int:
with self._ftp_client() as ftp:
self._log(f"开始清理 FTP 源文件: {self.config.remote_dir}")
return self._delete_ftp_files(ftp, self.config.remote_dir)
def _delete_ftp_files(self, ftp: FTP, remote_dir: str) -> int:
deleted_count = 0
for name, entry_type, _ in self._list_ftp_entries(ftp, remote_dir):
if name in {".", ".."}:
continue
remote_path = self._join_remote_path(remote_dir, name)
if entry_type == "dir" or (entry_type == "unknown" and self._ftp_is_dir(ftp, remote_path)):
deleted_count += self._delete_ftp_files(ftp, remote_path)
continue
self._log(f"删除远程文件: {remote_path}")
ftp.delete(remote_path)
deleted_count += 1
return deleted_count
def _delete_ftp_file_paths(self, remote_files: list[str]) -> int:
deleted_count = 0
with self._ftp_client() as ftp:
self._log(f"开始清理 FTP 源文件,共 {len(remote_files)} 个")
for remote_path in remote_files:
self._log(f"删除远程文件: {remote_path}")
ftp.delete(remote_path)
deleted_count += 1
return deleted_count
def _sftp_ssh_client(self): def _sftp_ssh_client(self):
try: try:
@@ -206,6 +251,47 @@ class RemoteDataDownloader:
sftp.get(remote_path, str(local_path)) sftp.get(remote_path, str(local_path))
result.file_count += 1 result.file_count += 1
result.total_bytes += int(getattr(attrs, "st_size", 0) or local_path.stat().st_size) result.total_bytes += int(getattr(attrs, "st_size", 0) or local_path.stat().st_size)
result.remote_files.append(remote_path)
def _delete_sftp_source_files(self) -> int:
ssh = self._sftp_ssh_client()
try:
with ssh.open_sftp() as sftp:
self._log(f"开始清理 SFTP 源文件: {self.config.remote_dir}")
return self._delete_sftp_files(sftp, self.config.remote_dir)
finally:
ssh.close()
def _delete_sftp_files(self, sftp, remote_path: str) -> int:
attrs = sftp.stat(remote_path)
if stat.S_ISDIR(attrs.st_mode):
deleted_count = 0
for item in sftp.listdir_attr(remote_path):
if item.filename in {".", ".."}:
continue
deleted_count += self._delete_sftp_files(
sftp,
self._join_remote_path(remote_path, item.filename),
)
return deleted_count
self._log(f"删除远程文件: {remote_path}")
sftp.remove(remote_path)
return 1
def _delete_sftp_file_paths(self, remote_files: list[str]) -> int:
deleted_count = 0
ssh = self._sftp_ssh_client()
try:
with ssh.open_sftp() as sftp:
self._log(f"开始清理 SFTP 源文件,共 {len(remote_files)} 个")
for remote_path in remote_files:
self._log(f"删除远程文件: {remote_path}")
sftp.remove(remote_path)
deleted_count += 1
finally:
ssh.close()
return deleted_count
@staticmethod @staticmethod
def _join_remote_path(parent: str, child: str) -> str: def _join_remote_path(parent: str, child: str) -> str:
+9 -1
View File
@@ -1,8 +1,16 @@
# 项目上下文记录 # 项目上下文记录
## 2026-05-19:拆分系统设置页并支持远程源文件自动清理
- `frontend/src/components/SettingsPanel.vue` 的系统设置改为 Naive UI Tabs:连接配置页放数据库配置和远程数据源,规则映射页放 Sheet 过滤规则和字段映射配置,修改密码独立一页,避免设置内容堆在一个长页面。
- `RemoteData` 配置新增 `auto_delete_source` 开关;前端在远程数据源配置中显示“处理成功后删除源文件”,默认关闭。
- `app/services/remote_download.py` 新增远程源文件清理能力,下载阶段会记录本次实际下载的远程文件路径;自动清理只删除这些文件,不删除目录,也不会删除处理期间新进入远程目录的文件。
- `app/api/routers/remote.py` 在远程下载并处理成功后才会执行源文件清理;清理失败只写入警告日志,不改变已完成的数据处理结果。
- 已执行 `.venv\Scripts\python.exe -m compileall app` 和 `npm run build`,均通过;未启动浏览器或 headless Chrome。
## 2026-05-18:新增 FTP/SFTP 远程自动化处理 ## 2026-05-18:新增 FTP/SFTP 远程自动化处理
- `Configure.json` 新增 `RemoteData` 配置,包含启用状态、协议、主机、端口、用户名、密码、远程目录、FTP 被动模式和超时时间;`app/config.py` 会兼容旧配置并在保存时写回该配置块。 - `Configure.json` 新增 `RemoteData` 配置,包含启用状态、协议、主机、端口、用户名、密码、远程目录、FTP 被动模式、超时时间和源文件自动清理开关;`app/config.py` 会兼容旧配置并在保存时写回该配置块。
- 新增 `app/services/remote_download.py`,FTP 使用标准库 `ftplib`,SFTP 使用 `paramiko`,会递归下载远程目录下的全部文件和文件夹到本地任务缓存目录。 - 新增 `app/services/remote_download.py`,FTP 使用标准库 `ftplib`,SFTP 使用 `paramiko`,会递归下载远程目录下的全部文件和文件夹到本地任务缓存目录。
- 新增 `app/api/routers/remote.py`,`POST /api/remote/test` 用于测试远程连接,`POST /api/remote/start` 会创建历史任务、下载远程数据并复用 `DataProcessor` 完成现有处理流程。 - 新增 `app/api/routers/remote.py`,`POST /api/remote/test` 用于测试远程连接,`POST /api/remote/start` 会创建历史任务、下载远程数据并复用 `DataProcessor` 完成现有处理流程。
- `frontend/src/components/SettingsPanel.vue` 新增“远程数据源”配置卡片,支持 FTP/SFTP 切换、保存和测试连接;`frontend/src/components/FileWorkflow.vue` 新增“远程下载并处理”入口。 - `frontend/src/components/SettingsPanel.vue` 新增“远程数据源”配置卡片,支持 FTP/SFTP 切换、保存和测试连接;`frontend/src/components/FileWorkflow.vue` 新增“远程下载并处理”入口。
+265 -237
View File
@@ -2,251 +2,276 @@
<div class="settings-workspace"> <div class="settings-workspace">
<input ref="configInput" class="hidden-input" type="file" accept=".json" @change="uploadConfigFile" /> <input ref="configInput" class="hidden-input" type="file" accept=".json" @change="uploadConfigFile" />
<div class="settings-column"> <n-card size="small" class="work-card settings-tabs-card">
<n-card title="数据库配置" size="small" class="work-card">
<template #header-extra>
<span class="muted-line">更新时间:{{ configUpdate || '-' }}</span>
</template>
<n-form label-placement="top">
<n-grid :cols="12" :x-gap="12">
<n-gi :span="8">
<n-form-item label="主机地址">
<n-input v-model:value="mysqlForm.host" placeholder="localhost" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="端口">
<n-input-number v-model:value="mysqlForm.port" class="full-width" :min="1" :max="65535" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="数据库名">
<n-input v-model:value="mysqlForm.dbname" placeholder="CapacityReport" />
</n-form-item>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="6">
<n-form-item label="用户名">
<n-input v-model:value="mysqlForm.user" placeholder="root" />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="密码">
<n-input v-model:value="mysqlForm.passwd" type="password" show-password-on="click" />
</n-form-item>
</n-gi>
</n-grid>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingMysql" @click="saveMysql">保存配置</n-button>
<n-button :loading="testingDb" @click="testDatabase">测试连接</n-button>
</n-space>
</template>
</n-card>
<n-card title="远程数据源" size="small" class="work-card">
<n-form label-placement="top">
<n-form-item label="启用远程自动化">
<n-switch v-model:value="remoteForm.enabled" />
</n-form-item>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="4">
<n-form-item label="协议">
<n-select
v-model:value="remoteForm.protocol"
:options="remoteProtocolOptions"
@update:value="updateRemoteProtocol"
/>
</n-form-item>
</n-gi>
<n-gi :span="8">
<n-form-item label="服务器地址">
<n-input v-model:value="remoteForm.host" placeholder="192.168.1.10" />
</n-form-item>
</n-gi>
</n-grid>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="4">
<n-form-item label="端口">
<n-input-number v-model:value="remoteForm.port" class="full-width" :min="1" :max="65535" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="超时秒数">
<n-input-number v-model:value="remoteForm.timeout" class="full-width" :min="1" :max="600" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="FTP 被动模式">
<n-switch v-model:value="remoteForm.passive" :disabled="remoteForm.protocol !== 'ftp'" />
</n-form-item>
</n-gi>
</n-grid>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="6">
<n-form-item label="用户名">
<n-input v-model:value="remoteForm.user" placeholder="remote user" />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="密码">
<n-input v-model:value="remoteForm.passwd" type="password" show-password-on="click" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="远程目录">
<n-input v-model:value="remoteForm.remote_dir" placeholder="/CapacityReportData" />
</n-form-item>
<p class="form-hint">自动化执行会递归下载该目录下的全部文件和文件夹到本地缓存,再按现有处理流程入库和执行脚本。</p>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingRemote" @click="saveRemote">保存配置</n-button>
<n-button :loading="testingRemote" @click="testRemote">测试连接</n-button>
</n-space>
</template>
</n-card>
<n-card title="Sheet 过滤规则" size="small" class="work-card">
<n-space vertical>
<p class="form-hint">匹配这些关键词的 Sheet 将被跳过处理</p>
<div class="filter-tags">
<n-tag
v-for="(filter, index) in sheetFilters"
:key="`${filter}-${index}`"
closable
@close="removeSheetFilter(index)"
>
{{ filter }}
</n-tag>
<n-empty v-if="sheetFilters.length === 0" size="small" description="暂无过滤规则" />
</div>
<n-input-group>
<n-input
v-model:value="newSheetFilter"
placeholder="输入需要跳过的 Sheet 关键词"
@keydown.enter.prevent="addSheetFilter"
/>
<n-button @click="addSheetFilter">添加</n-button>
</n-input-group>
</n-space>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingSheetFilter" @click="saveSheetFilter">保存规则</n-button>
</n-space>
</template>
</n-card>
<n-card title="修改登录密码" size="small" class="work-card">
<n-form label-placement="top">
<n-form-item label="当前密码">
<n-input v-model:value="passwordForm.current_password" type="password" show-password-on="click" />
</n-form-item>
<n-form-item label="新密码">
<n-input v-model:value="passwordForm.new_password" type="password" show-password-on="click" />
</n-form-item>
<n-form-item label="确认新密码">
<n-input v-model:value="passwordForm.confirm_password" type="password" show-password-on="click" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="changingPassword" @click="changePassword">修改密码</n-button>
</n-space>
</template>
</n-card>
</div>
<n-card size="small" class="work-card field-mapping-card">
<template #header> <template #header>
<div class="field-card-header"> <div class="settings-card-header">
<div class="field-card-title-row"> <span>系统设置</span>
<div class="field-card-title"> <span class="muted-line">更新时间:{{ configUpdate || '-' }}</span>
<span>字段映射配置</span>
<n-tag size="small" round>{{ extractFields.length }} 个字段</n-tag>
</div>
<n-input
v-model:value="fieldSearch"
size="small"
clearable
class="field-search"
placeholder="搜索字段..."
/>
</div>
<p class="form-hint">定义 Excel 列名到数据库字段的映射规则,提取来源匹配任意一个即可</p>
</div> </div>
</template> </template>
<n-scrollbar class="field-mapping-scroll"> <n-tabs type="line" animated class="settings-tabs">
<n-empty v-if="visibleFieldMappings.length === 0" description="没有匹配的字段配置" /> <n-tab-pane name="connection" tab="连接配置">
<div class="settings-section-grid">
<n-card title="数据库配置" size="small" class="work-card">
<n-form label-placement="top">
<n-grid :cols="12" :x-gap="12">
<n-gi :span="8">
<n-form-item label="主机地址">
<n-input v-model:value="mysqlForm.host" placeholder="localhost" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="端口">
<n-input-number v-model:value="mysqlForm.port" class="full-width" :min="1" :max="65535" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="数据库名">
<n-input v-model:value="mysqlForm.dbname" placeholder="CapacityReport" />
</n-form-item>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="6">
<n-form-item label="用户名">
<n-input v-model:value="mysqlForm.user" placeholder="root" />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="密码">
<n-input v-model:value="mysqlForm.passwd" type="password" show-password-on="click" />
</n-form-item>
</n-gi>
</n-grid>
</n-form>
<div <template #footer>
v-for="{ field, index } in visibleFieldMappings" <n-space justify="end">
:key="index" <n-button type="primary" :loading="savingMysql" @click="saveMysql">保存配置</n-button>
class="field-mapping-item" <n-button :loading="testingDb" @click="testDatabase">测试连接</n-button>
> </n-space>
<div class="field-mapping-number">{{ index + 1 }}</div> </template>
<n-button </n-card>
quaternary
circle
size="small"
type="error"
class="field-remove-button"
@click="removeFieldMapping(index)"
>
<template #icon>
<n-icon><CloseOutline /></n-icon>
</template>
</n-button>
<div class="field-mapping-header"> <n-card title="远程数据源" size="small" class="work-card">
<n-form-item label="数据库字段名" class="field-name-control"> <n-form label-placement="top">
<n-input v-model:value="field.Field" placeholder="输入字段名" /> <n-grid :cols="12" :x-gap="12">
</n-form-item> <n-gi :span="6">
<n-form-item label="字段类型" class="field-type-control"> <n-form-item label="启用远程自动化">
<n-select v-model:value="field.Type" :options="fieldTypeOptions" /> <n-switch v-model:value="remoteForm.enabled" />
</n-form-item> </n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="处理成功后删除源文件">
<n-switch v-model:value="remoteForm.auto_delete_source" />
</n-form-item>
</n-gi>
</n-grid>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="4">
<n-form-item label="协议">
<n-select
v-model:value="remoteForm.protocol"
:options="remoteProtocolOptions"
@update:value="updateRemoteProtocol"
/>
</n-form-item>
</n-gi>
<n-gi :span="8">
<n-form-item label="服务器地址">
<n-input v-model:value="remoteForm.host" placeholder="192.168.1.10" />
</n-form-item>
</n-gi>
</n-grid>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="4">
<n-form-item label="端口">
<n-input-number v-model:value="remoteForm.port" class="full-width" :min="1" :max="65535" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="超时秒数">
<n-input-number v-model:value="remoteForm.timeout" class="full-width" :min="1" :max="600" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="FTP 被动模式">
<n-switch v-model:value="remoteForm.passive" :disabled="remoteForm.protocol !== 'ftp'" />
</n-form-item>
</n-gi>
</n-grid>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="6">
<n-form-item label="用户名">
<n-input v-model:value="remoteForm.user" placeholder="remote user" />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="密码">
<n-input v-model:value="remoteForm.passwd" type="password" show-password-on="click" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="远程目录">
<n-input v-model:value="remoteForm.remote_dir" placeholder="/CapacityReportData" />
</n-form-item>
<p class="form-hint">自动化执行会递归下载该目录下的全部文件和文件夹到本地缓存,再按现有处理流程入库和执行脚本;自动删除源文件只会在处理成功后删除远程文件,保留目录结构。</p>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingRemote" @click="saveRemote">保存配置</n-button>
<n-button :loading="testingRemote" @click="testRemote">测试连接</n-button>
</n-space>
</template>
</n-card>
</div> </div>
</n-tab-pane>
<div class="extract-list"> <n-tab-pane name="rules" tab="规则映射">
<div class="extract-list-header"> <div class="settings-rules-grid">
<span>提取来源({{ field.Extract.length }} 个)</span> <n-card title="Sheet 过滤规则" size="small" class="work-card">
<n-input-group class="extract-add-group"> <n-space vertical>
<n-input <p class="form-hint">匹配这些关键词的 Sheet 将被跳过处理</p>
v-model:value="newExtractValues[index]" <div class="filter-tags">
size="small" <n-tag
placeholder="输入 Excel 列名" v-for="(filter, index) in sheetFilters"
@keydown.enter.prevent="addExtract(index)" :key="`${filter}-${index}`"
/> closable
<n-button size="small" @click="addExtract(index)">添加</n-button> @close="removeSheetFilter(index)"
</n-input-group> >
</div> {{ filter }}
</n-tag>
<n-empty v-if="sheetFilters.length === 0" size="small" description="暂无过滤规则" />
</div>
<n-input-group>
<n-input
v-model:value="newSheetFilter"
placeholder="输入需要跳过的 Sheet 关键词"
@keydown.enter.prevent="addSheetFilter"
/>
<n-button @click="addSheetFilter">添加</n-button>
</n-input-group>
</n-space>
<div v-if="field.Extract.length > 0" class="extract-tree"> <template #footer>
<div v-for="(source, sourceIndex) in field.Extract" :key="`${source}-${sourceIndex}`" class="extract-tree-item"> <n-space justify="end">
<span class="tree-text">{{ source }}</span> <n-button type="primary" :loading="savingSheetFilter" @click="saveSheetFilter">保存规则</n-button>
<n-button text size="tiny" type="warning" @click="removeExtract(index, sourceIndex)">删除</n-button> </n-space>
</div> </template>
</div> </n-card>
<div v-else class="extract-empty">暂无提取来源</div>
<n-card size="small" class="work-card field-mapping-card">
<template #header>
<div class="field-card-header">
<div class="field-card-title-row">
<div class="field-card-title">
<span>字段映射配置</span>
<n-tag size="small" round>{{ extractFields.length }} 个字段</n-tag>
</div>
<n-input
v-model:value="fieldSearch"
size="small"
clearable
class="field-search"
placeholder="搜索字段..."
/>
</div>
<p class="form-hint">定义 Excel 列名到数据库字段的映射规则,提取来源匹配任意一个即可</p>
</div>
</template>
<n-scrollbar class="field-mapping-scroll">
<n-empty v-if="visibleFieldMappings.length === 0" description="没有匹配的字段配置" />
<div
v-for="{ field, index } in visibleFieldMappings"
:key="index"
class="field-mapping-item"
>
<div class="field-mapping-number">{{ index + 1 }}</div>
<n-button
quaternary
circle
size="small"
type="error"
class="field-remove-button"
@click="removeFieldMapping(index)"
>
<template #icon>
<n-icon><CloseOutline /></n-icon>
</template>
</n-button>
<div class="field-mapping-header">
<n-form-item label="数据库字段名" class="field-name-control">
<n-input v-model:value="field.Field" placeholder="输入字段名" />
</n-form-item>
<n-form-item label="字段类型" class="field-type-control">
<n-select v-model:value="field.Type" :options="fieldTypeOptions" />
</n-form-item>
</div>
<div class="extract-list">
<div class="extract-list-header">
<span>提取来源({{ field.Extract.length }} 个)</span>
<n-input-group class="extract-add-group">
<n-input
v-model:value="newExtractValues[index]"
size="small"
placeholder="输入 Excel 列名"
@keydown.enter.prevent="addExtract(index)"
/>
<n-button size="small" @click="addExtract(index)">添加</n-button>
</n-input-group>
</div>
<div v-if="field.Extract.length > 0" class="extract-tree">
<div v-for="(source, sourceIndex) in field.Extract" :key="`${source}-${sourceIndex}`" class="extract-tree-item">
<span class="tree-text">{{ source }}</span>
<n-button text size="tiny" type="warning" @click="removeExtract(index, sourceIndex)">删除</n-button>
</div>
</div>
<div v-else class="extract-empty">暂无提取来源</div>
</div>
</div>
</n-scrollbar>
<template #footer>
<n-space justify="end">
<n-button @click="addFieldMapping">添加字段</n-button>
<n-button type="primary" :loading="savingExtractFields" @click="saveExtractFields">
保存映射
</n-button>
</n-space>
</template>
</n-card>
</div> </div>
</div> </n-tab-pane>
</n-scrollbar>
<template #footer> <n-tab-pane name="security" tab="修改密码">
<n-space justify="end"> <div class="settings-security-panel">
<n-button @click="addFieldMapping">添加字段</n-button> <n-card title="修改登录密码" size="small" class="work-card">
<n-button type="primary" :loading="savingExtractFields" @click="saveExtractFields"> <n-form label-placement="top">
保存映射 <n-form-item label="当前密码">
</n-button> <n-input v-model:value="passwordForm.current_password" type="password" show-password-on="click" />
</n-space> </n-form-item>
</template> <n-form-item label="新密码">
<n-input v-model:value="passwordForm.new_password" type="password" show-password-on="click" />
</n-form-item>
<n-form-item label="确认新密码">
<n-input v-model:value="passwordForm.confirm_password" type="password" show-password-on="click" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="changingPassword" @click="changePassword">修改密码</n-button>
</n-space>
</template>
</n-card>
</div>
</n-tab-pane>
</n-tabs>
</n-card> </n-card>
</div> </div>
</template> </template>
@@ -301,7 +326,8 @@ const remoteForm = reactive<RemoteDataConfig>({
passwd: '', passwd: '',
remote_dir: '/', remote_dir: '/',
passive: true, passive: true,
timeout: 30 timeout: 30,
auto_delete_source: false
}); });
const passwordForm = reactive({ const passwordForm = reactive({
@@ -408,7 +434,8 @@ function getRemotePayload(): RemoteDataConfig {
passwd: remoteForm.passwd || '', passwd: remoteForm.passwd || '',
remote_dir: remoteForm.remote_dir.trim() || '/', remote_dir: remoteForm.remote_dir.trim() || '/',
passive: remoteForm.passive, passive: remoteForm.passive,
timeout: remoteForm.timeout || 30 timeout: remoteForm.timeout || 30,
auto_delete_source: remoteForm.auto_delete_source
}; };
} }
@@ -611,7 +638,8 @@ function normalizeRemoteConfig(config: RemoteDataConfig | undefined): RemoteData
passwd: config?.passwd || '', passwd: config?.passwd || '',
remote_dir: config?.remote_dir || '/', remote_dir: config?.remote_dir || '/',
passive: config?.passive ?? true, passive: config?.passive ?? true,
timeout: config?.timeout || 30 timeout: config?.timeout || 30,
auto_delete_source: Boolean(config?.auto_delete_source)
}; };
} }
+38 -8
View File
@@ -1175,21 +1175,49 @@ select {
/* ==================== Settings ==================== */ /* ==================== Settings ==================== */
.settings-workspace { .settings-workspace {
display: grid;
grid-template-columns: 1fr 1.5fr;
align-items: start;
gap: 20px;
min-height: 100%; min-height: 100%;
padding: 24px 32px 40px; padding: 24px 32px 40px;
} }
.settings-column { .settings-tabs-card {
display: flex;
min-width: 0; min-width: 0;
flex-direction: column; }
.settings-card-header {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
gap: 16px;
}
.settings-tabs-card > .n-card__content {
padding-top: 8px;
}
.settings-tabs .n-tab-pane {
padding-top: 16px;
}
.settings-section-grid,
.settings-rules-grid {
display: grid;
align-items: start;
gap: 20px; gap: 20px;
} }
.settings-section-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-rules-grid {
grid-template-columns: minmax(280px, 0.72fr) minmax(0, 1.4fr);
}
.settings-security-panel {
max-width: 560px;
}
.card-actions { .card-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1505,7 +1533,8 @@ select {
/* ==================== Responsive ==================== */ /* ==================== Responsive ==================== */
@media (max-width: 1200px) { @media (max-width: 1200px) {
.settings-workspace { .settings-section-grid,
.settings-rules-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1557,6 +1586,7 @@ select {
} }
.card-actions, .card-actions,
.settings-card-header,
.field-card-title-row, .field-card-title-row,
.extract-list-header { .extract-list-header {
align-items: stretch; align-items: stretch;
+1
View File
@@ -72,6 +72,7 @@ export interface RemoteDataConfig {
remote_dir: string; remote_dir: string;
passive: boolean; passive: boolean;
timeout: number; timeout: number;
auto_delete_source: boolean;
} }
export interface TableInfo { export interface TableInfo {