Commit 5f6fe045 authored by Data Governance Dev's avatar Data Governance Dev

feat(web3): 规则设置弹窗 · 从其他任务导入规则 + 字段中文标签

动机
1) 每建一个新任务都要从零手写规则,重复劳动严重。
   任务间的规则往往通用(手机号格式、身份证校验、年龄范围),希望能从已有任务复制。
2) 导入弹框只显示 ZJHM 这种英文列名,业务人员看不懂字段是啥意思。
   需要在字段标题后补字段中文标签(数据库列注释)。

改动
src/components/ImportRulesDialog.vue (新)
- 左右两栏布局(960px 宽):
  · 左列:所有非自身的任务列表(listTasks(),过滤掉 currentTaskId)
  · 右列:选中任务的所有字段+规则(getTask(id) 拿详情,按字段分组),每条规则 checkbox
- 字段标题:field_key + field_comment 横排显示(comment 为空时「(无字段注释)」灰斜体兜底)
- 顶部「全选 / 反选」;底部「已选 N 条 + 取消 + 导入 N 条」

src/components/RuleModal.vue
- 在 + 添加规则 下方加「📥 从其他任务导入规则」按钮(绿色边区分)
- 新增 taskId prop + importOpen ref + onImportRules()
- 去掉后端 id(避免保存时按 id 当 update 走错路径),用 Date.now()+count 生成临时占位 id
- 清空 testVal/testResult(让用户在新字段下重新测试)

src/components/TaskModal.vue
- <RuleModal> 加 :task-id="props.task?.id || 0"

backend/routers/tasks.py
- FieldOut 加 comment: Optional[str] = None
- 新增 _fetch_field_comments(t, conn):用任务自身的 conn_json 连一次数据源,
  调 db_adapter.list_columns() 拿 {field_key: column_comment}
  · 连接信息不全 / 连不上 / 表不存在 → 降级返回空 dict,不报错(comment 是辅助信息)
  · 复用既有 db_adapter,无新依赖;schema/table_name 走绑定参数无 SQL 注入
- _row_to_out(include_field_list=True) 时拉一次 comment_map 注入每个 FieldOut

关键决策
- 后端主动拉 comment 而非前端:前端拿不到密码
- 失败不报错:comment 缺失不影响主流程
- 不持久化到 Field 表:跟 plan §4 决策一致——数据字典不存,每次按 task 重新拉

