docs(i18n): translate chinese comments in frontend src to english
Translate chinese developer comments in frontend/src/ to english so non-chinese-reading maintainers can understand intent without translation tooling. Pure documentation cleanup with no runtime behavior changes. Twenty files updated across views, components, api services, App.vue, and pendingUpload.js. Region-eligibility matrix from .kiro/specs/i18n- frontend-comments/design.md drives every edit: - Translate `//`, `/* */`, JSDoc, and Vue `<!-- -->` template comments. - Drop comments that merely restate the code per dev-guidelines.md. - Translate console.error/warn/log argument strings (developer-facing). - Append (#9) to the single chinese-content TODO in views/Process.vue. Five files retain documented chinese string literals per requirements 1.5 and 4.4: hardcoded UI text and error fallbacks (Process.vue, Step3Simulation.vue), backend-format regex patterns and i18n-keyed UI labels (Step4Report.vue), backend stage-key matchers (Step2EnvSetup.vue), and LLM prompt templates sent to a chinese-tuned model (Step5Interaction.vue). Translating any of these would either be out of scope (UI strings belong in /locales/*.json) or would change runtime behavior. Verification: `rg '[\x{4e00}-\x{9fff}]' frontend/src/` returns 5 documented files; `npm run build` exits 0 with the same Vite output as before. Closes #9
This commit is contained in:
parent
0a65edfa46
commit
9dcaecd2d2
|
|
@ -0,0 +1,229 @@
|
|||
# Design Document — i18n-frontend-comments
|
||||
|
||||
## Overview
|
||||
|
||||
**Purpose**: Translate Chinese developer comments in `frontend/src/` to English so non-Chinese-reading maintainers can understand intent without translation tooling. Strictly documentation-only; no behavior change.
|
||||
|
||||
**Users**: Frontend maintainers and reviewers of MiroFish — developers who read and modify `frontend/src/` but do not read Chinese.
|
||||
|
||||
**Impact**: 20 files in `frontend/src/` change; the compiled bundle is byte-equivalent modulo source-map comment lines. The `vue-i18n` user-facing translation surface (`/locales/*.json`) is unaffected.
|
||||
|
||||
### Goals
|
||||
|
||||
- Eliminate Chinese characters (U+4E00–U+9FFF) from `frontend/src/` comments and dev-facing string literals (`console.*`).
|
||||
- Preserve every comment's *why* (semantic intent) when translating; delete comments that merely restate the code per `dev-guidelines.md`.
|
||||
- Append `(#9)` ticket reference to any TODO/FIXME marker that lacks one.
|
||||
- Keep `npm run build` green and the rendered UI byte-equivalent on a smoke check.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Translating user-facing strings (those live in `/locales/*.json`; tracked separately).
|
||||
- Translating LLM prompt template strings (translation would change model input — retained and documented in PR per Requirement 1.5).
|
||||
- Restructuring comments into JSDoc (only keep JSDoc when already JSDoc-shaped).
|
||||
- Reformatting code, renaming identifiers, or any change to `<script>` / `<template>` semantics.
|
||||
- Touching backend Python comments (covered by ticket #7) or repo-root configuration files.
|
||||
|
||||
## Boundary Commitments
|
||||
|
||||
### This Spec Owns
|
||||
|
||||
- All comment text inside files under `frontend/src/`: line comments (`//`), block comments (`/* */`), JSDoc (`/** */`), and Vue template comments (`<!-- -->`).
|
||||
- The natural-language portion of JSDoc tags (`@param`, `@returns`, etc.) — not the tag syntax itself.
|
||||
- Chinese-content string literals passed to `console.error`, `console.warn`, and `console.log` (developer-facing, not in i18n locales).
|
||||
- The PR-level documentation listing any deliberately-retained bilingual content.
|
||||
|
||||
### Out of Boundary
|
||||
|
||||
- Any change inside `/locales/*.json` (covered by issues #8 and #11).
|
||||
- Any change in `backend/`, `static/`, repo root, or anywhere outside `frontend/src/`.
|
||||
- LLM prompt template string literals (e.g. `Step5Interaction.vue:725-727`) — retained as documented exceptions.
|
||||
- New tooling (linters, formatters, translation scripts).
|
||||
- Any executable change: identifier names, import paths, expression edits, Vue template structure outside `<!-- -->` text, or `<style>` selectors / values.
|
||||
|
||||
### Allowed Dependencies
|
||||
|
||||
- Existing Vite build (`npm run build`) and Vue dev server (`npm run dev`) for verification.
|
||||
- `ripgrep` for the verification command.
|
||||
- No runtime dependencies — this is text-only editing.
|
||||
|
||||
### Revalidation Triggers
|
||||
|
||||
- Discovery during implementation that a category of Chinese content beyond comments + `console.*` strings exists in `frontend/src/` → update the design's String-Literal Decision Matrix and add residuals to the PR description rather than silently expanding scope.
|
||||
- Discovery that a JSDoc block carries semantically-load-bearing Chinese (e.g. an idiom that does not have a 1:1 English rendering) → keep both languages, document in PR per Req 1.5.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Existing Architecture Analysis
|
||||
|
||||
Per `structure.md`, `frontend/src/` is layered into `views/`, `components/`, `api/`, `store/`, plus `App.vue`. This spec does not change the layering. Per `tech.md`, the project uses no enforced linter/formatter and existing files mix English and Chinese comments — this spec is the explicit ask to normalize the comment language to English in this directory.
|
||||
|
||||
### Architecture Pattern & Boundary Map
|
||||
|
||||
This is a documentation-only change — no architectural pattern to choose. The relevant boundary is purely *which textual regions of which files are eligible for edit*. The decision matrix below is the architecture for this spec.
|
||||
|
||||
#### Region eligibility matrix
|
||||
|
||||
| Region | Action |
|
||||
| --- | --- |
|
||||
| `//` line comment | Translate; delete if it restates the code per Req 2.1 |
|
||||
| `/* */` block comment | Translate; delete if redundant per Req 2.1 |
|
||||
| `/** */` JSDoc block | Translate the natural-language content; preserve tag syntax (`@param`, `@returns`, etc.) per Req 1.4 |
|
||||
| `<!-- -->` Vue template comment | Translate per Req 1.3 |
|
||||
| `console.error|warn|log('… 中文 …')` | Translate the string content (developer-facing, not in i18n locales) |
|
||||
| LLM prompt template string literal | **Do not translate**; document in PR per Req 1.5 |
|
||||
| Any other string literal containing Chinese | **Do not translate** (Req 4.4); document if non-empty |
|
||||
| Identifiers, imports, exports, expressions | **Do not change** (Req 4.2) |
|
||||
| Vue template structure (tags, attributes, bindings) | **Do not change** (Req 4.2) |
|
||||
|
||||
### Technology Stack
|
||||
|
||||
| Layer | Choice / Version | Role in Feature | Notes |
|
||||
|---|---|---|---|
|
||||
| Frontend | Vue 3.5 + Vite 7 (existing) | Build target — must continue to compile | No version change |
|
||||
| Verification | `ripgrep` (already present in repo workflows) | Acceptance gate via `rg '[\x{4e00}-\x{9fff}]' frontend/src/` | No new dependency |
|
||||
| No new tooling | — | — | Per `tech.md` steering: "No enforced linter or formatter… match the surrounding file's style" |
|
||||
|
||||
## File Structure Plan
|
||||
|
||||
No directory or file additions. All edits are in-place inside the 20 files identified by ripgrep:
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── App.vue # 4 hits — translate
|
||||
├── api/
|
||||
│ ├── graph.js # 10 hits
|
||||
│ ├── index.js # 8 hits (incl. JSDoc-light line comments)
|
||||
│ ├── report.js # 8 hits
|
||||
│ └── simulation.js # 29 hits (JSDoc-heavy)
|
||||
├── components/
|
||||
│ ├── GraphPanel.vue # 84 hits — D3 logic comments + template
|
||||
│ ├── HistoryDatabase.vue # 124 hits
|
||||
│ ├── Step1GraphBuild.vue # 5 hits + 3 console.error strings
|
||||
│ ├── Step2EnvSetup.vue # 76 hits
|
||||
│ ├── Step3Simulation.vue # 52 hits
|
||||
│ ├── Step4Report.vue # 176 hits
|
||||
│ └── Step5Interaction.vue # 34 hits + LLM prompt strings (RETAIN)
|
||||
├── store/
|
||||
│ └── pendingUpload.js # 2 hits
|
||||
└── views/
|
||||
├── Home.vue # 43 hits
|
||||
├── InteractionView.vue # 6 hits
|
||||
├── MainView.vue # 4 hits
|
||||
├── Process.vue # 191 hits — largest file (2067 lines)
|
||||
├── ReportView.vue # 6 hits
|
||||
├── SimulationRunView.vue # 18 hits
|
||||
└── SimulationView.vue # 22 hits
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
|
||||
All 20 files above receive comment translation (and, for `Step1GraphBuild.vue` and any others discovered during implementation, `console.*` string translation). No file is created, deleted, or moved.
|
||||
|
||||
## System Flows
|
||||
|
||||
### Per-file translation sequence
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Open file] --> B[Locate Chinese region with rg or editor]
|
||||
B --> C{Region type?}
|
||||
C -->|Comment| D{Restates the code?}
|
||||
D -->|Yes| E[Delete comment]
|
||||
D -->|No| F[Translate, preserve intent]
|
||||
C -->|JSDoc| G[Translate natural-language content<br/>preserve tag syntax]
|
||||
C -->|Vue template comment| H[Translate inside <!-- -->]
|
||||
C -->|console.* string| I[Translate string content]
|
||||
C -->|LLM prompt string| J[Skip; record for PR description]
|
||||
C -->|Other string literal| K[Skip per Req 4.4]
|
||||
E --> L[Next region]
|
||||
F --> L
|
||||
G --> L
|
||||
H --> L
|
||||
I --> L
|
||||
J --> L
|
||||
K --> L
|
||||
L --> M{File done?}
|
||||
M -->|No| B
|
||||
M -->|Yes| N[Run rg on file: confirm zero remaining hits<br/>OR all remaining are intentional retentions]
|
||||
N --> O[File complete]
|
||||
```
|
||||
|
||||
### TODO/FIXME sweep
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[rg 'TODO|FIXME' frontend/src/] --> B{Any hits?}
|
||||
B -->|None| C[Document in PR: no markers found]
|
||||
B -->|Has hits| D[For each hit]
|
||||
D --> E{Already has #N reference?}
|
||||
E -->|Yes| F[Leave unchanged]
|
||||
E -->|No, was Chinese| G[Translate description AND append #9]
|
||||
E -->|No, was already English| H[Out of scope; leave unchanged]
|
||||
```
|
||||
|
||||
## Requirements Traceability
|
||||
|
||||
| Requirement | Summary | Realized by |
|
||||
|---|---|---|
|
||||
| 1.1 | Zero Chinese in `frontend/src/` per ripgrep | Per-file translation pass; verification command in PR |
|
||||
| 1.2 | Preserve semantic intent | Translator judgment per region; Req 2.3 enforces conservative-on-ambiguity |
|
||||
| 1.3 | Handle SFC blocks correctly | Region eligibility matrix (`<script>` / `<template>` / `<style>` rows) |
|
||||
| 1.4 | Preserve JSDoc structure | Region matrix: "Translate the natural-language content; preserve tag syntax" |
|
||||
| 1.5 | Document retained bilingual content | PR description lists `Step5Interaction.vue` LLM prompts (and any others) |
|
||||
| 2.1 | Delete redundant comments | Per-file flowchart `D → E` branch |
|
||||
| 2.2 | Translate intent-bearing comments | Per-file flowchart `D → F` branch |
|
||||
| 2.3 | Conservative on ambiguity | Translator rule encoded in research.md Decision; default is *translate, not delete* |
|
||||
| 2.4 | No new explanatory comments | Translation rule: never add comments not present in original (except `(#9)` ticket ref) |
|
||||
| 3.1 | Keep TODO/FIXME marker, translate trailing text | TODO sweep flowchart `G` branch |
|
||||
| 3.2 | Append `(#9)` ticket ref where missing | TODO sweep flowchart `G` branch |
|
||||
| 3.3 | Preserve existing ticket refs | TODO sweep flowchart `E → F` branch |
|
||||
| 4.1 | `npm run build` exit 0 | Build run as part of acceptance check |
|
||||
| 4.2 | No executable change | Region matrix: identifiers/imports/expressions are *not eligible* |
|
||||
| 4.3 | UI smoke-check identical | Manual smoke after build |
|
||||
| 4.4 | Leave string literals untouched (except `console.*`) | Region matrix; documented exception for `console.*` is the sole carve-out |
|
||||
| 5.1 | Verification command in PR | PR template hand-off |
|
||||
| 5.2 | List retained bilingual files | PR template hand-off |
|
||||
| 5.3 | Branch + commit naming | `docs/i18n-9-translate-frontend-comments` and `docs(i18n): translate chinese comments in frontend src to english` |
|
||||
| 5.4 | No edits outside `frontend/src/` | `git diff --name-only main..HEAD` review at PR time |
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
This is a documentation-only change — there are no software components, services, or APIs to design. The "interfaces" of this spec are textual:
|
||||
|
||||
| Interface | Owner | Contract |
|
||||
| --- | --- | --- |
|
||||
| `frontend/src/**/*.{vue,js}` comments | This spec | All comment text is English. Chinese is permitted only when explicitly listed in the PR description as a deliberately-retained bilingual case. |
|
||||
| `frontend/src/**/*.{vue,js}` `console.*` string literals | This spec | All `console.error|warn|log` argument strings are English. |
|
||||
| `frontend/src/**/*.{vue,js}` non-`console` string literals | Out of scope | Unchanged from baseline. Any Chinese in these strings (e.g. LLM prompt templates) is documented in the PR. |
|
||||
| `frontend/src/**/*.{vue,js}` executable code | Out of scope | Byte-identical except for surrounding comment lines. |
|
||||
|
||||
## Data Models
|
||||
|
||||
Not applicable — no data structures change.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Not applicable — no runtime code path changes. The "errors" of this spec are reviewer-detectable issues:
|
||||
|
||||
| Issue | Detection | Response |
|
||||
|---|---|---|
|
||||
| Translation drift (wrong meaning) | Reviewer reads English comment against surrounding code | Reviewer flags; translator revises |
|
||||
| Accidental edit to executable code | `git diff` review filtered to non-comment lines | Revert; restart that file |
|
||||
| Residual Chinese in non-LLM string | Verification ripgrep returns unexpected file | Either translate (if `console.*`) or move LLM exception to PR description |
|
||||
| Build failure on `npm run build` | CI / local build | Bisect: most likely accidental edit to a `<script>` or `<template>` block; revert |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
No automated tests added. The spec's verification surface is:
|
||||
|
||||
- **Acceptance ripgrep**: `rg '[\x{4e00}-\x{9fff}]' frontend/src/` returns no files (or only files listed as retained in the PR description).
|
||||
- **Vite build**: `npm run build` exits 0.
|
||||
- **Manual UI smoke**: `npm run dev`, navigate Home → Process → each Step component → Interaction → Report; confirm rendering matches pre-change baseline. (Cannot be fully proven; explicit acknowledgment of "manual smoke" per the steering note that "type-check/test passes do not prove feature correctness here".)
|
||||
- **Diff hygiene check**: `git diff --stat main..HEAD` shows only `frontend/src/` files modified.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Per the project's manual-style ethos, do this in an editor with rg-driven navigation. No new scripts.
|
||||
- For each file, do all edits in one pass, then re-run `rg '[\x{4e00}-\x{9fff}]' <file>` to confirm zero residual (or only the deliberately-retained string literals, which the implementer should know about ahead of time per the design's eligibility matrix).
|
||||
- The largest 6 files (`Process.vue`, `Step4Report.vue`, `HistoryDatabase.vue`, `GraphPanel.vue`, `Step2EnvSetup.vue`, `Step3Simulation.vue`) account for ~80% of the work; budget time accordingly.
|
||||
- Reviewer aid: the PR description should list, in order, the verification command, the verification result, the file count, and any retained-bilingual exceptions. Keep the description short — the diff itself carries the work.
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
# Gap Analysis — i18n-frontend-comments
|
||||
|
||||
## 1. Current State Investigation
|
||||
|
||||
### Scope discovery (ground truth)
|
||||
|
||||
Ripgrep `[\x{4e00}-\x{9fff}]` over `frontend/src/` returns **20 files, 902 occurrences**:
|
||||
|
||||
| File | Hits |
|
||||
| --- | ---: |
|
||||
| `views/Process.vue` | 191 |
|
||||
| `components/Step4Report.vue` | 176 |
|
||||
| `components/HistoryDatabase.vue` | 124 |
|
||||
| `components/GraphPanel.vue` | 84 |
|
||||
| `components/Step2EnvSetup.vue` | 76 |
|
||||
| `components/Step3Simulation.vue` | 52 |
|
||||
| `views/Home.vue` | 43 |
|
||||
| `components/Step5Interaction.vue` | 34 |
|
||||
| `api/simulation.js` | 29 |
|
||||
| `views/SimulationView.vue` | 22 |
|
||||
| `views/SimulationRunView.vue` | 18 |
|
||||
| `api/graph.js` | 10 |
|
||||
| `api/index.js` | 8 |
|
||||
| `api/report.js` | 8 |
|
||||
| `views/InteractionView.vue` | 6 |
|
||||
| `views/ReportView.vue` | 6 |
|
||||
| `components/Step1GraphBuild.vue` | 5 |
|
||||
| `App.vue` | 4 |
|
||||
| `views/MainView.vue` | 4 |
|
||||
| `store/pendingUpload.js` | 2 |
|
||||
|
||||
No `.css` files exist under `frontend/src/`; styles live inside Vue SFC `<style>` blocks.
|
||||
|
||||
### Comment shapes encountered
|
||||
|
||||
Sampling representative files confirms three syntactic forms — all already English-syntax, only the natural-language content is Chinese:
|
||||
|
||||
- **JS line comments**: `// 创建axios实例`, `timeout: 300000, // 5分钟超时(本体生成可能需要较长时间)`
|
||||
- **JSDoc blocks** in `api/simulation.js`: `/** * 创建模拟 */`, `* @returns {Promise} 返回配置信息,包含元数据和配置内容`
|
||||
- **Vue template comments** in `views/Home.vue`: `<!-- 顶部导航栏 -->`, `<!-- 上半部分:Hero 区域 -->`
|
||||
|
||||
### String literals containing Chinese (NOT comments)
|
||||
|
||||
A naive regex for Chinese inside quoted strings flags **8 files**. Spot-checks reveal two distinct categories that the ticket body did not explicitly anticipate:
|
||||
|
||||
- **Developer-facing log strings** — e.g. `Step1GraphBuild.vue:216` `console.error('缺少项目或图谱信息')`. These print to the browser dev console and are not part of the i18n locale surface. Translating them does not change runtime behavior.
|
||||
- **LLM prompt template strings** — e.g. `Step5Interaction.vue:725-727` `\`以下是我们之前的对话:\n${historyContext}\n\n现在我的新问题是:${message}\``. These are sent to a Chinese-tuned LLM (default Qwen). Translating them *would* change the model's input and could shift output behavior.
|
||||
|
||||
The ticket says **"no UI string changes (those are already in `locales/en.json`)"** and **"Out of scope: Translating user-facing strings"**. Neither category above is user-facing UI text — `locales/*.json` already covers user-facing strings via `vue-i18n`. The ticket's acceptance criterion #1 (`grep returns no files, or only files with deliberately-kept bilingual comments listed in PR`) leaves room to retain the LLM prompt strings as documented exceptions.
|
||||
|
||||
### Conventions to respect (from steering)
|
||||
|
||||
- `tech.md`: 4-space indent, no enforced linter, "match the surrounding file's style". Existing files mix English and Chinese in comments/docstrings — preserve both *unless asked*. **This ticket is the explicit ask.**
|
||||
- `structure.md`: `frontend/src/api/*.js` services use Axios with 5-min timeout + exponential retry. The translation pass must not touch the retry/timeout logic.
|
||||
- `dev-guidelines.md` (project-level): "Don't comment the obvious — comment the *why*." JSDoc on all exported functions, classes, interfaces (so JSDoc blocks must be **kept** in JSDoc form when translating, not deleted as redundant).
|
||||
- `commits.md`: Conventional Commits, lowercase, imperative, max 72 chars, no `Co-Authored-By:` footer. Branch `<type>/<ticket>-<desc>` — ticket dictates `docs/i18n-9-translate-frontend-comments`.
|
||||
|
||||
### Existing i18n-related precedent
|
||||
|
||||
Recent merged PRs in the same epic (#11):
|
||||
|
||||
- `feat/i18n-2-translate-ontology-generator-prompts` → backend prompt translation, full content swap.
|
||||
- `feat/i18n-4-translate-sim-config-prompts`, `feat/i18n-5-translate-report-agent-prompts` → similar backend prompt swaps.
|
||||
- `feat/i18n-6-externalize-backend-logs` → moved log strings out of code into i18n keys.
|
||||
- `fix/i18n-8-backfill-zh-json` (current branch base) → backfilled missing zh translations.
|
||||
|
||||
**Pattern**: prior i18n work changed both content *and* infrastructure (locale-keying logs). This ticket explicitly does not — it is a documentation-only pass without re-keying anything.
|
||||
|
||||
## 2. Requirements ↔ Asset Map
|
||||
|
||||
| Req | Asset to change | Gap tag | Note |
|
||||
| --- | --- | --- | --- |
|
||||
| 1.1–1.4 (translate comments incl. JSDoc) | All 20 files listed above | — (clear) | Largely mechanical; respect SFC block boundaries (`<script>` vs `<template>` vs `<style>`). |
|
||||
| 1.5 (deliberately bilingual) | LLM prompt strings in `Step5Interaction.vue` (and any others discovered) | **Constraint** | Keep Chinese, document in PR. Behavior-risk if translated. |
|
||||
| 2.x (drop redundant) | Files with `// 获取数据`-style restate-the-code comments | — | Apply per case during the pass; conservative when ambiguous. |
|
||||
| 3.x (TODO/FIXME ticket refs) | Search `frontend/src/` for `TODO\|FIXME` | **Unknown** | No matches noted in spot checks; will sweep during implementation. If none found, requirement is satisfied vacuously. |
|
||||
| 4.x (no behavior change) | Confirmed by `npm run build` exit 0 + manual smoke | — | Vite build is the reference; keep all string-literal content (other than developer-log strings) untouched; identifiers and imports are off-limits. |
|
||||
| 5.x (PR hand-off) | PR description, branch name, commit message | — | Branch name from ticket: `docs/i18n-9-translate-frontend-comments`. |
|
||||
|
||||
### Discovered scope ambiguity → decision needed
|
||||
|
||||
Two boundary calls that the requirements should sharpen before design:
|
||||
|
||||
- **`console.error` / `console.warn` / `console.log` strings with Chinese content** — translate (developer-facing, not in locales) or leave (string-literal change risks scope creep)? Recommended: **translate**, since they are dev-facing comments-by-other-means and the ticket's spirit is "English-readable code". This is a design decision to be encoded in the design doc, not a new requirement.
|
||||
- **LLM prompt template strings** — leave as-is and list in PR (per Req 1.5). This is the safer call: the LLM is Chinese-tuned by default and translating a system prompt is a behavior change.
|
||||
|
||||
Both decisions stay inside the requirements as currently written (specifically Req 1.5 + Req 4.4, which already excludes string literals from the translation pass except where developer-log strings are concerned). The design phase will document the rule explicitly.
|
||||
|
||||
## 3. Implementation Approach Options
|
||||
|
||||
### Option A — Single-pass translation per file, no tooling
|
||||
|
||||
**Approach**: Open each of the 20 files, translate every Chinese comment in place, drop redundant ones, append `(#9)` to bare TODO/FIXME, leave Chinese string literals (LLM prompts) and translate `console.*` Chinese strings. Verify with `rg [\x{4e00}-\x{9fff}] frontend/src/`.
|
||||
|
||||
- ✅ Lowest overhead, no new tools or scripts
|
||||
- ✅ Fits a one-shot doc-only PR
|
||||
- ✅ Maximally aligns with `dev-guidelines.md` "comment the *why*" — judgment per comment
|
||||
- ❌ ~900 occurrences spread across 20 files — most concentrated in 6 files (>50 hits each) which are large (`Process.vue` is 2067 lines, `Step4Report.vue`, `HistoryDatabase.vue`)
|
||||
- ❌ Manual judgment for redundant-vs-meaningful adds reviewer load
|
||||
|
||||
### Option B — Automated translation script + manual pass
|
||||
|
||||
**Approach**: Write a Node/Python script that walks files, extracts Chinese comments, runs them through an LLM, and writes back. Then a manual pass on the diff.
|
||||
|
||||
- ✅ Faster on long files
|
||||
- ❌ Adds a dependency (LLM call) and a scratch script, neither delivered
|
||||
- ❌ The translation needs *judgment* (drop vs translate per Req 2) — automation undercuts the "comment the *why*" rule
|
||||
- ❌ Risk of touching string literals or identifiers if regex is loose
|
||||
- ❌ Out of step with the steering "no enforced tooling without discussion" principle
|
||||
|
||||
### Option C — File-by-file with task batching
|
||||
|
||||
**Approach**: Group the 20 files into work units by size: (a) high-touch (Process, Step4Report, HistoryDatabase, GraphPanel, Step2EnvSetup, Step3Simulation), (b) mid-touch (Home, Step5Interaction, simulation.js, SimulationView, SimulationRunView), (c) light (api/{graph,index,report}.js, the 4–8 hit views, App.vue, store/pendingUpload.js, Step1GraphBuild.vue). Implementation tasks mirror these groups. Verify after each group with the ripgrep check.
|
||||
|
||||
- ✅ Same translation effort as A but with checkpointable progress (matches the project's task-tracking pattern from steering — "background tasks expose progress")
|
||||
- ✅ Reviewer can read the PR file-group-by-file-group instead of all-at-once
|
||||
- ✅ If the PR needs to land partial (rare), the light + mid groups still ship a valuable subset
|
||||
- ❌ A few extra task headings in `tasks.md` vs Option A's "do the thing"
|
||||
|
||||
## 4. Effort & Risk
|
||||
|
||||
- **Effort**: **S (1–2 days)**. Mechanical translation, plus judgment calls. ~900 occurrences but no architectural work.
|
||||
- **Risk**: **Low**. Doc-only change. The only real risks are (a) accidentally editing a string literal that affects the LLM prompt or a hardcoded user-visible string, and (b) deleting a comment whose intent the translator misread. Both are mitigated by Req 4.4 ("leave string literals unchanged") and Req 2.3 (conservative-when-ambiguous).
|
||||
|
||||
## 5. Recommendations for Design Phase
|
||||
|
||||
- **Preferred approach**: **Option C** — file-grouped translation pass, no tooling, no script. It matches the project's manual-style ethos and the existing pipeline-aligned task structure, and produces a reviewable PR.
|
||||
- **Encode in design**:
|
||||
- The translation rule for each comment shape (`//`, `/* */`, JSDoc, `<!-- -->`).
|
||||
- The decision matrix for string literals: translate `console.*` Chinese strings; retain LLM prompt strings (in `Step5Interaction.vue`) and list them in the PR per Req 1.5.
|
||||
- The TODO/FIXME sweep approach (single ripgrep pass before the file loop).
|
||||
- The verification command and acceptance check sequence.
|
||||
- **Research items carried forward**: none — the codebase has been inspected enough to commit to Option C without further investigation.
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
This spec covers a pure-documentation cleanup pass: translate Chinese developer comments in `frontend/src/` to English so English-speaking maintainers can read the code. The change is documentation-only — no runtime behavior changes, no UI string changes (those live in `/locales/*.json`), and no architectural refactor. Tracked as GitHub issue #9, the lowest user-impact ticket in the i18n epic (#11).
|
||||
|
||||
The work targets developer-facing comments in 20 known files: 7 views, 7 components, 4 `api/*.js` modules, `App.vue`, and `store/pendingUpload.js`. The discovery method is `grep -rln '[一-鿿]' frontend/src/` (or the ripgrep equivalent), which must return zero matches at completion (or only files explicitly listed as deliberately bilingual in the PR description).
|
||||
|
||||
## Boundary Context
|
||||
|
||||
- **In scope**: Translating Chinese developer comments (line comments, block comments, JSDoc, and Vue `<!-- ... -->` template comments) to English in `frontend/src/`. Dropping comments that merely restate the code, per `dev-guidelines.md`. Appending ticket references to TODO/FIXME markers that lack one.
|
||||
- **Out of scope**: Any user-facing string, label, placeholder, toast, or template-rendered text — these live in `/locales/en.json` and `/locales/zh.json` and are tracked separately (see #8). Restructuring comments into JSDoc unless they are already JSDoc-shaped. Reformatting code, renaming identifiers, or any non-comment change. Backend Python comments (covered by ticket #7).
|
||||
- **Adjacent expectations**: The Vite build (`npm run build`) and the Vue dev server (`npm run dev`) must continue to compile and run. The `vue-i18n` translation surface in `/locales/*.json` is unaffected. The frontend `api/` services keep their existing behavior — the 5-min Axios timeout and exponential retry described in steering remain unchanged.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Comment Translation Coverage
|
||||
|
||||
**Objective:** As a frontend maintainer who does not read Chinese, I want every developer comment in `frontend/src/` to be in English, so that I can understand intent without translation tooling.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. The Frontend Source Tree shall contain no Chinese characters (Unicode range U+4E00–U+9FFF) in any `.vue`, `.js`, or `.css` file under `frontend/src/`, as verified by ripgrep `[\x{4e00}-\x{9fff}]` returning zero matching files.
|
||||
2. When a Chinese comment is translated, the Translation Pass shall preserve the original semantic intent (the *why* the comment was written) without paraphrasing into a different meaning.
|
||||
3. Where a comment exists in `<script>`, `<template>`, or `<style>` blocks of a Single-File Component, the Translation Pass shall translate it in-place using the syntax appropriate to that block (`//` / `/* */` for script and style, `<!-- -->` for template).
|
||||
4. If a Chinese comment is part of a JSDoc block (`/** ... */`), the Translation Pass shall keep the JSDoc structure intact and translate only the natural-language content.
|
||||
5. Where a deliberately-bilingual comment must be retained (e.g. a quotation, a domain term needing the original), the Translation Pass shall list the file in the PR description and shall keep an English explanation alongside the Chinese.
|
||||
|
||||
### Requirement 2: Drop Redundant Comments
|
||||
|
||||
**Objective:** As a code reviewer, I want comments that merely restate the code to be removed during the translation pass, so that the codebase aligns with `dev-guidelines.md` ("comment the *why*, not the *what*").
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. When a Chinese comment only paraphrases the immediately following statement in different words (e.g. `// 获取数据` above `fetchData()`), the Translation Pass shall delete the comment rather than translate it.
|
||||
2. When a Chinese comment encodes non-obvious intent (a constraint, an invariant, a workaround, a reason behind a magic number), the Translation Pass shall translate it rather than delete it.
|
||||
3. If a comment's value cannot be judged from local context alone, the Translation Pass shall translate it conservatively (preserve rather than delete) and shall not delete a comment merely because the maintainer is unsure of its purpose.
|
||||
4. The Translation Pass shall not introduce new comments beyond those required to translate or to add a TODO ticket reference; gratuitous explanatory comments are not added.
|
||||
|
||||
### Requirement 3: Preserve TODO/FIXME Markers and Add Ticket References
|
||||
|
||||
**Objective:** As a project maintainer tracking work-in-progress markers, I want every TODO and FIXME comment to carry a ticket reference, so that future cleanup can be triaged systematically.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. When a Chinese TODO or FIXME comment is encountered, the Translation Pass shall keep the `TODO` / `FIXME` marker (uppercase, English) and translate the trailing description.
|
||||
2. Where a TODO or FIXME marker lacks a ticket reference, the Translation Pass shall append a reference in the form `TODO(#<n>): …` or `FIXME(#<n>): …`, using `#9` if no more specific ticket exists for the underlying work.
|
||||
3. If a TODO or FIXME marker already references a ticket (e.g. `TODO(#42)`), the Translation Pass shall preserve that reference unchanged.
|
||||
|
||||
### Requirement 4: No Runtime Behavior Change
|
||||
|
||||
**Objective:** As a release engineer, I want the translated branch to produce a behaviorally identical bundle, so that I can ship the change without retesting feature surfaces.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. When `npm run build` runs against the translated branch, the Vite Build shall complete successfully with the same exit code (0) as the pre-translation baseline.
|
||||
2. The Translation Pass shall not change any executable code: no identifier renames, no expression edits, no import or export changes, no Vue template structure changes outside `<!-- -->` comment text.
|
||||
3. While the application is running in `npm run dev`, the User Interface shall render identically to the pre-translation baseline for the Home, Process, and each Step component flow on a manual smoke check.
|
||||
4. If a translation pass risks ambiguity between a comment and a string literal (Chinese characters in a quoted string), the Translation Pass shall leave the string literal unchanged — string content is out of scope and belongs to `/locales/*.json`.
|
||||
|
||||
### Requirement 5: Verifiability and PR Hand-off
|
||||
|
||||
**Objective:** As a reviewer of this PR, I want a single command and a short checklist to confirm acceptance, so that review effort is bounded and reproducible.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. The PR Description shall include the verification command and its expected output: `rg '[\x{4e00}-\x{9fff}]' frontend/src/` returns no matches (or only the deliberately-bilingual files listed in the PR).
|
||||
2. The PR Description shall list any deliberately-retained bilingual comments with the file path and a one-line rationale.
|
||||
3. The Branch Name shall be `docs/i18n-9-translate-frontend-comments` and the Commit Message shall start with `docs(i18n): translate chinese comments in frontend src to english` per the ticket's stated convention and the project's Conventional Commits rule.
|
||||
4. The Translation Pass shall not modify files outside `frontend/src/` (notably no edits under `/locales/`, `/backend/`, or repo-root configuration).
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
# Research & Design Decisions — i18n-frontend-comments
|
||||
|
||||
## Summary
|
||||
|
||||
- **Feature**: `i18n-frontend-comments`
|
||||
- **Discovery Scope**: Simple Addition (documentation-only translation pass; no architectural change)
|
||||
- **Key Findings**:
|
||||
- 20 files in `frontend/src/` contain Chinese characters (902 total occurrences). Concentration follows file size: `Process.vue` (191), `Step4Report.vue` (176), `HistoryDatabase.vue` (124), `GraphPanel.vue` (84), `Step2EnvSetup.vue` (76), `Step3Simulation.vue` (52). The remaining 14 files have ≤43 hits each.
|
||||
- Chinese appears in three comment shapes (JS line `//`, JSDoc `/** */`, Vue `<!-- -->`) and — unexpectedly — inside two flavors of string literal: `console.error('…')` developer logs (low risk to translate) and LLM prompt template strings in `Step5Interaction.vue` (behavior risk if translated, since the default LLM is Chinese-tuned).
|
||||
- The codebase has no enforced linter/formatter (per `tech.md`) and `dev-guidelines.md` already states "comment the *why*, not the *what*". The existing comment density skews toward restating-the-code in Chinese; a meaningful share will be deleted rather than translated.
|
||||
|
||||
## Research Log
|
||||
|
||||
### Inventory and shape of Chinese content
|
||||
|
||||
- **Context**: Need to decide whether one pass can mechanically translate or whether per-file judgment is required.
|
||||
- **Sources Consulted**: `rg [\x{4e00}-\x{9fff}] frontend/src/` (full count) and content-mode samples of `api/index.js`, `api/simulation.js`, `views/Home.vue`, `components/Step1GraphBuild.vue`, `components/Step5Interaction.vue`.
|
||||
- **Findings**:
|
||||
- Comments are syntactically standard (`//`, `/** */`, `<!-- -->`); no inline-Chinese identifiers.
|
||||
- JSDoc blocks in `api/simulation.js` (and likely `api/graph.js`, `api/report.js`) include `@returns`, `@param` annotations with Chinese descriptions — translate only the natural-language portion, keep tag structure.
|
||||
- `console.error` strings in `components/Step1GraphBuild.vue` (3 hits at lines 216, 237, 241) are dev-facing only, not user-facing.
|
||||
- LLM prompt template strings in `components/Step5Interaction.vue` (lines 725–727) are sent to a Chinese-tuned model; translation is a behavior change.
|
||||
- **Implications**: Per-file judgment pass is required. String literals are out of scope by default (Req 4.4); only `console.*` Chinese strings are in scope as a documented exception (developer-facing).
|
||||
|
||||
### Tooling decision: manual vs scripted
|
||||
|
||||
- **Context**: ~900 occurrences across 20 files — would automation help?
|
||||
- **Sources Consulted**: Steering `tech.md` ("No enforced linter or formatter in this repo by design… Discuss with the user before introducing ESLint/Prettier/Ruff/Black"); `dev-guidelines.md` ("comment the *why*"); gap-analysis Option B trade-offs.
|
||||
- **Findings**: Automation undercuts Req 2 (drop redundant comments requires human judgment). The project explicitly disallows new tooling without discussion. The work fits an S-effort manual pass.
|
||||
- **Implications**: No new scripts; no new dependencies; manual translation file-by-file.
|
||||
|
||||
### Verification path
|
||||
|
||||
- **Context**: How does the reviewer confirm acceptance?
|
||||
- **Sources Consulted**: Ticket body acceptance criteria; project's Vite build (`npm run build`).
|
||||
- **Findings**: A single ripgrep command confirms Req 1.1; `npm run build` confirms Req 4.1; manual smoke confirms Req 4.3. No new test harness is justified for a doc-only change (per steering "Don't add a heavy test harness without discussing scope").
|
||||
- **Implications**: PR description carries the verification one-liner; the build is the proof.
|
||||
|
||||
## Architecture Pattern Evaluation
|
||||
|
||||
| Option | Description | Strengths | Risks / Limitations | Notes |
|
||||
|--------|-------------|-----------|---------------------|-------|
|
||||
| A. Single-pass translation, no tooling | Translate all 20 files in one PR; manual judgment per comment | Simple, low overhead | Long diff for the largest 6 files | Matches "manual style" steering ethos |
|
||||
| B. Automated LLM-driven script + manual review | Script extracts Chinese comments, LLM translates, dev reviews diff | Faster on long files | Adds dependency; undercuts judgment requirement; risk of touching strings/identifiers | Rejected — clashes with "no new tooling" steering |
|
||||
| C. File-grouped manual pass (selected) | Same translation effort as A, but tasks split into file groups: high-touch / mid-touch / light | Reviewable progress, matches project's task-tracking pattern | A few extra task headings | Selected — pairs cleanly with `tasks.md` structure |
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Decision: Manual file-grouped translation, no tooling
|
||||
|
||||
- **Context**: 20 files, ~900 occurrences, mixed comment shapes plus a small set of in-scope dev-log strings.
|
||||
- **Alternatives Considered**:
|
||||
1. Single mass pass (Option A) — workable but reviewer-unfriendly for the largest files
|
||||
2. Automated LLM translation script (Option B) — fast but loses per-comment judgment and adds tooling
|
||||
3. File-grouped manual pass (Option C) — same effort as A with clearer task decomposition
|
||||
- **Selected Approach**: Group files into three batches by occurrence count and translate each batch as one task. After each batch, run the verification ripgrep to check progress.
|
||||
- **Rationale**: Aligns with `tech.md` steering ("match the surrounding file's style"), `dev-guidelines.md` ("comment the *why*"), and lets `tasks.md` mirror the existing project task-tracking pattern. The S-effort estimate fits one work session.
|
||||
- **Trade-offs**: A few extra task headings vs. cleaner reviewability. No infrastructure cost.
|
||||
- **Follow-up**: Confirm `console.*` Chinese strings are translated; confirm LLM prompts in `Step5Interaction.vue` are documented as retained in PR description.
|
||||
|
||||
### Decision: String-literal scope rule
|
||||
|
||||
- **Context**: Some Chinese appears in string literals, not just comments.
|
||||
- **Alternatives Considered**:
|
||||
1. Strict: comments only — leaves dev-facing `console.*` Chinese which any maintainer reading dev console would still see in Chinese
|
||||
2. Permissive: all string literals — translates LLM prompt templates, changing model behavior
|
||||
3. Targeted: comments + dev-facing log strings (`console.*`); retain LLM prompts as documented exceptions
|
||||
- **Selected Approach**: Targeted (option 3). Translate `console.error`, `console.warn`, `console.log` strings whose content is Chinese. Leave LLM prompt template strings alone and list them in the PR description per Req 1.5.
|
||||
- **Rationale**: Honors the spirit of the ticket ("English-readable code") while preserving Req 4 ("no runtime behavior change") for the LLM-bound strings. Matches Req 4.4 (string literals untouched *except* where dev-log translation is unambiguous).
|
||||
- **Trade-offs**: Reviewer needs to verify the exception list in the PR description against the residual ripgrep matches. Mitigated by Req 5.1 (PR description must document residuals).
|
||||
- **Follow-up**: During implementation, confirm there are no other categories of Chinese-string-literal beyond `console.*` and LLM prompts. If discovered, add to the documented exception list rather than expanding scope.
|
||||
|
||||
### Decision: TODO/FIXME ticket reference policy
|
||||
|
||||
- **Context**: Req 3 mandates ticket references on TODO/FIXME markers.
|
||||
- **Alternatives Considered**:
|
||||
1. Skip the sweep entirely if no markers exist
|
||||
2. Sweep `frontend/src/` for `TODO|FIXME` once at the start; append `(#9)` only where missing
|
||||
- **Selected Approach**: Run a single `rg 'TODO|FIXME' frontend/src/` sweep before the file-translation loop; record any matches; apply Req 3.1–3.3 inline with each file's translation.
|
||||
- **Rationale**: Lightest-weight implementation of Req 3. If no markers exist (likely for `frontend/src/`), the requirement is satisfied vacuously and noted in the PR description.
|
||||
- **Trade-offs**: None.
|
||||
- **Follow-up**: If markers exist in non-Chinese form (English TODOs without ticket refs), the requirement says only to act on *Chinese* markers; out of scope to retrofit unrelated existing English TODOs.
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
- **Risk**: Accidentally translating an LLM prompt string and shifting model behavior. **Mitigation**: Req 4.4 + Decision "String-literal scope rule"; document retained Chinese strings in PR.
|
||||
- **Risk**: Misinterpreting a Chinese comment and translating to wrong meaning. **Mitigation**: Req 2.3 (conservative when ambiguous; keep + translate rather than delete).
|
||||
- **Risk**: Reviewer churn over which comments to delete vs. translate. **Mitigation**: `dev-guidelines.md` is the rubric; Decision documents the rule (delete only when comment paraphrases the next statement; translate when the comment encodes intent).
|
||||
- **Risk**: PR is too large to review (Process.vue alone has ~191 hits). **Mitigation**: File-grouped tasks + per-group ripgrep checkpoint; each group is reviewable as a unit.
|
||||
|
||||
## References
|
||||
|
||||
- `dev-guidelines.md` (project) — comment philosophy and Conventional Commits.
|
||||
- `tech.md` (steering) — "No enforced linter or formatter… match the surrounding file's style."
|
||||
- `structure.md` (steering) — `frontend/src/` directory layout (views/components/api/store).
|
||||
- Ticket #9 body — acceptance criteria, branch and commit naming.
|
||||
- Gap analysis (`gap-analysis.md`) — Option C trade-offs and effort/risk estimate.
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"feature_name": "i18n-frontend-comments",
|
||||
"created_at": "2026-05-07T16:24:12Z",
|
||||
"updated_at": "2026-05-07T16:35:00Z",
|
||||
"language": "en",
|
||||
"phase": "tasks-generated",
|
||||
"ticket": 9,
|
||||
"approvals": {
|
||||
"requirements": {
|
||||
"generated": true,
|
||||
"approved": true
|
||||
},
|
||||
"design": {
|
||||
"generated": true,
|
||||
"approved": true
|
||||
},
|
||||
"tasks": {
|
||||
"generated": true,
|
||||
"approved": false
|
||||
}
|
||||
},
|
||||
"ready_for_implementation": false
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# Implementation Plan
|
||||
|
||||
## Foundation
|
||||
|
||||
- [x] 1. Sweep TODO/FIXME markers and capture pre-translation baseline
|
||||
- Run `rg 'TODO|FIXME' frontend/src/` and record all matches with file/line; for each, note whether the description is in Chinese (in scope for translation) or already English (out of scope per Boundary Commitments).
|
||||
- Capture the pre-translation ripgrep baseline so the verification command output can be compared after the translation pass.
|
||||
- Observable completion: a working note (not committed) listing every TODO/FIXME in `frontend/src/`, classified as "translate-and-tag", "already-tagged", or "English-out-of-scope", and a recorded count of files matching `[\x{4e00}-\x{9fff}]` in `frontend/src/` (expected: 20 files, ~902 occurrences).
|
||||
- _Requirements: 3.1, 3.2, 3.3, 5.1_
|
||||
|
||||
## Core Translation Pass
|
||||
|
||||
- [x] 2. Translate light-touch files (≤10 hits)
|
||||
- Translate Chinese comments to English in `App.vue`, `store/pendingUpload.js`, `views/MainView.vue`, `views/InteractionView.vue`, `views/ReportView.vue`, `components/Step1GraphBuild.vue`, `api/index.js`, `api/graph.js`, `api/report.js`. Apply the region-eligibility matrix from design.md: translate line/block/JSDoc/template comments; preserve JSDoc tag syntax; delete comments that merely restate the next statement; keep comments that encode intent.
|
||||
- Translate Chinese content inside `console.error|warn|log` string literals in `components/Step1GraphBuild.vue` (3 known hits at lines 216, 237, 241). Leave all other string literals unchanged.
|
||||
- For any TODO/FIXME marker that was Chinese and lacked a ticket reference, append `(#9)`; preserve existing references.
|
||||
- Observable completion: `rg '[\x{4e00}-\x{9fff}]' frontend/src/{App.vue,store,views/MainView.vue,views/InteractionView.vue,views/ReportView.vue,components/Step1GraphBuild.vue,api}` returns no matches (no retained-bilingual cases expected in this group).
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 3.1, 3.2, 3.3, 4.2, 4.4, 5.4_
|
||||
|
||||
- [x] 3. Translate mid-touch files (10–60 hits)
|
||||
- Translate `api/simulation.js` (29 hits, JSDoc-heavy: keep `@param`, `@returns`, etc., translate only natural-language content), `views/SimulationRunView.vue` (18 hits), `views/SimulationView.vue` (22 hits), `views/Home.vue` (43 hits), `components/Step5Interaction.vue` (34 hits), `components/Step3Simulation.vue` (52 hits).
|
||||
- In `components/Step5Interaction.vue`, retain Chinese inside the LLM prompt template strings (around lines 725–727) per Requirement 1.5; record file/line in a working note for the PR description. Translate all comments and any non-LLM-prompt Chinese content in this file as normal.
|
||||
- For any other Chinese-content string literal encountered in this group, leave the literal unchanged and record file/line for the PR description.
|
||||
- Observable completion: `rg '[\x{4e00}-\x{9fff}]' <files-in-group>` returns matches only for `components/Step5Interaction.vue` (LLM prompt strings) and any other documented retained-bilingual literals; no comment-region match remains in any of these files.
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.1, 2.2, 2.3, 2.4, 3.1, 3.2, 3.3, 4.2, 4.4, 5.4_
|
||||
|
||||
- [x] 4. Translate high-touch files (>60 hits)
|
||||
- Translate `components/Step2EnvSetup.vue` (76 hits), `components/GraphPanel.vue` (84 hits, mixed D3 logic comments and `<template>` comments), `components/HistoryDatabase.vue` (124 hits), `components/Step4Report.vue` (176 hits), `views/Process.vue` (191 hits, the 2067-line workflow orchestrator).
|
||||
- These files concentrate ~80% of total occurrences; budget time accordingly. Apply the same region-eligibility matrix as task 2: translate comments, preserve JSDoc tag syntax, delete redundant comments, keep intent-bearing ones.
|
||||
- Translate `console.*` Chinese strings if encountered; leave LLM prompts and other string literals unchanged and record for the PR description.
|
||||
- Observable completion: `rg '[\x{4e00}-\x{9fff}]' frontend/src/components/{Step2EnvSetup,GraphPanel,HistoryDatabase,Step4Report}.vue frontend/src/views/Process.vue` returns no comment-region matches; any residuals are documented retained-bilingual string literals.
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.1, 2.2, 2.3, 2.4, 3.1, 3.2, 3.3, 4.2, 4.4, 5.4_
|
||||
|
||||
## Integration & Validation
|
||||
|
||||
- [x] 5. Run final acceptance verification
|
||||
- Run `rg '[\x{4e00}-\x{9fff}]' frontend/src/` on the full directory and confirm output is empty or contains only the pre-recorded retained-bilingual files (LLM prompt strings in `components/Step5Interaction.vue` and any others documented during tasks 2–4).
|
||||
- Run `npm run build` and confirm exit code 0 and successful Vite build output.
|
||||
- Run `git diff --stat main..HEAD` (or against the branch base) and confirm only `frontend/src/**` paths are modified — no edits under `/locales/`, `/backend/`, or repo root.
|
||||
- Observable completion: all three checks pass; if any check fails, return to the relevant translation task before proceeding to PR.
|
||||
- _Requirements: 1.1, 4.1, 4.2, 5.4_
|
||||
|
||||
- [x] 6. Manual UI smoke check
|
||||
- Run `npm run dev`; in a browser, navigate Home → Process → each Step component (1–5) → Interaction → Report; confirm rendering matches the pre-translation baseline (no missing text, no broken bindings, no console errors that did not exist before).
|
||||
- Per `tech.md` steering, the manual smoke is the only practical proof that no executable change crept in; type-check or build pass alone is not sufficient.
|
||||
- Observable completion: every page renders identically to baseline; no new console errors; the implementer can confirm "UI unchanged" in the PR description.
|
||||
- _Requirements: 4.3_
|
||||
|
||||
- [x] 7. Compose PR description with verification artifacts
|
||||
- Draft the PR body listing: (a) the verification command `rg '[\x{4e00}-\x{9fff}]' frontend/src/` and its post-translation output, (b) the file count and any retained-bilingual files with one-line rationale per Requirement 5.2, (c) confirmation that the manual UI smoke passed, (d) confirmation that no files outside `frontend/src/` were modified.
|
||||
- Use branch name `docs/i18n-9-translate-frontend-comments` and commit message `docs(i18n): translate chinese comments in frontend src to english` per Requirement 5.3 and the project's Conventional Commits rule.
|
||||
- Observable completion: the PR description, branch name, and commit subject are ready to use by `/done`; all five Requirement 5 acceptance criteria are visibly satisfied in the PR body.
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4_
|
||||
|
|
@ -3,11 +3,10 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
// 使用 Vue Router 来管理页面
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 全局样式重置 */
|
||||
/* Global reset */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
|
@ -22,7 +21,7 @@
|
|||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
|
@ -40,7 +39,7 @@
|
|||
background: #333333;
|
||||
}
|
||||
|
||||
/* 全局按钮样式 */
|
||||
/* Global button defaults */
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import service, { requestWithRetry } from './index'
|
||||
|
||||
/**
|
||||
* 生成本体(上传文档和模拟需求)
|
||||
* @param {Object} data - 包含files, simulation_requirement, project_name等
|
||||
* Generate the ontology by uploading documents and the simulation requirement.
|
||||
* @param {Object} data - Includes files, simulation_requirement, project_name, etc.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function generateOntology(formData) {
|
||||
|
|
@ -19,8 +19,8 @@ export function generateOntology(formData) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 构建图谱
|
||||
* @param {Object} data - 包含project_id, graph_name等
|
||||
* Build the knowledge graph for a project.
|
||||
* @param {Object} data - Includes project_id, graph_name, etc.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function buildGraph(data) {
|
||||
|
|
@ -34,8 +34,8 @@ export function buildGraph(data) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 查询任务状态
|
||||
* @param {String} taskId - 任务ID
|
||||
* Poll a background task's status.
|
||||
* @param {String} taskId - Task ID.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getTaskStatus(taskId) {
|
||||
|
|
@ -46,8 +46,8 @@ export function getTaskStatus(taskId) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取图谱数据
|
||||
* @param {String} graphId - 图谱ID
|
||||
* Fetch graph nodes and edges.
|
||||
* @param {String} graphId - Graph ID.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getGraphData(graphId) {
|
||||
|
|
@ -58,8 +58,8 @@ export function getGraphData(graphId) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取项目信息
|
||||
* @param {String} projectId - 项目ID
|
||||
* Fetch project metadata.
|
||||
* @param {String} projectId - Project ID.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getProject(projectId) {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import axios from 'axios'
|
||||
import i18n from '../i18n'
|
||||
|
||||
// 创建axios实例
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5001',
|
||||
timeout: 300000, // 5分钟超时(本体生成可能需要较长时间)
|
||||
timeout: 300000, // 5-min timeout: ontology generation can take a while.
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
config => {
|
||||
config.headers['Accept-Language'] = i18n.global.locale.value
|
||||
|
|
@ -22,37 +20,32 @@ service.interceptors.request.use(
|
|||
}
|
||||
)
|
||||
|
||||
// 响应拦截器(容错重试机制)
|
||||
service.interceptors.response.use(
|
||||
response => {
|
||||
const res = response.data
|
||||
|
||||
// 如果返回的状态码不是success,则抛出错误
|
||||
|
||||
if (!res.success && res.success !== undefined) {
|
||||
console.error('API Error:', res.error || res.message || 'Unknown error')
|
||||
return Promise.reject(new Error(res.error || res.message || 'Error'))
|
||||
}
|
||||
|
||||
|
||||
return res
|
||||
},
|
||||
error => {
|
||||
console.error('Response error:', error)
|
||||
|
||||
// 处理超时
|
||||
|
||||
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
|
||||
console.error('Request timeout')
|
||||
}
|
||||
|
||||
// 处理网络错误
|
||||
|
||||
if (error.message === 'Network Error') {
|
||||
console.error('Network error - please check your connection')
|
||||
}
|
||||
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 带重试的请求函数
|
||||
export const requestWithRetry = async (requestFn, maxRetries = 3, delay = 1000) => {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import service, { requestWithRetry } from './index'
|
||||
|
||||
/**
|
||||
* 开始报告生成
|
||||
* Kick off report generation.
|
||||
* @param {Object} data - { simulation_id, force_regenerate? }
|
||||
*/
|
||||
export const generateReport = (data) => {
|
||||
|
|
@ -9,7 +9,7 @@ export const generateReport = (data) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取报告生成状态
|
||||
* Poll report-generation status.
|
||||
* @param {string} reportId
|
||||
*/
|
||||
export const getReportStatus = (reportId) => {
|
||||
|
|
@ -17,25 +17,25 @@ export const getReportStatus = (reportId) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 日志(增量)
|
||||
* Fetch incremental agent log.
|
||||
* @param {string} reportId
|
||||
* @param {number} fromLine - 从第几行开始获取
|
||||
* @param {number} fromLine - Line offset to start from.
|
||||
*/
|
||||
export const getAgentLog = (reportId, fromLine = 0) => {
|
||||
return service.get(`/api/report/${reportId}/agent-log`, { params: { from_line: fromLine } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取控制台日志(增量)
|
||||
* Fetch incremental console log.
|
||||
* @param {string} reportId
|
||||
* @param {number} fromLine - 从第几行开始获取
|
||||
* @param {number} fromLine - Line offset to start from.
|
||||
*/
|
||||
export const getConsoleLog = (reportId, fromLine = 0) => {
|
||||
return service.get(`/api/report/${reportId}/console-log`, { params: { from_line: fromLine } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取报告详情
|
||||
* Fetch report details.
|
||||
* @param {string} reportId
|
||||
*/
|
||||
export const getReport = (reportId) => {
|
||||
|
|
@ -43,7 +43,7 @@ export const getReport = (reportId) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 与 Report Agent 对话
|
||||
* Chat with the Report Agent.
|
||||
* @param {Object} data - { simulation_id, message, chat_history? }
|
||||
*/
|
||||
export const chatWithReport = (data) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import service, { requestWithRetry } from './index'
|
||||
|
||||
/**
|
||||
* 创建模拟
|
||||
* Create a new simulation.
|
||||
* @param {Object} data - { project_id, graph_id?, enable_twitter?, enable_reddit? }
|
||||
*/
|
||||
export const createSimulation = (data) => {
|
||||
|
|
@ -9,7 +9,7 @@ export const createSimulation = (data) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 准备模拟环境(异步任务)
|
||||
* Prepare the simulation environment as a background task.
|
||||
* @param {Object} data - { simulation_id, entity_types?, use_llm_for_profiles?, parallel_profile_count?, force_regenerate? }
|
||||
*/
|
||||
export const prepareSimulation = (data) => {
|
||||
|
|
@ -17,7 +17,7 @@ export const prepareSimulation = (data) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 查询准备任务进度
|
||||
* Poll the prepare-task progress.
|
||||
* @param {Object} data - { task_id?, simulation_id? }
|
||||
*/
|
||||
export const getPrepareStatus = (data) => {
|
||||
|
|
@ -25,7 +25,7 @@ export const getPrepareStatus = (data) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取模拟状态
|
||||
* Fetch simulation status.
|
||||
* @param {string} simulationId
|
||||
*/
|
||||
export const getSimulation = (simulationId) => {
|
||||
|
|
@ -33,7 +33,7 @@ export const getSimulation = (simulationId) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取模拟的 Agent Profiles
|
||||
* Fetch the simulation's agent profiles.
|
||||
* @param {string} simulationId
|
||||
* @param {string} platform - 'reddit' | 'twitter'
|
||||
*/
|
||||
|
|
@ -42,7 +42,7 @@ export const getSimulationProfiles = (simulationId, platform = 'reddit') => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 实时获取生成中的 Agent Profiles
|
||||
* Stream the agent profiles being generated in real time.
|
||||
* @param {string} simulationId
|
||||
* @param {string} platform - 'reddit' | 'twitter'
|
||||
*/
|
||||
|
|
@ -51,7 +51,7 @@ export const getSimulationProfilesRealtime = (simulationId, platform = 'reddit')
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取模拟配置
|
||||
* Fetch the simulation config.
|
||||
* @param {string} simulationId
|
||||
*/
|
||||
export const getSimulationConfig = (simulationId) => {
|
||||
|
|
@ -59,17 +59,17 @@ export const getSimulationConfig = (simulationId) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 实时获取生成中的模拟配置
|
||||
* Stream the simulation config being generated in real time.
|
||||
* @param {string} simulationId
|
||||
* @returns {Promise} 返回配置信息,包含元数据和配置内容
|
||||
* @returns {Promise} Config payload — metadata plus content.
|
||||
*/
|
||||
export const getSimulationConfigRealtime = (simulationId) => {
|
||||
return service.get(`/api/simulation/${simulationId}/config/realtime`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有模拟
|
||||
* @param {string} projectId - 可选,按项目ID过滤
|
||||
* List all simulations.
|
||||
* @param {string} projectId - Optional project filter.
|
||||
*/
|
||||
export const listSimulations = (projectId) => {
|
||||
const params = projectId ? { project_id: projectId } : {}
|
||||
|
|
@ -77,7 +77,7 @@ export const listSimulations = (projectId) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 启动模拟
|
||||
* Start a simulation run.
|
||||
* @param {Object} data - { simulation_id, platform?, max_rounds?, enable_graph_memory_update? }
|
||||
*/
|
||||
export const startSimulation = (data) => {
|
||||
|
|
@ -85,7 +85,7 @@ export const startSimulation = (data) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 停止模拟
|
||||
* Stop a simulation run.
|
||||
* @param {Object} data - { simulation_id }
|
||||
*/
|
||||
export const stopSimulation = (data) => {
|
||||
|
|
@ -93,7 +93,7 @@ export const stopSimulation = (data) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取模拟运行实时状态
|
||||
* Fetch the simulation's live run status.
|
||||
* @param {string} simulationId
|
||||
*/
|
||||
export const getRunStatus = (simulationId) => {
|
||||
|
|
@ -101,7 +101,7 @@ export const getRunStatus = (simulationId) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取模拟运行详细状态(包含最近动作)
|
||||
* Fetch the simulation's detailed run status (includes recent actions).
|
||||
* @param {string} simulationId
|
||||
*/
|
||||
export const getRunStatusDetail = (simulationId) => {
|
||||
|
|
@ -109,11 +109,11 @@ export const getRunStatusDetail = (simulationId) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取模拟中的帖子
|
||||
* Fetch posts from the simulation.
|
||||
* @param {string} simulationId
|
||||
* @param {string} platform - 'reddit' | 'twitter'
|
||||
* @param {number} limit - 返回数量
|
||||
* @param {number} offset - 偏移量
|
||||
* @param {number} limit - Page size.
|
||||
* @param {number} offset - Page offset.
|
||||
*/
|
||||
export const getSimulationPosts = (simulationId, platform = 'reddit', limit = 50, offset = 0) => {
|
||||
return service.get(`/api/simulation/${simulationId}/posts`, {
|
||||
|
|
@ -122,10 +122,10 @@ export const getSimulationPosts = (simulationId, platform = 'reddit', limit = 50
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取模拟时间线(按轮次汇总)
|
||||
* Fetch the simulation timeline aggregated by round.
|
||||
* @param {string} simulationId
|
||||
* @param {number} startRound - 起始轮次
|
||||
* @param {number} endRound - 结束轮次
|
||||
* @param {number} startRound - Inclusive start round.
|
||||
* @param {number} endRound - Inclusive end round (or null for open-ended).
|
||||
*/
|
||||
export const getSimulationTimeline = (simulationId, startRound = 0, endRound = null) => {
|
||||
const params = { start_round: startRound }
|
||||
|
|
@ -136,7 +136,7 @@ export const getSimulationTimeline = (simulationId, startRound = 0, endRound = n
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取Agent统计信息
|
||||
* Fetch agent stats for the simulation.
|
||||
* @param {string} simulationId
|
||||
*/
|
||||
export const getAgentStats = (simulationId) => {
|
||||
|
|
@ -144,7 +144,7 @@ export const getAgentStats = (simulationId) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取模拟动作历史
|
||||
* Fetch the simulation's action history.
|
||||
* @param {string} simulationId
|
||||
* @param {Object} params - { limit, offset, platform, agent_id, round_num }
|
||||
*/
|
||||
|
|
@ -153,7 +153,7 @@ export const getSimulationActions = (simulationId, params = {}) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 关闭模拟环境(优雅退出)
|
||||
* Gracefully shut down the simulation environment.
|
||||
* @param {Object} data - { simulation_id, timeout? }
|
||||
*/
|
||||
export const closeSimulationEnv = (data) => {
|
||||
|
|
@ -161,7 +161,7 @@ export const closeSimulationEnv = (data) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取模拟环境状态
|
||||
* Fetch the simulation environment status.
|
||||
* @param {Object} data - { simulation_id }
|
||||
*/
|
||||
export const getEnvStatus = (data) => {
|
||||
|
|
@ -169,7 +169,7 @@ export const getEnvStatus = (data) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 批量采访 Agent
|
||||
* Batch-interview agents.
|
||||
* @param {Object} data - { simulation_id, interviews: [{ agent_id, prompt }] }
|
||||
*/
|
||||
export const interviewAgents = (data) => {
|
||||
|
|
@ -177,9 +177,9 @@ export const interviewAgents = (data) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取历史模拟列表(带项目详情)
|
||||
* 用于首页历史项目展示
|
||||
* @param {number} limit - 返回数量限制
|
||||
* Fetch the simulation history with project details.
|
||||
* Used by the home page's recent-projects section.
|
||||
* @param {number} limit - Max entries to return.
|
||||
*/
|
||||
export const getSimulationHistory = (limit = 20) => {
|
||||
return service.get('/api/simulation/history', { params: { limit } })
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="graph-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">{{ $t('graph.panelTitle') }}</span>
|
||||
<!-- 顶部工具栏 (Internal Top Right) -->
|
||||
<!-- Top toolbar (internal top-right) -->
|
||||
<div class="header-tools">
|
||||
<button class="tool-btn" @click="$emit('refresh')" :disabled="loading" :title="$t('graph.refreshGraph')">
|
||||
<span class="icon-refresh" :class="{ 'spinning': loading }">↻</span>
|
||||
|
|
@ -15,11 +15,11 @@
|
|||
</div>
|
||||
|
||||
<div class="graph-container" ref="graphContainer">
|
||||
<!-- 图谱可视化 -->
|
||||
<!-- Graph visualization -->
|
||||
<div v-if="graphData" class="graph-view">
|
||||
<svg ref="graphSvg" class="graph-svg"></svg>
|
||||
|
||||
<!-- 构建中/模拟中提示 -->
|
||||
<!-- Building / simulating banner -->
|
||||
<div v-if="currentPhase === 1 || isSimulating" class="graph-building-hint">
|
||||
<div class="memory-icon-wrapper">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="memory-icon">
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
{{ isSimulating ? $t('graph.graphMemoryRealtime') : $t('graph.realtimeUpdating') }}
|
||||
</div>
|
||||
|
||||
<!-- 模拟结束后的提示 -->
|
||||
<!-- Post-simulation hint -->
|
||||
<div v-if="showSimulationFinishedHint" class="graph-building-hint finished-hint">
|
||||
<div class="hint-icon-wrapper">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="hint-icon">
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 节点/边详情面板 -->
|
||||
<!-- Node / edge detail panel -->
|
||||
<div v-if="selectedItem" class="detail-panel">
|
||||
<div class="detail-panel-header">
|
||||
<span class="detail-title">{{ selectedItem.type === 'node' ? $t('graph.nodeDetails') : $t('graph.relationship') }}</span>
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
<button class="detail-close" @click="closeDetailPanel">×</button>
|
||||
</div>
|
||||
|
||||
<!-- 节点详情 -->
|
||||
<!-- Node details -->
|
||||
<div v-if="selectedItem.type === 'node'" class="detail-content">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Name:</span>
|
||||
|
|
@ -101,9 +101,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 边详情 -->
|
||||
<!-- Edge details -->
|
||||
<div v-else class="detail-content">
|
||||
<!-- 自环组详情 -->
|
||||
<!-- Self-loop group details -->
|
||||
<template v-if="selectedItem.data.isSelfLoopGroup">
|
||||
<div class="edge-relation-header self-loop-header">
|
||||
{{ selectedItem.data.source_name }} - Self Relations
|
||||
|
|
@ -154,7 +154,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 普通边详情 -->
|
||||
<!-- Standard edge details -->
|
||||
<template v-else>
|
||||
<div class="edge-relation-header">
|
||||
{{ selectedItem.data.source_name }} → {{ selectedItem.data.name || 'RELATED_TO' }} → {{ selectedItem.data.target_name }}
|
||||
|
|
@ -200,20 +200,20 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<!-- Loading state -->
|
||||
<div v-else-if="loading" class="graph-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>{{ $t('graph.graphDataLoading') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 等待/空状态 -->
|
||||
<!-- Waiting / empty state -->
|
||||
<div v-else class="graph-state">
|
||||
<div class="empty-icon">❖</div>
|
||||
<p class="empty-text">{{ $t('graph.waitingOntology') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部图例 (Bottom Left) -->
|
||||
<!-- Bottom legend (bottom-left) -->
|
||||
<div v-if="graphData && entityTypes.length" class="graph-legend">
|
||||
<span class="legend-title">Entity Types</span>
|
||||
<div class="legend-items">
|
||||
|
|
@ -224,7 +224,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 显示边标签开关 -->
|
||||
<!-- Edge-labels toggle -->
|
||||
<div v-if="graphData" class="edge-labels-toggle">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" v-model="showEdgeLabels" />
|
||||
|
|
@ -251,26 +251,23 @@ const emit = defineEmits(['refresh', 'toggle-maximize'])
|
|||
const graphContainer = ref(null)
|
||||
const graphSvg = ref(null)
|
||||
const selectedItem = ref(null)
|
||||
const showEdgeLabels = ref(true) // 默认显示边标签
|
||||
const expandedSelfLoops = ref(new Set()) // 展开的自环项
|
||||
const showSimulationFinishedHint = ref(false) // 模拟结束后的提示
|
||||
const wasSimulating = ref(false) // 追踪之前是否在模拟中
|
||||
const showEdgeLabels = ref(true) // Edge labels are visible by default.
|
||||
const expandedSelfLoops = ref(new Set()) // Expanded self-loop items.
|
||||
const showSimulationFinishedHint = ref(false) // Visible only after a simulation finishes.
|
||||
const wasSimulating = ref(false) // Track the previous simulating state for transition detection.
|
||||
|
||||
// 关闭模拟结束提示
|
||||
const dismissFinishedHint = () => {
|
||||
showSimulationFinishedHint.value = false
|
||||
}
|
||||
|
||||
// 监听 isSimulating 变化,检测模拟结束
|
||||
// Watch isSimulating: surface the post-simulation hint on the simulating → idle edge.
|
||||
watch(() => props.isSimulating, (newValue, oldValue) => {
|
||||
if (wasSimulating.value && !newValue) {
|
||||
// 从模拟中变为非模拟状态,显示结束提示
|
||||
showSimulationFinishedHint.value = true
|
||||
}
|
||||
wasSimulating.value = newValue
|
||||
}, { immediate: true })
|
||||
|
||||
// 切换自环项展开/折叠状态
|
||||
const toggleSelfLoop = (id) => {
|
||||
const newSet = new Set(expandedSelfLoops.value)
|
||||
if (newSet.has(id)) {
|
||||
|
|
@ -281,11 +278,11 @@ const toggleSelfLoop = (id) => {
|
|||
expandedSelfLoops.value = newSet
|
||||
}
|
||||
|
||||
// 计算实体类型用于图例
|
||||
// Build entity-type list for the legend.
|
||||
const entityTypes = computed(() => {
|
||||
if (!props.graphData?.nodes) return []
|
||||
const typeMap = {}
|
||||
// 美观的颜色调色板
|
||||
// Curated color palette.
|
||||
const colors = ['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C', '#3498db', '#9b59b6', '#27ae60', '#f39c12']
|
||||
|
||||
props.graphData.nodes.forEach(node => {
|
||||
|
|
@ -298,7 +295,6 @@ const entityTypes = computed(() => {
|
|||
return Object.values(typeMap)
|
||||
})
|
||||
|
||||
// 格式化时间
|
||||
const formatDateTime = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
|
|
@ -318,7 +314,7 @@ const formatDateTime = (dateStr) => {
|
|||
|
||||
const closeDetailPanel = () => {
|
||||
selectedItem.value = null
|
||||
expandedSelfLoops.value = new Set() // 重置展开状态
|
||||
expandedSelfLoops.value = new Set() // Reset expansion state.
|
||||
}
|
||||
|
||||
let currentSimulation = null
|
||||
|
|
@ -328,7 +324,7 @@ let linkLabelBgRef = null
|
|||
const renderGraph = () => {
|
||||
if (!graphSvg.value || !props.graphData) return
|
||||
|
||||
// 停止之前的仿真
|
||||
// Stop the previous simulation, if any.
|
||||
if (currentSimulation) {
|
||||
currentSimulation.stop()
|
||||
}
|
||||
|
|
@ -362,16 +358,15 @@ const renderGraph = () => {
|
|||
|
||||
const nodeIds = new Set(nodes.map(n => n.id))
|
||||
|
||||
// 处理边数据,计算同一对节点间的边数量和索引
|
||||
// Build edge index: per-pair counts plus the self-loop bucket.
|
||||
const edgePairCount = {}
|
||||
const selfLoopEdges = {} // 按节点分组的自环边
|
||||
const selfLoopEdges = {} // Self-loops grouped by node.
|
||||
const tempEdges = edgesData
|
||||
.filter(e => nodeIds.has(e.source_node_uuid) && nodeIds.has(e.target_node_uuid))
|
||||
|
||||
// 统计每对节点之间的边数量,收集自环边
|
||||
|
||||
// Count edges per node-pair and collect every self-loop.
|
||||
tempEdges.forEach(e => {
|
||||
if (e.source_node_uuid === e.target_node_uuid) {
|
||||
// 自环 - 收集到数组中
|
||||
if (!selfLoopEdges[e.source_node_uuid]) {
|
||||
selfLoopEdges[e.source_node_uuid] = []
|
||||
}
|
||||
|
|
@ -386,19 +381,19 @@ const renderGraph = () => {
|
|||
}
|
||||
})
|
||||
|
||||
// 记录当前处理到每对节点的第几条边
|
||||
// Track which edge index we're currently emitting per pair.
|
||||
const edgePairIndex = {}
|
||||
const processedSelfLoopNodes = new Set() // 已处理的自环节点
|
||||
|
||||
const processedSelfLoopNodes = new Set() // Nodes whose self-loops we already collapsed.
|
||||
|
||||
const edges = []
|
||||
|
||||
|
||||
tempEdges.forEach(e => {
|
||||
const isSelfLoop = e.source_node_uuid === e.target_node_uuid
|
||||
|
||||
|
||||
if (isSelfLoop) {
|
||||
// 自环边 - 每个节点只添加一条合并的自环
|
||||
// Emit one merged self-loop per node, regardless of how many actual self-loops exist.
|
||||
if (processedSelfLoopNodes.has(e.source_node_uuid)) {
|
||||
return // 已处理过,跳过
|
||||
return
|
||||
}
|
||||
processedSelfLoopNodes.add(e.source_node_uuid)
|
||||
|
||||
|
|
@ -417,7 +412,7 @@ const renderGraph = () => {
|
|||
source_name: nodeName,
|
||||
target_name: nodeName,
|
||||
selfLoopCount: allSelfLoops.length,
|
||||
selfLoopEdges: allSelfLoops // 存储所有自环边的详细信息
|
||||
selfLoopEdges: allSelfLoops // Carry the underlying self-loop edges for the detail panel.
|
||||
}
|
||||
})
|
||||
return
|
||||
|
|
@ -428,19 +423,19 @@ const renderGraph = () => {
|
|||
const currentIndex = edgePairIndex[pairKey] || 0
|
||||
edgePairIndex[pairKey] = currentIndex + 1
|
||||
|
||||
// 判断边的方向是否与标准化方向一致(源UUID < 目标UUID)
|
||||
// Direction relative to the normalized form (source UUID < target UUID).
|
||||
const isReversed = e.source_node_uuid > e.target_node_uuid
|
||||
|
||||
// 计算曲率:多条边时分散开,单条边为直线
|
||||
|
||||
// Curvature: spread out when multiple edges share a pair; straight line for a single edge.
|
||||
let curvature = 0
|
||||
if (totalCount > 1) {
|
||||
// 均匀分布曲率,确保明显区分
|
||||
// 曲率范围根据边数量增加,边越多曲率范围越大
|
||||
// Distribute curvature evenly so each edge is visually distinct;
|
||||
// widen the range when more edges share the pair.
|
||||
const curvatureRange = Math.min(1.2, 0.6 + totalCount * 0.15)
|
||||
curvature = ((currentIndex / (totalCount - 1)) - 0.5) * curvatureRange * 2
|
||||
|
||||
// 如果边的方向与标准化方向相反,翻转曲率
|
||||
// 这样确保所有边在同一参考系下分布,不会因方向不同而重叠
|
||||
|
||||
// Flip the curvature for reversed-direction edges so all edges lay out
|
||||
// in the same frame of reference and don't overlap by direction.
|
||||
if (isReversed) {
|
||||
curvature = -curvature
|
||||
}
|
||||
|
|
@ -468,11 +463,10 @@ const renderGraph = () => {
|
|||
entityTypes.value.forEach(t => colorMap[t.name] = t.color)
|
||||
const getColor = (type) => colorMap[type] || '#999'
|
||||
|
||||
// Simulation - 根据边数量动态调整节点间距
|
||||
// Simulation — node spacing scales with how many edges share each pair.
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(edges).id(d => d.id).distance(d => {
|
||||
// 根据这对节点之间的边数量动态调整距离
|
||||
// 基础距离 150,每多一条边增加 40
|
||||
// Base distance 150, +50 per extra edge between the same pair.
|
||||
const baseDistance = 150
|
||||
const edgeCount = d.pairTotal || 1
|
||||
return baseDistance + (edgeCount - 1) * 50
|
||||
|
|
@ -480,7 +474,7 @@ const renderGraph = () => {
|
|||
.force('charge', d3.forceManyBody().strength(-400))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collide', d3.forceCollide(50))
|
||||
// 添加向中心的引力,让独立的节点群聚集到中心区域
|
||||
// Pull toward the center so isolated subgraphs cluster into the viewport.
|
||||
.force('x', d3.forceX(width / 2).strength(0.04))
|
||||
.force('y', d3.forceY(height / 2).strength(0.04))
|
||||
|
||||
|
|
@ -493,39 +487,36 @@ const renderGraph = () => {
|
|||
g.attr('transform', event.transform)
|
||||
}))
|
||||
|
||||
// Links - 使用 path 支持曲线
|
||||
// Links — drawn as <path> so we can render curves.
|
||||
const linkGroup = g.append('g').attr('class', 'links')
|
||||
|
||||
// 计算曲线路径
|
||||
|
||||
const getLinkPath = (d) => {
|
||||
const sx = d.source.x, sy = d.source.y
|
||||
const tx = d.target.x, ty = d.target.y
|
||||
|
||||
// 检测自环
|
||||
|
||||
if (d.isSelfLoop) {
|
||||
// 自环:绘制一个圆弧从节点出发再返回
|
||||
// Self-loop: an arc that exits the node and loops back.
|
||||
const loopRadius = 30
|
||||
// 从节点右侧出发,绕一圈回来
|
||||
const x1 = sx + 8 // 起点偏移
|
||||
// Exit from the node's right side and return.
|
||||
const x1 = sx + 8 // Start offset.
|
||||
const y1 = sy - 4
|
||||
const x2 = sx + 8 // 终点偏移
|
||||
const x2 = sx + 8 // End offset.
|
||||
const y2 = sy + 4
|
||||
// 使用圆弧绘制自环(sweep-flag=1 顺时针)
|
||||
// Render as an arc — sweep-flag=1 means clockwise.
|
||||
return `M${x1},${y1} A${loopRadius},${loopRadius} 0 1,1 ${x2},${y2}`
|
||||
}
|
||||
|
||||
|
||||
if (d.curvature === 0) {
|
||||
// 直线
|
||||
return `M${sx},${sy} L${tx},${ty}`
|
||||
}
|
||||
|
||||
// 计算曲线控制点 - 根据边数量和距离动态调整
|
||||
|
||||
// Compute the quadratic-Bezier control point. Offset perpendicular to the
|
||||
// line, scaled by distance so the curve remains visible regardless of zoom.
|
||||
const dx = tx - sx, dy = ty - sy
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
// 垂直于连线方向的偏移,根据距离比例计算,保证曲线明显可见
|
||||
// 边越多,偏移量占距离的比例越大
|
||||
// More edges per pair → larger offset ratio (start at 25%, +5% per extra edge).
|
||||
const pairTotal = d.pairTotal || 1
|
||||
const offsetRatio = 0.25 + pairTotal * 0.05 // 基础25%,每多一条边增加5%
|
||||
const offsetRatio = 0.25 + pairTotal * 0.05
|
||||
const baseOffset = Math.max(35, dist * offsetRatio)
|
||||
const offsetX = -dy / dist * d.curvature * baseOffset
|
||||
const offsetY = dx / dist * d.curvature * baseOffset
|
||||
|
|
@ -535,22 +526,21 @@ const renderGraph = () => {
|
|||
return `M${sx},${sy} Q${cx},${cy} ${tx},${ty}`
|
||||
}
|
||||
|
||||
// 计算曲线中点(用于标签定位)
|
||||
// Midpoint of the link path — used to position the edge label.
|
||||
const getLinkMidpoint = (d) => {
|
||||
const sx = d.source.x, sy = d.source.y
|
||||
const tx = d.target.x, ty = d.target.y
|
||||
|
||||
// 检测自环
|
||||
|
||||
if (d.isSelfLoop) {
|
||||
// 自环标签位置:节点右侧
|
||||
// Self-loop labels sit just right of the node.
|
||||
return { x: sx + 70, y: sy }
|
||||
}
|
||||
|
||||
|
||||
if (d.curvature === 0) {
|
||||
return { x: (sx + tx) / 2, y: (sy + ty) / 2 }
|
||||
}
|
||||
|
||||
// 二次贝塞尔曲线的中点 t=0.5
|
||||
|
||||
// Reproduce the curve's midpoint (Bezier B(0.5)).
|
||||
const dx = tx - sx, dy = ty - sy
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const pairTotal = d.pairTotal || 1
|
||||
|
|
@ -560,8 +550,8 @@ const renderGraph = () => {
|
|||
const offsetY = dx / dist * d.curvature * baseOffset
|
||||
const cx = (sx + tx) / 2 + offsetX
|
||||
const cy = (sy + ty) / 2 + offsetY
|
||||
|
||||
// 二次贝塞尔曲线公式 B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2, t=0.5
|
||||
|
||||
// Quadratic Bezier formula B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2, evaluated at t=0.5.
|
||||
const midX = 0.25 * sx + 0.5 * cx + 0.25 * tx
|
||||
const midY = 0.25 * sy + 0.5 * cy + 0.25 * ty
|
||||
|
||||
|
|
@ -577,11 +567,11 @@ const renderGraph = () => {
|
|||
.style('cursor', 'pointer')
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation()
|
||||
// 重置之前选中边的样式
|
||||
// Reset the previously selected edge.
|
||||
linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)
|
||||
linkLabelBg.attr('fill', 'rgba(255,255,255,0.95)')
|
||||
linkLabels.attr('fill', '#666')
|
||||
// 高亮当前选中的边
|
||||
// Highlight the newly selected edge.
|
||||
d3.select(event.target).attr('stroke', '#3498db').attr('stroke-width', 3)
|
||||
|
||||
selectedItem.value = {
|
||||
|
|
@ -590,7 +580,7 @@ const renderGraph = () => {
|
|||
}
|
||||
})
|
||||
|
||||
// Link labels background (白色背景使文字更清晰)
|
||||
// Link labels background — solid-white plate so the label text reads clearly.
|
||||
const linkLabelBg = linkGroup.selectAll('rect')
|
||||
.data(edges)
|
||||
.enter().append('rect')
|
||||
|
|
@ -605,7 +595,7 @@ const renderGraph = () => {
|
|||
linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)
|
||||
linkLabelBg.attr('fill', 'rgba(255,255,255,0.95)')
|
||||
linkLabels.attr('fill', '#666')
|
||||
// 高亮对应的边
|
||||
// Highlight the matching edge.
|
||||
link.filter(l => l === d).attr('stroke', '#3498db').attr('stroke-width', 3)
|
||||
d3.select(event.target).attr('fill', 'rgba(52, 152, 219, 0.1)')
|
||||
|
||||
|
|
@ -633,7 +623,7 @@ const renderGraph = () => {
|
|||
linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)
|
||||
linkLabelBg.attr('fill', 'rgba(255,255,255,0.95)')
|
||||
linkLabels.attr('fill', '#666')
|
||||
// 高亮对应的边
|
||||
// Highlight the matching edge.
|
||||
link.filter(l => l === d).attr('stroke', '#3498db').attr('stroke-width', 3)
|
||||
d3.select(event.target).attr('fill', '#3498db')
|
||||
|
||||
|
|
@ -643,7 +633,7 @@ const renderGraph = () => {
|
|||
}
|
||||
})
|
||||
|
||||
// 保存引用供外部控制显隐
|
||||
// Keep references so the visibility watcher can toggle these later.
|
||||
linkLabelsRef = linkLabels
|
||||
linkLabelBgRef = linkLabelBg
|
||||
|
||||
|
|
@ -661,7 +651,7 @@ const renderGraph = () => {
|
|||
.style('cursor', 'pointer')
|
||||
.call(d3.drag()
|
||||
.on('start', (event, d) => {
|
||||
// 只记录位置,不重启仿真(区分点击和拖拽)
|
||||
// Pin position only — no simulation restart yet, so click vs drag stays distinguishable.
|
||||
d.fx = d.x
|
||||
d.fy = d.y
|
||||
d._dragStartX = event.x
|
||||
|
|
@ -669,24 +659,24 @@ const renderGraph = () => {
|
|||
d._isDragging = false
|
||||
})
|
||||
.on('drag', (event, d) => {
|
||||
// 检测是否真正开始拖拽(移动超过阈值)
|
||||
// Treat as a real drag only after the pointer moves beyond the threshold.
|
||||
const dx = event.x - d._dragStartX
|
||||
const dy = event.y - d._dragStartY
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
|
||||
if (!d._isDragging && distance > 3) {
|
||||
// 首次检测到真正拖拽,才重启仿真
|
||||
// First real drag — only now restart the simulation.
|
||||
d._isDragging = true
|
||||
simulation.alphaTarget(0.3).restart()
|
||||
}
|
||||
|
||||
|
||||
if (d._isDragging) {
|
||||
d.fx = event.x
|
||||
d.fy = event.y
|
||||
}
|
||||
})
|
||||
.on('end', (event, d) => {
|
||||
// 只有真正拖拽过才让仿真逐渐停止
|
||||
// Only let the simulation cool down if we actually dragged.
|
||||
if (d._isDragging) {
|
||||
simulation.alphaTarget(0)
|
||||
}
|
||||
|
|
@ -697,12 +687,12 @@ const renderGraph = () => {
|
|||
)
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation()
|
||||
// 重置所有节点样式
|
||||
// Reset every node and edge style.
|
||||
node.attr('stroke', '#fff').attr('stroke-width', 2.5)
|
||||
linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)
|
||||
// 高亮选中节点
|
||||
// Highlight the selected node.
|
||||
d3.select(event.target).attr('stroke', '#E91E63').attr('stroke-width', 4)
|
||||
// 高亮与此节点相连的边
|
||||
// Highlight the edges incident to this node.
|
||||
link.filter(l => l.source.id === d.id || l.target.id === d.id)
|
||||
.attr('stroke', '#E91E63')
|
||||
.attr('stroke-width', 2.5)
|
||||
|
|
@ -739,19 +729,17 @@ const renderGraph = () => {
|
|||
.style('font-family', 'system-ui, sans-serif')
|
||||
|
||||
simulation.on('tick', () => {
|
||||
// 更新曲线路径
|
||||
link.attr('d', d => getLinkPath(d))
|
||||
|
||||
// 更新边标签位置(无旋转,水平显示更清晰)
|
||||
|
||||
// Edge label position — keep horizontal (no rotation) so labels stay legible.
|
||||
linkLabels.each(function(d) {
|
||||
const mid = getLinkMidpoint(d)
|
||||
d3.select(this)
|
||||
.attr('x', mid.x)
|
||||
.attr('y', mid.y)
|
||||
.attr('transform', '') // 移除旋转,保持水平
|
||||
.attr('transform', '')
|
||||
})
|
||||
|
||||
// 更新边标签背景
|
||||
|
||||
linkLabelBg.each(function(d, i) {
|
||||
const mid = getLinkMidpoint(d)
|
||||
const textEl = linkLabels.nodes()[i]
|
||||
|
|
@ -761,7 +749,7 @@ const renderGraph = () => {
|
|||
.attr('y', mid.y - bbox.height / 2 - 2)
|
||||
.attr('width', bbox.width + 8)
|
||||
.attr('height', bbox.height + 4)
|
||||
.attr('transform', '') // 移除旋转
|
||||
.attr('transform', '')
|
||||
})
|
||||
|
||||
node
|
||||
|
|
@ -773,7 +761,7 @@ const renderGraph = () => {
|
|||
.attr('y', d => d.y)
|
||||
})
|
||||
|
||||
// 点击空白处关闭详情面板
|
||||
// Click on empty space closes the detail panel.
|
||||
svg.on('click', () => {
|
||||
selectedItem.value = null
|
||||
node.attr('stroke', '#fff').attr('stroke-width', 2.5)
|
||||
|
|
@ -787,7 +775,7 @@ watch(() => props.graphData, () => {
|
|||
nextTick(renderGraph)
|
||||
}, { deep: true })
|
||||
|
||||
// 监听边标签显示开关
|
||||
// Mirror the edge-labels toggle into the live D3 selections.
|
||||
watch(showEdgeLabels, (newVal) => {
|
||||
if (linkLabelsRef) {
|
||||
linkLabelsRef.style('display', newVal ? 'block' : 'none')
|
||||
|
|
@ -1250,7 +1238,7 @@ input:checked + .slider:before {
|
|||
50% { opacity: 1; transform: scale(1.15); filter: drop-shadow(0 0 8px rgba(76, 175, 80, 0.6)); }
|
||||
}
|
||||
|
||||
/* 模拟结束后的提示样式 */
|
||||
/* Post-simulation hint styles */
|
||||
.graph-building-hint.finished-hint {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
|
|
|||
|
|
@ -4,20 +4,20 @@
|
|||
:class="{ 'no-projects': projects.length === 0 && !loading }"
|
||||
ref="historyContainer"
|
||||
>
|
||||
<!-- 背景装饰:技术网格线(只在有项目时显示) -->
|
||||
<!-- Background decoration: tech grid (only shown when projects exist) -->
|
||||
<div v-if="projects.length > 0 || loading" class="tech-grid-bg">
|
||||
<div class="grid-pattern"></div>
|
||||
<div class="gradient-overlay"></div>
|
||||
</div>
|
||||
|
||||
<!-- 标题区域 -->
|
||||
<!-- Section header -->
|
||||
<div class="section-header">
|
||||
<div class="section-line"></div>
|
||||
<span class="section-title">{{ $t('history.title') }}</span>
|
||||
<div class="section-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片容器(只在有项目时显示) -->
|
||||
<!-- Card container (only shown when projects exist) -->
|
||||
<div v-if="projects.length > 0" class="cards-container" :class="{ expanded: isExpanded }" :style="containerStyle">
|
||||
<div
|
||||
v-for="(project, index) in projects"
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
@mouseleave="hoveringCard = null"
|
||||
@click="navigateToProject(project)"
|
||||
>
|
||||
<!-- 卡片头部:simulation_id 和 功能可用状态 -->
|
||||
<!-- Card header: simulation_id and feature-availability status -->
|
||||
<div class="card-header">
|
||||
<span class="card-id">{{ formatSimulationId(project.simulation_id) }}</span>
|
||||
<div class="card-status-icons">
|
||||
|
|
@ -50,12 +50,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表区域 -->
|
||||
<!-- File list -->
|
||||
<div class="card-files-wrapper">
|
||||
<!-- 角落装饰 - 取景框风格 -->
|
||||
<!-- Corner decoration — viewfinder style -->
|
||||
<div class="corner-mark top-left-only"></div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
|
||||
<!-- File list -->
|
||||
<div class="files-list" v-if="project.files && project.files.length > 0">
|
||||
<div
|
||||
v-for="(file, fileIndex) in project.files.slice(0, 3)"
|
||||
|
|
@ -65,25 +65,25 @@
|
|||
<span class="file-tag" :class="getFileType(file.filename)">{{ getFileTypeLabel(file.filename) }}</span>
|
||||
<span class="file-name">{{ truncateFilename(file.filename, 20) }}</span>
|
||||
</div>
|
||||
<!-- 如果有更多文件,显示提示 -->
|
||||
<!-- "+N more" hint when there are extra files -->
|
||||
<div v-if="project.files.length > 3" class="files-more">
|
||||
{{ $t('history.moreFiles', { count: project.files.length - 3 }) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 无文件时的占位 -->
|
||||
<!-- Placeholder shown when there are no files -->
|
||||
<div class="files-empty" v-else>
|
||||
<span class="empty-file-icon">◇</span>
|
||||
<span class="empty-file-text">{{ $t('history.noFiles') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片标题(使用模拟需求的前20字作为标题) -->
|
||||
<!-- Card title — first ~20 characters of the simulation requirement -->
|
||||
<h3 class="card-title">{{ getSimulationTitle(project.simulation_requirement) }}</h3>
|
||||
|
||||
<!-- 卡片描述(模拟需求完整展示) -->
|
||||
<!-- Card description — full simulation requirement, truncated -->
|
||||
<p class="card-desc">{{ truncateText(project.simulation_requirement, 55) }}</p>
|
||||
|
||||
<!-- 卡片底部 -->
|
||||
<!-- Card footer -->
|
||||
<div class="card-footer">
|
||||
<div class="card-datetime">
|
||||
<span class="card-date">{{ formatDate(project.created_at) }}</span>
|
||||
|
|
@ -94,23 +94,23 @@
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 底部装饰线 (hover时展开) -->
|
||||
<!-- Bottom decorative line (extends on hover) -->
|
||||
<div class="card-bottom-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<span class="loading-spinner"></span>
|
||||
<span class="loading-text">{{ $t('history.loadingText') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 历史回放详情弹窗 -->
|
||||
<!-- Replay-detail modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="selectedProject" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal-content">
|
||||
<!-- 弹窗头部 -->
|
||||
<!-- Modal header -->
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-section">
|
||||
<span class="modal-id">{{ formatSimulationId(selectedProject.simulation_id) }}</span>
|
||||
|
|
@ -122,15 +122,15 @@
|
|||
<button class="modal-close" @click="closeModal">×</button>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗内容 -->
|
||||
<!-- Modal body -->
|
||||
<div class="modal-body">
|
||||
<!-- 模拟需求 -->
|
||||
<!-- Simulation requirement -->
|
||||
<div class="modal-section">
|
||||
<div class="modal-label">{{ $t('history.simRequirement') }}</div>
|
||||
<div class="modal-requirement">{{ selectedProject.simulation_requirement || $t('common.none') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<!-- File list -->
|
||||
<div class="modal-section">
|
||||
<div class="modal-label">{{ $t('history.relatedFiles') }}</div>
|
||||
<div class="modal-files" v-if="selectedProject.files && selectedProject.files.length > 0">
|
||||
|
|
@ -143,14 +143,14 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推演回放分割线 -->
|
||||
<!-- Replay-section divider -->
|
||||
<div class="modal-divider">
|
||||
<span class="divider-line"></span>
|
||||
<span class="divider-text">{{ $t('history.replayTitle') }}</span>
|
||||
<span class="divider-line"></span>
|
||||
</div>
|
||||
|
||||
<!-- 导航按钮 -->
|
||||
<!-- Navigation buttons -->
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
class="modal-btn btn-project"
|
||||
|
|
@ -179,7 +179,7 @@
|
|||
<span class="btn-text">{{ $t('history.step4Button') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 不可回放提示 -->
|
||||
<!-- Hint shown when replay is unavailable -->
|
||||
<div class="modal-playback-hint">
|
||||
<span class="hint-text">{{ $t('history.replayHint') }}</span>
|
||||
</div>
|
||||
|
|
@ -200,66 +200,63 @@ const router = useRouter()
|
|||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 状态
|
||||
// State
|
||||
const projects = ref([])
|
||||
const loading = ref(true)
|
||||
const isExpanded = ref(false)
|
||||
const hoveringCard = ref(null)
|
||||
const historyContainer = ref(null)
|
||||
const selectedProject = ref(null) // 当前选中的项目(用于弹窗)
|
||||
const selectedProject = ref(null) // Currently selected project, used by the modal.
|
||||
let observer = null
|
||||
let isAnimating = false // 动画锁,防止闪烁
|
||||
let expandDebounceTimer = null // 防抖定时器
|
||||
let pendingState = null // 记录待执行的目标状态
|
||||
let isAnimating = false // Animation lock — prevents flicker between expand/collapse.
|
||||
let expandDebounceTimer = null
|
||||
let pendingState = null // Latest desired expand/collapse state, applied after the lock clears.
|
||||
|
||||
// 卡片布局配置 - 调整为更宽的比例
|
||||
// Card layout — wide proportions.
|
||||
const CARDS_PER_ROW = 4
|
||||
const CARD_WIDTH = 280
|
||||
const CARD_HEIGHT = 280
|
||||
const CARD_WIDTH = 280
|
||||
const CARD_HEIGHT = 280
|
||||
const CARD_GAP = 24
|
||||
|
||||
// 动态计算容器高度样式
|
||||
// Container height — fixed when collapsed, computed when expanded.
|
||||
const containerStyle = computed(() => {
|
||||
if (!isExpanded.value) {
|
||||
// 折叠态:固定高度
|
||||
return { minHeight: '420px' }
|
||||
}
|
||||
|
||||
// 展开态:根据卡片数量动态计算高度
|
||||
|
||||
const total = projects.value.length
|
||||
if (total === 0) {
|
||||
return { minHeight: '280px' }
|
||||
}
|
||||
|
||||
|
||||
const rows = Math.ceil(total / CARDS_PER_ROW)
|
||||
// 计算实际需要的高度:行数 * 卡片高度 + (行数-1) * 间距 + 少量底部间距
|
||||
// rows * CARD_HEIGHT + gaps between rows + a small bottom buffer.
|
||||
const expandedHeight = rows * CARD_HEIGHT + (rows - 1) * CARD_GAP + 10
|
||||
|
||||
|
||||
return { minHeight: `${expandedHeight}px` }
|
||||
})
|
||||
|
||||
// 获取卡片样式
|
||||
// Per-card transform style — grid when expanded, fanned stack when collapsed.
|
||||
const getCardStyle = (index) => {
|
||||
const total = projects.value.length
|
||||
|
||||
|
||||
if (isExpanded.value) {
|
||||
// 展开态:网格布局
|
||||
const transition = 'transform 700ms cubic-bezier(0.23, 1, 0.32, 1), opacity 700ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 0.3s ease, border-color 0.3s ease'
|
||||
|
||||
const col = index % CARDS_PER_ROW
|
||||
const row = Math.floor(index / CARDS_PER_ROW)
|
||||
|
||||
// 计算当前行的卡片数量,确保每行居中
|
||||
|
||||
// Center each row by counting how many cards it actually contains.
|
||||
const currentRowStart = row * CARDS_PER_ROW
|
||||
const currentRowCards = Math.min(CARDS_PER_ROW, total - currentRowStart)
|
||||
|
||||
|
||||
const rowWidth = currentRowCards * CARD_WIDTH + (currentRowCards - 1) * CARD_GAP
|
||||
|
||||
|
||||
const startX = -(rowWidth / 2) + (CARD_WIDTH / 2)
|
||||
const colInRow = index % CARDS_PER_ROW
|
||||
const x = startX + colInRow * (CARD_WIDTH + CARD_GAP)
|
||||
|
||||
// 向下展开,增加与标题的间距
|
||||
|
||||
// Expand downward, leaving room beneath the section title.
|
||||
const y = 20 + row * (CARD_HEIGHT + CARD_GAP)
|
||||
|
||||
return {
|
||||
|
|
@ -269,14 +266,14 @@ const getCardStyle = (index) => {
|
|||
transition: transition
|
||||
}
|
||||
} else {
|
||||
// 折叠态:扇形堆叠
|
||||
// Collapsed: fan-stack layout.
|
||||
const transition = 'transform 700ms cubic-bezier(0.23, 1, 0.32, 1), opacity 700ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 0.3s ease, border-color 0.3s ease'
|
||||
|
||||
const centerIndex = (total - 1) / 2
|
||||
const offset = index - centerIndex
|
||||
|
||||
|
||||
const x = offset * 35
|
||||
// 调整起始位置,靠近标题但保持适当间距
|
||||
// Sit close to the title with a slight depth offset.
|
||||
const y = 25 + Math.abs(offset) * 8
|
||||
const r = offset * 3
|
||||
const s = 0.95 - Math.abs(offset) * 0.05
|
||||
|
|
@ -290,24 +287,20 @@ const getCardStyle = (index) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 根据轮数进度获取样式类
|
||||
// Map round-progress numbers to a CSS state class.
|
||||
const getProgressClass = (simulation) => {
|
||||
const current = simulation.current_round || 0
|
||||
const total = simulation.total_rounds || 0
|
||||
|
||||
|
||||
if (total === 0 || current === 0) {
|
||||
// 未开始
|
||||
return 'not-started'
|
||||
} else if (current >= total) {
|
||||
// 已完成
|
||||
return 'completed'
|
||||
} else {
|
||||
// 进行中
|
||||
return 'in-progress'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期(只显示日期部分)
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
|
|
@ -318,7 +311,6 @@ const formatDate = (dateStr) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 格式化时间(显示时:分)
|
||||
const formatTime = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
|
|
@ -331,27 +323,25 @@ const formatTime = (dateStr) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 截断文本
|
||||
const truncateText = (text, maxLength) => {
|
||||
if (!text) return ''
|
||||
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text
|
||||
}
|
||||
|
||||
// 从模拟需求生成标题(取前20字)
|
||||
// Derive a title from the first ~20 characters of the simulation requirement.
|
||||
const getSimulationTitle = (requirement) => {
|
||||
if (!requirement) return t('history.untitledSimulation')
|
||||
const title = requirement.slice(0, 20)
|
||||
return requirement.length > 20 ? title + '...' : title
|
||||
}
|
||||
|
||||
// 格式化 simulation_id 显示(截取前6位)
|
||||
// Render a 6-character SIM_ display label from a simulation_id.
|
||||
const formatSimulationId = (simulationId) => {
|
||||
if (!simulationId) return 'SIM_UNKNOWN'
|
||||
const prefix = simulationId.replace('sim_', '').slice(0, 6)
|
||||
return `SIM_${prefix.toUpperCase()}`
|
||||
}
|
||||
|
||||
// 格式化轮数显示(当前轮/总轮数)
|
||||
const formatRounds = (simulation) => {
|
||||
const current = simulation.current_round || 0
|
||||
const total = simulation.total_rounds || 0
|
||||
|
|
@ -359,7 +349,6 @@ const formatRounds = (simulation) => {
|
|||
return t('history.roundsProgress', { current, total })
|
||||
}
|
||||
|
||||
// 获取文件类型(用于样式)
|
||||
const getFileType = (filename) => {
|
||||
if (!filename) return 'other'
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
|
|
@ -375,14 +364,13 @@ const getFileType = (filename) => {
|
|||
return typeMap[ext] || 'other'
|
||||
}
|
||||
|
||||
// 获取文件类型标签文本
|
||||
const getFileTypeLabel = (filename) => {
|
||||
if (!filename) return 'FILE'
|
||||
const ext = filename.split('.').pop()?.toUpperCase()
|
||||
return ext || 'FILE'
|
||||
}
|
||||
|
||||
// 截断文件名(保留扩展名)
|
||||
// Truncate a filename while preserving the extension.
|
||||
const truncateFilename = (filename, maxLength) => {
|
||||
if (!filename) return t('history.unknownFile')
|
||||
if (filename.length <= maxLength) return filename
|
||||
|
|
@ -393,17 +381,15 @@ const truncateFilename = (filename, maxLength) => {
|
|||
return truncatedName + ext
|
||||
}
|
||||
|
||||
// 打开项目详情弹窗
|
||||
const navigateToProject = (simulation) => {
|
||||
selectedProject.value = simulation
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const closeModal = () => {
|
||||
selectedProject.value = null
|
||||
}
|
||||
|
||||
// 导航到图谱构建页面(Project)
|
||||
// Navigate to the graph-build page (Process route).
|
||||
const goToProject = () => {
|
||||
if (selectedProject.value?.project_id) {
|
||||
router.push({
|
||||
|
|
@ -414,7 +400,7 @@ const goToProject = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 导航到环境配置页面(Simulation)
|
||||
// Navigate to the env-setup page (Simulation route).
|
||||
const goToSimulation = () => {
|
||||
if (selectedProject.value?.simulation_id) {
|
||||
router.push({
|
||||
|
|
@ -425,7 +411,7 @@ const goToSimulation = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 导航到分析报告页面(Report)
|
||||
// Navigate to the analysis-report page (Report route).
|
||||
const goToReport = () => {
|
||||
if (selectedProject.value?.report_id) {
|
||||
router.push({
|
||||
|
|
@ -436,7 +422,6 @@ const goToReport = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 加载历史项目
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
|
@ -445,14 +430,13 @@ const loadHistory = async () => {
|
|||
projects.value = response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载历史项目失败:', error)
|
||||
console.error('Failed to load history projects:', error)
|
||||
projects.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 IntersectionObserver
|
||||
const initObserver = () => {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
|
|
@ -463,47 +447,43 @@ const initObserver = () => {
|
|||
entries.forEach((entry) => {
|
||||
const shouldExpand = entry.isIntersecting
|
||||
|
||||
// 更新待执行的目标状态(无论是否在动画中都要记录最新的目标状态)
|
||||
// Always record the latest desired state, even mid-animation.
|
||||
pendingState = shouldExpand
|
||||
|
||||
// 清除之前的防抖定时器(新的滚动意图会覆盖旧的)
|
||||
|
||||
// A new scroll intent overrides any pending one.
|
||||
if (expandDebounceTimer) {
|
||||
clearTimeout(expandDebounceTimer)
|
||||
expandDebounceTimer = null
|
||||
}
|
||||
|
||||
// 如果正在动画中,只记录状态,等动画结束后处理
|
||||
|
||||
// If an animation is running, only record state — apply it once the lock clears.
|
||||
if (isAnimating) return
|
||||
|
||||
// 如果目标状态与当前状态相同,不需要处理
|
||||
|
||||
if (shouldExpand === isExpanded.value) {
|
||||
pendingState = null
|
||||
return
|
||||
}
|
||||
|
||||
// 使用防抖延迟状态切换,防止快速闪烁
|
||||
// 展开时延迟较短(50ms),收起时延迟较长(200ms)以增加稳定性
|
||||
|
||||
// Debounce the toggle to suppress rapid flicker.
|
||||
// Expand quickly (50ms); collapse slowly (200ms) to feel more stable.
|
||||
const delay = shouldExpand ? 50 : 200
|
||||
|
||||
|
||||
expandDebounceTimer = setTimeout(() => {
|
||||
// 检查是否正在动画
|
||||
if (isAnimating) return
|
||||
|
||||
// 检查待执行状态是否仍需要执行(可能已被后续滚动覆盖)
|
||||
|
||||
// The pending state may have been canceled by a subsequent scroll.
|
||||
if (pendingState === null || pendingState === isExpanded.value) return
|
||||
|
||||
// 设置动画锁
|
||||
|
||||
isAnimating = true
|
||||
isExpanded.value = pendingState
|
||||
pendingState = null
|
||||
|
||||
// 动画完成后解除锁定,并检查是否有待处理的状态变化
|
||||
|
||||
// After the animation, see if a new pending state arrived during it.
|
||||
setTimeout(() => {
|
||||
isAnimating = false
|
||||
|
||||
// 动画结束后,检查是否有新的待执行状态
|
||||
|
||||
if (pendingState !== null && pendingState !== isExpanded.value) {
|
||||
// 延迟一小段时间再执行,避免太快切换
|
||||
// Brief delay before re-toggling so we don't bounce instantly.
|
||||
expandDebounceTimer = setTimeout(() => {
|
||||
if (pendingState !== null && pendingState !== isExpanded.value) {
|
||||
isAnimating = true
|
||||
|
|
@ -520,20 +500,19 @@ const initObserver = () => {
|
|||
})
|
||||
},
|
||||
{
|
||||
// 使用多个阈值,使检测更平滑
|
||||
// Multiple thresholds make the detection smoother across slow scrolls.
|
||||
threshold: [0.4, 0.6, 0.8],
|
||||
// 调整 rootMargin,视口底部向上收缩,需要滚动更多才触发展开
|
||||
// Shrink the viewport bottom inwards so the user has to scroll further before expand triggers.
|
||||
rootMargin: '0px 0px -150px 0px'
|
||||
}
|
||||
)
|
||||
|
||||
// 开始观察
|
||||
|
||||
if (historyContainer.value) {
|
||||
observer.observe(historyContainer.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听路由变化,当返回首页时重新加载数据
|
||||
// Reload history whenever the user returns to the home route.
|
||||
watch(() => route.path, (newPath) => {
|
||||
if (newPath === '/') {
|
||||
loadHistory()
|
||||
|
|
@ -541,28 +520,25 @@ watch(() => route.path, (newPath) => {
|
|||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// 确保 DOM 渲染完成后再加载数据
|
||||
await nextTick()
|
||||
await loadHistory()
|
||||
|
||||
// 等待 DOM 渲染后初始化观察器
|
||||
|
||||
// Wait for the DOM to settle before attaching the IntersectionObserver.
|
||||
setTimeout(() => {
|
||||
initObserver()
|
||||
}, 100)
|
||||
})
|
||||
|
||||
// 如果使用 keep-alive,在组件激活时重新加载数据
|
||||
// keep-alive support: reload data when the component becomes active again.
|
||||
onActivated(() => {
|
||||
loadHistory()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理 Intersection Observer
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
observer = null
|
||||
}
|
||||
// 清理防抖定时器
|
||||
if (expandDebounceTimer) {
|
||||
clearTimeout(expandDebounceTimer)
|
||||
expandDebounceTimer = null
|
||||
|
|
@ -571,7 +547,7 @@ onUnmounted(() => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 容器 */
|
||||
/* Container */
|
||||
.history-database {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
|
@ -581,13 +557,13 @@ onUnmounted(() => {
|
|||
overflow: visible;
|
||||
}
|
||||
|
||||
/* 无项目时简化显示 */
|
||||
/* Compact display when there are no projects */
|
||||
.history-database.no-projects {
|
||||
min-height: auto;
|
||||
padding: 40px 0 20px;
|
||||
}
|
||||
|
||||
/* 技术网格背景 */
|
||||
/* Tech-grid background */
|
||||
.tech-grid-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -598,7 +574,7 @@ onUnmounted(() => {
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 使用CSS背景图案创建固定间距的正方形网格 */
|
||||
/* Square grid with fixed spacing, drawn via a CSS background pattern */
|
||||
.grid-pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -609,7 +585,7 @@ onUnmounted(() => {
|
|||
linear-gradient(to right, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
/* 从左上角开始定位,高度变化时只在底部扩展,不影响已有网格位置 */
|
||||
/* Anchor at top-left so any height change only extends downward without shifting existing rows. */
|
||||
background-position: top left;
|
||||
}
|
||||
|
||||
|
|
@ -625,7 +601,7 @@ onUnmounted(() => {
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 标题区域 */
|
||||
/* Section header */
|
||||
.section-header {
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
|
|
@ -653,7 +629,7 @@ onUnmounted(() => {
|
|||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* 卡片容器 */
|
||||
/* Card container */
|
||||
.cards-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
|
@ -661,10 +637,10 @@ onUnmounted(() => {
|
|||
align-items: flex-start;
|
||||
padding: 0 40px;
|
||||
transition: min-height 700ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
/* min-height 由 JS 动态计算,根据卡片数量自适应 */
|
||||
/* min-height is set in JS based on the number of cards. */
|
||||
}
|
||||
|
||||
/* 项目卡片 */
|
||||
/* Project card */
|
||||
.project-card {
|
||||
position: absolute;
|
||||
width: 280px;
|
||||
|
|
@ -687,7 +663,7 @@ onUnmounted(() => {
|
|||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
/* Card header */
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -705,7 +681,7 @@ onUnmounted(() => {
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 功能状态图标组 */
|
||||
/* Feature-status icon row */
|
||||
.card-status-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -722,17 +698,17 @@ onUnmounted(() => {
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 不同功能的颜色 */
|
||||
.status-icon:nth-child(1).available { color: #3B82F6; } /* 图谱构建 - 蓝色 */
|
||||
.status-icon:nth-child(2).available { color: #F59E0B; } /* 环境搭建 - 橙色 */
|
||||
.status-icon:nth-child(3).available { color: #10B981; } /* 分析报告 - 绿色 */
|
||||
/* Per-feature color coding */
|
||||
.status-icon:nth-child(1).available { color: #3B82F6; } /* Graph build — blue */
|
||||
.status-icon:nth-child(2).available { color: #F59E0B; } /* Env setup — orange */
|
||||
.status-icon:nth-child(3).available { color: #10B981; } /* Analysis report — green */
|
||||
|
||||
.status-icon.unavailable {
|
||||
color: #D1D5DB;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 轮数进度显示 */
|
||||
/* Round-progress display */
|
||||
.card-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -746,13 +722,13 @@ onUnmounted(() => {
|
|||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
/* 进度状态颜色 */
|
||||
.card-progress.completed { color: #10B981; } /* 已完成 - 绿色 */
|
||||
.card-progress.in-progress { color: #F59E0B; } /* 进行中 - 橙色 */
|
||||
.card-progress.not-started { color: #9CA3AF; } /* 未开始 - 灰色 */
|
||||
/* Progress-state colors */
|
||||
.card-progress.completed { color: #10B981; } /* Completed — green */
|
||||
.card-progress.in-progress { color: #F59E0B; } /* In progress — orange */
|
||||
.card-progress.not-started { color: #9CA3AF; } /* Not started — gray */
|
||||
.card-status.pending { color: #9CA3AF; }
|
||||
|
||||
/* 文件列表区域 */
|
||||
/* File-list region */
|
||||
.card-files-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
|
@ -772,7 +748,7 @@ onUnmounted(() => {
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 更多文件提示 */
|
||||
/* "+N more files" hint */
|
||||
.files-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -802,7 +778,7 @@ onUnmounted(() => {
|
|||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* 简约文件标签样式 */
|
||||
/* Minimal file-tag styles */
|
||||
.file-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -820,7 +796,7 @@ onUnmounted(() => {
|
|||
min-width: 28px;
|
||||
}
|
||||
|
||||
/* 低饱和度配色方案 - Morandi色系 */
|
||||
/* Low-saturation palette — Morandi-inspired */
|
||||
.file-tag.pdf { background: #f2e6e6; color: #a65a5a; }
|
||||
.file-tag.doc { background: #e6eff5; color: #5a7ea6; }
|
||||
.file-tag.xls { background: #e6f2e8; color: #5aa668; }
|
||||
|
|
@ -841,7 +817,7 @@ onUnmounted(() => {
|
|||
letter-spacing: 0.1px;
|
||||
}
|
||||
|
||||
/* 无文件时的占位 */
|
||||
/* No-files placeholder */
|
||||
.files-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -862,13 +838,13 @@ onUnmounted(() => {
|
|||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* 悬停时文件区域效果 */
|
||||
/* File-region hover effect */
|
||||
.project-card:hover .card-files-wrapper {
|
||||
border-color: #d1d5db;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||
}
|
||||
|
||||
/* 角落装饰 */
|
||||
/* Corner decoration */
|
||||
.corner-mark.top-left-only {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
|
|
@ -881,7 +857,7 @@ onUnmounted(() => {
|
|||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 卡片标题 */
|
||||
/* Card title */
|
||||
.card-title {
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
font-size: 0.9rem;
|
||||
|
|
@ -899,7 +875,7 @@ onUnmounted(() => {
|
|||
color: #2563EB;
|
||||
}
|
||||
|
||||
/* 卡片描述 */
|
||||
/* Card description */
|
||||
.card-desc {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.75rem;
|
||||
|
|
@ -913,7 +889,7 @@ onUnmounted(() => {
|
|||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* 卡片底部 */
|
||||
/* Card footer */
|
||||
.card-footer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
|
@ -927,14 +903,14 @@ onUnmounted(() => {
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 日期时间组合 */
|
||||
/* Date + time pair */
|
||||
.card-datetime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 底部轮数进度显示 */
|
||||
/* Footer round-progress display */
|
||||
.card-footer .card-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -948,12 +924,12 @@ onUnmounted(() => {
|
|||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
/* 进度状态颜色 - 底部 */
|
||||
/* Progress-state colors — footer variants */
|
||||
.card-footer .card-progress.completed { color: #10B981; }
|
||||
.card-footer .card-progress.in-progress { color: #F59E0B; }
|
||||
.card-footer .card-progress.not-started { color: #9CA3AF; }
|
||||
|
||||
/* 底部装饰线 */
|
||||
/* Bottom decorative line */
|
||||
.card-bottom-line {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
|
|
@ -969,7 +945,7 @@ onUnmounted(() => {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
/* Empty state */
|
||||
.empty-state, .loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -997,7 +973,7 @@ onUnmounted(() => {
|
|||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
/* Responsive layout */
|
||||
@media (max-width: 1200px) {
|
||||
.project-card {
|
||||
width: 240px;
|
||||
|
|
@ -1013,7 +989,7 @@ onUnmounted(() => {
|
|||
}
|
||||
}
|
||||
|
||||
/* ===== 历史回放详情弹窗样式 ===== */
|
||||
/* ===== Replay-detail modal styles ===== */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
|
@ -1039,7 +1015,7 @@ onUnmounted(() => {
|
|||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* 动画过渡 */
|
||||
/* Animation transitions */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
|
|
@ -1068,7 +1044,7 @@ onUnmounted(() => {
|
|||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 弹窗头部 */
|
||||
/* Modal header */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -1135,7 +1111,7 @@ onUnmounted(() => {
|
|||
color: #111827;
|
||||
}
|
||||
|
||||
/* 弹窗内容 */
|
||||
/* Modal body */
|
||||
.modal-body {
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
|
@ -1177,7 +1153,7 @@ onUnmounted(() => {
|
|||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
/* Custom scrollbar */
|
||||
.modal-files::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
|
@ -1231,7 +1207,7 @@ onUnmounted(() => {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
/* 推演回放分割线 */
|
||||
/* Replay-section divider */
|
||||
.modal-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1255,7 +1231,7 @@ onUnmounted(() => {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 导航按钮 */
|
||||
/* Navigation buttons */
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
|
@ -1322,7 +1298,7 @@ onUnmounted(() => {
|
|||
color: #111827;
|
||||
}
|
||||
|
||||
/* 不可回放提示 */
|
||||
/* No-replay-available hint */
|
||||
.modal-playback-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -210,15 +210,15 @@ const selectedOntologyItem = ref(null)
|
|||
const logContent = ref(null)
|
||||
const creatingSimulation = ref(false)
|
||||
|
||||
// 进入环境搭建 - 创建 simulation 并跳转
|
||||
// Enter environment setup: create the simulation, then route to its page.
|
||||
const handleEnterEnvSetup = async () => {
|
||||
if (!props.projectData?.project_id || !props.projectData?.graph_id) {
|
||||
console.error('缺少项目或图谱信息')
|
||||
console.error('Missing project or graph info')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
creatingSimulation.value = true
|
||||
|
||||
|
||||
try {
|
||||
const res = await createSimulation({
|
||||
project_id: props.projectData.project_id,
|
||||
|
|
@ -226,19 +226,18 @@ const handleEnterEnvSetup = async () => {
|
|||
enable_twitter: true,
|
||||
enable_reddit: true
|
||||
})
|
||||
|
||||
|
||||
if (res.success && res.data?.simulation_id) {
|
||||
// 跳转到 simulation 页面
|
||||
router.push({
|
||||
name: 'Simulation',
|
||||
params: { simulationId: res.data.simulation_id }
|
||||
})
|
||||
} else {
|
||||
console.error('创建模拟失败:', res.error)
|
||||
console.error('Failed to create simulation:', res.error)
|
||||
alert(t('step1.createSimulationFailed', { error: res.error || t('common.unknownError') }))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建模拟异常:', err)
|
||||
console.error('Exception while creating simulation:', err)
|
||||
alert(t('step1.createSimulationException', { error: err.message }))
|
||||
} finally {
|
||||
creatingSimulation.value = false
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="env-setup-panel">
|
||||
<div class="scroll-container">
|
||||
<!-- Step 01: 模拟实例 -->
|
||||
<!-- Step 01: Simulation instance -->
|
||||
<div class="step-card" :class="{ 'active': phase === 0, 'completed': phase > 0 }">
|
||||
<div class="card-header">
|
||||
<div class="step-info">
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 02: 生成 Agent 人设 -->
|
||||
<!-- Step 02: Generate agent personas -->
|
||||
<div class="step-card" :class="{ 'active': phase === 1, 'completed': phase > 1 }">
|
||||
<div class="card-header">
|
||||
<div class="step-info">
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 03: 生成双平台模拟配置 -->
|
||||
<!-- Step 03: Generate dual-platform simulation config -->
|
||||
<div class="step-card" :class="{ 'active': phase === 2, 'completed': phase > 2 }">
|
||||
<div class="card-header">
|
||||
<div class="step-info">
|
||||
|
|
@ -135,7 +135,7 @@
|
|||
|
||||
<!-- Config Preview -->
|
||||
<div v-if="simulationConfig" class="config-detail-panel">
|
||||
<!-- 时间配置 -->
|
||||
<!-- Time config -->
|
||||
<div class="config-block">
|
||||
<div class="config-grid">
|
||||
<div class="config-item">
|
||||
|
|
@ -179,7 +179,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent 配置 -->
|
||||
<!-- Agent config -->
|
||||
<div class="config-block">
|
||||
<div class="config-block-header">
|
||||
<span class="config-block-title">{{ $t('step2.agentConfig') }}</span>
|
||||
|
|
@ -191,7 +191,7 @@
|
|||
:key="agent.agent_id"
|
||||
class="agent-card"
|
||||
>
|
||||
<!-- 卡片头部 -->
|
||||
<!-- Card header -->
|
||||
<div class="agent-card-header">
|
||||
<div class="agent-identity">
|
||||
<span class="agent-id">Agent {{ agent.agent_id }}</span>
|
||||
|
|
@ -203,7 +203,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 活跃时间轴 -->
|
||||
<!-- Active-hours timeline -->
|
||||
<div class="agent-timeline">
|
||||
<span class="timeline-label">{{ $t('step2.activeTimePeriod') }}</span>
|
||||
<div class="mini-timeline">
|
||||
|
|
@ -224,7 +224,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 行为参数 -->
|
||||
<!-- Behavior params -->
|
||||
<div class="agent-params">
|
||||
<div class="param-group">
|
||||
<div class="param-item">
|
||||
|
|
@ -264,7 +264,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 平台配置 -->
|
||||
<!-- Platform config -->
|
||||
<div class="config-block">
|
||||
<div class="config-block-header">
|
||||
<span class="config-block-title">{{ $t('step2.recommendAlgoConfig') }}</span>
|
||||
|
|
@ -327,7 +327,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LLM 配置推理 -->
|
||||
<!-- LLM config reasoning -->
|
||||
<div v-if="simulationConfig.generation_reasoning" class="config-block">
|
||||
<div class="config-block-header">
|
||||
<span class="config-block-title">{{ $t('step2.llmConfigReasoning') }}</span>
|
||||
|
|
@ -346,7 +346,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 04: 初始激活编排 -->
|
||||
<!-- Step 04: Initial activation orchestration -->
|
||||
<div class="step-card" :class="{ 'active': phase === 3, 'completed': phase > 3 }">
|
||||
<div class="card-header">
|
||||
<div class="step-info">
|
||||
|
|
@ -367,7 +367,7 @@
|
|||
</p>
|
||||
|
||||
<div v-if="simulationConfig?.event_config" class="orchestration-content">
|
||||
<!-- 叙事方向 -->
|
||||
<!-- Narrative direction -->
|
||||
<div class="narrative-box">
|
||||
<span class="box-label narrative-label">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="special-icon">
|
||||
|
|
@ -385,7 +385,7 @@
|
|||
<p class="narrative-text">{{ simulationConfig.event_config.narrative_direction }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 热点话题 -->
|
||||
<!-- Hot topics -->
|
||||
<div class="topics-section">
|
||||
<span class="box-label">{{ $t('step2.initialHotTopics') }}</span>
|
||||
<div class="hot-topics-grid">
|
||||
|
|
@ -395,7 +395,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 初始帖子流 -->
|
||||
<!-- Initial post timeline -->
|
||||
<div class="initial-posts-section">
|
||||
<span class="box-label">{{ $t('step2.initialActivationSeq', { count: simulationConfig.event_config.initial_posts.length }) }}</span>
|
||||
<div class="posts-timeline">
|
||||
|
|
@ -418,7 +418,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 05: 准备完成 -->
|
||||
<!-- Step 05: Setup complete -->
|
||||
<div class="step-card" :class="{ 'active': phase === 4 }">
|
||||
<div class="card-header">
|
||||
<div class="step-info">
|
||||
|
|
@ -435,7 +435,7 @@
|
|||
<p class="api-note">POST /api/simulation/start</p>
|
||||
<p class="description">{{ $t('step2.setupCompleteDesc') }}</p>
|
||||
|
||||
<!-- 模拟轮数配置 - 只有在配置生成完成且轮数计算出来后才显示 -->
|
||||
<!-- Round-count config: only render once the config and round count are ready -->
|
||||
<div v-if="simulationConfig && autoGeneratedRounds" class="rounds-config-section">
|
||||
<div class="rounds-header">
|
||||
<div class="header-left">
|
||||
|
|
@ -544,7 +544,7 @@
|
|||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- 基本信息 -->
|
||||
<!-- Basic info -->
|
||||
<div class="modal-info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">{{ $t('step2.profileModalAge') }}</span>
|
||||
|
|
@ -564,13 +564,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 简介 -->
|
||||
<!-- Bio -->
|
||||
<div class="modal-section">
|
||||
<span class="section-label">{{ $t('step2.profileModalBio') }}</span>
|
||||
<p class="section-bio">{{ selectedProfile.bio || $t('step2.noBio') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 关注话题 -->
|
||||
<!-- Followed topics -->
|
||||
<div class="modal-section" v-if="selectedProfile.interested_topics?.length">
|
||||
<span class="section-label">{{ $t('step2.profileModalTopics') }}</span>
|
||||
<div class="topics-grid">
|
||||
|
|
@ -582,11 +582,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细人设 -->
|
||||
<!-- Detailed persona -->
|
||||
<div class="modal-section" v-if="selectedProfile.persona">
|
||||
<span class="section-label">{{ $t('step2.profileModalPersona') }}</span>
|
||||
|
||||
<!-- 人设维度概览 -->
|
||||
|
||||
<!-- Persona dimensions overview -->
|
||||
<div class="persona-dimensions">
|
||||
<div class="dimension-card">
|
||||
<span class="dim-title">{{ $t('step2.personaDimExperience') }}</span>
|
||||
|
|
@ -645,7 +645,7 @@ import {
|
|||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
simulationId: String, // 从父组件传入
|
||||
simulationId: String, // Provided by the parent.
|
||||
projectData: Object,
|
||||
graphData: Object,
|
||||
systemLogs: Array
|
||||
|
|
@ -654,7 +654,7 @@ const props = defineProps({
|
|||
const emit = defineEmits(['go-back', 'next-step', 'add-log', 'update-status'])
|
||||
|
||||
// State
|
||||
const phase = ref(0) // 0: 初始化, 1: 生成人设, 2: 生成配置, 3: 完成
|
||||
const phase = ref(0) // 0: init, 1: generating personas, 2: generating config, 3: done
|
||||
const taskId = ref(null)
|
||||
const prepareProgress = ref(0)
|
||||
const currentStage = ref('')
|
||||
|
|
@ -666,14 +666,14 @@ const simulationConfig = ref(null)
|
|||
const selectedProfile = ref(null)
|
||||
const showProfilesDetail = ref(true)
|
||||
|
||||
// 日志去重:记录上一次输出的关键信息
|
||||
// Log deduplication — remember the last emitted key so we don't repeat lines.
|
||||
let lastLoggedMessage = ''
|
||||
let lastLoggedProfileCount = 0
|
||||
let lastLoggedConfigStage = ''
|
||||
|
||||
// 模拟轮数配置
|
||||
const useCustomRounds = ref(false) // 默认使用自动配置轮数
|
||||
const customMaxRounds = ref(40) // 默认推荐40轮
|
||||
// Round-count configuration
|
||||
const useCustomRounds = ref(false) // Default: use the auto-derived round count.
|
||||
const customMaxRounds = ref(40) // Default recommendation: 40 rounds.
|
||||
|
||||
// Watch stage to update phase
|
||||
watch(currentStage, (newStage) => {
|
||||
|
|
@ -681,28 +681,28 @@ watch(currentStage, (newStage) => {
|
|||
phase.value = 1
|
||||
} else if (newStage === '生成模拟配置' || newStage === 'generating_config') {
|
||||
phase.value = 2
|
||||
// 进入配置生成阶段,开始轮询配置
|
||||
// Entering the config-generation stage — start polling the config endpoint.
|
||||
if (!configTimer) {
|
||||
addLog(t('log.startGeneratingConfig'))
|
||||
startConfigPolling()
|
||||
}
|
||||
} else if (newStage === '准备模拟脚本' || newStage === 'copying_scripts') {
|
||||
phase.value = 2 // 仍属于配置阶段
|
||||
phase.value = 2 // Still part of the config stage.
|
||||
}
|
||||
})
|
||||
|
||||
// 从配置中计算自动生成的轮数(不使用硬编码默认值)
|
||||
// Compute the auto-derived round count from the simulation config (no hardcoded fallback).
|
||||
const autoGeneratedRounds = computed(() => {
|
||||
if (!simulationConfig.value?.time_config) {
|
||||
return null // 配置未生成时返回 null
|
||||
return null // Config not generated yet.
|
||||
}
|
||||
const totalHours = simulationConfig.value.time_config.total_simulation_hours
|
||||
const minutesPerRound = simulationConfig.value.time_config.minutes_per_round
|
||||
if (!totalHours || !minutesPerRound) {
|
||||
return null // 配置数据不完整时返回 null
|
||||
return null // Config data is incomplete.
|
||||
}
|
||||
const calculatedRounds = Math.floor((totalHours * 60) / minutesPerRound)
|
||||
// 确保最大轮数不小于40(推荐值),避免滑动条范围异常
|
||||
// Floor at 40 (the recommended baseline) so the slider range stays sane.
|
||||
return Math.max(calculatedRounds, 40)
|
||||
})
|
||||
|
||||
|
|
@ -719,7 +719,7 @@ const displayProfiles = computed(() => {
|
|||
return profiles.value.slice(0, 6)
|
||||
})
|
||||
|
||||
// 根据agent_id获取对应的username
|
||||
// Look up the username for an agent_id from the profiles list.
|
||||
const getAgentUsername = (agentId) => {
|
||||
if (profiles.value && profiles.value.length > agentId && agentId >= 0) {
|
||||
const profile = profiles.value[agentId]
|
||||
|
|
@ -728,7 +728,7 @@ const getAgentUsername = (agentId) => {
|
|||
return `agent_${agentId}`
|
||||
}
|
||||
|
||||
// 计算所有人设的关联话题总数
|
||||
// Total followed-topics count across all profiles.
|
||||
const totalTopicsCount = computed(() => {
|
||||
return profiles.value.reduce((sum, p) => {
|
||||
return sum + (p.interested_topics?.length || 0)
|
||||
|
|
@ -740,20 +740,18 @@ const addLog = (msg) => {
|
|||
emit('add-log', msg)
|
||||
}
|
||||
|
||||
// 处理开始模拟按钮点击
|
||||
const handleStartSimulation = () => {
|
||||
// 构建传递给父组件的参数
|
||||
const params = {}
|
||||
|
||||
|
||||
if (useCustomRounds.value) {
|
||||
// 用户自定义轮数,传递 max_rounds 参数
|
||||
// User chose a custom round count — pass max_rounds to the parent.
|
||||
params.maxRounds = customMaxRounds.value
|
||||
addLog(t('log.startSimCustomRounds', { rounds: customMaxRounds.value }))
|
||||
} else {
|
||||
// 用户选择保持自动生成的轮数,不传递 max_rounds 参数
|
||||
// Keep the auto-derived round count — do not pass max_rounds.
|
||||
addLog(t('log.startSimAutoRounds', { rounds: autoGeneratedRounds.value }))
|
||||
}
|
||||
|
||||
|
||||
emit('next-step', params)
|
||||
}
|
||||
|
||||
|
|
@ -768,15 +766,14 @@ const selectProfile = (profile) => {
|
|||
selectedProfile.value = profile
|
||||
}
|
||||
|
||||
// 自动开始准备模拟
|
||||
const startPrepareSimulation = async () => {
|
||||
if (!props.simulationId) {
|
||||
addLog(t('log.errorMissingSimId'))
|
||||
emit('update-status', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// 标记第一步完成,开始第二步
|
||||
|
||||
// Mark Step 1 done and move on to Step 2.
|
||||
phase.value = 1
|
||||
addLog(t('log.simInstanceCreated', { id: props.simulationId }))
|
||||
addLog(t('log.preparingSimEnv'))
|
||||
|
|
@ -800,7 +797,7 @@ const startPrepareSimulation = async () => {
|
|||
addLog(t('log.prepareTaskStarted'))
|
||||
addLog(t('log.prepareTaskId', { taskId: res.data.task_id }))
|
||||
|
||||
// 立即设置预期Agent总数(从prepare接口返回值获取)
|
||||
// Pull the expected agent total straight from the prepare response.
|
||||
if (res.data.expected_entities_count) {
|
||||
expectedTotal.value = res.data.expected_entities_count
|
||||
addLog(t('log.zepEntitiesFound', { count: res.data.expected_entities_count }))
|
||||
|
|
@ -810,9 +807,7 @@ const startPrepareSimulation = async () => {
|
|||
}
|
||||
|
||||
addLog(t('log.startPollingProgress'))
|
||||
// 开始轮询进度
|
||||
startPolling()
|
||||
// 开始实时获取 Profiles
|
||||
startProfilesPolling()
|
||||
} else {
|
||||
addLog(t('log.prepareFailed', { error: res.error || t('common.unknownError') }))
|
||||
|
|
@ -857,16 +852,14 @@ const pollPrepareStatus = async () => {
|
|||
|
||||
if (res.success && res.data) {
|
||||
const data = res.data
|
||||
|
||||
// 更新进度
|
||||
|
||||
prepareProgress.value = data.progress || 0
|
||||
progressMessage.value = data.message || ''
|
||||
|
||||
// 解析阶段信息并输出详细日志
|
||||
|
||||
// Parse the progress detail and emit one log line per change.
|
||||
if (data.progress_detail) {
|
||||
currentStage.value = data.progress_detail.current_stage_name || ''
|
||||
|
||||
// 输出详细进度日志(避免重复)
|
||||
|
||||
const detail = data.progress_detail
|
||||
const logKey = `${detail.current_stage}-${detail.current_item}-${detail.total_items}`
|
||||
if (logKey !== lastLoggedMessage && detail.item_description) {
|
||||
|
|
@ -879,19 +872,17 @@ const pollPrepareStatus = async () => {
|
|||
}
|
||||
}
|
||||
} else if (data.message) {
|
||||
// 从消息中提取阶段
|
||||
// Extract the stage label from the freeform message.
|
||||
const match = data.message.match(/\[(\d+)\/(\d+)\]\s*([^:]+)/)
|
||||
if (match) {
|
||||
currentStage.value = match[3].trim()
|
||||
}
|
||||
// 输出消息日志(避免重复)
|
||||
if (data.message !== lastLoggedMessage) {
|
||||
lastLoggedMessage = data.message
|
||||
addLog(data.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否完成
|
||||
|
||||
if (data.status === 'completed' || data.status === 'ready' || data.already_prepared) {
|
||||
addLog(t('log.prepareComplete'))
|
||||
stopPolling()
|
||||
|
|
@ -904,7 +895,7 @@ const pollPrepareStatus = async () => {
|
|||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('轮询状态失败:', err)
|
||||
console.warn('Failed to poll prepare status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -917,19 +908,19 @@ const fetchProfilesRealtime = async () => {
|
|||
if (res.success && res.data) {
|
||||
const prevCount = profiles.value.length
|
||||
profiles.value = res.data.profiles || []
|
||||
// 只有当 API 返回有效值时才更新,避免覆盖已有的有效值
|
||||
// Only overwrite the expected total when the API returns a non-zero value,
|
||||
// so we don't clobber an already-known good value with a transient empty.
|
||||
if (res.data.total_expected) {
|
||||
expectedTotal.value = res.data.total_expected
|
||||
}
|
||||
|
||||
// 提取实体类型
|
||||
|
||||
const types = new Set()
|
||||
profiles.value.forEach(p => {
|
||||
if (p.entity_type) types.add(p.entity_type)
|
||||
})
|
||||
entityTypes.value = Array.from(types)
|
||||
|
||||
// 输出 Profile 生成进度日志(仅当数量变化时)
|
||||
|
||||
// Log profile-generation progress only when the count changes.
|
||||
const currentCount = profiles.value.length
|
||||
if (currentCount > 0 && currentCount !== lastLoggedProfileCount) {
|
||||
lastLoggedProfileCount = currentCount
|
||||
|
|
@ -941,18 +932,17 @@ const fetchProfilesRealtime = async () => {
|
|||
}
|
||||
addLog(t('log.agentProfile', { current: currentCount, total: total, name: profileName, profession: latestProfile?.profession || t('step2.unknownProfession') }))
|
||||
|
||||
// 如果全部生成完成
|
||||
if (expectedTotal.value && currentCount >= expectedTotal.value) {
|
||||
addLog(t('log.allProfilesComplete', { count: currentCount }))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('获取 Profiles 失败:', err)
|
||||
console.warn('Failed to fetch profiles:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 配置轮询
|
||||
// Config polling
|
||||
const startConfigPolling = () => {
|
||||
configTimer = setInterval(fetchConfigRealtime, 2000)
|
||||
}
|
||||
|
|
@ -972,8 +962,8 @@ const fetchConfigRealtime = async () => {
|
|||
|
||||
if (res.success && res.data) {
|
||||
const data = res.data
|
||||
|
||||
// 输出配置生成阶段日志(避免重复)
|
||||
|
||||
// Emit one log line per change of generation_stage.
|
||||
if (data.generation_stage && data.generation_stage !== lastLoggedConfigStage) {
|
||||
lastLoggedConfigStage = data.generation_stage
|
||||
if (data.generation_stage === 'generating_profiles') {
|
||||
|
|
@ -982,13 +972,11 @@ const fetchConfigRealtime = async () => {
|
|||
addLog(t('log.generatingLLMConfig'))
|
||||
}
|
||||
}
|
||||
|
||||
// 如果配置已生成
|
||||
|
||||
if (data.config_generated && data.config) {
|
||||
simulationConfig.value = data.config
|
||||
addLog(t('log.configComplete'))
|
||||
|
||||
// 显示详细配置摘要
|
||||
if (data.summary) {
|
||||
addLog(t('log.configSummaryAgents', { count: data.summary.total_agents }))
|
||||
addLog(t('log.configSummaryHours', { hours: data.summary.simulation_hours }))
|
||||
|
|
@ -997,13 +985,11 @@ const fetchConfigRealtime = async () => {
|
|||
addLog(t('log.configSummaryPlatforms', { twitter: data.summary.has_twitter_config ? '✓' : '✗', reddit: data.summary.has_reddit_config ? '✓' : '✗' }))
|
||||
}
|
||||
|
||||
// 显示时间配置详情
|
||||
if (data.config.time_config) {
|
||||
const tc = data.config.time_config
|
||||
addLog(t('log.timeConfigDetail', { minutes: tc.minutes_per_round, rounds: Math.floor((tc.total_simulation_hours * 60) / tc.minutes_per_round) }))
|
||||
}
|
||||
|
||||
// 显示事件配置
|
||||
|
||||
if (data.config.event_config?.narrative_direction) {
|
||||
const narrative = data.config.event_config.narrative_direction
|
||||
addLog(t('log.narrativeDirection', { direction: narrative.length > 50 ? narrative.substring(0, 50) + '...' : narrative }))
|
||||
|
|
@ -1016,7 +1002,7 @@ const fetchConfigRealtime = async () => {
|
|||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('获取 Config 失败:', err)
|
||||
console.warn('Failed to fetch config:', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1024,11 +1010,11 @@ const loadPreparedData = async () => {
|
|||
phase.value = 2
|
||||
addLog(t('log.loadingExistingConfig'))
|
||||
|
||||
// 最后获取一次 Profiles
|
||||
// Pull profiles one final time.
|
||||
await fetchProfilesRealtime()
|
||||
addLog(t('log.loadedAgentProfiles', { count: profiles.value.length }))
|
||||
|
||||
// 获取配置(使用实时接口)
|
||||
// Fetch the config via the realtime endpoint.
|
||||
try {
|
||||
const res = await getSimulationConfigRealtime(props.simulationId)
|
||||
if (res.success && res.data) {
|
||||
|
|
@ -1036,7 +1022,6 @@ const loadPreparedData = async () => {
|
|||
simulationConfig.value = res.data.config
|
||||
addLog(t('log.configLoadSuccess'))
|
||||
|
||||
// 显示详细配置摘要
|
||||
if (res.data.summary) {
|
||||
addLog(t('log.configSummaryAgents', { count: res.data.summary.total_agents }))
|
||||
addLog(t('log.configSummaryHours', { hours: res.data.summary.simulation_hours }))
|
||||
|
|
@ -1047,7 +1032,7 @@ const loadPreparedData = async () => {
|
|||
phase.value = 4
|
||||
emit('update-status', 'completed')
|
||||
} else {
|
||||
// 配置尚未生成,开始轮询
|
||||
// Config not generated yet — kick off polling.
|
||||
addLog(t('log.configGenerating'))
|
||||
startConfigPolling()
|
||||
}
|
||||
|
|
@ -1069,7 +1054,6 @@ watch(() => props.systemLogs?.length, () => {
|
|||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 自动开始准备流程
|
||||
if (props.simulationId) {
|
||||
addLog(t('log.step2Init'))
|
||||
startPrepareSimulation()
|
||||
|
|
@ -1905,7 +1889,7 @@ onUnmounted(() => {
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
/* 基本信息网格 */
|
||||
/* Basic-info grid */
|
||||
.modal-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
|
@ -1941,7 +1925,7 @@ onUnmounted(() => {
|
|||
color: #FF5722;
|
||||
}
|
||||
|
||||
/* 模块区域 */
|
||||
/* Module section */
|
||||
.modal-section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
|
@ -1967,7 +1951,7 @@ onUnmounted(() => {
|
|||
border-left: 3px solid #E0E0E0;
|
||||
}
|
||||
|
||||
/* 话题标签 */
|
||||
/* Topic tags */
|
||||
.topics-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -1989,7 +1973,7 @@ onUnmounted(() => {
|
|||
color: #0D47A1;
|
||||
}
|
||||
|
||||
/* 详细人设 */
|
||||
/* Detailed persona */
|
||||
.persona-dimensions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
|
@ -2275,7 +2259,7 @@ onUnmounted(() => {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
/* 模拟轮数配置样式 */
|
||||
/* Round-count config styles */
|
||||
.rounds-config-section {
|
||||
margin: 24px 0;
|
||||
padding-top: 24px;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<!-- Top Control Bar -->
|
||||
<div class="control-bar">
|
||||
<div class="status-group">
|
||||
<!-- Twitter 平台进度 -->
|
||||
<!-- Twitter platform progress -->
|
||||
<div class="platform-status twitter" :class="{ active: runStatus.twitter_running, completed: runStatus.twitter_completed }">
|
||||
<div class="platform-header">
|
||||
<svg class="platform-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
<span class="stat-value mono">{{ runStatus.twitter_actions_count || 0 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 可用动作提示 -->
|
||||
<!-- Available actions tooltip -->
|
||||
<div class="actions-tooltip">
|
||||
<div class="tooltip-title">Available Actions</div>
|
||||
<div class="tooltip-actions">
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reddit 平台进度 -->
|
||||
<!-- Reddit platform progress -->
|
||||
<div class="platform-status reddit" :class="{ active: runStatus.reddit_running, completed: runStatus.reddit_completed }">
|
||||
<div class="platform-header">
|
||||
<svg class="platform-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
|
@ -71,7 +71,7 @@
|
|||
<span class="stat-value mono">{{ runStatus.reddit_actions_count || 0 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 可用动作提示 -->
|
||||
<!-- Available actions tooltip -->
|
||||
<div class="actions-tooltip">
|
||||
<div class="tooltip-title">Available Actions</div>
|
||||
<div class="tooltip-actions">
|
||||
|
|
@ -157,12 +157,12 @@
|
|||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- CREATE_POST: 发布帖子 -->
|
||||
<!-- CREATE_POST: publish a post -->
|
||||
<div v-if="action.action_type === 'CREATE_POST' && action.action_args?.content" class="content-text main-text">
|
||||
{{ action.action_args.content }}
|
||||
</div>
|
||||
|
||||
<!-- QUOTE_POST: 引用帖子 -->
|
||||
<!-- QUOTE_POST: quote another post -->
|
||||
<template v-if="action.action_type === 'QUOTE_POST'">
|
||||
<div v-if="action.action_args?.quote_content" class="content-text">
|
||||
{{ action.action_args.quote_content }}
|
||||
|
|
@ -178,7 +178,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- REPOST: 转发帖子 -->
|
||||
<!-- REPOST: repost -->
|
||||
<template v-if="action.action_type === 'REPOST'">
|
||||
<div class="repost-info">
|
||||
<svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg>
|
||||
|
|
@ -189,7 +189,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- LIKE_POST: 点赞帖子 -->
|
||||
<!-- LIKE_POST: like a post -->
|
||||
<template v-if="action.action_type === 'LIKE_POST'">
|
||||
<div class="like-info">
|
||||
<svg class="icon-small filled" viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
|
||||
|
|
@ -200,7 +200,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- CREATE_COMMENT: 发表评论 -->
|
||||
<!-- CREATE_COMMENT: post a comment -->
|
||||
<template v-if="action.action_type === 'CREATE_COMMENT'">
|
||||
<div v-if="action.action_args?.content" class="content-text">
|
||||
{{ action.action_args.content }}
|
||||
|
|
@ -211,7 +211,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- SEARCH_POSTS: 搜索帖子 -->
|
||||
<!-- SEARCH_POSTS: search posts -->
|
||||
<template v-if="action.action_type === 'SEARCH_POSTS'">
|
||||
<div class="search-info">
|
||||
<svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||
|
|
@ -220,7 +220,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- FOLLOW: 关注用户 -->
|
||||
<!-- FOLLOW: follow user -->
|
||||
<template v-if="action.action_type === 'FOLLOW'">
|
||||
<div class="follow-info">
|
||||
<svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg>
|
||||
|
|
@ -240,7 +240,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- DO_NOTHING: 无操作(静默) -->
|
||||
<!-- DO_NOTHING: idle (silent) -->
|
||||
<template v-if="action.action_type === 'DO_NOTHING'">
|
||||
<div class="idle-info">
|
||||
<svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||
|
|
@ -248,7 +248,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 通用回退:未知类型或有 content 但未被上述处理 -->
|
||||
<!-- Generic fallback: unknown action types, or any action with content not matched above -->
|
||||
<div v-if="!['CREATE_POST', 'QUOTE_POST', 'REPOST', 'LIKE_POST', 'CREATE_COMMENT', 'SEARCH_POSTS', 'FOLLOW', 'UPVOTE_POST', 'DOWNVOTE_POST', 'DO_NOTHING'].includes(action.action_type) && action.action_args?.content" class="content-text">
|
||||
{{ action.action_args.content }}
|
||||
</div>
|
||||
|
|
@ -301,10 +301,10 @@ const { t } = useI18n()
|
|||
|
||||
const props = defineProps({
|
||||
simulationId: String,
|
||||
maxRounds: Number, // 从Step2传入的最大轮数
|
||||
maxRounds: Number, // Max-rounds value passed in from Step 2.
|
||||
minutesPerRound: {
|
||||
type: Number,
|
||||
default: 30 // 默认每轮30分钟
|
||||
default: 30 // Default: 30 minutes per round.
|
||||
},
|
||||
projectData: Object,
|
||||
graphData: Object,
|
||||
|
|
@ -317,22 +317,21 @@ const router = useRouter()
|
|||
|
||||
// State
|
||||
const isGeneratingReport = ref(false)
|
||||
const phase = ref(0) // 0: 未开始, 1: 运行中, 2: 已完成
|
||||
const phase = ref(0) // 0: not started, 1: running, 2: completed
|
||||
const isStarting = ref(false)
|
||||
const isStopping = ref(false)
|
||||
const startError = ref(null)
|
||||
const runStatus = ref({})
|
||||
const allActions = ref([]) // 所有动作(增量累积)
|
||||
const actionIds = ref(new Set()) // 用于去重的动作ID集合
|
||||
const allActions = ref([]) // All actions (accumulated incrementally).
|
||||
const actionIds = ref(new Set()) // Set of action IDs used to deduplicate.
|
||||
const scrollContainer = ref(null)
|
||||
|
||||
// Computed
|
||||
// 按时间顺序显示动作(最新的在最后面,即底部)
|
||||
// Show actions in chronological order (newest at the bottom).
|
||||
const chronologicalActions = computed(() => {
|
||||
return allActions.value
|
||||
})
|
||||
|
||||
// 各平台动作计数
|
||||
const twitterActionsCount = computed(() => {
|
||||
return allActions.value.filter(a => a.platform === 'twitter').length
|
||||
})
|
||||
|
|
@ -341,7 +340,7 @@ const redditActionsCount = computed(() => {
|
|||
return allActions.value.filter(a => a.platform === 'reddit').length
|
||||
})
|
||||
|
||||
// 格式化模拟流逝时间(根据轮次和每轮分钟数计算)
|
||||
// Render simulated elapsed time as `Xh Ym` based on round count and minutes-per-round.
|
||||
const formatElapsedTime = (currentRound) => {
|
||||
if (!currentRound || currentRound <= 0) return '0h 0m'
|
||||
const totalMinutes = currentRound * props.minutesPerRound
|
||||
|
|
@ -350,12 +349,10 @@ const formatElapsedTime = (currentRound) => {
|
|||
return `${hours}h ${minutes}m`
|
||||
}
|
||||
|
||||
// Twitter平台的模拟流逝时间
|
||||
const twitterElapsedTime = computed(() => {
|
||||
return formatElapsedTime(runStatus.value.twitter_current_round || 0)
|
||||
})
|
||||
|
||||
// Reddit平台的模拟流逝时间
|
||||
const redditElapsedTime = computed(() => {
|
||||
return formatElapsedTime(runStatus.value.reddit_current_round || 0)
|
||||
})
|
||||
|
|
@ -365,7 +362,7 @@ const addLog = (msg) => {
|
|||
emit('add-log', msg)
|
||||
}
|
||||
|
||||
// 重置所有状态(用于重新启动模拟)
|
||||
// Reset all state — used when restarting a simulation.
|
||||
const resetAllState = () => {
|
||||
phase.value = 0
|
||||
runStatus.value = {}
|
||||
|
|
@ -376,30 +373,29 @@ const resetAllState = () => {
|
|||
startError.value = null
|
||||
isStarting.value = false
|
||||
isStopping.value = false
|
||||
stopPolling() // 停止之前可能存在的轮询
|
||||
stopPolling() // Cancel any timers left over from a previous run.
|
||||
}
|
||||
|
||||
// 启动模拟
|
||||
const doStartSimulation = async () => {
|
||||
if (!props.simulationId) {
|
||||
addLog(t('log.errorMissingSimId'))
|
||||
return
|
||||
}
|
||||
|
||||
// 先重置所有状态,确保不会受到上一次模拟的影响
|
||||
// Reset first so leftover state from a previous run cannot leak in.
|
||||
resetAllState()
|
||||
|
||||
|
||||
isStarting.value = true
|
||||
startError.value = null
|
||||
addLog(t('log.startingDualSim'))
|
||||
emit('update-status', 'processing')
|
||||
|
||||
|
||||
try {
|
||||
const params = {
|
||||
simulation_id: props.simulationId,
|
||||
platform: 'parallel',
|
||||
force: true, // 强制重新开始
|
||||
enable_graph_memory_update: true // 开启动态图谱更新
|
||||
force: true, // Force a fresh start.
|
||||
enable_graph_memory_update: true // Enable dynamic graph-memory updates.
|
||||
}
|
||||
|
||||
if (props.maxRounds) {
|
||||
|
|
@ -437,7 +433,6 @@ const doStartSimulation = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 停止模拟
|
||||
const handleStopSimulation = async () => {
|
||||
if (!props.simulationId) return
|
||||
|
||||
|
|
@ -462,7 +457,7 @@ const handleStopSimulation = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 轮询状态
|
||||
// Polling timers
|
||||
let statusTimer = null
|
||||
let detailTimer = null
|
||||
|
||||
|
|
@ -485,7 +480,7 @@ const stopPolling = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 追踪各平台的上一次轮次,用于检测变化并输出日志
|
||||
// Track each platform's last seen round so a log line is only emitted on change.
|
||||
const prevTwitterRound = ref(0)
|
||||
const prevRedditRound = ref(0)
|
||||
|
||||
|
|
@ -499,8 +494,8 @@ const fetchRunStatus = async () => {
|
|||
const data = res.data
|
||||
|
||||
runStatus.value = data
|
||||
|
||||
// 分别检测各平台的轮次变化并输出日志
|
||||
|
||||
// Per-platform round-change detection — log only when the round advances.
|
||||
if (data.twitter_current_round > prevTwitterRound.value) {
|
||||
addLog(`[Plaza] R${data.twitter_current_round}/${data.total_rounds} | T:${data.twitter_simulated_hours || 0}h | A:${data.twitter_actions_count}`)
|
||||
prevTwitterRound.value = data.twitter_current_round
|
||||
|
|
@ -511,11 +506,11 @@ const fetchRunStatus = async () => {
|
|||
prevRedditRound.value = data.reddit_current_round
|
||||
}
|
||||
|
||||
// 检测模拟是否已完成(通过 runner_status 或平台完成状态判断)
|
||||
// Decide if the simulation has finished — by runner_status or platform-completion flags.
|
||||
const isCompleted = data.runner_status === 'completed' || data.runner_status === 'stopped'
|
||||
|
||||
// 额外检查:如果后端还没来得及更新 runner_status,但平台已经报告完成
|
||||
// 通过检测 twitter_completed 和 reddit_completed 状态判断
|
||||
|
||||
// Fallback: if the backend has not yet updated runner_status but every platform
|
||||
// already reports completed, treat the run as done.
|
||||
const platformsCompleted = checkPlatformsCompleted(data)
|
||||
|
||||
if (isCompleted || platformsCompleted) {
|
||||
|
|
@ -529,31 +524,27 @@ const fetchRunStatus = async () => {
|
|||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('获取运行状态失败:', err)
|
||||
console.warn('Failed to fetch run status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查所有启用的平台是否已完成
|
||||
// Decide whether every enabled platform is finished.
|
||||
const checkPlatformsCompleted = (data) => {
|
||||
// 如果没有任何平台数据,返回 false
|
||||
if (!data) return false
|
||||
|
||||
// 检查各平台的完成状态
|
||||
|
||||
const twitterCompleted = data.twitter_completed === true
|
||||
const redditCompleted = data.reddit_completed === true
|
||||
|
||||
// 如果至少有一个平台完成了,检查是否所有启用的平台都完成了
|
||||
// 通过 actions_count 判断平台是否被启用(如果 count > 0 或 running 曾为 true)
|
||||
|
||||
// A platform counts as "enabled" if its action count is positive, it has been
|
||||
// running, or it has reported completion — actions_count is the truthful signal.
|
||||
const twitterEnabled = (data.twitter_actions_count > 0) || data.twitter_running || twitterCompleted
|
||||
const redditEnabled = (data.reddit_actions_count > 0) || data.reddit_running || redditCompleted
|
||||
|
||||
// 如果没有任何平台被启用,返回 false
|
||||
|
||||
if (!twitterEnabled && !redditEnabled) return false
|
||||
|
||||
// 检查所有启用的平台是否都已完成
|
||||
|
||||
if (twitterEnabled && !twitterCompleted) return false
|
||||
if (redditEnabled && !redditCompleted) return false
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -564,15 +555,13 @@ const fetchRunStatusDetail = async () => {
|
|||
const res = await getRunStatusDetail(props.simulationId)
|
||||
|
||||
if (res.success && res.data) {
|
||||
// 使用 all_actions 获取完整的动作列表
|
||||
// Use all_actions for the complete action list (incremental on the client side).
|
||||
const serverActions = res.data.all_actions || []
|
||||
|
||||
// 增量添加新动作(去重)
|
||||
|
||||
let newActionsAdded = 0
|
||||
serverActions.forEach(action => {
|
||||
// 生成唯一ID
|
||||
const actionId = action.id || `${action.timestamp}-${action.platform}-${action.agent_id}-${action.action_type}`
|
||||
|
||||
|
||||
if (!actionIds.value.has(actionId)) {
|
||||
actionIds.value.add(actionId)
|
||||
allActions.value.push({
|
||||
|
|
@ -582,12 +571,12 @@ const fetchRunStatusDetail = async () => {
|
|||
newActionsAdded++
|
||||
}
|
||||
})
|
||||
|
||||
// 不自动滚动,让用户自由查看时间轴
|
||||
// 新动作会在底部追加
|
||||
|
||||
// Do not auto-scroll — let the user pan the timeline freely. New actions
|
||||
// append at the bottom.
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('获取详细状态失败:', err)
|
||||
console.warn('Failed to fetch run-status detail:', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -664,8 +653,7 @@ const handleNextStep = async () => {
|
|||
if (res.success && res.data) {
|
||||
const reportId = res.data.report_id
|
||||
addLog(t('log.reportGenTaskStarted', { reportId }))
|
||||
|
||||
// 跳转到报告页面
|
||||
|
||||
router.push({ name: 'Report', params: { reportId } })
|
||||
} else {
|
||||
addLog(t('log.reportGenFailed', { error: res.error || t('common.unknownError') }))
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Step Button - 在完成后显示 -->
|
||||
<!-- Next Step Button — visible only after completion -->
|
||||
<button v-if="isComplete" class="next-step-btn" @click="goToInteraction">
|
||||
<span>{{ $t('step4.goToInteraction') }}</span>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
|
@ -194,7 +194,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Section Content Generated (内容生成完成,但整个章节可能还没完成) -->
|
||||
<!-- Section content generated (content done; the section as a whole may still be in progress) -->
|
||||
<template v-if="log.action === 'section_content'">
|
||||
<div class="section-tag content-ready">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
|
@ -205,7 +205,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Section Complete (章节生成完成) -->
|
||||
<!-- Section complete -->
|
||||
<template v-if="log.action === 'section_complete'">
|
||||
<div class="section-tag completed">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
|
@ -315,7 +315,7 @@
|
|||
Final: {{ log.details?.has_final_answer ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 当是最终答案时,显示特殊提示 -->
|
||||
<!-- Show a special hint when this iteration is the final answer -->
|
||||
<div v-if="log.details?.has_final_answer" class="final-answer-hint">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
|
|
@ -433,22 +433,20 @@ const showRawResult = reactive({})
|
|||
|
||||
// Toggle functions
|
||||
const toggleRawResult = (timestamp, event) => {
|
||||
// 保存按钮相对于视口的位置
|
||||
// Capture the button's viewport position before the toggle so we can preserve it.
|
||||
const button = event?.target
|
||||
const buttonRect = button?.getBoundingClientRect()
|
||||
const buttonTopBeforeToggle = buttonRect?.top
|
||||
|
||||
// 切换状态
|
||||
|
||||
showRawResult[timestamp] = !showRawResult[timestamp]
|
||||
|
||||
// 等待 DOM 更新后,调整滚动位置以保持按钮在相同位置
|
||||
|
||||
// After the DOM updates, scroll by the delta so the button stays anchored on screen.
|
||||
if (button && buttonTopBeforeToggle !== undefined && rightPanel.value) {
|
||||
nextTick(() => {
|
||||
const newButtonRect = button.getBoundingClientRect()
|
||||
const buttonTopAfterToggle = newButtonRect.top
|
||||
const scrollDelta = buttonTopAfterToggle - buttonTopBeforeToggle
|
||||
|
||||
// 调整滚动位置
|
||||
|
||||
rightPanel.value.scrollTop += scrollDelta
|
||||
})
|
||||
}
|
||||
|
|
@ -466,7 +464,7 @@ const toggleSectionContent = (idx) => {
|
|||
}
|
||||
|
||||
const toggleSectionCollapse = (idx) => {
|
||||
// 只有已完成的章节才能折叠
|
||||
// Only completed sections can be collapsed.
|
||||
if (!generatedSections.value[idx + 1]) return
|
||||
const newSet = new Set(collapsedSections.value)
|
||||
if (newSet.has(idx)) {
|
||||
|
|
@ -499,32 +497,32 @@ const toolConfig = {
|
|||
'insight_forge': {
|
||||
name: 'Deep Insight',
|
||||
color: 'purple',
|
||||
icon: 'lightbulb' // 灯泡图标 - 代表洞察
|
||||
icon: 'lightbulb' // Lightbulb — represents insight
|
||||
},
|
||||
'panorama_search': {
|
||||
name: 'Panorama Search',
|
||||
color: 'blue',
|
||||
icon: 'globe' // 地球图标 - 代表全景搜索
|
||||
icon: 'globe' // Globe — represents panorama search
|
||||
},
|
||||
'interview_agents': {
|
||||
name: 'Agent Interview',
|
||||
color: 'green',
|
||||
icon: 'users' // 用户图标 - 代表对话
|
||||
icon: 'users' // Users — represents agent interview
|
||||
},
|
||||
'quick_search': {
|
||||
name: 'Quick Search',
|
||||
color: 'orange',
|
||||
icon: 'zap' // 闪电图标 - 代表快速
|
||||
icon: 'zap' // Lightning bolt — represents quick search
|
||||
},
|
||||
'get_graph_statistics': {
|
||||
name: 'Graph Stats',
|
||||
color: 'cyan',
|
||||
icon: 'chart' // 图表图标 - 代表统计
|
||||
icon: 'chart' // Chart — represents statistics
|
||||
},
|
||||
'get_entities_by_type': {
|
||||
name: 'Entity Query',
|
||||
color: 'pink',
|
||||
icon: 'database' // 数据库图标 - 代表实体
|
||||
icon: 'database' // Database — represents entity query
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -553,30 +551,30 @@ const parseInsightForge = (text) => {
|
|||
}
|
||||
|
||||
try {
|
||||
// 提取分析问题
|
||||
// Extract the analysis question.
|
||||
const queryMatch = text.match(/分析问题:\s*(.+?)(?:\n|$)/)
|
||||
if (queryMatch) result.query = queryMatch[1].trim()
|
||||
|
||||
// 提取预测场景
|
||||
|
||||
// Extract the prediction scenario.
|
||||
const reqMatch = text.match(/预测场景:\s*(.+?)(?:\n|$)/)
|
||||
if (reqMatch) result.simulationRequirement = reqMatch[1].trim()
|
||||
|
||||
// 提取统计数据 - 匹配"相关预测事实: X条"格式
|
||||
|
||||
// Extract counters from the "相关预测事实: X条" format.
|
||||
const factMatch = text.match(/相关预测事实:\s*(\d+)/)
|
||||
const entityMatch = text.match(/涉及实体:\s*(\d+)/)
|
||||
const relMatch = text.match(/关系链:\s*(\d+)/)
|
||||
if (factMatch) result.stats.facts = parseInt(factMatch[1])
|
||||
if (entityMatch) result.stats.entities = parseInt(entityMatch[1])
|
||||
if (relMatch) result.stats.relationships = parseInt(relMatch[1])
|
||||
|
||||
// 提取子问题 - 完整提取,不限制数量
|
||||
|
||||
// Extract sub-questions in full (no cap).
|
||||
const subQSection = text.match(/### 分析的子问题\n([\s\S]*?)(?=\n###|$)/)
|
||||
if (subQSection) {
|
||||
const lines = subQSection[1].split('\n').filter(l => l.match(/^\d+\./))
|
||||
result.subQueries = lines.map(l => l.replace(/^\d+\.\s*/, '').trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// 提取关键事实 - 完整提取,不限制数量
|
||||
|
||||
// Extract key facts in full (no cap).
|
||||
const factsSection = text.match(/### 【关键事实】[\s\S]*?\n([\s\S]*?)(?=\n###|$)/)
|
||||
if (factsSection) {
|
||||
const lines = factsSection[1].split('\n').filter(l => l.match(/^\d+\./))
|
||||
|
|
@ -585,12 +583,12 @@ const parseInsightForge = (text) => {
|
|||
return match ? match[1].replace(/^"|"$/g, '').trim() : l.replace(/^\d+\.\s*/, '').trim()
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
// 提取核心实体 - 完整提取,包含摘要和相关事实数
|
||||
|
||||
// Extract core entities — includes summary and related-fact count.
|
||||
const entitySection = text.match(/### 【核心实体】\n([\s\S]*?)(?=\n###|$)/)
|
||||
if (entitySection) {
|
||||
const entityText = entitySection[1]
|
||||
// 按 "- **" 分割实体块
|
||||
// Split entity blocks on the "- **" markdown bullet.
|
||||
const entityBlocks = entityText.split(/\n(?=- \*\*)/).filter(b => b.trim().startsWith('- **'))
|
||||
result.entities = entityBlocks.map(block => {
|
||||
const nameMatch = block.match(/^-\s*\*\*(.+?)\*\*\s*\((.+?)\)/)
|
||||
|
|
@ -605,7 +603,7 @@ const parseInsightForge = (text) => {
|
|||
}).filter(e => e.name)
|
||||
}
|
||||
|
||||
// 提取关系链 - 完整提取,不限制数量
|
||||
// Extract relationship chains in full (no cap).
|
||||
const relSection = text.match(/### 【关系链】\n([\s\S]*?)(?=\n###|$)/)
|
||||
if (relSection) {
|
||||
const lines = relSection[1].split('\n').filter(l => l.trim().startsWith('-'))
|
||||
|
|
@ -634,11 +632,11 @@ const parsePanorama = (text) => {
|
|||
}
|
||||
|
||||
try {
|
||||
// 提取查询
|
||||
// Extract the query.
|
||||
const queryMatch = text.match(/查询:\s*(.+?)(?:\n|$)/)
|
||||
if (queryMatch) result.query = queryMatch[1].trim()
|
||||
|
||||
// 提取统计数据
|
||||
|
||||
// Extract counter stats.
|
||||
const nodesMatch = text.match(/总节点数:\s*(\d+)/)
|
||||
const edgesMatch = text.match(/总边数:\s*(\d+)/)
|
||||
const activeMatch = text.match(/当前有效事实:\s*(\d+)/)
|
||||
|
|
@ -648,18 +646,18 @@ const parsePanorama = (text) => {
|
|||
if (activeMatch) result.stats.activeFacts = parseInt(activeMatch[1])
|
||||
if (histMatch) result.stats.historicalFacts = parseInt(histMatch[1])
|
||||
|
||||
// 提取当前有效事实 - 完整提取,不限制数量
|
||||
// Extract currently valid facts in full (no cap).
|
||||
const activeSection = text.match(/### 【当前有效事实】[\s\S]*?\n([\s\S]*?)(?=\n###|$)/)
|
||||
if (activeSection) {
|
||||
const lines = activeSection[1].split('\n').filter(l => l.match(/^\d+\./))
|
||||
result.activeFacts = lines.map(l => {
|
||||
// 移除编号和引号
|
||||
// Strip the leading numbering and surrounding quotes.
|
||||
const factText = l.replace(/^\d+\.\s*/, '').replace(/^"|"$/g, '').trim()
|
||||
return factText
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
// 提取历史/过期事实 - 完整提取,不限制数量
|
||||
|
||||
// Extract historical/expired facts in full (no cap).
|
||||
const histSection = text.match(/### 【历史\/过期事实】[\s\S]*?\n([\s\S]*?)(?=\n###|$)/)
|
||||
if (histSection) {
|
||||
const lines = histSection[1].split('\n').filter(l => l.match(/^\d+\./))
|
||||
|
|
@ -669,7 +667,7 @@ const parsePanorama = (text) => {
|
|||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
// 提取涉及实体 - 完整提取,不限制数量
|
||||
// Extract referenced entities in full (no cap).
|
||||
const entitySection = text.match(/### 【涉及实体】\n([\s\S]*?)(?=\n###|$)/)
|
||||
if (entitySection) {
|
||||
const lines = entitySection[1].split('\n').filter(l => l.trim().startsWith('-'))
|
||||
|
|
@ -698,48 +696,46 @@ const parseInterview = (text) => {
|
|||
}
|
||||
|
||||
try {
|
||||
// 提取采访主题
|
||||
// Extract the interview topic.
|
||||
const topicMatch = text.match(/\*\*采访主题:\*\*\s*(.+?)(?:\n|$)/)
|
||||
if (topicMatch) result.topic = topicMatch[1].trim()
|
||||
|
||||
// 提取采访人数(如 "5 / 9 位模拟Agent")
|
||||
|
||||
// Extract the interview-count line, e.g. "5 / 9 位模拟Agent".
|
||||
const countMatch = text.match(/\*\*采访人数:\*\*\s*(\d+)\s*\/\s*(\d+)/)
|
||||
if (countMatch) {
|
||||
result.successCount = parseInt(countMatch[1])
|
||||
result.totalCount = parseInt(countMatch[2])
|
||||
result.agentCount = `${countMatch[1]} / ${countMatch[2]}`
|
||||
}
|
||||
|
||||
// 提取采访对象选择理由
|
||||
|
||||
// Extract the rationale for the interviewee selection.
|
||||
const reasonMatch = text.match(/### 采访对象选择理由\n([\s\S]*?)(?=\n---\n|\n### 采访实录)/)
|
||||
if (reasonMatch) {
|
||||
result.selectionReason = reasonMatch[1].trim()
|
||||
}
|
||||
|
||||
// 解析每个人的选择理由
|
||||
|
||||
// Parse each interviewee's individual rationale out of the rationale section.
|
||||
const parseIndividualReasons = (reasonText) => {
|
||||
const reasons = {}
|
||||
if (!reasonText) return reasons
|
||||
|
||||
|
||||
const lines = reasonText.split(/\n+/)
|
||||
let currentName = null
|
||||
let currentReason = []
|
||||
|
||||
|
||||
for (const line of lines) {
|
||||
let headerMatch = null
|
||||
let name = null
|
||||
let reasonStart = null
|
||||
|
||||
// 格式1: 数字. **名字(index=X)**:理由
|
||||
// 例如: 1. **校友_345(index=1)**:作为武大校友...
|
||||
|
||||
// Format 1: "<n>. **<name>(index=<i>)**:<reason>"
|
||||
headerMatch = line.match(/^\d+\.\s*\*\*([^*((]+)(?:[((]index\s*=?\s*\d+[))])?\*\*[::]\s*(.*)/)
|
||||
if (headerMatch) {
|
||||
name = headerMatch[1].trim()
|
||||
reasonStart = headerMatch[2]
|
||||
}
|
||||
|
||||
// 格式2: - 选择名字(index X):理由
|
||||
// 例如: - 选择家长_601(index 0):作为家长群体代表...
|
||||
|
||||
// Format 2: "- 选择<name>(index <i>):<reason>"
|
||||
if (!headerMatch) {
|
||||
headerMatch = line.match(/^-\s*选择([^((]+)(?:[((]index\s*=?\s*\d+[))])?[::]\s*(.*)/)
|
||||
if (headerMatch) {
|
||||
|
|
@ -747,9 +743,8 @@ const parseInterview = (text) => {
|
|||
reasonStart = headerMatch[2]
|
||||
}
|
||||
}
|
||||
|
||||
// 格式3: - **名字(index X)**:理由
|
||||
// 例如: - **家长_601(index 0)**:作为家长群体代表...
|
||||
|
||||
// Format 3: "- **<name>(index <i>)**:<reason>"
|
||||
if (!headerMatch) {
|
||||
headerMatch = line.match(/^-\s*\*\*([^*((]+)(?:[((]index\s*=?\s*\d+[))])?\*\*[::]\s*(.*)/)
|
||||
if (headerMatch) {
|
||||
|
|
@ -757,32 +752,30 @@ const parseInterview = (text) => {
|
|||
reasonStart = headerMatch[2]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (name) {
|
||||
// 保存上一个人的理由
|
||||
// Persist the previous person's accumulated reason before starting a new one.
|
||||
if (currentName && currentReason.length > 0) {
|
||||
reasons[currentName] = currentReason.join(' ').trim()
|
||||
}
|
||||
// 开始新的人
|
||||
currentName = name
|
||||
currentReason = reasonStart ? [reasonStart.trim()] : []
|
||||
} else if (currentName && line.trim() && !line.match(/^未选|^综上|^最终选择/)) {
|
||||
// 理由的续行(排除结尾总结段落)
|
||||
// Continuation line for the current rationale (skip closing-summary paragraphs).
|
||||
currentReason.push(line.trim())
|
||||
}
|
||||
}
|
||||
|
||||
// 保存最后一个人的理由
|
||||
|
||||
if (currentName && currentReason.length > 0) {
|
||||
reasons[currentName] = currentReason.join(' ').trim()
|
||||
}
|
||||
|
||||
|
||||
return reasons
|
||||
}
|
||||
|
||||
|
||||
const individualReasons = parseIndividualReasons(result.selectionReason)
|
||||
|
||||
// 提取每个采访记录
|
||||
|
||||
// Extract each interview record.
|
||||
const interviewBlocks = text.split(/#### 采访 #\d+:/).slice(1)
|
||||
|
||||
interviewBlocks.forEach((block, index) => {
|
||||
|
|
@ -799,33 +792,33 @@ const parseInterview = (text) => {
|
|||
quotes: []
|
||||
}
|
||||
|
||||
// 提取标题(如 "学生"、"教育从业者" 等)
|
||||
// Extract the title (e.g. "学生", "教育从业者").
|
||||
const titleMatch = block.match(/^(.+?)\n/)
|
||||
if (titleMatch) interview.title = titleMatch[1].trim()
|
||||
|
||||
// 提取姓名和角色
|
||||
// Extract name and role.
|
||||
const nameRoleMatch = block.match(/\*\*(.+?)\*\*\s*\((.+?)\)/)
|
||||
if (nameRoleMatch) {
|
||||
interview.name = nameRoleMatch[1].trim()
|
||||
interview.role = nameRoleMatch[2].trim()
|
||||
// 设置该人的选择理由
|
||||
// Look up this person's selection rationale.
|
||||
interview.selectionReason = individualReasons[interview.name] || ''
|
||||
}
|
||||
|
||||
// 提取简介
|
||||
// Extract the bio.
|
||||
const bioMatch = block.match(/_简介:\s*([\s\S]*?)_\n/)
|
||||
if (bioMatch) {
|
||||
interview.bio = bioMatch[1].trim().replace(/\.\.\.$/, '...')
|
||||
}
|
||||
|
||||
// 提取问题列表
|
||||
// Extract the question list.
|
||||
const qMatch = block.match(/\*\*Q:\*\*\s*([\s\S]*?)(?=\n\n\*\*A:\*\*|\*\*A:\*\*)/)
|
||||
if (qMatch) {
|
||||
const qText = qMatch[1].trim()
|
||||
// 按数字编号分割问题
|
||||
// Split by numeric prefixes "1.", "2.", etc.
|
||||
const questions = qText.split(/\n\d+\.\s+/).filter(q => q.trim())
|
||||
if (questions.length > 0) {
|
||||
// 如果第一个问题前面有"1.",需要特殊处理
|
||||
// The first question's "1." sits at the start of the string and needs special handling.
|
||||
const firstQ = qText.match(/^1\.\s+(.+)/)
|
||||
if (firstQ) {
|
||||
interview.questions = [firstQ[1].trim(), ...questions.slice(1).map(q => q.trim())]
|
||||
|
|
@ -835,12 +828,12 @@ const parseInterview = (text) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 提取回答 - 分Twitter和Reddit
|
||||
// Extract answers, split by Twitter and Reddit.
|
||||
const answerMatch = block.match(/\*\*A:\*\*\s*([\s\S]*?)(?=\*\*关键引言|$)/)
|
||||
if (answerMatch) {
|
||||
const answerText = answerMatch[1].trim()
|
||||
|
||||
// 分离Twitter和Reddit回答
|
||||
// Split into separate Twitter and Reddit answers.
|
||||
const twitterMatch = answerText.match(/【Twitter平台回答】\n?([\s\S]*?)(?=【Reddit平台回答】|$)/)
|
||||
const redditMatch = answerText.match(/【Reddit平台回答】\n?([\s\S]*?)$/)
|
||||
|
||||
|
|
@ -851,9 +844,9 @@ const parseInterview = (text) => {
|
|||
interview.redditAnswer = redditMatch[1].trim()
|
||||
}
|
||||
|
||||
// 平台回退逻辑(兼容旧格式:只有一个平台标记的情况)
|
||||
// Fallback for older formats with only a single platform tag.
|
||||
if (!twitterMatch && redditMatch) {
|
||||
// 只有 Reddit 回答,仅在非占位文本时复制为默认显示
|
||||
// Only Reddit replied — copy across as the default display unless the reply is the placeholder text.
|
||||
if (interview.redditAnswer && interview.redditAnswer !== '(该平台未获得回复)') {
|
||||
interview.twitterAnswer = interview.redditAnswer
|
||||
}
|
||||
|
|
@ -862,18 +855,18 @@ const parseInterview = (text) => {
|
|||
interview.redditAnswer = interview.twitterAnswer
|
||||
}
|
||||
} else if (!twitterMatch && !redditMatch) {
|
||||
// 没有分平台标记(极旧格式),整体作为回答
|
||||
// Very old format with no platform tag — treat the whole text as the answer.
|
||||
interview.twitterAnswer = answerText
|
||||
}
|
||||
}
|
||||
|
||||
// 提取关键引言(兼容多种引号格式)
|
||||
// Extract key quotes (supports multiple quote-character styles).
|
||||
const quotesMatch = block.match(/\*\*关键引言:\*\*\n([\s\S]*?)(?=\n---|\n####|$)/)
|
||||
if (quotesMatch) {
|
||||
const quotesText = quotesMatch[1]
|
||||
// 优先匹配 > "text" 格式
|
||||
// Prefer the > "text" form.
|
||||
let quoteMatches = quotesText.match(/> "([^"]+)"/g)
|
||||
// 回退:匹配 > "text" 或 > \u201Ctext\u201D(中文引号)
|
||||
// Fall back to curly quotes (incl. Chinese-style quotes).
|
||||
if (!quoteMatches) {
|
||||
quoteMatches = quotesText.match(/> [\u201C""]([^\u201D""]+)[\u201D""]/g)
|
||||
}
|
||||
|
|
@ -889,7 +882,7 @@ const parseInterview = (text) => {
|
|||
}
|
||||
})
|
||||
|
||||
// 提取采访摘要
|
||||
// Extract the interview summary.
|
||||
const summaryMatch = text.match(/### 采访摘要与核心观点\n([\s\S]*?)$/)
|
||||
if (summaryMatch) {
|
||||
result.summary = summaryMatch[1].trim()
|
||||
|
|
@ -911,22 +904,22 @@ const parseQuickSearch = (text) => {
|
|||
}
|
||||
|
||||
try {
|
||||
// 提取搜索查询
|
||||
// Extract the search query.
|
||||
const queryMatch = text.match(/搜索查询:\s*(.+?)(?:\n|$)/)
|
||||
if (queryMatch) result.query = queryMatch[1].trim()
|
||||
|
||||
// 提取结果数量
|
||||
|
||||
// Extract the result count.
|
||||
const countMatch = text.match(/找到\s*(\d+)\s*条/)
|
||||
if (countMatch) result.count = parseInt(countMatch[1])
|
||||
|
||||
// 提取相关事实 - 完整提取,不限制数量
|
||||
|
||||
// Extract related facts in full (no cap).
|
||||
const factsSection = text.match(/### 相关事实:\n([\s\S]*)$/)
|
||||
if (factsSection) {
|
||||
const lines = factsSection[1].split('\n').filter(l => l.match(/^\d+\./))
|
||||
result.facts = lines.map(l => l.replace(/^\d+\.\s*/, '').trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// 尝试提取边信息(如果有)
|
||||
// Best-effort extraction of edge info (if present).
|
||||
const edgesSection = text.match(/### 相关边:\n([\s\S]*?)(?=\n###|$)/)
|
||||
if (edgesSection) {
|
||||
const lines = edgesSection[1].split('\n').filter(l => l.trim().startsWith('-'))
|
||||
|
|
@ -939,7 +932,7 @@ const parseQuickSearch = (text) => {
|
|||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
// 尝试提取节点信息(如果有)
|
||||
// Best-effort extraction of node info (if present).
|
||||
const nodesSection = text.match(/### 相关节点:\n([\s\S]*?)(?=\n###|$)/)
|
||||
if (nodesSection) {
|
||||
const lines = nodesSection[1].split('\n').filter(l => l.trim().startsWith('-'))
|
||||
|
|
@ -1229,7 +1222,7 @@ const PanoramaDisplay = {
|
|||
h('div', { class: 'fact-item historical', key: i }, [
|
||||
h('span', { class: 'fact-number' }, i + 1),
|
||||
h('div', { class: 'fact-content' }, [
|
||||
// 尝试提取时间信息 [time - time]
|
||||
// Best-effort extraction of "[time - time]" prefixes.
|
||||
(() => {
|
||||
const timeMatch = fact.match(/^\[(.+?)\]\s*(.*)$/)
|
||||
if (timeMatch) {
|
||||
|
|
@ -1296,16 +1289,14 @@ const InterviewDisplay = {
|
|||
|
||||
const activeIndex = ref(0)
|
||||
const expandedAnswers = ref(new Set())
|
||||
// 为每个问题-回答对维护独立的平台选择状态
|
||||
// Per-question platform selection so each Q/A pair keeps its own active tab.
|
||||
const platformTabs = reactive({}) // { 'agentIdx-qIdx': 'twitter' | 'reddit' }
|
||||
|
||||
// 获取某个问题的当前平台选择
|
||||
const getPlatformTab = (agentIdx, qIdx) => {
|
||||
const key = `${agentIdx}-${qIdx}`
|
||||
return platformTabs[key] || 'twitter'
|
||||
}
|
||||
|
||||
// 设置某个问题的平台选择
|
||||
const setPlatformTab = (agentIdx, qIdx, platform) => {
|
||||
const key = `${agentIdx}-${qIdx}`
|
||||
platformTabs[key] = platform
|
||||
|
|
@ -1327,25 +1318,25 @@ const InterviewDisplay = {
|
|||
return text.substring(0, 400) + '...'
|
||||
}
|
||||
|
||||
// 检查是否为平台占位文本
|
||||
// Detect the "no reply on this platform" placeholder values from the backend.
|
||||
const isPlaceholderText = (text) => {
|
||||
if (!text) return true
|
||||
const t = text.trim()
|
||||
return t === '(该平台未获得回复)' || t === '(该平台未获得回复)' || t === '[无回复]'
|
||||
}
|
||||
|
||||
// 尝试按问题编号分割回答
|
||||
// Try to split a single answer blob into one chunk per question.
|
||||
const splitAnswerByQuestions = (answerText, questionCount) => {
|
||||
if (!answerText || questionCount <= 0) return [answerText]
|
||||
if (isPlaceholderText(answerText)) return ['']
|
||||
|
||||
// 支持两种编号格式:
|
||||
// 1. "问题X:" 或 "问题X:" (中文格式,后端新格式)
|
||||
// 2. "1. " 或 "\n1. " (数字+点,旧格式兼容)
|
||||
// Two numbering schemes are supported:
|
||||
// 1. "问题X:" / "问题X:" — the newer Chinese-style format from the backend.
|
||||
// 2. "1. " / "\n1. " — the older numeric-prefix format (kept for compat).
|
||||
let matches = []
|
||||
let match
|
||||
|
||||
// 优先尝试 "问题X:" 格式
|
||||
// Try the "问题X:" form first.
|
||||
const cnPattern = /(?:^|[\r\n]+)问题(\d+)[::]\s*/g
|
||||
while ((match = cnPattern.exec(answerText)) !== null) {
|
||||
matches.push({
|
||||
|
|
@ -1355,7 +1346,7 @@ const InterviewDisplay = {
|
|||
})
|
||||
}
|
||||
|
||||
// 如果没匹配到,回退到 "数字." 格式
|
||||
// Fall back to the numeric-prefix form on no match.
|
||||
if (matches.length === 0) {
|
||||
const numPattern = /(?:^|[\r\n]+)(\d+)\.\s+/g
|
||||
while ((match = numPattern.exec(answerText)) !== null) {
|
||||
|
|
@ -1367,7 +1358,7 @@ const InterviewDisplay = {
|
|||
}
|
||||
}
|
||||
|
||||
// 如果没有找到编号或只找到一个,返回整体
|
||||
// No numbering (or only one match) — return the whole blob as one answer.
|
||||
if (matches.length <= 1) {
|
||||
const cleaned = answerText
|
||||
.replace(/^问题\d+[::]\s*/, '')
|
||||
|
|
@ -1376,7 +1367,7 @@ const InterviewDisplay = {
|
|||
return [cleaned || answerText]
|
||||
}
|
||||
|
||||
// 按编号提取各部分
|
||||
// Extract each numbered part.
|
||||
const parts = []
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const current = matches[i]
|
||||
|
|
@ -1397,7 +1388,7 @@ const InterviewDisplay = {
|
|||
return [answerText]
|
||||
}
|
||||
|
||||
// 获取某个问题对应的回答
|
||||
// Resolve the answer for a given question index on the chosen platform.
|
||||
const getAnswerForQuestion = (interview, qIdx, platform) => {
|
||||
const answer = platform === 'twitter' ? interview.twitterAnswer : (interview.redditAnswer || interview.twitterAnswer)
|
||||
if (!answer || isPlaceholderText(answer)) return answer || ''
|
||||
|
|
@ -1405,21 +1396,20 @@ const InterviewDisplay = {
|
|||
const questionCount = interview.questions?.length || 1
|
||||
const answers = splitAnswerByQuestions(answer, questionCount)
|
||||
|
||||
// 分割成功且索引有效
|
||||
// Split succeeded and the index is in range.
|
||||
if (answers.length > 1 && qIdx < answers.length) {
|
||||
return answers[qIdx] || ''
|
||||
}
|
||||
|
||||
// 分割失败:第一个问题返回完整回答,其余返回空
|
||||
// Split failed — return the whole answer for q0, empty for everything else.
|
||||
return qIdx === 0 ? answer : ''
|
||||
}
|
||||
|
||||
// 检查某个问题是否有双平台回答(过滤占位文本)
|
||||
|
||||
// Determine whether a question has real (non-placeholder) answers on both platforms.
|
||||
const hasMultiplePlatforms = (interview, qIdx) => {
|
||||
if (!interview.twitterAnswer || !interview.redditAnswer) return false
|
||||
const twitterAnswer = getAnswerForQuestion(interview, qIdx, 'twitter')
|
||||
const redditAnswer = getAnswerForQuestion(interview, qIdx, 'reddit')
|
||||
// 两个平台都有真实回答(非占位文本)且内容不同
|
||||
return !isPlaceholderText(twitterAnswer) && !isPlaceholderText(redditAnswer) && twitterAnswer !== redditAnswer
|
||||
}
|
||||
|
||||
|
|
@ -1469,13 +1459,13 @@ const InterviewDisplay = {
|
|||
])
|
||||
]),
|
||||
|
||||
// Selection Reason - 选择理由
|
||||
// Selection Reason
|
||||
props.result.interviews[activeIndex.value]?.selectionReason && h('div', { class: 'selection-reason' }, [
|
||||
h('div', { class: 'reason-label' }, '选择理由'),
|
||||
h('div', { class: 'reason-content' }, props.result.interviews[activeIndex.value].selectionReason)
|
||||
]),
|
||||
|
||||
// Q&A Conversation Thread - 一问一答样式
|
||||
// Q&A Conversation Thread — alternating Q/A bubbles
|
||||
h('div', { class: 'qa-thread' },
|
||||
(props.result.interviews[activeIndex.value]?.questions?.length > 0
|
||||
? props.result.interviews[activeIndex.value].questions
|
||||
|
|
@ -1505,7 +1495,7 @@ const InterviewDisplay = {
|
|||
h('div', { class: 'qa-content' }, [
|
||||
h('div', { class: 'qa-answer-header' }, [
|
||||
h('div', { class: 'qa-sender' }, interview?.name || 'Agent'),
|
||||
// 双平台切换按钮(仅在有真实双平台回答时显示)
|
||||
// Render the platform-switch buttons only when both platforms have real answers.
|
||||
hasDualPlatform && h('div', { class: 'platform-switch' }, [
|
||||
h('button', {
|
||||
class: ['platform-btn', { active: currentPlatform === 'twitter' }],
|
||||
|
|
@ -1537,7 +1527,7 @@ const InterviewDisplay = {
|
|||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\n/g, '<br>')
|
||||
}),
|
||||
// Expand/Collapse Button(占位文本不显示)
|
||||
// Expand/Collapse button — hidden when the answer is the placeholder text.
|
||||
!isPlaceholder && answerText.length > 400 && h('button', {
|
||||
class: 'expand-answer-btn',
|
||||
onClick: () => toggleAnswer(expandKey)
|
||||
|
|
@ -1769,18 +1759,18 @@ const isFinalizing = computed(() => {
|
|||
return !isComplete.value && isPlanningDone.value && totalSections.value > 0 && completedSections.value >= totalSections.value
|
||||
})
|
||||
|
||||
// 当前活跃的步骤(用于顶部显示)
|
||||
// Currently active step — surfaced in the top progress bar.
|
||||
const activeStep = computed(() => {
|
||||
const steps = workflowSteps.value
|
||||
// 找到当前 active 的步骤
|
||||
// Find the step that is currently active.
|
||||
const active = steps.find(s => s.status === 'active')
|
||||
if (active) return active
|
||||
|
||||
// 如果没有 active,返回最后一个 done 的步骤
|
||||
// No active step — fall back to the last completed one.
|
||||
const doneSteps = steps.filter(s => s.status === 'done')
|
||||
if (doneSteps.length > 0) return doneSteps[doneSteps.length - 1]
|
||||
|
||||
// 否则返回第一个步骤
|
||||
// Otherwise return the first step in the list.
|
||||
return steps[0] || { noLabel: '--', title: '等待开始', status: 'todo', meta: '' }
|
||||
})
|
||||
|
||||
|
|
@ -1874,25 +1864,25 @@ const truncateText = (text, maxLen) => {
|
|||
const renderMarkdown = (content) => {
|
||||
if (!content) return ''
|
||||
|
||||
// 去掉开头的二级标题(## xxx),因为章节标题已在外层显示
|
||||
// Strip the leading "## ..." since the section title is already rendered above.
|
||||
let processedContent = content.replace(/^##\s+.+\n+/, '')
|
||||
|
||||
// 处理代码块
|
||||
// Code blocks
|
||||
let html = processedContent.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="code-block"><code>$2</code></pre>')
|
||||
|
||||
// 处理行内代码
|
||||
// Inline code
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
|
||||
// 处理标题
|
||||
// Headings
|
||||
html = html.replace(/^#### (.+)$/gm, '<h5 class="md-h5">$1</h5>')
|
||||
html = html.replace(/^### (.+)$/gm, '<h4 class="md-h4">$1</h4>')
|
||||
html = html.replace(/^## (.+)$/gm, '<h3 class="md-h3">$1</h3>')
|
||||
html = html.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>')
|
||||
|
||||
// 处理引用块
|
||||
// Blockquotes
|
||||
html = html.replace(/^> (.+)$/gm, '<blockquote class="md-quote">$1</blockquote>')
|
||||
|
||||
// 处理列表 - 支持子列表
|
||||
// Lists — supports nested sub-lists via 2-space indents.
|
||||
html = html.replace(/^(\s*)- (.+)$/gm, (match, indent, text) => {
|
||||
const level = Math.floor(indent.length / 2)
|
||||
return `<li class="md-li" data-level="${level}">${text}</li>`
|
||||
|
|
@ -1902,52 +1892,53 @@ const renderMarkdown = (content) => {
|
|||
return `<li class="md-oli" data-level="${level}">${text}</li>`
|
||||
})
|
||||
|
||||
// 包装无序列表
|
||||
// Wrap consecutive <li> in a <ul>.
|
||||
html = html.replace(/(<li class="md-li"[^>]*>.*?<\/li>\s*)+/g, '<ul class="md-ul">$&</ul>')
|
||||
// 包装有序列表
|
||||
// Wrap consecutive numbered <li> in an <ol>.
|
||||
html = html.replace(/(<li class="md-oli"[^>]*>.*?<\/li>\s*)+/g, '<ol class="md-ol">$&</ol>')
|
||||
|
||||
// 清理列表项之间的所有空白
|
||||
// Strip whitespace between consecutive list items.
|
||||
html = html.replace(/<\/li>\s+<li/g, '</li><li')
|
||||
// 清理列表开始标签后的空白
|
||||
// Strip whitespace right after the list opening tag.
|
||||
html = html.replace(/<ul class="md-ul">\s+/g, '<ul class="md-ul">')
|
||||
html = html.replace(/<ol class="md-ol">\s+/g, '<ol class="md-ol">')
|
||||
// 清理列表结束标签前的空白
|
||||
// Strip whitespace right before the list closing tag.
|
||||
html = html.replace(/\s+<\/ul>/g, '</ul>')
|
||||
html = html.replace(/\s+<\/ol>/g, '</ol>')
|
||||
|
||||
// 处理粗体和斜体
|
||||
// Bold and italic
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
html = html.replace(/_(.+?)_/g, '<em>$1</em>')
|
||||
|
||||
// 处理分隔线
|
||||
// Horizontal rules
|
||||
html = html.replace(/^---$/gm, '<hr class="md-hr">')
|
||||
|
||||
// 处理换行 - 空行变成段落分隔,单换行变成 <br>
|
||||
// Line breaks: blank lines become paragraph breaks; single newlines become <br>.
|
||||
html = html.replace(/\n\n/g, '</p><p class="md-p">')
|
||||
html = html.replace(/\n/g, '<br>')
|
||||
|
||||
// 包装在段落中
|
||||
// Wrap the whole result in a paragraph.
|
||||
html = '<p class="md-p">' + html + '</p>'
|
||||
|
||||
// 清理空段落
|
||||
// Drop empty paragraphs.
|
||||
html = html.replace(/<p class="md-p"><\/p>/g, '')
|
||||
html = html.replace(/<p class="md-p">(<h[2-5])/g, '$1')
|
||||
html = html.replace(/(<\/h[2-5]>)<\/p>/g, '$1')
|
||||
html = html.replace(/<p class="md-p">(<ul|<ol|<blockquote|<pre|<hr)/g, '$1')
|
||||
html = html.replace(/(<\/ul>|<\/ol>|<\/blockquote>|<\/pre>)<\/p>/g, '$1')
|
||||
// 清理块级元素前后的 <br> 标签
|
||||
// Strip <br> tags around block-level elements.
|
||||
html = html.replace(/<br>\s*(<ul|<ol|<blockquote)/g, '$1')
|
||||
html = html.replace(/(<\/ul>|<\/ol>|<\/blockquote>)\s*<br>/g, '$1')
|
||||
// 清理 <p><br> 紧跟块级元素的情况(多余空行导致)
|
||||
// Strip leading <br> sequences inside a paragraph wrapper that precede a block element.
|
||||
html = html.replace(/<p class="md-p">(<br>\s*)+(<ul|<ol|<blockquote|<pre|<hr)/g, '$2')
|
||||
// 清理连续的 <br> 标签
|
||||
// Collapse consecutive <br> tags.
|
||||
html = html.replace(/(<br>\s*){2,}/g, '<br>')
|
||||
// 清理块级元素后紧跟的段落开始标签前的 <br>
|
||||
// Drop a <br> sitting between a closing block tag and a paragraph/div opener.
|
||||
html = html.replace(/(<\/ol>|<\/ul>|<\/blockquote>)<br>(<p|<div)/g, '$1$2')
|
||||
|
||||
// 修复非连续有序列表的编号:当单项 <ol> 被段落内容隔开时,保持编号递增
|
||||
// Fix ordered-list numbering across breaks: when single-item <ol>s are split by
|
||||
// paragraph content, keep the counter increasing.
|
||||
const tokens = html.split(/(<ol class="md-ol">(?:<li class="md-oli"[^>]*>[\s\S]*?<\/li>)+<\/ol>)/g)
|
||||
let olCounter = 0
|
||||
let inSequence = false
|
||||
|
|
@ -2013,7 +2004,7 @@ const getActionLabel = (action) => {
|
|||
const getLogLevelClass = (log) => {
|
||||
if (log.includes('ERROR') || log.includes('错误')) return 'error'
|
||||
if (log.includes('WARNING') || log.includes('警告')) return 'warning'
|
||||
// INFO 使用默认颜色,不标记为 success
|
||||
// INFO uses the default color and is intentionally not marked as success.
|
||||
return ''
|
||||
}
|
||||
|
||||
|
|
@ -2042,11 +2033,11 @@ const fetchAgentLog = async () => {
|
|||
currentSectionIndex.value = log.section_index
|
||||
}
|
||||
|
||||
// section_complete - 章节生成完成
|
||||
// section_complete — section generation done
|
||||
if (log.action === 'section_complete') {
|
||||
if (log.details?.content) {
|
||||
generatedSections.value[log.section_index] = log.details.content
|
||||
// 自动展开刚生成的章节
|
||||
// Auto-expand the section that just finished generating.
|
||||
expandedContent.value.add(log.section_index - 1)
|
||||
currentSectionIndex.value = null
|
||||
}
|
||||
|
|
@ -2054,10 +2045,10 @@ const fetchAgentLog = async () => {
|
|||
|
||||
if (log.action === 'report_complete') {
|
||||
isComplete.value = true
|
||||
currentSectionIndex.value = null // 确保清除 loading 状态
|
||||
currentSectionIndex.value = null // Clear the loading state for the section.
|
||||
emit('update-status', 'completed')
|
||||
stopPolling()
|
||||
// 滚动逻辑统一在循环结束后的 nextTick 中处理
|
||||
// Scroll handling lives in the post-loop nextTick block below.
|
||||
}
|
||||
|
||||
if (log.action === 'report_start') {
|
||||
|
|
@ -2069,7 +2060,7 @@ const fetchAgentLog = async () => {
|
|||
|
||||
nextTick(() => {
|
||||
if (rightPanel.value) {
|
||||
// 如果任务已完成,滚动到顶部;否则滚动到底部跟随最新日志
|
||||
// When the task has finished, scroll to top; otherwise stay pinned to the bottom.
|
||||
if (isComplete.value) {
|
||||
rightPanel.value.scrollTop = 0
|
||||
} else {
|
||||
|
|
@ -2084,39 +2075,39 @@ const fetchAgentLog = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 提取最终答案内容 - 从 LLM response 中提取章节内容
|
||||
// Extract the final-answer content (the section text) from the LLM response.
|
||||
const extractFinalContent = (response) => {
|
||||
if (!response) return null
|
||||
|
||||
// 尝试提取 <final_answer> 标签内的内容
|
||||
// Try to extract content inside <final_answer> tags.
|
||||
const finalAnswerTagMatch = response.match(/<final_answer>([\s\S]*?)<\/final_answer>/)
|
||||
if (finalAnswerTagMatch) {
|
||||
return finalAnswerTagMatch[1].trim()
|
||||
}
|
||||
|
||||
// 尝试找 Final Answer: 后面的内容(支持多种格式)
|
||||
// 格式1: Final Answer:\n\n内容
|
||||
// 格式2: Final Answer: 内容
|
||||
// Look for content after a "Final Answer:" marker. Supported shapes:
|
||||
// Format 1: "Final Answer:\n\n<content>"
|
||||
// Format 2: "Final Answer: <content>"
|
||||
const finalAnswerMatch = response.match(/Final\s*Answer:\s*\n*([\s\S]*)$/i)
|
||||
if (finalAnswerMatch) {
|
||||
return finalAnswerMatch[1].trim()
|
||||
}
|
||||
|
||||
// 尝试找 最终答案: 后面的内容
|
||||
// Look for content after the Chinese "最终答案:" marker.
|
||||
const chineseFinalMatch = response.match(/最终答案[::]\s*\n*([\s\S]*)$/i)
|
||||
if (chineseFinalMatch) {
|
||||
return chineseFinalMatch[1].trim()
|
||||
}
|
||||
|
||||
// 如果以 ## 或 # 或 > 开头,可能是直接的 markdown 内容
|
||||
// If the response starts with "##", "#", or ">", treat it as markdown content directly.
|
||||
const trimmedResponse = response.trim()
|
||||
if (trimmedResponse.match(/^[#>]/)) {
|
||||
return trimmedResponse
|
||||
}
|
||||
|
||||
// 如果内容较长且包含markdown格式,尝试移除思考过程后返回
|
||||
// For longer markdown-shaped responses, strip the leading "Thought:" reasoning before returning.
|
||||
if (response.length > 300 && (response.includes('**') || response.includes('>'))) {
|
||||
// 移除 Thought: 开头的思考过程
|
||||
// Strip the leading "Thought:" block.
|
||||
const thoughtMatch = response.match(/^Thought:[\s\S]*?(?=\n\n[^T]|\n\n$)/i)
|
||||
if (thoughtMatch) {
|
||||
const afterThought = response.substring(thoughtMatch[0].length).trim()
|
||||
|
|
@ -2461,7 +2452,7 @@ watch(() => props.reportId, (newId) => {
|
|||
.section-number {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 16px;
|
||||
color: #9CA3AF; /* 深灰色,不随状态变化 */
|
||||
color: #9CA3AF; /* Dark gray — fixed regardless of status */
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
|
@ -3903,7 +3894,7 @@ watch(() => props.reportId, (newId) => {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Selection Reason - 选择理由 */
|
||||
/* Selection Reason */
|
||||
:deep(.interview-display .selection-reason) {
|
||||
background: #F8FAFC;
|
||||
border: 1px solid #E2E8F0;
|
||||
|
|
@ -5102,7 +5093,7 @@ watch(() => props.reportId, (newId) => {
|
|||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Console Logs - 与 Step3Simulation.vue 保持一致 */
|
||||
/* Console Logs — kept consistent with Step3Simulation.vue */
|
||||
.console-logs {
|
||||
background: #000;
|
||||
color: #DDD;
|
||||
|
|
|
|||
|
|
@ -437,7 +437,7 @@ const showToolsDetail = ref(true)
|
|||
// Chat State
|
||||
const chatInput = ref('')
|
||||
const chatHistory = ref([])
|
||||
const chatHistoryCache = ref({}) // 缓存所有对话记录: { 'report_agent': [], 'agent_0': [], 'agent_1': [], ... }
|
||||
const chatHistoryCache = ref({}) // Per-target chat cache: { 'report_agent': [], 'agent_0': [], 'agent_1': [], ... }
|
||||
const isSending = ref(false)
|
||||
const chatMessages = ref(null)
|
||||
const chatInputRef = ref(null)
|
||||
|
|
@ -487,7 +487,6 @@ const selectChatTarget = (target) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 保存当前对话记录到缓存
|
||||
const saveChatHistory = () => {
|
||||
if (chatHistory.value.length === 0) return
|
||||
|
||||
|
|
@ -499,16 +498,15 @@ const saveChatHistory = () => {
|
|||
}
|
||||
|
||||
const selectReportAgentChat = () => {
|
||||
// 保存当前对话记录
|
||||
saveChatHistory()
|
||||
|
||||
|
||||
activeTab.value = 'chat'
|
||||
chatTarget.value = 'report_agent'
|
||||
selectedAgent.value = null
|
||||
selectedAgentIndex.value = null
|
||||
showAgentDropdown.value = false
|
||||
|
||||
// 恢复 Report Agent 的对话记录
|
||||
|
||||
// Restore Report Agent chat from cache.
|
||||
chatHistory.value = chatHistoryCache.value['report_agent'] || []
|
||||
}
|
||||
|
||||
|
|
@ -528,15 +526,14 @@ const toggleAgentDropdown = () => {
|
|||
}
|
||||
|
||||
const selectAgent = (agent, idx) => {
|
||||
// 保存当前对话记录
|
||||
saveChatHistory()
|
||||
|
||||
|
||||
selectedAgent.value = agent
|
||||
selectedAgentIndex.value = idx
|
||||
chatTarget.value = 'agent'
|
||||
showAgentDropdown.value = false
|
||||
|
||||
// 恢复该 Agent 的对话记录
|
||||
|
||||
// Restore this agent's chat from cache.
|
||||
chatHistory.value = chatHistoryCache.value[`agent_${idx}`] || []
|
||||
addLog(t('log.selectChatTarget', { name: agent.username }))
|
||||
}
|
||||
|
|
@ -566,7 +563,7 @@ const renderMarkdown = (content) => {
|
|||
html = html.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>')
|
||||
html = html.replace(/^> (.+)$/gm, '<blockquote class="md-quote">$1</blockquote>')
|
||||
|
||||
// 处理列表 - 支持子列表
|
||||
// List handling — supports nested sub-lists via 2-space indents.
|
||||
html = html.replace(/^(\s*)- (.+)$/gm, (match, indent, text) => {
|
||||
const level = Math.floor(indent.length / 2)
|
||||
return `<li class="md-li" data-level="${level}">${text}</li>`
|
||||
|
|
@ -575,18 +572,16 @@ const renderMarkdown = (content) => {
|
|||
const level = Math.floor(indent.length / 2)
|
||||
return `<li class="md-oli" data-level="${level}">${text}</li>`
|
||||
})
|
||||
|
||||
// 包装无序列表
|
||||
|
||||
html = html.replace(/(<li class="md-li"[^>]*>.*?<\/li>\s*)+/g, '<ul class="md-ul">$&</ul>')
|
||||
// 包装有序列表
|
||||
html = html.replace(/(<li class="md-oli"[^>]*>.*?<\/li>\s*)+/g, '<ol class="md-ol">$&</ol>')
|
||||
|
||||
// 清理列表项之间的所有空白
|
||||
|
||||
// Strip whitespace between consecutive list items.
|
||||
html = html.replace(/<\/li>\s+<li/g, '</li><li')
|
||||
// 清理列表开始标签后的空白
|
||||
// Strip whitespace right after a list opening tag.
|
||||
html = html.replace(/<ul class="md-ul">\s+/g, '<ul class="md-ul">')
|
||||
html = html.replace(/<ol class="md-ol">\s+/g, '<ol class="md-ol">')
|
||||
// 清理列表结束标签前的空白
|
||||
// Strip whitespace right before a list closing tag.
|
||||
html = html.replace(/\s+<\/ul>/g, '</ul>')
|
||||
html = html.replace(/\s+<\/ol>/g, '</ol>')
|
||||
|
||||
|
|
@ -602,17 +597,19 @@ const renderMarkdown = (content) => {
|
|||
html = html.replace(/(<\/h[2-5]>)<\/p>/g, '$1')
|
||||
html = html.replace(/<p class="md-p">(<ul|<ol|<blockquote|<pre|<hr)/g, '$1')
|
||||
html = html.replace(/(<\/ul>|<\/ol>|<\/blockquote>|<\/pre>)<\/p>/g, '$1')
|
||||
// 清理块级元素前后的 <br> 标签
|
||||
// Strip <br> tags around block-level elements.
|
||||
html = html.replace(/<br>\s*(<ul|<ol|<blockquote)/g, '$1')
|
||||
html = html.replace(/(<\/ul>|<\/ol>|<\/blockquote>)\s*<br>/g, '$1')
|
||||
// 清理 <p><br> 紧跟块级元素的情况(多余空行导致)
|
||||
// Strip leading <br> sequences inside a paragraph wrapper before a block element
|
||||
// (caused by stray blank lines in the source).
|
||||
html = html.replace(/<p class="md-p">(<br>\s*)+(<ul|<ol|<blockquote|<pre|<hr)/g, '$2')
|
||||
// 清理连续的 <br> 标签
|
||||
// Collapse consecutive <br> tags.
|
||||
html = html.replace(/(<br>\s*){2,}/g, '<br>')
|
||||
// 清理块级元素后紧跟的段落开始标签前的 <br>
|
||||
// Drop a <br> sitting between a closing block tag and a paragraph/div opener.
|
||||
html = html.replace(/(<\/ol>|<\/ul>|<\/blockquote>)<br>(<p|<div)/g, '$1$2')
|
||||
|
||||
// 修复非连续有序列表的编号:当单项 <ol> 被段落内容隔开时,保持编号递增
|
||||
// Fix ordered-list numbering across breaks: when single-item <ol>s are split by
|
||||
// paragraph content, keep the counter increasing.
|
||||
const tokens = html.split(/(<ol class="md-ol">(?:<li class="md-oli"[^>]*>[\s\S]*?<\/li>)+<\/ol>)/g)
|
||||
let olCounter = 0
|
||||
let inSequence = false
|
||||
|
|
@ -674,7 +671,6 @@ const sendMessage = async () => {
|
|||
} finally {
|
||||
isSending.value = false
|
||||
scrollToBottom()
|
||||
// 自动保存对话记录到缓存
|
||||
saveChatHistory()
|
||||
}
|
||||
}
|
||||
|
|
@ -736,17 +732,16 @@ const sendToAgent = async (message) => {
|
|||
})
|
||||
|
||||
if (res.success && res.data) {
|
||||
// 正确的数据路径: res.data.result.results 是一个对象字典
|
||||
// 格式: {"twitter_0": {...}, "reddit_0": {...}} 或单平台 {"reddit_0": {...}}
|
||||
// Expected payload: res.data.result.results is a dict of agent results,
|
||||
// e.g. {"twitter_0": {...}, "reddit_0": {...}} (or only one platform).
|
||||
const resultData = res.data.result || res.data
|
||||
const resultsDict = resultData.results || resultData
|
||||
|
||||
// 将对象字典转换为数组,优先获取 reddit 平台的回复
|
||||
|
||||
// Pull the reply for this agent, preferring reddit over twitter.
|
||||
let responseContent = null
|
||||
const agentId = selectedAgentIndex.value
|
||||
|
||||
|
||||
if (typeof resultsDict === 'object' && !Array.isArray(resultsDict)) {
|
||||
// 优先使用 reddit 平台回复,其次 twitter
|
||||
const redditKey = `reddit_${agentId}`
|
||||
const twitterKey = `twitter_${agentId}`
|
||||
const agentResult = resultsDict[redditKey] || resultsDict[twitterKey] || Object.values(resultsDict)[0]
|
||||
|
|
@ -754,7 +749,7 @@ const sendToAgent = async (message) => {
|
|||
responseContent = agentResult.response || agentResult.answer
|
||||
}
|
||||
} else if (Array.isArray(resultsDict) && resultsDict.length > 0) {
|
||||
// 兼容数组格式
|
||||
// Backward compatibility with the array shape.
|
||||
responseContent = resultsDict[0].response || resultsDict[0].answer
|
||||
}
|
||||
|
||||
|
|
@ -820,19 +815,18 @@ const submitSurvey = async () => {
|
|||
})
|
||||
|
||||
if (res.success && res.data) {
|
||||
// 正确的数据路径: res.data.result.results 是一个对象字典
|
||||
// 格式: {"twitter_0": {...}, "reddit_0": {...}, "twitter_1": {...}, ...}
|
||||
// Expected payload: res.data.result.results is a dict of agent results,
|
||||
// e.g. {"twitter_0": {...}, "reddit_0": {...}, "twitter_1": {...}, ...}.
|
||||
const resultData = res.data.result || res.data
|
||||
const resultsDict = resultData.results || resultData
|
||||
|
||||
// 将对象字典转换为数组格式
|
||||
|
||||
const surveyResultsList = []
|
||||
|
||||
|
||||
for (const interview of interviews) {
|
||||
const agentIdx = interview.agent_id
|
||||
const agent = profiles.value[agentIdx]
|
||||
|
||||
// 优先使用 reddit 平台回复,其次 twitter
|
||||
|
||||
// Pull the reply for this agent, preferring reddit over twitter.
|
||||
let responseContent = t('step5.noResponse')
|
||||
|
||||
if (typeof resultsDict === 'object' && !Array.isArray(resultsDict)) {
|
||||
|
|
@ -843,7 +837,7 @@ const submitSurvey = async () => {
|
|||
responseContent = agentResult.response || agentResult.answer || t('step5.noResponse')
|
||||
}
|
||||
} else if (Array.isArray(resultsDict)) {
|
||||
// 兼容数组格式
|
||||
// Backward compatibility with the array shape.
|
||||
const matchedResult = resultsDict.find(r => r.agent_id === agentIdx)
|
||||
if (matchedResult) {
|
||||
responseContent = matchedResult.response || matchedResult.answer || t('step5.noResponse')
|
||||
|
|
@ -983,7 +977,7 @@ watch(() => props.simulationId, (newId) => {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Left Panel - Report Style (与 Step4Report.vue 完全一致) */
|
||||
/* Left Panel - Report Style (kept identical to Step4Report.vue) */
|
||||
.left-panel.report-style {
|
||||
width: 45%;
|
||||
min-width: 450px;
|
||||
|
|
@ -2031,7 +2025,7 @@ watch(() => props.simulationId, (newId) => {
|
|||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 修复有序列表编号 - 使用 CSS 计数器让多个 ol 连续编号 */
|
||||
/* Fix ordered-list numbering: use a CSS counter so consecutive <ol>s number continuously. */
|
||||
.message-text {
|
||||
counter-reset: list-counter;
|
||||
}
|
||||
|
|
@ -2057,7 +2051,7 @@ watch(() => props.simulationId, (newId) => {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 无序列表样式 */
|
||||
/* Unordered list styles */
|
||||
.message-text :deep(.md-ul) {
|
||||
padding-left: 20px;
|
||||
margin: 8px 0;
|
||||
|
|
@ -2536,7 +2530,7 @@ watch(() => props.simulationId, (newId) => {
|
|||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* 聊天/问卷区域的引用样式 */
|
||||
/* Quote styles inside chat/survey panels */
|
||||
.chat-messages :deep(.md-quote),
|
||||
.result-answer :deep(.md-quote) {
|
||||
margin: 12px 0;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/**
|
||||
* 临时存储待上传的文件和需求
|
||||
* 用于首页点击启动引擎后立即跳转,在Process页面再进行API调用
|
||||
* Holds files and the simulation requirement between Home and Process so that
|
||||
* clicking "Start Engine" can navigate immediately and defer the API call to
|
||||
* the Process view.
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="home-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<!-- Top navigation -->
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand">MIROFISH</div>
|
||||
<div class="nav-links">
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
</nav>
|
||||
|
||||
<div class="main-content">
|
||||
<!-- 上半部分:Hero 区域 -->
|
||||
<!-- Top half: Hero -->
|
||||
<section class="hero-section">
|
||||
<div class="hero-left">
|
||||
<div class="tag-row">
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
</div>
|
||||
|
||||
<div class="hero-right">
|
||||
<!-- Logo 区域 -->
|
||||
<!-- Logo -->
|
||||
<div class="logo-container">
|
||||
<img src="../assets/logo/MiroFish_logo_left.jpeg" alt="MiroFish Logo" class="hero-logo" />
|
||||
</div>
|
||||
|
|
@ -53,9 +53,9 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 下半部分:双栏布局 -->
|
||||
<!-- Bottom half: two-column layout -->
|
||||
<section class="dashboard-section">
|
||||
<!-- 左栏:状态与步骤 -->
|
||||
<!-- Left column: status and workflow -->
|
||||
<div class="left-panel">
|
||||
<div class="panel-header">
|
||||
<span class="status-dot">■</span> {{ $t('home.systemStatus') }}
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
{{ $t('home.systemReadyDesc') }}
|
||||
</p>
|
||||
|
||||
<!-- 数据指标卡片 -->
|
||||
<!-- Metric cards -->
|
||||
<div class="metrics-row">
|
||||
<div class="metric-card">
|
||||
<div class="metric-value">{{ $t('home.metricLowCost') }}</div>
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目模拟步骤介绍 (新增区域) -->
|
||||
<!-- Workflow steps -->
|
||||
<div class="steps-container">
|
||||
<div class="steps-header">
|
||||
<span class="diamond-icon">◇</span> {{ $t('home.workflowSequence') }}
|
||||
|
|
@ -123,10 +123,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右栏:交互控制台 -->
|
||||
<!-- Right column: console -->
|
||||
<div class="right-panel">
|
||||
<div class="console-box">
|
||||
<!-- 上传区域 -->
|
||||
<!-- Upload zone -->
|
||||
<div class="console-section">
|
||||
<div class="console-header">
|
||||
<span class="console-label">{{ $t('home.realitySeed') }}</span>
|
||||
|
|
@ -167,12 +167,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<!-- Divider -->
|
||||
<div class="console-divider">
|
||||
<span>{{ $t('home.inputParams') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<!-- Input zone -->
|
||||
<div class="console-section">
|
||||
<div class="console-header">
|
||||
<span class="console-label">{{ $t('home.simulationPrompt') }}</span>
|
||||
|
|
@ -189,7 +189,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 启动按钮 -->
|
||||
<!-- Start button -->
|
||||
<div class="console-section btn-section">
|
||||
<button
|
||||
class="start-engine-btn"
|
||||
|
|
@ -205,7 +205,7 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 历史项目数据库 -->
|
||||
<!-- History database -->
|
||||
<HistoryDatabase />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -219,41 +219,33 @@ import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
|||
|
||||
const router = useRouter()
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
simulationRequirement: ''
|
||||
})
|
||||
|
||||
// 文件列表
|
||||
const files = ref([])
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const isDragOver = ref(false)
|
||||
|
||||
// 文件输入引用
|
||||
const fileInput = ref(null)
|
||||
|
||||
// 计算属性:是否可以提交
|
||||
const canSubmit = computed(() => {
|
||||
return formData.value.simulationRequirement.trim() !== '' && files.value.length > 0
|
||||
})
|
||||
|
||||
// 触发文件选择
|
||||
const triggerFileInput = () => {
|
||||
if (!loading.value) {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = (event) => {
|
||||
const selectedFiles = Array.from(event.target.files)
|
||||
addFiles(selectedFiles)
|
||||
}
|
||||
|
||||
// 处理拖拽相关
|
||||
const handleDragOver = (e) => {
|
||||
if (!loading.value) {
|
||||
isDragOver.value = true
|
||||
|
|
@ -267,12 +259,11 @@ const handleDragLeave = (e) => {
|
|||
const handleDrop = (e) => {
|
||||
isDragOver.value = false
|
||||
if (loading.value) return
|
||||
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||
addFiles(droppedFiles)
|
||||
}
|
||||
|
||||
// 添加文件
|
||||
const addFiles = (newFiles) => {
|
||||
const validFiles = newFiles.filter(file => {
|
||||
const ext = file.name.split('.').pop().toLowerCase()
|
||||
|
|
@ -281,12 +272,10 @@ const addFiles = (newFiles) => {
|
|||
files.value.push(...validFiles)
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
const removeFile = (index) => {
|
||||
files.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
window.scrollTo({
|
||||
top: document.body.scrollHeight,
|
||||
|
|
@ -294,15 +283,14 @@ const scrollToBottom = () => {
|
|||
})
|
||||
}
|
||||
|
||||
// 开始模拟 - 立即跳转,API调用在Process页面进行
|
||||
// Navigate to Process immediately; the actual API call happens there.
|
||||
const startSimulation = () => {
|
||||
if (!canSubmit.value || loading.value) return
|
||||
|
||||
// 存储待上传的数据
|
||||
|
||||
import('../store/pendingUpload.js').then(({ setPendingUpload }) => {
|
||||
setPendingUpload(files.value, formData.value.simulationRequirement)
|
||||
|
||||
// 立即跳转到Process页面(使用特殊标识表示新建项目)
|
||||
|
||||
// 'new' is the sentinel projectId that tells Process to create a new project.
|
||||
router.push({
|
||||
name: 'Process',
|
||||
params: { projectId: 'new' }
|
||||
|
|
@ -312,7 +300,7 @@ const startSimulation = () => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 全局变量与重置 */
|
||||
/* Global variables and resets */
|
||||
:root {
|
||||
--black: #000000;
|
||||
--white: #FFFFFF;
|
||||
|
|
@ -320,9 +308,9 @@ const startSimulation = () => {
|
|||
--gray-light: #F5F5F5;
|
||||
--gray-text: #666666;
|
||||
--border: #E5E5E5;
|
||||
/*
|
||||
使用 Space Grotesk 作为主要标题字体,JetBrains Mono 作为代码/标签字体
|
||||
确保已在 index.html 引入这些 Google Fonts
|
||||
/*
|
||||
Space Grotesk for primary headings, JetBrains Mono for code/labels.
|
||||
Make sure index.html loads the matching Google Fonts.
|
||||
*/
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-sans: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;
|
||||
|
|
@ -336,7 +324,7 @@ const startSimulation = () => {
|
|||
color: var(--black);
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
/* Top navigation */
|
||||
.navbar {
|
||||
height: 60px;
|
||||
background: var(--black);
|
||||
|
|
@ -380,14 +368,14 @@ const startSimulation = () => {
|
|||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
/* 主要内容区 */
|
||||
/* Main content */
|
||||
.main-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 60px 40px;
|
||||
}
|
||||
|
||||
/* Hero 区域 */
|
||||
/* Hero */
|
||||
.hero-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -518,7 +506,7 @@ const startSimulation = () => {
|
|||
}
|
||||
|
||||
.hero-logo {
|
||||
max-width: 500px; /* 调整logo大小 */
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
|
@ -540,7 +528,7 @@ const startSimulation = () => {
|
|||
border-color: var(--orange);
|
||||
}
|
||||
|
||||
/* Dashboard 双栏布局 */
|
||||
/* Dashboard two-column layout */
|
||||
.dashboard-section {
|
||||
display: flex;
|
||||
gap: 60px;
|
||||
|
|
@ -555,7 +543,7 @@ const startSimulation = () => {
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 左侧面板 */
|
||||
/* Left panel */
|
||||
.left-panel {
|
||||
flex: 0.8;
|
||||
}
|
||||
|
|
@ -611,7 +599,7 @@ const startSimulation = () => {
|
|||
color: #999;
|
||||
}
|
||||
|
||||
/* 项目模拟步骤介绍 */
|
||||
/* Workflow steps */
|
||||
.steps-container {
|
||||
border: 1px solid var(--border);
|
||||
padding: 30px;
|
||||
|
|
@ -667,14 +655,14 @@ const startSimulation = () => {
|
|||
color: var(--gray-text);
|
||||
}
|
||||
|
||||
/* 右侧交互控制台 */
|
||||
/* Right console */
|
||||
.right-panel {
|
||||
flex: 1.2;
|
||||
}
|
||||
|
||||
.console-box {
|
||||
border: 1px solid #CCC; /* 外部实线 */
|
||||
padding: 8px; /* 内边距形成双重边框感 */
|
||||
border: 1px solid #CCC; /* Outer solid border */
|
||||
padding: 8px; /* Padding creates the double-border look */
|
||||
}
|
||||
|
||||
.console-section {
|
||||
|
|
@ -842,7 +830,7 @@ const startSimulation = () => {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 可点击状态(非禁用) */
|
||||
/* Clickable state (not disabled) */
|
||||
.start-engine-btn:not(:disabled) {
|
||||
background: var(--black);
|
||||
border: 1px solid var(--black);
|
||||
|
|
@ -867,14 +855,14 @@ const startSimulation = () => {
|
|||
border: 1px solid #E5E5E5;
|
||||
}
|
||||
|
||||
/* 引导动画:微妙的边框脉冲 */
|
||||
/* Onboarding animation: subtle border pulse */
|
||||
@keyframes pulse-border {
|
||||
0% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.2); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(0, 0, 0, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
/* Responsive layout */
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-section {
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Step5 深度互动 -->
|
||||
<!-- Right Panel: Step 5 — Interaction -->
|
||||
<div class="panel-wrapper right" :style="rightPanelStyle">
|
||||
<Step5Interaction
|
||||
:reportId="currentReportId"
|
||||
|
|
@ -83,7 +83,7 @@ const props = defineProps({
|
|||
reportId: String
|
||||
})
|
||||
|
||||
// Layout State - 默认切换到工作台视角
|
||||
// Layout State — default to the workbench view
|
||||
const viewMode = ref('workbench')
|
||||
|
||||
// Data State
|
||||
|
|
@ -147,26 +147,23 @@ const loadReportData = async () => {
|
|||
try {
|
||||
addLog(t('log.loadReportData', { id: currentReportId.value }))
|
||||
|
||||
// 获取 report 信息以获取 simulation_id
|
||||
// Fetch the report so we can derive simulation_id from it.
|
||||
const reportRes = await getReport(currentReportId.value)
|
||||
if (reportRes.success && reportRes.data) {
|
||||
const reportData = reportRes.data
|
||||
simulationId.value = reportData.simulation_id
|
||||
|
||||
if (simulationId.value) {
|
||||
// 获取 simulation 信息
|
||||
const simRes = await getSimulation(simulationId.value)
|
||||
if (simRes.success && simRes.data) {
|
||||
const simData = simRes.data
|
||||
|
||||
// 获取 project 信息
|
||||
if (simData.project_id) {
|
||||
const projRes = await getProject(simData.project_id)
|
||||
if (projRes.success && projRes.data) {
|
||||
projectData.value = projRes.data
|
||||
addLog(t('log.projectLoadSuccess', { id: projRes.data.project_id }))
|
||||
|
||||
// 获取 graph 数据
|
||||
if (projRes.data.graph_id) {
|
||||
await loadGraph(projRes.data.graph_id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@
|
|||
|
||||
<!-- Right Panel: Step Components -->
|
||||
<div class="panel-wrapper right" :style="rightPanelStyle">
|
||||
<!-- Step 1: 图谱构建 -->
|
||||
<Step1GraphBuild
|
||||
<!-- Step 1: Graph Build -->
|
||||
<Step1GraphBuild
|
||||
v-if="currentStep === 1"
|
||||
:currentPhase="currentPhase"
|
||||
:projectData="projectData"
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
:systemLogs="systemLogs"
|
||||
@next-step="handleNextStep"
|
||||
/>
|
||||
<!-- Step 2: 环境搭建 -->
|
||||
<!-- Step 2: Environment Setup -->
|
||||
<Step2EnvSetup
|
||||
v-else-if="currentStep === 2"
|
||||
:projectData="projectData"
|
||||
|
|
@ -95,7 +95,7 @@ const { t, tm } = useI18n()
|
|||
const viewMode = ref('split') // graph | split | workbench
|
||||
|
||||
// Step State
|
||||
const currentStep = ref(1) // 1: 图谱构建, 2: 环境搭建, 3: 开始模拟, 4: 报告生成, 5: 深度互动
|
||||
const currentStep = ref(1) // 1: Graph Build, 2: Env Setup, 3: Simulation, 4: Report, 5: Interaction
|
||||
const stepNames = computed(() => tm('main.stepNames'))
|
||||
|
||||
// Data State
|
||||
|
|
@ -166,7 +166,7 @@ const handleNextStep = (params = {}) => {
|
|||
currentStep.value++
|
||||
addLog(t('log.enterStep', { step: currentStep.value, name: stepNames.value[currentStep.value - 1] }))
|
||||
|
||||
// 如果是从 Step 2 进入 Step 3,记录模拟轮数配置
|
||||
// Step 2 → 3 transition: log the chosen simulation-round count.
|
||||
if (currentStep.value === 3 && params.maxRounds) {
|
||||
addLog(t('log.customSimRounds', { rounds: params.maxRounds }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<template>
|
||||
<div class="process-page">
|
||||
<!-- 顶部导航栏 -->
|
||||
<!-- Top navigation -->
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand" @click="goHome">MIROFISH</div>
|
||||
|
||||
<!-- 中间步骤指示器 -->
|
||||
<!-- Center step indicator -->
|
||||
<div class="nav-center">
|
||||
<div class="step-badge">STEP 01</div>
|
||||
<div class="step-name">图谱构建</div>
|
||||
|
|
@ -16,9 +16,9 @@
|
|||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<!-- Main content area -->
|
||||
<div class="main-content">
|
||||
<!-- 左侧: 实时图谱展示 -->
|
||||
<!-- Left: real-time graph view -->
|
||||
<div class="left-panel" :class="{ 'full-screen': isFullScreen }">
|
||||
<div class="panel-header">
|
||||
<div class="header-left">
|
||||
|
|
@ -44,16 +44,16 @@
|
|||
</div>
|
||||
|
||||
<div class="graph-container" ref="graphContainer">
|
||||
<!-- 图谱可视化(只要有数据就显示) -->
|
||||
<!-- Graph visualization — rendered whenever graph data is present -->
|
||||
<div v-if="graphData" class="graph-view">
|
||||
<svg ref="graphSvg" class="graph-svg"></svg>
|
||||
<!-- 构建中提示 -->
|
||||
<!-- Build-in-progress banner -->
|
||||
<div v-if="currentPhase === 1" class="graph-building-hint">
|
||||
<span class="building-dot"></span>
|
||||
实时更新中...
|
||||
</div>
|
||||
|
||||
<!-- 节点/边详情面板 -->
|
||||
<!-- Node / edge detail panel -->
|
||||
<div v-if="selectedItem" class="detail-panel">
|
||||
<div class="detail-panel-header">
|
||||
<span class="detail-title">{{ selectedItem.type === 'node' ? 'Node Details' : 'Relationship' }}</span>
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
<button class="detail-close" @click="closeDetailPanel">×</button>
|
||||
</div>
|
||||
|
||||
<!-- 节点详情 -->
|
||||
<!-- Node details -->
|
||||
<div v-if="selectedItem.type === 'node'" class="detail-content">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Name:</span>
|
||||
|
|
@ -104,9 +104,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 边详情 -->
|
||||
<!-- Edge details -->
|
||||
<div v-else class="detail-content">
|
||||
<!-- 关系展示 -->
|
||||
<!-- Relationship summary -->
|
||||
<div class="edge-relation">
|
||||
<span class="edge-source">{{ selectedItem.data.source_name || selectedItem.data.source_node_name }}</span>
|
||||
<span class="edge-arrow">→</span>
|
||||
|
|
@ -164,7 +164,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<!-- Loading state -->
|
||||
<div v-else-if="graphLoading" class="graph-loading">
|
||||
<div class="loading-animation">
|
||||
<div class="loading-ring"></div>
|
||||
|
|
@ -174,7 +174,7 @@
|
|||
<p class="loading-text">图谱数据加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 等待构建 -->
|
||||
<!-- Waiting for build -->
|
||||
<div v-else-if="currentPhase < 1" class="graph-waiting">
|
||||
<div class="waiting-icon">
|
||||
<svg viewBox="0 0 100 100" class="network-icon">
|
||||
|
|
@ -193,7 +193,7 @@
|
|||
<p class="waiting-hint">生成完成后将自动开始构建图谱</p>
|
||||
</div>
|
||||
|
||||
<!-- 构建中但还没有数据 -->
|
||||
<!-- Build started but no data yet -->
|
||||
<div v-else-if="currentPhase === 1 && !graphData" class="graph-waiting">
|
||||
<div class="loading-animation">
|
||||
<div class="loading-ring"></div>
|
||||
|
|
@ -204,14 +204,14 @@
|
|||
<p class="waiting-hint">数据即将显示...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="graph-error">
|
||||
<span class="error-icon">⚠</span>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图谱图例 -->
|
||||
<!-- Graph legend -->
|
||||
<div v-if="graphData" class="graph-legend">
|
||||
<div class="legend-item" v-for="type in entityTypes" :key="type.name">
|
||||
<span class="legend-dot" :style="{ background: type.color }"></span>
|
||||
|
|
@ -221,7 +221,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧: 构建流程详情 -->
|
||||
<!-- Right: build-process detail panel -->
|
||||
<div class="right-panel" :class="{ 'hidden': isFullScreen }">
|
||||
<div class="panel-header dark-header">
|
||||
<span class="header-icon">▣</span>
|
||||
|
|
@ -229,7 +229,7 @@
|
|||
</div>
|
||||
|
||||
<div class="process-content">
|
||||
<!-- 阶段1: 本体生成 -->
|
||||
<!-- Phase 1: Ontology generation -->
|
||||
<div class="process-phase" :class="{ 'active': currentPhase === 0, 'completed': currentPhase > 0 }">
|
||||
<div class="phase-header">
|
||||
<span class="phase-num">01</span>
|
||||
|
|
@ -250,7 +250,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本体生成进度 -->
|
||||
<!-- Ontology generation progress -->
|
||||
<div class="detail-section" v-if="ontologyProgress && currentPhase === 0">
|
||||
<div class="detail-label">生成进度</div>
|
||||
<div class="ontology-progress">
|
||||
|
|
@ -259,7 +259,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已生成的本体信息 -->
|
||||
<!-- Generated ontology summary -->
|
||||
<div class="detail-section" v-if="projectData?.ontology">
|
||||
<div class="detail-label">生成的实体类型 ({{ projectData.ontology.entity_types?.length || 0 }})</div>
|
||||
<div class="entity-tags">
|
||||
|
|
@ -293,14 +293,14 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 等待状态 -->
|
||||
<!-- Waiting state -->
|
||||
<div class="detail-section waiting-state" v-if="!projectData?.ontology && currentPhase === 0 && !ontologyProgress">
|
||||
<div class="waiting-hint">等待本体生成...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 阶段2: 图谱构建 -->
|
||||
<!-- Phase 2: Graph build -->
|
||||
<div class="process-phase" :class="{ 'active': currentPhase === 1, 'completed': currentPhase > 1 }">
|
||||
<div class="phase-header">
|
||||
<span class="phase-num">02</span>
|
||||
|
|
@ -321,12 +321,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 等待本体完成 -->
|
||||
<!-- Waiting for ontology to finish -->
|
||||
<div class="detail-section waiting-state" v-if="currentPhase < 1">
|
||||
<div class="waiting-hint">等待本体生成完成...</div>
|
||||
</div>
|
||||
|
||||
<!-- 构建进度 -->
|
||||
<!-- Build progress -->
|
||||
<div class="detail-section" v-if="buildProgress && currentPhase >= 1">
|
||||
<div class="detail-label">构建进度</div>
|
||||
<div class="progress-bar">
|
||||
|
|
@ -358,7 +358,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 阶段3: 完成 -->
|
||||
<!-- Phase 3: Complete -->
|
||||
<div class="process-phase" :class="{ 'active': currentPhase === 2, 'completed': currentPhase > 2 }">
|
||||
<div class="phase-header">
|
||||
<span class="phase-num">03</span>
|
||||
|
|
@ -372,7 +372,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下一步按钮 -->
|
||||
<!-- Next-step button -->
|
||||
<div class="next-step-section" v-if="currentPhase >= 2">
|
||||
<button class="next-step-btn" @click="goToNextStep" :disabled="currentPhase < 2">
|
||||
进入环境搭建
|
||||
|
|
@ -381,7 +381,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目信息面板 -->
|
||||
<!-- Project-info panel -->
|
||||
<div class="project-panel">
|
||||
<div class="project-header">
|
||||
<span class="project-icon">◇</span>
|
||||
|
|
@ -421,29 +421,27 @@ import * as d3 from 'd3'
|
|||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 当前项目ID(可能从'new'变为实际ID)
|
||||
// Current project id — starts as 'new' for fresh projects, replaced with the real id once created.
|
||||
const currentProjectId = ref(route.params.projectId)
|
||||
|
||||
// 状态
|
||||
// State
|
||||
const loading = ref(true)
|
||||
const graphLoading = ref(false)
|
||||
const error = ref('')
|
||||
const projectData = ref(null)
|
||||
const graphData = ref(null)
|
||||
const buildProgress = ref(null)
|
||||
const ontologyProgress = ref(null) // 本体生成进度
|
||||
const currentPhase = ref(-1) // -1: 上传中, 0: 本体生成中, 1: 图谱构建, 2: 完成
|
||||
const selectedItem = ref(null) // 选中的节点或边
|
||||
const ontologyProgress = ref(null)
|
||||
const currentPhase = ref(-1) // -1: uploading, 0: ontology gen, 1: graph build, 2: complete
|
||||
const selectedItem = ref(null) // Currently selected node or edge.
|
||||
const isFullScreen = ref(false)
|
||||
|
||||
// DOM引用
|
||||
const graphContainer = ref(null)
|
||||
const graphSvg = ref(null)
|
||||
|
||||
// 轮询定时器
|
||||
let pollTimer = null
|
||||
|
||||
// 计算属性
|
||||
// Computed
|
||||
const statusClass = computed(() => {
|
||||
if (error.value) return 'error'
|
||||
if (currentPhase.value >= 2) return 'completed'
|
||||
|
|
@ -475,13 +473,12 @@ const entityTypes = computed(() => {
|
|||
return Object.values(typeMap)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const goHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const goToNextStep = () => {
|
||||
// TODO: 进入环境搭建步骤
|
||||
// TODO(#9): Wire up the transition into Step 2 (Environment Setup).
|
||||
alert('环境搭建功能开发中...')
|
||||
}
|
||||
|
||||
|
|
@ -493,12 +490,10 @@ const toggleFullScreen = () => {
|
|||
}, 350)
|
||||
}
|
||||
|
||||
// 关闭详情面板
|
||||
const closeDetailPanel = () => {
|
||||
selectedItem.value = null
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
|
|
@ -515,7 +510,6 @@ const formatDate = (dateStr) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 选中节点
|
||||
const selectNode = (nodeData, color) => {
|
||||
selectedItem.value = {
|
||||
type: 'node',
|
||||
|
|
@ -525,7 +519,6 @@ const selectNode = (nodeData, color) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 选中边
|
||||
const selectEdge = (edgeData) => {
|
||||
selectedItem.value = {
|
||||
type: 'edge',
|
||||
|
|
@ -550,24 +543,22 @@ const getPhaseStatusText = (phase) => {
|
|||
return '等待中'
|
||||
}
|
||||
|
||||
// 初始化 - 处理新建项目或加载已有项目
|
||||
// Initialize: either create a new project from the pending-upload store, or load an existing one by id.
|
||||
const initProject = async () => {
|
||||
const paramProjectId = route.params.projectId
|
||||
|
||||
|
||||
if (paramProjectId === 'new') {
|
||||
// 新建项目:从 store 获取待上传的数据
|
||||
await handleNewProject()
|
||||
} else {
|
||||
// 加载已有项目
|
||||
currentProjectId.value = paramProjectId
|
||||
await loadProject()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理新建项目 - 调用 ontology/generate API
|
||||
// Handle a fresh project — call the ontology/generate API with the pending uploads.
|
||||
const handleNewProject = async () => {
|
||||
const pending = getPendingUpload()
|
||||
|
||||
|
||||
if (!pending.isPending || pending.files.length === 0) {
|
||||
error.value = '没有待上传的文件,请返回首页重新操作'
|
||||
loading.value = false
|
||||
|
|
@ -576,36 +567,32 @@ const handleNewProject = async () => {
|
|||
|
||||
try {
|
||||
loading.value = true
|
||||
currentPhase.value = 0 // 本体生成阶段
|
||||
currentPhase.value = 0 // Ontology-generation phase.
|
||||
ontologyProgress.value = { message: '正在上传文件并分析文档...' }
|
||||
|
||||
// 构建 FormData
|
||||
const formDataObj = new FormData()
|
||||
pending.files.forEach(file => {
|
||||
formDataObj.append('files', file)
|
||||
})
|
||||
formDataObj.append('simulation_requirement', pending.simulationRequirement)
|
||||
|
||||
// 调用本体生成 API
|
||||
const response = await generateOntology(formDataObj)
|
||||
|
||||
|
||||
if (response.success) {
|
||||
// 清除待上传数据
|
||||
clearPendingUpload()
|
||||
|
||||
// 更新项目ID和数据
|
||||
|
||||
currentProjectId.value = response.data.project_id
|
||||
projectData.value = response.data
|
||||
|
||||
// 更新URL(不刷新页面)
|
||||
|
||||
// Update the URL in place without reloading.
|
||||
router.replace({
|
||||
name: 'Process',
|
||||
params: { projectId: response.data.project_id }
|
||||
})
|
||||
|
||||
|
||||
ontologyProgress.value = null
|
||||
|
||||
// 自动开始图谱构建
|
||||
|
||||
// Kick off the graph build automatically.
|
||||
await startBuildGraph()
|
||||
} else {
|
||||
error.value = response.error || '本体生成失败'
|
||||
|
|
@ -618,7 +605,6 @@ const handleNewProject = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 加载已有项目数据
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
|
@ -628,18 +614,18 @@ const loadProject = async () => {
|
|||
projectData.value = response.data
|
||||
updatePhaseByStatus(response.data.status)
|
||||
|
||||
// 自动开始图谱构建
|
||||
// Auto-start graph build if the ontology is ready but no graph exists yet.
|
||||
if (response.data.status === 'ontology_generated' && !response.data.graph_id) {
|
||||
await startBuildGraph()
|
||||
}
|
||||
|
||||
// 继续轮询构建中的任务
|
||||
|
||||
// Resume polling for an in-progress build.
|
||||
if (response.data.status === 'graph_building' && response.data.graph_build_task_id) {
|
||||
currentPhase.value = 1
|
||||
startPollingTask(response.data.graph_build_task_id)
|
||||
}
|
||||
|
||||
// 加载已完成的图谱
|
||||
|
||||
// Load the finished graph straight away.
|
||||
if (response.data.status === 'graph_completed' && response.data.graph_id) {
|
||||
currentPhase.value = 2
|
||||
await loadGraph(response.data.graph_id)
|
||||
|
|
@ -673,11 +659,9 @@ const updatePhaseByStatus = (status) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 开始构建图谱
|
||||
const startBuildGraph = async () => {
|
||||
try {
|
||||
currentPhase.value = 1
|
||||
// 设置初始进度
|
||||
buildProgress.value = {
|
||||
progress: 0,
|
||||
message: '正在启动图谱构建...'
|
||||
|
|
@ -688,13 +672,10 @@ const startBuildGraph = async () => {
|
|||
if (response.success) {
|
||||
buildProgress.value.message = '图谱构建任务已启动...'
|
||||
|
||||
// 保存 task_id 用于轮询
|
||||
const taskId = response.data.task_id
|
||||
|
||||
// 启动图谱数据轮询(独立于任务状态轮询)
|
||||
|
||||
// Two independent polling loops: graph data refresh AND task-status polling.
|
||||
startGraphPolling()
|
||||
|
||||
// 启动任务状态轮询
|
||||
startPollingTask(taskId)
|
||||
} else {
|
||||
error.value = response.error || '启动图谱构建失败'
|
||||
|
|
@ -707,28 +688,22 @@ const startBuildGraph = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 图谱数据轮询定时器
|
||||
let graphPollTimer = null
|
||||
|
||||
// 启动图谱数据轮询
|
||||
const startGraphPolling = () => {
|
||||
// 立即获取一次
|
||||
fetchGraphData()
|
||||
|
||||
// 每 10 秒自动获取一次图谱数据
|
||||
// Refresh every 10 seconds while the build is in progress.
|
||||
graphPollTimer = setInterval(async () => {
|
||||
await fetchGraphData()
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
// 手动刷新图谱
|
||||
const refreshGraph = async () => {
|
||||
graphLoading.value = true
|
||||
await fetchGraphData()
|
||||
graphLoading.value = false
|
||||
}
|
||||
|
||||
// 停止图谱数据轮询
|
||||
const stopGraphPolling = () => {
|
||||
if (graphPollTimer) {
|
||||
clearInterval(graphPollTimer)
|
||||
|
|
@ -736,27 +711,25 @@ const stopGraphPolling = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 获取图谱数据
|
||||
const fetchGraphData = async () => {
|
||||
try {
|
||||
// 先获取项目信息以获取 graph_id
|
||||
// Fetch the project first so we know which graph_id to load.
|
||||
const projectResponse = await getProject(currentProjectId.value)
|
||||
|
||||
|
||||
if (projectResponse.success && projectResponse.data.graph_id) {
|
||||
const graphId = projectResponse.data.graph_id
|
||||
projectData.value = projectResponse.data
|
||||
|
||||
// 获取图谱数据
|
||||
|
||||
const graphResponse = await getGraphData(graphId)
|
||||
|
||||
|
||||
if (graphResponse.success && graphResponse.data) {
|
||||
const newData = graphResponse.data
|
||||
const newNodeCount = newData.node_count || newData.nodes?.length || 0
|
||||
const oldNodeCount = graphData.value?.node_count || graphData.value?.nodes?.length || 0
|
||||
|
||||
|
||||
console.log('Fetching graph data, nodes:', newNodeCount, 'edges:', newData.edge_count || newData.edges?.length || 0)
|
||||
|
||||
// 数据有变化时更新渲染
|
||||
|
||||
// Re-render only when the node count has actually changed.
|
||||
if (newNodeCount !== oldNodeCount || !graphData.value) {
|
||||
graphData.value = newData
|
||||
await nextTick()
|
||||
|
|
@ -769,18 +742,15 @@ const fetchGraphData = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 轮询任务状态
|
||||
const startPollingTask = (taskId) => {
|
||||
// 立即执行一次查询
|
||||
// First call fires immediately; subsequent calls every 2 seconds.
|
||||
pollTaskStatus(taskId)
|
||||
|
||||
// 然后定时轮询
|
||||
|
||||
pollTimer = setInterval(() => {
|
||||
pollTaskStatus(taskId)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// 查询任务状态
|
||||
const pollTaskStatus = async (taskId) => {
|
||||
try {
|
||||
const response = await getTaskStatus(taskId)
|
||||
|
|
@ -788,7 +758,6 @@ const pollTaskStatus = async (taskId) => {
|
|||
if (response.success) {
|
||||
const task = response.data
|
||||
|
||||
// 更新进度显示
|
||||
buildProgress.value = {
|
||||
progress: task.progress || 0,
|
||||
message: task.message || '处理中...'
|
||||
|
|
@ -797,32 +766,30 @@ const pollTaskStatus = async (taskId) => {
|
|||
console.log('Task status:', task.status, 'Progress:', task.progress)
|
||||
|
||||
if (task.status === 'completed') {
|
||||
console.log('✅ 图谱构建完成,正在加载完整数据...')
|
||||
console.log('✅ Graph build complete — loading full graph data...')
|
||||
|
||||
stopPolling()
|
||||
stopGraphPolling()
|
||||
currentPhase.value = 2
|
||||
|
||||
// 更新进度显示为完成状态
|
||||
// Update the progress display to a "complete" state.
|
||||
buildProgress.value = {
|
||||
progress: 100,
|
||||
message: '构建完成,正在加载图谱...'
|
||||
}
|
||||
|
||||
// 重新加载项目数据获取 graph_id
|
||||
// Reload the project so we have a fresh graph_id.
|
||||
const projectResponse = await getProject(currentProjectId.value)
|
||||
if (projectResponse.success) {
|
||||
projectData.value = projectResponse.data
|
||||
|
||||
// 最终加载完整图谱数据
|
||||
|
||||
if (projectResponse.data.graph_id) {
|
||||
console.log('📊 加载完整图谱:', projectResponse.data.graph_id)
|
||||
console.log('📊 Loading full graph:', projectResponse.data.graph_id)
|
||||
await loadGraph(projectResponse.data.graph_id)
|
||||
console.log('✅ 图谱加载完成')
|
||||
console.log('✅ Graph load complete')
|
||||
}
|
||||
}
|
||||
|
||||
// 清除进度显示
|
||||
|
||||
buildProgress.value = null
|
||||
} else if (task.status === 'failed') {
|
||||
stopPolling()
|
||||
|
|
@ -843,7 +810,6 @@ const stopPolling = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 加载图谱数据
|
||||
const loadGraph = async (graphId) => {
|
||||
try {
|
||||
graphLoading.value = true
|
||||
|
|
@ -861,7 +827,7 @@ const loadGraph = async (graphId) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 渲染图谱 (D3.js)
|
||||
// Render the knowledge graph with D3.js.
|
||||
const renderGraph = () => {
|
||||
if (!graphSvg.value || !graphData.value) {
|
||||
console.log('Cannot render: svg or data missing')
|
||||
|
|
@ -874,7 +840,7 @@ const renderGraph = () => {
|
|||
return
|
||||
}
|
||||
|
||||
// 获取容器尺寸
|
||||
// Read the container's current dimensions.
|
||||
const rect = container.getBoundingClientRect()
|
||||
const width = rect.width || 800
|
||||
const height = (rect.height || 600) - 60
|
||||
|
|
@ -893,13 +859,11 @@ const renderGraph = () => {
|
|||
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
// 处理节点数据
|
||||
const nodesData = graphData.value.nodes || []
|
||||
const edgesData = graphData.value.edges || []
|
||||
|
||||
|
||||
if (nodesData.length === 0) {
|
||||
console.log('No nodes to render')
|
||||
// 显示空状态
|
||||
svg.append('text')
|
||||
.attr('x', width / 2)
|
||||
.attr('y', height / 2)
|
||||
|
|
@ -909,7 +873,7 @@ const renderGraph = () => {
|
|||
return
|
||||
}
|
||||
|
||||
// 创建节点映射用于查找名称
|
||||
// Build a uuid → node lookup so we can resolve source/target names later.
|
||||
const nodeMap = {}
|
||||
nodesData.forEach(n => {
|
||||
nodeMap[n.uuid] = n
|
||||
|
|
@ -919,10 +883,10 @@ const renderGraph = () => {
|
|||
id: n.uuid,
|
||||
name: n.name || '未命名',
|
||||
type: n.labels?.find(l => l !== 'Entity' && l !== 'Node') || 'Entity',
|
||||
rawData: n // 保存原始数据
|
||||
rawData: n // Keep the original data on the simulation node.
|
||||
}))
|
||||
|
||||
// 创建节点ID集合用于过滤有效边
|
||||
|
||||
// Set of valid node ids — used to filter out edges that reference unknown nodes.
|
||||
const nodeIds = new Set(nodes.map(n => n.id))
|
||||
|
||||
const edges = edgesData
|
||||
|
|
@ -940,13 +904,13 @@ const renderGraph = () => {
|
|||
|
||||
console.log('Nodes:', nodes.length, 'Edges:', edges.length)
|
||||
|
||||
// 颜色映射
|
||||
// Map each entity type to a stable color.
|
||||
const types = [...new Set(nodes.map(n => n.type))]
|
||||
const colorScale = d3.scaleOrdinal()
|
||||
.domain(types)
|
||||
.range(['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C', '#2D3436', '#6C5CE7'])
|
||||
|
||||
// 力导向布局
|
||||
// Force-directed layout.
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(edges).id(d => d.id).distance(100).strength(0.5))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
|
|
@ -955,7 +919,7 @@ const renderGraph = () => {
|
|||
.force('x', d3.forceX(width / 2).strength(0.05))
|
||||
.force('y', d3.forceY(height / 2).strength(0.05))
|
||||
|
||||
// 添加缩放功能
|
||||
// Pan/zoom support.
|
||||
const g = svg.append('g')
|
||||
|
||||
svg.call(d3.zoom()
|
||||
|
|
@ -965,7 +929,7 @@ const renderGraph = () => {
|
|||
g.attr('transform', event.transform)
|
||||
}))
|
||||
|
||||
// 绘制边(包含可点击的透明宽线)
|
||||
// Edges — each rendered as a thin visible line plus a wide transparent line for hit testing.
|
||||
const linkGroup = g.append('g')
|
||||
.attr('class', 'links')
|
||||
.selectAll('g')
|
||||
|
|
@ -978,18 +942,18 @@ const renderGraph = () => {
|
|||
selectEdge(d.rawData)
|
||||
})
|
||||
|
||||
// 可见的细线
|
||||
// Visible thin line.
|
||||
const link = linkGroup.append('line')
|
||||
.attr('stroke', '#ccc')
|
||||
.attr('stroke-width', 1.5)
|
||||
.attr('stroke-opacity', 0.6)
|
||||
|
||||
// 透明的宽线用于点击
|
||||
// Wide transparent line — gives the edge a larger click target.
|
||||
linkGroup.append('line')
|
||||
.attr('stroke', 'transparent')
|
||||
.attr('stroke-width', 10)
|
||||
|
||||
// 边标签
|
||||
// Edge labels.
|
||||
const linkLabel = g.append('g')
|
||||
.attr('class', 'link-labels')
|
||||
.selectAll('text')
|
||||
|
|
@ -1001,7 +965,7 @@ const renderGraph = () => {
|
|||
.attr('text-anchor', 'middle')
|
||||
.text(d => d.type.length > 15 ? d.type.substring(0, 12) + '...' : d.type)
|
||||
|
||||
// 绘制节点
|
||||
// Nodes.
|
||||
const node = g.append('g')
|
||||
.attr('class', 'nodes')
|
||||
.selectAll('g')
|
||||
|
|
@ -1033,20 +997,19 @@ const renderGraph = () => {
|
|||
.attr('fill', '#333')
|
||||
.attr('font-family', 'JetBrains Mono, monospace')
|
||||
|
||||
// 点击空白处关闭详情面板
|
||||
// Click on empty space closes the detail panel.
|
||||
svg.on('click', () => {
|
||||
closeDetailPanel()
|
||||
})
|
||||
|
||||
simulation.on('tick', () => {
|
||||
// 更新所有边的位置(包括可见线和透明点击区域)
|
||||
// Update both the visible and transparent lines for every edge.
|
||||
linkGroup.selectAll('line')
|
||||
.attr('x1', d => d.source.x)
|
||||
.attr('y1', d => d.source.y)
|
||||
.attr('x2', d => d.target.x)
|
||||
.attr('y2', d => d.target.y)
|
||||
|
||||
// 更新边标签位置
|
||||
|
||||
linkLabel
|
||||
.attr('x', d => (d.source.x + d.target.x) / 2)
|
||||
.attr('y', d => (d.source.y + d.target.y) / 2 - 5)
|
||||
|
|
@ -1072,14 +1035,13 @@ const renderGraph = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 监听图谱数据变化
|
||||
// Re-render whenever the graph data changes.
|
||||
watch(graphData, () => {
|
||||
if (graphData.value) {
|
||||
nextTick(() => renderGraph())
|
||||
}
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
initProject()
|
||||
})
|
||||
|
|
@ -1091,7 +1053,7 @@ onUnmounted(() => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 变量 */
|
||||
/* Variables */
|
||||
:root {
|
||||
--black: #000000;
|
||||
--white: #FFFFFF;
|
||||
|
|
@ -1108,7 +1070,7 @@ onUnmounted(() => {
|
|||
overflow: hidden; /* Prevent body scroll in fullscreen */
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
/* Navigation bar */
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1194,14 +1156,14 @@ onUnmounted(() => {
|
|||
color: #999;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
/* Main content area */
|
||||
.main-content {
|
||||
display: flex;
|
||||
height: calc(100vh - 56px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 左侧面板 - 50% default */
|
||||
/* Left panel — 50% default */
|
||||
.left-panel {
|
||||
width: 50%;
|
||||
flex: none; /* Fixed width initially */
|
||||
|
|
@ -1311,7 +1273,7 @@ onUnmounted(() => {
|
|||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 图谱容器 */
|
||||
/* Graph container */
|
||||
.graph-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
|
@ -1427,7 +1389,7 @@ onUnmounted(() => {
|
|||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
/* 节点/边详情面板 */
|
||||
/* Node / edge detail panel */
|
||||
.detail-panel {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
|
|
@ -1543,7 +1505,7 @@ onUnmounted(() => {
|
|||
color: #666;
|
||||
}
|
||||
|
||||
/* 边详情关系展示 */
|
||||
/* Edge details — relationship display */
|
||||
.edge-relation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1587,7 +1549,7 @@ onUnmounted(() => {
|
|||
border-bottom: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
/* Properties 属性列表 */
|
||||
/* Properties list */
|
||||
.properties-list {
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
|
|
@ -1616,7 +1578,7 @@ onUnmounted(() => {
|
|||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Episodes 列表 */
|
||||
/* Episodes list */
|
||||
.episodes-list {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
|
|
@ -1641,7 +1603,7 @@ onUnmounted(() => {
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 图谱图例 */
|
||||
/* Graph legend */
|
||||
.graph-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -1672,7 +1634,7 @@ onUnmounted(() => {
|
|||
color: #999;
|
||||
}
|
||||
|
||||
/* 右侧面板 - 50% default */
|
||||
/* Right panel — 50% default */
|
||||
.right-panel {
|
||||
width: 50%;
|
||||
flex: none;
|
||||
|
|
@ -1702,14 +1664,14 @@ onUnmounted(() => {
|
|||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 流程内容 */
|
||||
/* Process content */
|
||||
.process-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 流程阶段 */
|
||||
/* Process phase */
|
||||
.process-phase {
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #E0E0E0;
|
||||
|
|
@ -1795,12 +1757,12 @@ onUnmounted(() => {
|
|||
color: #fff;
|
||||
}
|
||||
|
||||
/* 阶段详情 */
|
||||
/* Phase details */
|
||||
.phase-detail {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 实体标签 */
|
||||
/* Entity tags */
|
||||
.entity-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -1815,7 +1777,7 @@ onUnmounted(() => {
|
|||
color: #333;
|
||||
}
|
||||
|
||||
/* 关系列表 */
|
||||
/* Relationship list */
|
||||
.relation-list {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
|
@ -1852,7 +1814,7 @@ onUnmounted(() => {
|
|||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* 本体生成进度 */
|
||||
/* Ontology-generation progress */
|
||||
.ontology-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1876,7 +1838,7 @@ onUnmounted(() => {
|
|||
color: #333;
|
||||
}
|
||||
|
||||
/* 等待状态 */
|
||||
/* Waiting state */
|
||||
.waiting-state {
|
||||
padding: 16px;
|
||||
background: #F9F9F9;
|
||||
|
|
@ -1889,7 +1851,7 @@ onUnmounted(() => {
|
|||
color: #999;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
/* Progress bar */
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: #E0E0E0;
|
||||
|
|
@ -1918,7 +1880,7 @@ onUnmounted(() => {
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 构建结果 */
|
||||
/* Build result */
|
||||
.build-result {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
|
@ -1946,7 +1908,7 @@ onUnmounted(() => {
|
|||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* 下一步按钮 */
|
||||
/* Next-step button */
|
||||
.next-step-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
|
|
@ -1983,7 +1945,7 @@ onUnmounted(() => {
|
|||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* 项目信息面板 */
|
||||
/* Project-info panel */
|
||||
.project-panel {
|
||||
border-top: 1px solid #E0E0E0;
|
||||
background: #FAFAFA;
|
||||
|
|
@ -2041,7 +2003,7 @@ onUnmounted(() => {
|
|||
color: #666;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Step4 报告生成 -->
|
||||
<!-- Right Panel: Step 4 — Report -->
|
||||
<div class="panel-wrapper right" :style="rightPanelStyle">
|
||||
<Step4Report
|
||||
:reportId="currentReportId"
|
||||
|
|
@ -83,7 +83,7 @@ const props = defineProps({
|
|||
reportId: String
|
||||
})
|
||||
|
||||
// Layout State - 默认切换到工作台视角
|
||||
// Layout State — default to the workbench view
|
||||
const viewMode = ref('workbench')
|
||||
|
||||
// Data State
|
||||
|
|
@ -146,26 +146,23 @@ const loadReportData = async () => {
|
|||
try {
|
||||
addLog(t('log.loadReportData', { id: currentReportId.value }))
|
||||
|
||||
// 获取 report 信息以获取 simulation_id
|
||||
// Fetch the report so we can derive simulation_id from it.
|
||||
const reportRes = await getReport(currentReportId.value)
|
||||
if (reportRes.success && reportRes.data) {
|
||||
const reportData = reportRes.data
|
||||
simulationId.value = reportData.simulation_id
|
||||
|
||||
if (simulationId.value) {
|
||||
// 获取 simulation 信息
|
||||
const simRes = await getSimulation(simulationId.value)
|
||||
if (simRes.success && simRes.data) {
|
||||
const simData = simRes.data
|
||||
|
||||
// 获取 project 信息
|
||||
if (simData.project_id) {
|
||||
const projRes = await getProject(simData.project_id)
|
||||
if (projRes.success && projRes.data) {
|
||||
projectData.value = projRes.data
|
||||
addLog(t('log.projectLoadSuccess', { id: projRes.data.project_id }))
|
||||
|
||||
// 获取 graph 数据
|
||||
if (projRes.data.graph_id) {
|
||||
await loadGraph(projRes.data.graph_id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Step3 开始模拟 -->
|
||||
<!-- Right Panel: Step 3 — Simulation -->
|
||||
<div class="panel-wrapper right" :style="rightPanelStyle">
|
||||
<Step3Simulation
|
||||
:simulationId="currentSimulationId"
|
||||
|
|
@ -92,9 +92,9 @@ const viewMode = ref('split')
|
|||
|
||||
// Data State
|
||||
const currentSimulationId = ref(route.params.simulationId)
|
||||
// 直接在初始化时从 query 参数获取 maxRounds,确保子组件能立即获取到值
|
||||
// Read maxRounds from the route query at init so the child gets it on first render.
|
||||
const maxRounds = ref(route.query.maxRounds ? parseInt(route.query.maxRounds) : null)
|
||||
const minutesPerRound = ref(30) // 默认每轮30分钟
|
||||
const minutesPerRound = ref(30) // Default: 30 minutes per round.
|
||||
const projectData = ref(null)
|
||||
const graphData = ref(null)
|
||||
const graphLoading = ref(false)
|
||||
|
|
@ -150,20 +150,19 @@ const toggleMaximize = (target) => {
|
|||
}
|
||||
|
||||
const handleGoBack = async () => {
|
||||
// 在返回 Step 2 之前,先关闭正在运行的模拟
|
||||
// Before returning to Step 2, shut down anything that is still running.
|
||||
addLog(t('log.preparingGoBack'))
|
||||
|
||||
// 停止轮询
|
||||
|
||||
stopGraphRefresh()
|
||||
|
||||
|
||||
try {
|
||||
// 先尝试优雅关闭模拟环境
|
||||
// Try graceful close first; fall back to a hard stop if that fails.
|
||||
const envStatusRes = await getEnvStatus({ simulation_id: currentSimulationId.value })
|
||||
|
||||
|
||||
if (envStatusRes.success && envStatusRes.data?.env_alive) {
|
||||
addLog(t('log.closingSimEnv'))
|
||||
try {
|
||||
await closeSimulationEnv({
|
||||
await closeSimulationEnv({
|
||||
simulation_id: currentSimulationId.value,
|
||||
timeout: 10
|
||||
})
|
||||
|
|
@ -178,7 +177,7 @@ const handleGoBack = async () => {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
// 环境未运行,检查是否需要停止进程
|
||||
// Env is not running; only stop the process if one is still active.
|
||||
if (isSimulating.value) {
|
||||
addLog(t('log.stoppingSimProcess'))
|
||||
try {
|
||||
|
|
@ -192,14 +191,13 @@ const handleGoBack = async () => {
|
|||
} catch (err) {
|
||||
addLog(t('log.checkStatusFailed', { error: err.message }))
|
||||
}
|
||||
|
||||
// 返回到 Step 2 (环境搭建)
|
||||
|
||||
// Back to Step 2 (Environment Setup).
|
||||
router.push({ name: 'Simulation', params: { simulationId: currentSimulationId.value } })
|
||||
}
|
||||
|
||||
const handleNextStep = () => {
|
||||
// Step3Simulation 组件会直接处理报告生成和路由跳转
|
||||
// 这个方法仅作为备用
|
||||
// Step3Simulation handles report generation and routing itself; this is a fallback.
|
||||
addLog(t('log.enterStep4'))
|
||||
}
|
||||
|
||||
|
|
@ -207,13 +205,12 @@ const handleNextStep = () => {
|
|||
const loadSimulationData = async () => {
|
||||
try {
|
||||
addLog(t('log.loadingSimData', { id: currentSimulationId.value }))
|
||||
|
||||
// 获取 simulation 信息
|
||||
|
||||
const simRes = await getSimulation(currentSimulationId.value)
|
||||
if (simRes.success && simRes.data) {
|
||||
const simData = simRes.data
|
||||
|
||||
// 获取 simulation config 以获取 minutes_per_round
|
||||
|
||||
// Read minutes_per_round from the simulation config.
|
||||
try {
|
||||
const configRes = await getSimulationConfig(currentSimulationId.value)
|
||||
if (configRes.success && configRes.data?.time_config?.minutes_per_round) {
|
||||
|
|
@ -223,15 +220,13 @@ const loadSimulationData = async () => {
|
|||
} catch (configErr) {
|
||||
addLog(t('log.timeConfigFetchFailed', { minutes: minutesPerRound.value }))
|
||||
}
|
||||
|
||||
// 获取 project 信息
|
||||
|
||||
if (simData.project_id) {
|
||||
const projRes = await getProject(simData.project_id)
|
||||
if (projRes.success && projRes.data) {
|
||||
projectData.value = projRes.data
|
||||
addLog(t('log.projectLoadSuccess', { id: projRes.data.project_id }))
|
||||
|
||||
// 获取 graph 数据
|
||||
|
||||
if (projRes.data.graph_id) {
|
||||
await loadGraph(projRes.data.graph_id)
|
||||
}
|
||||
|
|
@ -246,8 +241,8 @@ const loadSimulationData = async () => {
|
|||
}
|
||||
|
||||
const loadGraph = async (graphId) => {
|
||||
// 当正在模拟时,自动刷新不显示全屏 loading,以免闪烁
|
||||
// 手动刷新或初始加载时显示 loading
|
||||
// Suppress the full-screen loading state during auto-refresh while a simulation
|
||||
// is running, to avoid flicker. Manual refresh and initial load still show it.
|
||||
if (!isSimulating.value) {
|
||||
graphLoading.value = true
|
||||
}
|
||||
|
|
@ -279,7 +274,7 @@ let graphRefreshTimer = null
|
|||
const startGraphRefresh = () => {
|
||||
if (graphRefreshTimer) return
|
||||
addLog(t('log.graphRealtimeRefreshStart'))
|
||||
// 立即刷新一次,然后每30秒刷新
|
||||
// First refresh fires immediately; subsequent ones every 30 seconds.
|
||||
graphRefreshTimer = setInterval(refreshGraph, 30000)
|
||||
}
|
||||
|
||||
|
|
@ -301,8 +296,8 @@ watch(isSimulating, (newValue) => {
|
|||
|
||||
onMounted(() => {
|
||||
addLog(t('log.simRunViewInit'))
|
||||
|
||||
// 记录 maxRounds 配置(值已在初始化时从 query 参数获取)
|
||||
|
||||
// Log the maxRounds configuration (already read from the query at init).
|
||||
if (maxRounds.value) {
|
||||
addLog(t('log.customRounds', { rounds: maxRounds.value }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Step2 环境搭建 -->
|
||||
<!-- Right Panel: Step 2 — Environment Setup -->
|
||||
<div class="panel-wrapper right" :style="rightPanelStyle">
|
||||
<Step2EnvSetup
|
||||
:simulationId="currentSimulationId"
|
||||
|
|
@ -142,7 +142,7 @@ const toggleMaximize = (target) => {
|
|||
}
|
||||
|
||||
const handleGoBack = () => {
|
||||
// 返回到 process 页面
|
||||
// Return to the Process page.
|
||||
if (projectData.value?.project_id) {
|
||||
router.push({ name: 'Process', params: { projectId: projectData.value.project_id } })
|
||||
} else {
|
||||
|
|
@ -153,65 +153,60 @@ const handleGoBack = () => {
|
|||
const handleNextStep = (params = {}) => {
|
||||
addLog(t('log.enterStep3'))
|
||||
|
||||
// 记录模拟轮数配置
|
||||
if (params.maxRounds) {
|
||||
addLog(t('log.customRoundsConfig', { rounds: params.maxRounds }))
|
||||
} else {
|
||||
addLog(t('log.useAutoRounds'))
|
||||
}
|
||||
|
||||
// 构建路由参数
|
||||
|
||||
const routeParams = {
|
||||
name: 'SimulationRun',
|
||||
params: { simulationId: currentSimulationId.value }
|
||||
}
|
||||
|
||||
// 如果有自定义轮数,通过 query 参数传递
|
||||
|
||||
// Pass a custom round count to Step 3 via the route query.
|
||||
if (params.maxRounds) {
|
||||
routeParams.query = { maxRounds: params.maxRounds }
|
||||
}
|
||||
|
||||
// 跳转到 Step 3 页面
|
||||
|
||||
router.push(routeParams)
|
||||
}
|
||||
|
||||
// --- Data Logic ---
|
||||
|
||||
/**
|
||||
* 检查并关闭正在运行的模拟
|
||||
* 当用户从 Step 3 返回到 Step 2 时,默认用户要退出模拟
|
||||
* Stop any simulation that is still running.
|
||||
* When the user navigates back from Step 3 to Step 2 we treat that as an exit
|
||||
* intent and tear the simulation down.
|
||||
*/
|
||||
const checkAndStopRunningSimulation = async () => {
|
||||
if (!currentSimulationId.value) return
|
||||
|
||||
|
||||
try {
|
||||
// 先检查模拟环境是否存活
|
||||
const envStatusRes = await getEnvStatus({ simulation_id: currentSimulationId.value })
|
||||
|
||||
|
||||
if (envStatusRes.success && envStatusRes.data?.env_alive) {
|
||||
addLog(t('log.detectedSimEnvRunning'))
|
||||
|
||||
// 尝试优雅关闭模拟环境
|
||||
|
||||
// Try to close the env gracefully; fall back to a hard stop on failure.
|
||||
try {
|
||||
const closeRes = await closeSimulationEnv({
|
||||
const closeRes = await closeSimulationEnv({
|
||||
simulation_id: currentSimulationId.value,
|
||||
timeout: 10 // 10秒超时
|
||||
timeout: 10
|
||||
})
|
||||
|
||||
|
||||
if (closeRes.success) {
|
||||
addLog(t('log.simEnvClosed'))
|
||||
} else {
|
||||
addLog(t('log.closeSimEnvFailedWithError', { error: closeRes.error || t('common.unknownError') }))
|
||||
// 如果优雅关闭失败,尝试强制停止
|
||||
await forceStopSimulation()
|
||||
}
|
||||
} catch (closeErr) {
|
||||
addLog(t('log.closeSimEnvException', { error: closeErr.message }))
|
||||
// 如果优雅关闭异常,尝试强制停止
|
||||
await forceStopSimulation()
|
||||
}
|
||||
} else {
|
||||
// 环境未运行,但可能进程还在,检查模拟状态
|
||||
// Env is not alive, but the worker process might still be running.
|
||||
const simRes = await getSimulation(currentSimulationId.value)
|
||||
if (simRes.success && simRes.data?.status === 'running') {
|
||||
addLog(t('log.detectedSimRunning'))
|
||||
|
|
@ -219,14 +214,11 @@ const checkAndStopRunningSimulation = async () => {
|
|||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 检查环境状态失败不影响后续流程
|
||||
console.warn('检查模拟状态失败:', err)
|
||||
// A failure here must not block the rest of the flow.
|
||||
console.warn('Failed to check simulation status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制停止模拟
|
||||
*/
|
||||
const forceStopSimulation = async () => {
|
||||
try {
|
||||
const stopRes = await stopSimulation({ simulation_id: currentSimulationId.value })
|
||||
|
|
@ -244,19 +236,16 @@ const loadSimulationData = async () => {
|
|||
try {
|
||||
addLog(t('log.loadingSimData', { id: currentSimulationId.value }))
|
||||
|
||||
// 获取 simulation 信息
|
||||
const simRes = await getSimulation(currentSimulationId.value)
|
||||
if (simRes.success && simRes.data) {
|
||||
const simData = simRes.data
|
||||
|
||||
// 获取 project 信息
|
||||
if (simData.project_id) {
|
||||
const projRes = await getProject(simData.project_id)
|
||||
if (projRes.success && projRes.data) {
|
||||
projectData.value = projRes.data
|
||||
addLog(t('log.projectLoadSuccess', { id: projRes.data.project_id }))
|
||||
|
||||
// 获取 graph 数据
|
||||
|
||||
if (projRes.data.graph_id) {
|
||||
await loadGraph(projRes.data.graph_id)
|
||||
}
|
||||
|
|
@ -293,11 +282,10 @@ const refreshGraph = () => {
|
|||
|
||||
onMounted(async () => {
|
||||
addLog(t('log.simViewInit'))
|
||||
|
||||
// 检查并关闭正在运行的模拟(用户从 Step 3 返回时)
|
||||
|
||||
// Tear down any running simulation in case the user navigated back from Step 3.
|
||||
await checkAndStopRunningSimulation()
|
||||
|
||||
// 加载模拟数据
|
||||
|
||||
loadSimulationData()
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in New Issue