From 509b40625467017b782dc8d97faa2a03984dbb85 Mon Sep 17 00:00:00 2001 From: Jayway Liu <14022498+bluejayway@user.noreply.gitee.com> Date: Sun, 11 Oct 2026 09:43:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(formula):=20=E5=AF=BC=E5=87=BA=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=AE=89=E5=85=A8=E3=80=81=E6=B8=85=E7=90=86=E5=9B=9E?= =?UTF-8?q?=E6=94=B6=E3=80=81=E6=92=A4=E6=9D=83=E6=A0=A1=E9=AA=8C=E4=B8=8E?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=BA=93=E5=8F=AF=E9=9D=A0=E6=80=A7=EF=BC=88?= =?UTF-8?q?F01/F02/F05-F08=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F01: _safe_unlink_export 不跟随符号链接、逐层校验父目录,导出失败路径与 清理命令统一使用,只清理受控目录内本次创建的文件 - F02: 清理失败记录告警日志;清理命令回收 failed 任务残留文件 - F05: 定义保存事务内锁数据集行,配额原子检查;revision 冲突返回 409; 保留最近 20 版历史;支持个人/团队共享;审计与变更同事务 - F06: 工作簿参数页毛利率改为只读快照;可编辑单元格解锁并加数值校验 - F07: 对账按指标分容差(人数 0、比率 0.0001、金额 0.01);自定义列使用 服务端 Decimal 表达式求值参考 - F08: 导出任务执行/下载阶段重校验账号有效性与角色 - 对话草稿会话恢复;载入定义后追问携带原定义(F04);下载改浏览器直传 - 迁移 0006-0008;PostgreSQL 咨询锁语义修正(import_worker) --- finance/exporting.py | 104 +++++++++++++-- finance/formula_columns.py | 25 ++++ finance/formula_views.py | 96 +++++++++++--- .../commands/cleanup_formula_exports.py | 28 ++-- finance/management/commands/import_worker.py | 2 +- .../0006_formuladefinition_revision.py | 15 +++ finance/migrations/0007_alter_dataset_file.py | 15 +++ ...nition_history_formuladefinition_shared.py | 22 ++++ finance/models.py | 5 +- frontend/src/dialogs.tsx | 120 +++++++++++++----- frontend/src/types.ts | 26 ++++ tests/e2e/formula-export.spec.ts | 27 +++- tests/test_assistant_api.py | 45 ++++++- tests/test_formula_columns.py | 26 +++- tests/test_formula_export_reliability.py | 78 ++++++++++++ tests/test_operations.py | 42 +++++- 16 files changed, 594 insertions(+), 82 deletions(-) create mode 100644 finance/migrations/0006_formuladefinition_revision.py create mode 100644 finance/migrations/0007_alter_dataset_file.py create mode 100644 finance/migrations/0008_formuladefinition_history_formuladefinition_shared.py diff --git a/finance/exporting.py b/finance/exporting.py index 28e5e18..bf9641f 100644 --- a/finance/exporting.py +++ b/finance/exporting.py @@ -1,5 +1,6 @@ import hashlib import hmac +import logging import os import secrets import warnings @@ -11,22 +12,62 @@ from django.conf import settings from django.utils import timezone from openpyxl import Workbook from openpyxl.cell import WriteOnlyCell -from openpyxl.styles import Font, PatternFill +from openpyxl.styles import Font, PatternFill, Protection +from openpyxl.worksheet.datavalidation import DataValidation from openpyxl.worksheet.table import Table, TableColumn, TableStyleInfo from .analysis import METRICS, aggregations, context, dimensions, metrics, scoped_facts from .formula_columns import ( RESERVED_DATA_HEADERS, ensure_available_metrics, + evaluate, metrics_used, to_excel, validate_column, ) from .models import FormulaExport, Membership +# Keep the private test seam while sharing the single evaluator implementation. +_evaluate_expression = evaluate + FORMULA_VERSION = "3" MAX_ROWS = 100_000 MAX_GROUPS = 200 +RECONCILE_TOLERANCES = { + "customers": Decimal("0"), + "margin": Decimal("0.0001"), + "discount_rate": Decimal("0.0001"), + "upgrade_rate": Decimal("0.0001"), +} +DEFAULT_RECONCILE_TOLERANCE = Decimal("0.01") +logger = logging.getLogger(__name__) + + +def _safe_unlink_export(path, root): + """Delete only a file below the owned export root without following links.""" + path, root = Path(path), Path(root).absolute() + if not path.is_absolute() or root.is_symlink() or path.is_symlink(): + logger.warning("formula export cleanup refused unsafe path: %s", path) + return False + parent = path.parent + try: + if not parent.is_relative_to(root): + logger.warning("formula export cleanup refused out-of-root path: %s", path) + return False + except ValueError: + return False + current = parent + while current != root: + if current.is_symlink() or current.parent == current: + logger.warning("formula export cleanup refused linked parent: %s", path) + return False + current = current.parent + try: + path.unlink(missing_ok=True) + return True + except OSError: + logger.warning("formula export cleanup failed for %s", path, exc_info=True) + return False def _excel_number(value): @@ -269,6 +310,19 @@ def build_formula_workbook(ds, member, export_context, output): ), ) data.append(headers) + editable_indices = {len(dims) + 2, len(dims) + 3, len(dims) + 4, len(dims) + 5, len(dims) + 7} + if count: + numeric_input = DataValidation( + type="decimal", operator="between", formula1="-1e15", formula2="1e15", allow_blank=True + ) + numeric_input.showErrorMessage = True + numeric_input.errorStyle = "stop" + numeric_input.errorTitle = "请输入数值" + numeric_input.error = "计算输入只接受范围内的数值或空白;请勿粘贴公式或文本。" + for index in editable_indices: + column_name = _column_name(index + 1) + numeric_input.add(f"{column_name}2:{column_name}{count + 1}") + data.data_validations.append(numeric_input) token_secret = secrets.token_bytes(32) customer_roster = {} gross_index = next((col["index"] for col in mapping["columns"] if col["role"] == "gross"), None) @@ -329,6 +383,12 @@ def build_formula_workbook(ds, member, export_context, output): f'{_column_name(len(dims) + 6)}{row_number},""))' ), ) + for index in editable_indices: + cell = row[index] + if not hasattr(cell, "protection"): + cell = WriteOnlyCell(data, value=cell) + cell.protection = Protection(locked=False) + row[index] = cell data.append(row) customer_headers = [ "Group", @@ -408,7 +468,13 @@ def build_formula_workbook(ds, member, export_context, output): ], ["ROI 口径", mapping["roi_formula"], "按导入映射保存的公式分支", "否"], ["自营产品集合", _text_cell(parameters, ", ".join(products)), "升单率和自营收入口径", "否"], - ["产品毛利率", str(rates), "直接毛利缺失时的可编辑计算输入", "是"], + ["产品毛利率快照", str(rates), "导入时保存的只读口径;修改需重新确认导入映射", "否"], + [ + "对账容差", + "人数 0;金额 0.01;比率 0.0001;自定义 0.01", + "服务器 Decimal 参考与 Excel 结果的绝对差;正式财务验收前需确认", + "否", + ], [ "Excel 数值精度", "IEEE-754 双精度", @@ -443,6 +509,10 @@ def build_formula_workbook(ds, member, export_context, output): ["变化率", "差额/ABS(基期);基期为0或期间缺失时不可用。"], ["精度", "原始 Decimal 以文本留存;Excel 双精度存在细微误差可能。"], ["隐私", "客户标识替换为本文件随机 token,文件仍按敏感财务信息保护。"], + [ + "编辑保护", + "工作表结构、人数去重、原始证据和公式列已保护;计算明细仅收入、成本、毛利输入、毛利率和原价金额可编辑。行结构变化请重新导出。", + ], ]: instructions.append(row) for definition in custom_columns: @@ -559,6 +629,7 @@ def build_formula_workbook(ds, member, export_context, output): for key, name in METRICS.items(): formula_row = metric_rows[(label, key)] value = reference[key] + tolerance = RECONCILE_TOLERANCES.get(key, DEFAULT_RECONCILE_TOLERANCE) reconcile.append( [ name, @@ -567,24 +638,30 @@ def build_formula_workbook(ds, member, export_context, output): "" if value is None else str(value), f"='分析汇总'!{col}{formula_row}", f'=IFERROR(E{reconcile_row}-D{reconcile_row},"")', - f'=IF(D{reconcile_row}="","无参考",IF(ISNUMBER(F{reconcile_row}),IF(ABS(F{reconcile_row})<0.01,"通过","待复核"),"待复核"))', + f'=IF(D{reconcile_row}="","无参考",IF(ISNUMBER(F{reconcile_row}),IF(ABS(F{reconcile_row})<={tolerance},"通过","待复核"),"待复核"))', ] ) reconcile_row += 1 for index, definition in enumerate(custom_columns): formula_row = metric_rows[(label, f"custom_{index}")] + custom_reference = evaluate(definition["expression"], reference) reconcile.append( [ _text_cell(reconcile, definition["name"]), _text_cell(reconcile, label), side_name, - "自定义公式无服务器参考", + "" if custom_reference is None else str(custom_reference), f"='分析汇总'!{col}{formula_row}", - "", - "需业务验收", + f'=IFERROR(E{reconcile_row}-D{reconcile_row},"")', + f'=IF(D{reconcile_row}="","无参考",IF(ISNUMBER(F{reconcile_row}),IF(ABS(F{reconcile_row})<={DEFAULT_RECONCILE_TOLERANCE},"通过","待复核"),"待复核"))', ] ) reconcile_row += 1 + for sheet in wb.worksheets: + sheet.protection.sheet = True + sheet.protection.selectLockedCells = False + sheet.protection.selectUnlockedCells = True + wb.security.lockStructure = True wb.calculation.fullCalcOnLoad = True wb.calculation.forceFullCalc = True wb.calculation.calcMode = "auto" @@ -596,8 +673,8 @@ def process_formula_export(export_id): job = FormulaExport.objects.select_related("dataset", "user").get(pk=export_id) temporary = None target = None + root = Path(settings.MEDIA_ROOT) / "formula-exports" try: - root = Path(settings.MEDIA_ROOT) / "formula-exports" if root.is_symlink(): raise ValueError("公式导出目录不能是符号链接") root.mkdir(parents=True, exist_ok=True, mode=0o700) @@ -609,6 +686,8 @@ def process_formula_export(export_id): target.parent.chmod(0o700) temporary = target.with_name(f"{job.pk}.{secrets.token_hex(8)}.tmp") member = Membership.objects.get(user=job.user, organization=job.dataset.organization) + if not job.user.is_active or member.role not in ("admin", "analyst"): + raise PermissionError("账号已撤权,请重新提交导出") if set(member.departments) != set(job.departments): raise PermissionError("科室授权已变更,请重新提交导出") descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) @@ -621,13 +700,12 @@ def process_formula_export(export_id): job.save(update_fields=["path", "status", "error", "finished_at", "expires_at"]) return stats except Exception as exc: + remaining = "" for path in (temporary, target): - try: - if path is not None and not path.is_symlink(): - path.unlink(missing_ok=True) - except OSError: - pass - job.status, job.error, job.path = "failed", str(exc)[:500], "" + if path is not None and not _safe_unlink_export(path, root): + if path.exists() or path.is_symlink(): + remaining = str(path) + job.status, job.error, job.path = "failed", str(exc)[:500], remaining job.finished_at, job.expires_at = timezone.now(), None job.save(update_fields=["status", "error", "path", "finished_at", "expires_at"]) raise diff --git a/finance/formula_columns.py b/finance/formula_columns.py index 9981ceb..48d40fc 100644 --- a/finance/formula_columns.py +++ b/finance/formula_columns.py @@ -1,4 +1,5 @@ import math +from decimal import Decimal, localcontext from .analysis import METRICS @@ -107,3 +108,27 @@ def to_excel(node, references): if "number" in node: return str(node["number"]) return f"({to_excel(node['left'], references)}{OPERATORS[node['operator']]}{to_excel(node['right'], references)})" + + +def evaluate(node, references): + """Evaluate a validated expression with Decimal values and #N/A semantics.""" + with localcontext() as decimal_context: + decimal_context.prec = 64 + return _evaluate(node, references) + + +def _evaluate(node, references): + if "metric" in node: + return references.get(node["metric"]) + if "number" in node: + return Decimal(str(node["number"])) + left, right = _evaluate(node["left"], references), _evaluate(node["right"], references) + if left is None or right is None: + return None + if node["operator"] == "add": + return left + right + if node["operator"] == "subtract": + return left - right + if node["operator"] == "multiply": + return left * right + return None if right == 0 else left / right diff --git a/finance/formula_views.py b/finance/formula_views.py index 898b741..5e7d78d 100644 --- a/finance/formula_views.py +++ b/finance/formula_views.py @@ -3,12 +3,14 @@ from pathlib import Path import httpx from django.conf import settings +from django.db import transaction +from django.db.models import Q from django.http import FileResponse, JsonResponse from django.utils import timezone from .analysis import METRICS, context, dimensions from .formula_columns import available_metrics, ensure_available_metrics, validate_column -from .models import FormulaDefinition, FormulaExport +from .models import Dataset, FormulaDefinition, FormulaExport from .views import APIError, audit, body, dataset, endpoint, limit @@ -190,13 +192,23 @@ def formula_definitions(request, dataset_id): if request.member.departments: raise APIError("科室授权账号不可管理跨范围公式定义", 403) if request.method == "GET": - items = FormulaDefinition.objects.filter(dataset=ds, user=request.user).order_by("normalized_name") + items = ( + FormulaDefinition.objects.filter(dataset=ds) + .filter(Q(user=request.user) | Q(shared=True)) + .select_related("user") + .order_by("normalized_name") + ) return JsonResponse( { "items": [ { "id": str(item.pk), "definition": item.definition, + "revision": item.revision, + "shared": item.shared, + "owned": item.user_id == request.user.pk, + "owner": item.user.username, + "history": item.history, "updated_at": item.updated_at.isoformat(), } for item in items @@ -204,28 +216,74 @@ def formula_definitions(request, dataset_id): } ) - definition = validate_column(body(request).get("definition")) + data = body(request) + definition = validate_column(data.get("definition")) + shared = data.get("shared", False) + if type(shared) is not bool: + raise APIError("共享选项无效") try: ensure_available_metrics(ds, definition) except ValueError as exc: raise APIError(str(exc)) from exc normalized_name = definition["name"].strip().casefold() - existing = FormulaDefinition.objects.filter( - dataset=ds, user=request.user, normalized_name=normalized_name - ).first() - if not existing and FormulaDefinition.objects.filter(dataset=ds, user=request.user).count() >= 50: - raise APIError("每个数据集最多保存 50 个公式定义") - item, created = FormulaDefinition.objects.update_or_create( - dataset=ds, - user=request.user, - normalized_name=normalized_name, - defaults={"definition": definition}, - ) - audit(request, "formula_definition.save", item.pk, {"dataset": str(ds.pk), "name": definition["name"]}) + requested_revision = data.get("revision") + with transaction.atomic(): + Dataset.objects.select_for_update().get(pk=ds.pk) + existing = ( + FormulaDefinition.objects.select_for_update() + .filter(dataset=ds, user=request.user, normalized_name=normalized_name) + .first() + ) + if existing: + if requested_revision is not None and ( + type(requested_revision) is not int or requested_revision != existing.revision + ): + raise APIError("公式定义已在其他窗口更新,请重新载入", 409) + # ponytail: retain 20 revisions; use a separate history table if long-term retention is required. + existing.history = [ + *existing.history, + { + "revision": existing.revision, + "definition": existing.definition, + "updated_at": existing.updated_at.isoformat(), + }, + ][-20:] + existing.definition = definition + existing.shared = shared if "shared" in data else existing.shared + existing.revision += 1 + existing.save(update_fields=["definition", "revision", "shared", "history", "updated_at"]) + item, created = existing, False + else: + if FormulaDefinition.objects.filter(dataset=ds, user=request.user).count() >= 50: + raise APIError("每个数据集最多保存 50 个公式定义") + item = FormulaDefinition.objects.create( + dataset=ds, + user=request.user, + normalized_name=normalized_name, + definition=definition, + shared=shared, + ) + created = True + audit( + request, + "formula_definition.save", + item.pk, + { + "dataset": str(ds.pk), + "name": definition["name"], + "shared": item.shared, + "revision": item.revision, + }, + ) return JsonResponse( { "id": str(item.pk), "definition": item.definition, + "revision": item.revision, + "shared": item.shared, + "owned": True, + "owner": request.user.username, + "history": item.history, "updated_at": item.updated_at.isoformat(), }, status=201 if created else 200, @@ -241,8 +299,10 @@ def formula_definition_delete(request, dataset_id, definition_id): if not item: raise APIError("公式定义不存在", 404) name = item.definition.get("name", "") - audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name}) - item.delete() + with transaction.atomic(): + item = FormulaDefinition.objects.select_for_update().get(pk=item.pk) + audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name}) + item.delete() return JsonResponse({"deleted": True}) @@ -279,6 +339,8 @@ def formula_export_create(request, dataset_id): def _owned_job(request, dataset_id, export_id): + if not request.user.is_active or request.member.role not in ("admin", "analyst"): + raise APIError("没有此导出任务的访问权限", 403) job = FormulaExport.objects.filter( pk=export_id, dataset_id=dataset_id, diff --git a/finance/management/commands/cleanup_formula_exports.py b/finance/management/commands/cleanup_formula_exports.py index 692e1aa..1775b85 100644 --- a/finance/management/commands/cleanup_formula_exports.py +++ b/finance/management/commands/cleanup_formula_exports.py @@ -5,8 +5,10 @@ from pathlib import Path from django.conf import settings from django.core.management.base import BaseCommand from django.db import transaction +from django.db.models import Q from django.utils import timezone +from finance.exporting import _safe_unlink_export from finance.models import FormulaExport @@ -29,22 +31,30 @@ class Command(BaseCommand): export_root = Path(settings.MEDIA_ROOT) / "formula-exports" if export_root.is_symlink(): raise ValueError("公式导出目录不能是符号链接") - root = export_root.resolve() + root = export_root.absolute() now = timezone.now() jobs = files = 0 - for job in FormulaExport.objects.filter(status="succeeded", expires_at__lte=now).iterator(): + jobs_qs = FormulaExport.objects.filter( + Q(status="succeeded", expires_at__lte=now) | Q(status="failed", path__gt="") + ).iterator() + for job in jobs_qs: path = Path(job.path) if job.path else None - resolved = path.resolve() if path and path.is_absolute() and not path.is_symlink() else None - in_root = bool(resolved and resolved.is_relative_to(root)) try: with transaction.atomic(): - if in_root and resolved.is_file(): - resolved.unlink() + deleted = bool(path and _safe_unlink_export(path, root)) + if job.status == "succeeded": + if deleted or not path or not path.is_absolute() or not path.is_relative_to(root): + if deleted: + files += 1 + job.status, job.path = "expired", "" + job.save(update_fields=["status", "path"]) + jobs += 1 + elif deleted: files += 1 - job.status, job.path = "expired", "" - job.save(update_fields=["status", "path"]) - jobs += 1 + job.path = "" + job.save(update_fields=["path"]) + jobs += 1 except OSError as exc: self.stderr.write(f"Could not remove expired export {job.pk}: {exc}") diff --git a/finance/management/commands/import_worker.py b/finance/management/commands/import_worker.py index 469b76b..7eee4de 100644 --- a/finance/management/commands/import_worker.py +++ b/finance/management/commands/import_worker.py @@ -24,7 +24,7 @@ class Command(BaseCommand): self.stdout.write("Another worker owns the database lock") return try: - self.run_jobs(options, postgres) + self.run_jobs(options, True) finally: if postgres: with connection.cursor() as cursor: diff --git a/finance/migrations/0006_formuladefinition_revision.py b/finance/migrations/0006_formuladefinition_revision.py new file mode 100644 index 0000000..42c3462 --- /dev/null +++ b/finance/migrations/0006_formuladefinition_revision.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("finance", "0005_aiconfig"), + ] + + operations = [ + migrations.AddField( + model_name="formuladefinition", + name="revision", + field=models.PositiveIntegerField(default=1), + ), + ] diff --git a/finance/migrations/0007_alter_dataset_file.py b/finance/migrations/0007_alter_dataset_file.py new file mode 100644 index 0000000..d9a7ab3 --- /dev/null +++ b/finance/migrations/0007_alter_dataset_file.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("finance", "0006_formuladefinition_revision"), + ] + + operations = [ + migrations.AlterField( + model_name="dataset", + name="file", + field=models.FileField(max_length=500, upload_to="sources/%Y/%m"), + ), + ] diff --git a/finance/migrations/0008_formuladefinition_history_formuladefinition_shared.py b/finance/migrations/0008_formuladefinition_history_formuladefinition_shared.py new file mode 100644 index 0000000..0ec9378 --- /dev/null +++ b/finance/migrations/0008_formuladefinition_history_formuladefinition_shared.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.17 on 2026-10-07 23:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("finance", "0007_alter_dataset_file"), + ] + + operations = [ + migrations.AddField( + model_name="formuladefinition", + name="history", + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name="formuladefinition", + name="shared", + field=models.BooleanField(default=False), + ), + ] diff --git a/finance/models.py b/finance/models.py index 84a0a1e..1871f0e 100644 --- a/finance/models.py +++ b/finance/models.py @@ -22,7 +22,7 @@ class Dataset(models.Model): organization = models.ForeignKey(Organization, on_delete=models.PROTECT) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) name = models.CharField(max_length=200) - file = models.FileField(upload_to="sources/%Y/%m") + file = models.FileField(upload_to="sources/%Y/%m", max_length=500) sha256 = models.CharField(max_length=64) status = models.CharField(max_length=16, default="mapping") profile = models.JSONField(default=dict) @@ -133,6 +133,9 @@ class FormulaDefinition(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) normalized_name = models.CharField(max_length=320) definition = models.JSONField() + revision = models.PositiveIntegerField(default=1) + shared = models.BooleanField(default=False) + history = models.JSONField(default=list) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/frontend/src/dialogs.tsx b/frontend/src/dialogs.tsx index 179084e..c608a26 100644 --- a/frontend/src/dialogs.tsx +++ b/frontend/src/dialogs.tsx @@ -6,6 +6,9 @@ import { roleLabels, type AIConfig, type Context, + type FormulaColumn, + type FormulaExpression, + type SavedFormulaDefinition, type Dataset, type Metric, type Preferences, @@ -14,21 +17,6 @@ import { type User = NonNullable; -type FormulaExpression = - | { metric: Metric } - | { number: number } - | { - operator: "add" | "subtract" | "multiply" | "divide"; - left: FormulaExpression; - right: FormulaExpression; - }; - -type SavedFormulaDefinition = { - id: string; - definition: Record; - updated_at: string; -}; - function describeExpression(expression: FormulaExpression): string { if ("metric" in expression) return labels[expression.metric]; if ("number" in expression) return String(expression.number); @@ -827,7 +815,7 @@ export function FormulaExportDialog({ { role: "user" | "assistant"; content: string }[] >([]), [draft, setDraft] = useState(""), - [column, setColumn] = useState | null>(null), + [column, setColumn] = useState(null), [questions, setQuestions] = useState([]), [task, setTask] = useState<{ id: string; @@ -836,9 +824,30 @@ export function FormulaExportDialog({ }>(), [definitions, setDefinitions] = useState([]), [selectedDefinition, setSelectedDefinition] = useState(""), + [shared, setShared] = useState(false), + [loadedDefinition, setLoadedDefinition] = useState(null), [consent, setConsent] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(""); + const draftKey = `traceiq.formula-draft.${ds.id}`; + const [draftRestored, setDraftRestored] = useState(false); + useEffect(() => { + try { + const stored = sessionStorage.getItem(draftKey); + if (stored) { + const saved = JSON.parse(stored); + setDraft(saved.draft || ""); + setMessages(saved.messages || []); + setColumn(saved.column || null); + } + } catch { + sessionStorage.removeItem(draftKey); + } + setDraftRestored(true); + }, [draftKey]); + useEffect(() => { + if (draftRestored) sessionStorage.setItem(draftKey, JSON.stringify({ draft, messages, column })); + }, [draftKey, draftRestored, draft, messages, column]); useEffect(() => { api<{ items: SavedFormulaDefinition[] }>( `/datasets/${ds.id}/formula-definitions`, @@ -865,7 +874,10 @@ export function FormulaExportDialog({ async function send() { const text = draft.trim(); if (!text || !consent || userTurns >= 6) return; - const next = [...messages, { role: "user" as const, content: text }]; + const content = loadedDefinition + ? `已载入的原始定义:${JSON.stringify(loadedDefinition)}\n本次补充:${text}` + : text; + const next = [...messages, { role: "user" as const, content }]; setBusy(true); setError(""); setQuestions([]); @@ -874,7 +886,7 @@ export function FormulaExportDialog({ const result = await api<{ complete: boolean; questions: string[]; - column: Record | null; + column: FormulaColumn | null; }>(`/datasets/${ds.id}/formula-design`, "POST", { context: ctx, messages: next, @@ -891,6 +903,7 @@ export function FormulaExportDialog({ ]); setQuestions(result.questions); setColumn(result.column); + setLoadedDefinition(null); setDraft(""); } catch (e) { setError((e as Error).message); @@ -899,7 +912,7 @@ export function FormulaExportDialog({ } } async function create() { - if (!column) return; + if (!column && !ctx.custom_columns?.length) return; setBusy(true); setError(""); try { @@ -907,7 +920,12 @@ export function FormulaExportDialog({ await api<{ id: string; status: string }>( `/datasets/${ds.id}/formula-exports`, "POST", - { ...ctx, custom_columns: [column] }, + { + ...ctx, + custom_columns: column + ? [...(ctx.custom_columns || []).filter((item) => item.name !== column.name), column] + : ctx.custom_columns, + }, ), ); } catch (e) { @@ -924,11 +942,24 @@ export function FormulaExportDialog({ const saved = await api( `/datasets/${ds.id}/formula-definitions`, "POST", - { definition: column }, + { + definition: column, + shared, + revision: definitions.find( + (item) => + (item.id === selectedDefinition && item.owned) || + (item.owned && + String(item.definition.name).trim().toLowerCase() === + String(column.name).trim().toLowerCase()), + )?.revision, + }, ); setDefinitions((items) => [...items.filter((item) => item.id !== saved.id), saved].sort((a, b) => - String(a.definition.name).localeCompare(String(b.definition.name), "zh-CN"), + String(a.definition.name).localeCompare( + String(b.definition.name), + "zh-CN", + ), ), ); setSelectedDefinition(saved.id); @@ -941,8 +972,14 @@ export function FormulaExportDialog({ function loadDefinition(id: string) { setSelectedDefinition(id); const saved = definitions.find((item) => item.id === id); - if (!saved) return; + if (!saved) { + setLoadedDefinition(null); + setColumn(null); + return; + } setColumn(saved.definition); + setShared(saved.owned && saved.shared); + setLoadedDefinition(saved.definition); setMessages([]); setQuestions([]); setDraft(""); @@ -960,6 +997,7 @@ export function FormulaExportDialog({ items.filter((item) => item.id !== selectedDefinition), ); setSelectedDefinition(""); + setLoadedDefinition(null); setColumn(null); } catch (e) { setError((e as Error).message); @@ -969,15 +1007,10 @@ export function FormulaExportDialog({ } async function getFile() { try { - const blob = await api( - `/datasets/${ds.id}/formula-exports/${task!.id}/download`, - ), - url = URL.createObjectURL(blob), - link = document.createElement("a"); - link.href = url; + const link = document.createElement("a"); + link.href = `/api/datasets/${ds.id}/formula-exports/${task!.id}/download`; link.download = "traceiq-formula-analysis.xlsx"; link.click(); - window.setTimeout(() => URL.revokeObjectURL(url), 1000); } catch (e) { setError((e as Error).message); } @@ -986,6 +1019,11 @@ export function FormulaExportDialog({

描述新列含义、计算方式、适用范围,以及缺失值或零分母处理方式。

+ {!column && !task && !!ctx.custom_columns?.length && ( + + )} {definitions.length > 0 && !task && (
)} + {definitions.find((item) => item.id === selectedDefinition)?.history.length ? ( +
+ 版本历史(最近 20 次,可载入后另存) + {definitions.find((item) => item.id === selectedDefinition)!.history.map((version) => ( + + ))} +
+ ) : null}