Bug 修复
第一次实现时 computed 返 {fieldKey, fieldComment, rules},但模板里写了 v-if="group.comment",
comment 没定义 → 永远 falsy → 显示「(无字段注释」兜底。不报错只静默渲染空。
改成 v-if="group.fieldComment"。已记到 memory(vue-computed-template-prop-name-mismatch)。

验证
- 后端 TestClient: GET /api/tasks/24 → field_list[ZJHM].comment = '证件号码 | 数据标准: 01010007' ✓
- npx vite build → ✓ built in 7.09s(1640 modules,无报错)

注
- 4 个 web3/tests/*.png modified 未提交(与本功能无关)
- 后端有改动需用户重启进程
parent 5cb07f5a
......@@ -27,13 +27,17 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from web3.backend.db.database import get_session
from web3.backend.core.db_adapter import DBConfig, DBConnection
from web3.backend.models.field import Field
from web3.backend.models.rule import Rule
from web3.backend.models.task import Task
from web3.backend.models.task_group import TaskGroup
from web3.backend._logging import get_logger
router = APIRouter(prefix="/tasks", tags=["tasks"])
logger = get_logger("backend.routers.tasks")
# ── Pydantic schemas ──────────────────────────────────────────────
......@@ -95,6 +99,10 @@ class FieldOut(BaseModel):
show_default: bool
ord: int
rules: list["RuleOut"] = []
# 2026-08-24:字段中文标签 / 数据库列注释(从数据源 info_schema 实时拉,不持久化)
# 详情接口附带返回,ImportRulesDialog 用它来展示「字段是啥意思」
# 没拉到(连不上 / 没源表)时为 None —— 前端按 None 处理:只显示 field_key
comment: Optional[str] = None
class RuleOut(BaseModel):
......@@ -157,6 +165,70 @@ def _parse_conn_json(conn_json: Optional[str]) -> dict:
return {}
# 2026-08-24:db_type 中文 label → 内部小写值(与 db.py::_LABEL_TO_DB_TYPE 保持一致)
_TASK_DB_TYPE_LABEL_TO_INTERNAL = {
"MySQL": "mysql",
"Oracle": "oracle",
"达梦 DM": "dameng",
}
def _fetch_field_comments(t: Task, conn: dict) -> dict[str, str]:
"""从任务自身的数据源拉一次字段中文注释,返回 {field_key: comment} dict。
用于详情接口:让 ImportRulesDialog 能展示「这个字段是啥意思」,
不让前端单独再连一次库(拿不到密码)。
失败 / 没 source_table / 没密码:返回空 dict —— 前端按 None 处理。
"""
source_table = t.data_source_path or t.source_table
# 优先用 source_table 字段(任务保存了「sources/customer_db/customer.sqlite」形式)
# 拿不到表名 → 直接放弃
if not source_table:
return {}
table_name = source_table.split("/")[-1] if "/" in source_table else source_table
schema = conn.get("schema") or conn.get("database") or ""
db_type_label = t.db_type or "MySQL"
db_type_internal = _TASK_DB_TYPE_LABEL_TO_INTERNAL.get(db_type_label, db_type_label.lower())
host = conn.get("host")
port = conn.get("port")
user = conn.get("user")
password = conn.get("password")
database = conn.get("db")
if not (host and port and user and database):
# 连接信息不全(典型:密码缺失 / 任务用预设建但预设不存在)—— 不报错,直接降级
return {}
try:
cfg = DBConfig(
db_type=db_type_internal,
host=host,
port=int(port),
user=user,
password=password or "",
database=database,
oracle_client_dir=conn.get("oracleClientDir"),
)
with DBConnection(cfg) as db:
rows = db.list_columns(schema, table_name)
except Exception as e:
# 连不上 / 权限不够 / 表不存在 —— 都不致命,只是不显示 comment
logger.warning(
f"[tasks] _fetch_field_comments 失败(task_id={t.id} table={table_name!r}):"
f"{type(e).__name__}: {e}"
)
return {}
out: dict[str, str] = {}
for r in rows:
key = r.get("column_name") or r.get("COLUMN_NAME")
comment = r.get("column_comment") or r.get("COLUMN_COMMENT")
if key and comment:
out[key] = str(comment)
return out
def _replace_fields(db: Session, task: Task, fields_payload: list[FieldPayload]) -> None:
"""整体替换 task 下的 fields + rules(先清空再插入)。
......@@ -242,6 +314,8 @@ def _row_to_out(db: Session, t: Task, *, include_field_list: bool = False) -> Ta
field_list: list[FieldOut] = []
if include_field_list:
# 2026-08-24:详情接口顺带从数据源拉一次列注释(连不上 / 没 source_table 时降级为空 dict)
comment_map = _fetch_field_comments(t, conn)
for f in db.query(Field).filter(Field.task_id == t.id).order_by(Field.ord).all():
rules = [
RuleOut(
......@@ -261,6 +335,7 @@ def _row_to_out(db: Session, t: Task, *, include_field_list: bool = False) -> Ta
show_default=bool(f.show_default),
ord=f.ord,
rules=rules,
comment=comment_map.get(f.field_key), # 2026-08-24:字段中文标签(连不上时 None)
))
return TaskOut(
......
<!--
ImportRulesDialog · 从其他任务导入规则弹框(2026-08-24)
用法:
open v-model —— 父组件控制开关
currentTaskId 当前编辑的任务 id(0 = 新建任务,没 id);
弹框左侧会把自身任务过滤掉,避免误选
交互:
左列:所有非自身的任务列表
右列:选中任务的所有字段+规则(按字段分组),每条规则前 checkbox
footer:已选 N 条 + 取消/导入
导入时:emit('import', rules),由 RuleModal 接管后续写入
数据流:
1. 打开 → listTasks() 拿任务列表
2. 点任务 → getTask(id) 拿详情 → field_list → 平铺成
[{ fieldKey, ruleId, name, desc, ruleType, regex, code, skipNull }, ...]
3. 多选 → 「导入」按钮 emit
-->
<template>
<Teleport to="body">
<div class="modal-mask" :class="{ show: open }" @click="$emit('update:open', false)">
<div class="modal import-rules-dialog" @click.stop>
<div class="modal-header">
<span class="modal-title">📥 从其他任务导入规则</span>
<button class="modal-close" @click="$emit('update:open', false)">×</button>
</div>
<div class="modal-body">
<div v-if="loading" class="state state-loading">
<div class="spinner"></div>
<div class="state-text">加载任务列表…</div>
</div>
<div v-else class="import-grid">
<!-- 左列:任务列表 -->
<div class="import-pane import-pane-left">
<div class="pane-title">选择任务</div>
<div class="task-list">
<div
v-for="t in taskList"
:key="t.id"
class="task-row"
:class="{ active: selectedTaskId === t.id }"
@click="selectTask(t)"
>
<div class="task-name">{{ t.name }}</div>
<div class="task-meta">
<span class="task-group">{{ t.group || '未分组' }}</span>
<span class="task-counts">{{ t.field_count || 0 }}字段 / {{ t.rule_count || 0 }}规则</span>
</div>
</div>
<div v-if="taskList.length === 0" class="empty-tip">没有其他任务可导入</div>
</div>
</div>
<!-- 右列:选中任务的规则列表 -->
<div class="import-pane import-pane-right">
<div class="pane-title">
<span v-if="selectedTask">
「{{ selectedTask.name }}」的规则(共 {{ flatRules.length }} 条)
</span>
<span v-else>请先在左侧选择一个任务</span>
<span class="pane-title-tools" v-if="flatRules.length > 0">
<button class="btn-mini" @click="selectAll">全选</button>
<button class="btn-mini" @click="selectInverse">反选</button>
</span>
</div>
<div v-if="loadingDetail" class="state state-loading state-inline">
<div class="spinner"></div>
<div class="state-text">加载规则…</div>
</div>
<div v-else-if="selectedTask && flatRules.length === 0" class="empty-tip">
该任务还没有规则
</div>
<div v-else-if="selectedTask" class="rule-list">
<!-- 按字段分组展示(fieldKey 小标题 + 下方该字段的所有规则) -->
<template v-for="(group, gIdx) in ruleGroups" :key="gIdx">
<div class="rule-group-head">
<span class="field-key-cell">
<span class="field-key">{{ group.fieldKey }}</span>
<!-- 2026-08-24:字段中文标签(comment)—— 后端从数据源 info_schema 拉的列注释 -->
<span v-if="group.fieldComment" class="field-comment">{{ group.fieldComment }}</span>
<span v-else class="field-comment field-comment-empty">(无字段注释)</span>
</span>
<span class="field-rule-count">{{ group.rules.length }} 条规则</span>
</div>
<label
v-for="r in group.rules"
:key="r.ruleId"
class="rule-row"
:class="{ checked: selectedIds.has(r.ruleId) }"
>
<input
type="checkbox"
:checked="selectedIds.has(r.ruleId)"
@change="toggleRule(r)"
/>
<div class="rule-info">
<div class="rule-head-line">
<span class="rule-name">{{ r.name || r.desc || `规则 #${r.ruleId}` }}</span>
<span class="rule-type-tag" :class="`tag-${r.ruleType}`">
{{ RULE_TYPE_LABEL[r.ruleType] || r.ruleType }}
</span>
</div>
<div v-if="r.desc" class="rule-desc">{{ r.desc }}</div>
<pre v-if="r.ruleType === 'regex'" class="rule-code">{{ r.regex || '(空)' }}</pre>
<pre v-else class="rule-code">{{ r.code || '(空)' }}</pre>
</div>
</label>
</template>
</div>
<div v-else class="empty-tip">左侧点一个任务,规则列表会出现在这里</div>
</div>
</div>
</div>
<div class="modal-footer">
<span class="footer-info">
已选 <strong>{{ selectedIds.size }}</strong> 条规则
</span>
<button class="btn" @click="$emit('update:open', false)">取消</button>
<button class="btn btn-primary" :disabled="selectedIds.size === 0" @click="onImport">
导入 {{ selectedIds.size }} 条
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { listTasks, getTask } from '@/api/tasks'
const props = defineProps({
open: { type: Boolean, default: false },
currentTaskId: { type: Number, default: 0 },
})
const emit = defineEmits(['update:open', 'import'])
const RULE_TYPE_LABEL = {
regex: '正则',
number: '数值',
date: '日期',
string: '字符串',
}
// 任务列表
const taskList = ref([])
const selectedTaskId = ref(null)
const selectedTask = ref(null) // 选中任务的概要(从 listTasks 拿)
const flatRules = ref([]) // 选中任务的所有规则(平铺,每条带 fieldKey 上下文)
const loading = ref(false)
const loadingDetail = ref(false)
const selectedIds = ref(new Set()) // 已勾选的 rule.id
// 打开时拉任务列表
watch(() => props.open, async (isOpen) => {
if (isOpen) {
selectedTaskId.value = null
selectedTask.value = null
flatRules.value = []
selectedIds.value = new Set()
await fetchTaskList()
}
}, { immediate: true })
async function fetchTaskList() {
loading.value = true
try {
const list = await listTasks()
// 过滤掉自身任务(避免误选自己导入)
taskList.value = (Array.isArray(list) ? list : []).filter((t) => t.id !== props.currentTaskId)
} catch (e) {
ElMessage.error(`加载任务列表失败:${e.message || e}`)
taskList.value = []
} finally {
loading.value = false
}
}
async function selectTask(t) {
if (selectedTaskId.value === t.id) return
selectedTaskId.value = t.id
selectedTask.value = t
selectedIds.value = new Set()
flatRules.value = []
loadingDetail.value = true
try {
const detail = await getTask(t.id)
const fields = Array.isArray(detail?.field_list) ? detail.field_list : []
const out = []
for (const f of fields) {
for (const r of (f.rules || [])) {
out.push({
ruleId: r.id,
fieldKey: f.field_key,
// 2026-08-24:字段中文标签 —— 后端 _fetch_field_comments 从数据源拉,
// 没拉到时为 null/undefined;UI 上显示「(无字段注释)」兜底
fieldComment: f.comment || '',
name: r.name || '',
desc: r.desc || '',
ruleType: r.rule_type || 'regex',
regex: r.regex || '',
code: r.code || '',
skipNull: !!r.skip_null,
})
}
}
flatRules.value = out
} catch (e) {
ElMessage.error(`加载任务详情失败:${e.message || e}`)
flatRules.value = []
} finally {
loadingDetail.value = false
}
}
// 按字段分组的规则列表(用于渲染)
const ruleGroups = computed(() => {
const groups = []
const byKey = new Map()
for (const r of flatRules.value) {
if (!byKey.has(r.fieldKey)) {
const g = { fieldKey: r.fieldKey, fieldComment: r.fieldComment || '', rules: [] }
byKey.set(r.fieldKey, g.rules)
groups.push(g)
}
byKey.get(r.fieldKey).push(r)
}
return groups
})
function toggleRule(r) {
const next = new Set(selectedIds.value)
if (next.has(r.ruleId)) next.delete(r.ruleId)
else next.add(r.ruleId)
selectedIds.value = next
}
function selectAll() {
selectedIds.value = new Set(flatRules.value.map((r) => r.ruleId))
}
function selectInverse() {
const next = new Set()
for (const r of flatRules.value) {
if (!selectedIds.value.has(r.ruleId)) next.add(r.ruleId)
}
selectedIds.value = next
}
function onImport() {
const picked = flatRules.value.filter((r) => selectedIds.value.has(r.ruleId))
if (picked.length === 0) {
ElMessage.warning('请至少勾选一条规则')
return
}
emit('import', picked)
emit('update:open', false)
ElMessage.success(`已导入 ${picked.length} 条规则,请检查规则类型与字段类型是否匹配`)
}
</script>
<style scoped>
/* 弹框尺寸:左右两栏 + 容纳规则列表,需要宽一点 */
.import-rules-dialog {
width: 960px;
max-width: 96vw;
max-height: 86vh;
display: flex;
flex-direction: column;
}
.modal-body {
padding: 0 !important; /* 让 .import-grid 自己控边距 */
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
/* 加载中态 */
.state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 16px;
gap: 12px;
color: #909399;
font-size: 13px;
}
.state-inline { padding: 30px 16px; }
.spinner {
width: 28px;
height: 28px;
border: 3px solid #e4e7ed;
border-top-color: #409eff;
border-radius: 50%;
animation: import-spin 0.9s linear infinite;
}
@keyframes import-spin { to { transform: rotate(360deg); } }
/* 左右两栏 */
.import-grid {
display: flex;
flex: 1;
min-height: 0;
border-top: 1px solid #ebeef5;
}
.import-pane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
min-height: 0;
}
.import-pane-left {
flex: 0 0 280px;
border-right: 1px solid #ebeef5;
}
.import-pane-right {
flex: 1;
}
.pane-title {
padding: 10px 16px;
font-size: 13px;
color: #606266;
font-weight: 500;
background: #fafafa;
border-bottom: 1px solid #ebeef5;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.pane-title-tools {
display: inline-flex;
gap: 6px;
}
.btn-mini {
padding: 2px 10px;
font-size: 12px;
border: 1px solid #dcdfe6;
border-radius: 3px;
background: #fff;
color: #606266;
cursor: pointer;
transition: all 0.15s;
}
.btn-mini:hover {
border-color: #409eff;
color: #409eff;
}
/* 左列:任务列表 */
.task-list {
flex: 1;
overflow-y: auto;
padding: 6px 0;
}
.task-row {
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid #f5f5f5;
transition: background 0.12s;
}
.task-row:hover { background: #f5f7fa; }
.task-row.active {
background: #ecf5ff;
border-left: 3px solid #409eff;
padding-left: 13px;
}
.task-name {
font-size: 14px;
color: #303133;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-meta {
margin-top: 4px;
font-size: 12px;
color: #909399;
display: flex;
gap: 8px;
}
.task-group {
background: #f0f2f5;
padding: 1px 8px;
border-radius: 8px;
}
.task-counts { color: #909399; }
/* 右列:规则列表(按字段分组) */
.rule-list {
flex: 1;
overflow-y: auto;
padding: 8px 16px 16px;
}
.rule-group-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 10px 0 6px;
padding-bottom: 4px;
border-bottom: 1px dashed #e4e7ed;
}
.rule-group-head:first-child { margin-top: 4px; }
.field-key {
font-family: ui-monospace, 'Cascadia Code', 'Consolas', 'Menlo', monospace;
font-size: 13px;
color: #409eff;
font-weight: 600;
}
/* 2026-08-24:字段标题左侧包一层 flex,让 field-key 和 field-comment 横排对齐 */
.field-key-cell {
display: inline-flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
/* 字段中文标签(comment)—— 后端从数据源拉 */
.field-comment {
font-size: 13px;
color: #303133;
font-weight: 400;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.field-comment-empty {
color: #c0c4cc;
font-size: 12px;
font-style: italic;
}
.field-rule-count {
font-size: 12px;
color: #909399;
}
.rule-row {
display: flex;
gap: 10px;
padding: 10px 12px;
margin-bottom: 8px;
border: 1px solid #ebeef5;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.12s, background 0.12s;
}
.rule-row:hover { border-color: #c0c4cc; }
.rule-row.checked {
border-color: #409eff;
background: #ecf5ff;
}
.rule-row input[type="checkbox"] {
margin-top: 3px;
cursor: pointer;
flex-shrink: 0;
}
.rule-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.rule-head-line {
display: flex;
align-items: center;
gap: 8px;
}
.rule-name {
font-size: 13.5px;
color: #303133;
font-weight: 500;
}
.rule-type-tag {
font-size: 11px;
padding: 1px 8px;
border-radius: 8px;
font-weight: 400;
}
.tag-regex { background: #ecf5ff; color: #409eff; }
.tag-number { background: #fdf6ec; color: #e6a23c; }
.tag-date { background: #f0f9eb; color: #67c23a; }
.tag-string { background: #f4f4f5; color: #909399; }
.rule-desc {
font-size: 12.5px;
color: #606266;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.rule-code {
margin: 0;
padding: 6px 10px;
background: #fafafa;
border: 1px solid #ebeef5;
border-radius: 3px;
font-family: ui-monospace, 'Cascadia Code', 'Consolas', 'Menlo', monospace;
font-size: 12px;
color: #303133;
white-space: pre-wrap;
word-break: break-all;
max-height: 120px;
overflow-y: auto;
}
.empty-tip {
padding: 40px 16px;
text-align: center;
color: #909399;
font-size: 13px;
}
/* footer */
.modal-footer {
display: flex;
align-items: center;
gap: 8px;
}
.footer-info {
margin-right: auto;
color: #606266;
font-size: 13px;
}
.footer-info strong {
color: #409eff;
font-weight: 600;
margin: 0 4px;
}
</style>
\ No newline at end of file
......@@ -134,6 +134,10 @@
<div v-if="ruleList.length === 0" class="rule-empty-tip">还没有规则,点击下方按钮添加</div>
<button class="add-rule-btn" @click="addRule">+ 添加规则</button>
<!-- 2026-08-24:从其他任务导入规则 -->
<button class="add-rule-btn import-rule-btn" @click="importOpen = true">
📥 从其他任务导入规则
</button>
</div>
<div class="modal-footer">
<button class="btn" @click="$emit('update:open', false)">取消</button>
......@@ -141,6 +145,13 @@
</div>
</div>
</div>
<!-- 从其他任务导入规则弹框 -->
<ImportRulesDialog
v-model:open="importOpen"
:current-task-id="props.taskId || 0"
@import="onImportRules"
/>
</Teleport>
</template>
......@@ -148,10 +159,13 @@
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { genRule as apiGenRule, testRule as apiTestRule } from '@/api/ai'
import ImportRulesDialog from '@/components/ImportRulesDialog.vue'
const props = defineProps({
open: { type: Boolean, default: false },
field: { type: Object, default: null },
// 2026-08-24:当前编辑的任务 id —— 传给 ImportRulesDialog 用于过滤自身
taskId: { type: Number, default: 0 },
})
const emit = defineEmits(['update:open', 'save'])
......@@ -172,6 +186,9 @@ const title = computed(() => {
const ruleList = ref([])
const aiLoading = ref({})
// 2026-08-24:从其他任务导入规则弹框开关
const importOpen = ref(false)
// 「每条规则的 name + desc + code/regex 都填了」才能保存(避免保存半成品规则)
// 注意:ruleList 可以为空 —— 用户明确「删空所有规则」就该能保存,让该字段回到无规则状态
// 2026-08-24:name 也是必填(前端展示「违反哪条规则」用)
......@@ -254,6 +271,31 @@ function removeRule(idx) {
ruleList.value.splice(idx, 1)
}
// 2026-08-24:从其他任务导入规则 —— 把 ImportRulesDialog 选中的规则 push 到 ruleList
// 注意:去掉后端 id(避免保存时按 id 当成已有规则 update),用临时 id 占位(保存时统一按数组顺序重建)
function onImportRules(rules) {
if (!Array.isArray(rules) || rules.length === 0) return
let count = 0
for (const r of rules) {
ruleList.value.push({
// 用 Date.now() + count 防同毫秒内导入多条时 id 冲突
id: Date.now() + count,
name: r.name || '',
desc: r.desc || '',
rule_type: r.ruleType || DEFAULT_RULE_TYPE,
regex: r.regex || '',
code: r.code || '',
skip_null: !!r.skipNull,
// 导入后让用户重新测试 —— 不同字段下原值可能通不过
testVal: '',
testResult: null,
})
count++
}
// 滚动到新导入的规则区域(让用户立刻看到)
// 这里只 push,不主动滚动;父容器可滚动自然能看到
}
// AI 生成:按当前规则类型调后端 /api/ai/rule
async function genAI(idx) {
const rule = ruleList.value[idx]
......@@ -409,6 +451,20 @@ function onSave() {
margin: 0;
}
/* 2026-08-24:从其他任务导入规则按钮 —— 复用 .add-rule-btn(global.css)样式,
跟 + 添加规则 视觉上拉开一点距离,用绿色边表示「导入」语义 */
.import-rule-btn {
margin-top: 8px;
color: #67c23a;
border-color: #c2e7b0;
background: #f0f9eb;
}
.import-rule-btn:hover {
border-color: #67c23a;
color: #fff;
background: #67c23a;
}
/* Python 函数编辑框:等宽字体 + 稍高 + 灰底 */
.code-line {
display: flex;
......
......@@ -235,7 +235,12 @@
</Teleport>
<!-- 规则弹窗 -->
<RuleModal v-model:open="ruleModalOpen" :field="activeField" @save="onRuleSave" />
<RuleModal
v-model:open="ruleModalOpen"
:field="activeField"
:task-id="props.task?.id || 0"
@save="onRuleSave"
/>
</template>
<script setup>
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment