refactor: consolidate finance workflow modules

This commit is contained in:
caoxiaozhu
2026-06-23 11:21:18 +08:00
parent 1f40ce3df3
commit 73966b3a7b
52 changed files with 3468 additions and 2865 deletions

View File

@@ -0,0 +1,410 @@
# X-Financial Duplicate Code Refactor Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans for multi-task execution. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Reduce duplicated business logic, renderer helpers, protocol constants, and test fixtures without changing existing behavior.
**Architecture:** Start with low-risk pure helpers, then move toward business contract consolidation. Each round must add or preserve regression tests before production code changes, and backend validation must run inside `x-financial-main`.
**Tech Stack:** Vue 3, Vite, Node test runner, FastAPI, SQLAlchemy, pytest, Docker Compose.
---
## Scope
This document records the duplicate-code audit from 2026-06-23 and defines a staged cleanup path. The first implementation slice is intentionally small: extract shared frontend conversation rendering helpers used by `markdown.js` and `aiConversationHtmlRenderer.js`.
The following areas are in scope:
- Frontend AI markdown / HTML trusted block normalization.
- Frontend reimbursement review panel model duplication.
- Workbench AI composer / attachment strip duplication.
- Backend application gate, fact extraction, amount/date/location parsing.
- Backend platform risk context helper duplication.
- Cross-layer status, expense type, document type, and risk-level taxonomy drift.
- Test helper duplication for DB sessions, FastAPI client overrides, and OCR fakes.
The following areas are out of scope for the first implementation slice:
- Changing application submission behavior.
- Changing reimbursement association decision flow.
- Changing API response shapes.
- Editing unrelated notification top bar changes already present in the worktree.
## Findings
### P0: Frontend Trusted HTML And Conversation Text Helpers
`web/src/utils/markdown.js` and `web/src/utils/aiConversationHtmlRenderer.js` both implement:
- `ALLOWED_COLON_HEADING_TITLES`
- `BUSINESS_FIELD_LABELS`
- `TRUSTED_HTML_ALLOWED_TAGS`
- `TRUSTED_HTML_ALLOWED_ATTRS`
- `splitColonHeadingLine`
- `normalizeBusinessFieldLine`
- `hasOnlyTrustedHtmlTags`
- `sanitizeTrustedHtmlBlock`
- `extractTrustedHtmlBlocks`
- `restoreTrustedHtmlBlocks`
Plan:
- [x] Create `web/src/utils/conversationTrustedHtml.js`.
- [x] Move trusted HTML sanitizing and business-line normalization into the helper.
- [x] Keep renderer-specific output differences in each renderer.
- [x] Verify both markdown and AI conversation renderers still preserve valid trusted document cards and reject unsafe trusted HTML.
Expected benefit:
- One XSS whitelist.
- One business field normalization rule.
- Less drift between AI workbench and reimbursement assistant rendering.
### P0: Reimbursement Review Panel Model Duplication
`web/src/views/scripts/travelReimbursementCreateReviewModel.js` and `web/src/views/scripts/travelReimbursementReviewPanelModel.js` duplicate review scope, fact cards, risk item mapping, risk conversation text, and message cleanup.
Plan:
- [x] Choose `travelReimbursementReviewPanelModel.js` as the shared model.
- [x] Convert create-view imports to the shared model, or make the create-view module a thin compatibility re-export.
- [x] Add behavior tests for exported review helpers before deleting duplicate code.
Expected benefit:
- One risk item mapping.
- One review fact card model.
- Lower risk when changing reimbursement review copy or drawer behavior.
### P0: Workbench AI Composer And File Strip Template Duplication
`web/src/components/business/PersonalWorkbenchAiMode.template.html` keeps two near-identical composer forms and two near-identical selected-file strips for the welcome and inline conversation states. `web/src/components/business/workbench-ai/WorkbenchAiComposer.vue` and `web/src/components/business/workbench-ai/WorkbenchAiFileStrip.vue` already exist, but the main template still duplicates the markup.
Plan:
- [x] Reuse `WorkbenchAiComposer.vue` for both welcome and inline composer surfaces.
- [x] Reuse `WorkbenchAiFileStrip.vue` for both welcome and inline selected-file strips.
- [x] Keep the parent runtime API stable by passing a proxied runtime object into shared components.
- [x] Preserve OCR state display in the shared file-strip component.
- [x] Keep input focus behavior by exposing an explicit assistant input ref setter.
Expected benefit:
- One composer markup surface.
- One selected-file/OCR badge markup surface.
- Lower maintenance cost when upload, date picker, lock state, or send-button behavior changes.
### P1: Application Gate And Fact Extraction
Backend application flow repeats checks across `user_agent_application.py`, `steward_planner.py`, `orchestrator.py`, `ontology_detection.py`, and `ontology_extraction.py`.
Plan:
- [ ] Extract application intent / context gate helpers.
- [ ] Extract application fact resolver for date, location, reason, amount, transport, and expense type.
- [ ] Route UserAgent, StewardPlanner, and Orchestrator through the same helpers.
- [ ] Preserve existing application vs reimbursement stage boundaries.
Expected benefit:
- Fewer mismatches between button actions and text-input actions.
- Less chance of direct submit re-entering slow `/orchestrator/run` paths.
- More consistent missing-field prompts.
### P1: Backend Parsing And Risk Context Utilities
Several backend modules repeat city extraction, document field lookup, item-id dedupe, Decimal conversion, endpoint normalization, and JSON error parsing.
Plan:
- [ ] Extract platform risk context helpers for item-id and document field utilities.
- [ ] Reuse existing amount utilities before adding new regex parsing.
- [ ] Share model connectivity URL/header/error helpers between RAG runtime and connectivity checks.
- [ ] Cache sorted travel-policy city names per policy snapshot.
Expected benefit:
- Less CPU churn in repeated risk/OCR loops.
- Fewer provider connectivity behavior differences.
- Easier review of platform-risk regressions.
### P1: Cross-Layer Taxonomy And Protocol Constants
Status labels, expense types, document types, risk levels, API paths, and snake_case/camelCase mappings are repeated across backend schemas, frontend services, and tests.
Plan:
- [ ] Establish read-only contract baseline from OpenAPI export.
- [ ] Export status / approval-stage registry to frontend constants.
- [ ] Consolidate expense type, document type, and risk-level taxonomy.
- [ ] Move high-churn API path and payload builders into shared frontend test helpers.
Expected benefit:
- Fewer display inconsistencies.
- Easier API evolution.
- Less brittle source-string tests.
### P2: Test Fixture Duplication
`server/tests` repeatedly defines `build_session`, `build_session_factory`, `override_db`, `build_client`, and OCR fake functions.
Plan:
- [ ] Add backend test fixtures in `server/tests/conftest.py`.
- [ ] Move OCR fake builders into a small test helper.
- [ ] Migrate tests in batches, one behavior area at a time.
Expected benefit:
- Less boilerplate in large test files.
- Easier targeted regression coverage before service refactors.
## First Slice Execution Plan
### Task 1: Lock Shared Renderer Helper Behavior
**Files:**
- Create: `web/tests/conversation-trusted-html.test.mjs`
- Create: `web/src/utils/conversationTrustedHtml.js`
- Modify: `web/src/utils/markdown.js`
- Modify: `web/src/utils/aiConversationHtmlRenderer.js`
Steps:
- [x] Add a failing Node test that imports `conversationTrustedHtml.js`.
- [x] Assert valid trusted document-card HTML is preserved through placeholder extraction and restore.
- [x] Assert unsafe tags, event handlers, and non-document hrefs are rejected.
- [x] Assert colon headings and business field lines normalize outside fenced code blocks.
- [x] Run the new test and confirm it fails because the helper does not exist.
- [x] Implement the helper with pure functions only.
- [x] Refactor both renderers to use the helper.
- [x] Run targeted renderer tests.
- [x] Run `npm --prefix web run build`.
Validation commands:
```bash
node --test web/tests/conversation-trusted-html.test.mjs
node --test web/tests/ai-conversation-html-renderer.test.mjs web/tests/travel-reimbursement-review-drawer-switch.test.mjs
npm --prefix web run build
```
## Third Slice Execution Plan
### Task 3: Reuse Workbench AI Composer Components
**Files:**
- Create: `web/tests/workbench-ai-composer-components.test.mjs`
- Modify: `web/src/components/business/PersonalWorkbenchAiMode.vue`
- Modify: `web/src/components/business/PersonalWorkbenchAiMode.template.html`
- Modify: `web/src/components/business/workbench-ai/WorkbenchAiFileStrip.vue`
- Modify: `web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js`
Steps:
- [x] Add a failing Node test that expects the main template to use `WorkbenchAiComposer` and `WorkbenchAiFileStrip`.
- [x] Assert the shared file strip preserves OCR state badges.
- [x] Assert the runtime exposes an input ref setter for the shared composer.
- [x] Run the new test and confirm it fails on the duplicated template.
- [x] Pass a proxied runtime object from `PersonalWorkbenchAiMode.vue` into shared components.
- [x] Replace duplicate composer and file-strip markup in the external template.
- [x] Add OCR badge markup to `WorkbenchAiFileStrip.vue`.
- [x] Run targeted workbench AI tests.
- [x] Run `npm --prefix web run build`.
Validation commands:
```bash
node --test web/tests/workbench-ai-composer-components.test.mjs
node --test web/tests/workbench-ai-composer-components.test.mjs web/tests/workbench-ai-mode-switch.test.mjs web/tests/workbench-ai-mode-expense-scene-action.test.mjs
npm --prefix web run build
```
## Remaining Execution Plan
The remaining work is intentionally split into bounded slices. Each slice extracts shared code without changing user-facing flow, API response shape, or submission semantics.
### Task 4: Frontend Application Gate Helpers
**Files:**
- Create: `web/src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js`
- Create: `web/tests/workbench-ai-application-gate-model.test.mjs`
- Modify: `web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js`
- Modify: `web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js`
Steps:
- [x] Add a failing Node test for reimbursement creation intent, submit/save text action resolution, and orphan preview detection.
- [x] Move pure gate predicates into `workbenchAiApplicationGateModel.js`.
- [x] Replace inline copies in personal workbench and application-preview flow.
- [x] Run targeted workbench AI application tests.
Validation commands:
```bash
node --test web/tests/workbench-ai-application-gate-model.test.mjs
node --test web/tests/workbench-ai-application-gate-model.test.mjs web/tests/workbench-ai-mode-switch.test.mjs web/tests/workbench-ai-action-router.test.mjs
```
### Task 5: Backend Application Fact Resolver
**Files:**
- Create: `server/src/app/services/application_fact_resolver.py`
- Create: `server/tests/test_application_fact_resolver.py`
- Modify: `server/src/app/services/steward_planner.py`
Steps:
- [x] Add failing pytest coverage for time, location, reason, transport, and task-type inference.
- [x] Extract pure resolver helpers that preserve existing planner output.
- [x] Route `StewardPlannerService` extraction wrappers through the resolver.
- [x] Run targeted planner/fact tests inside the active app container.
Validation commands:
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_application_fact_resolver.py server/tests/test_steward_planner.py
```
### Task 6: Backend Platform Risk Context Utilities
**Files:**
- Modify: `server/src/app/services/expense_claim_platform_context_tools.py`
- Create: `server/tests/test_expense_claim_platform_context_tools.py`
- Modify: `server/src/app/services/expense_claim_platform_route_risk.py`
- Modify: `server/src/app/services/expense_claim_platform_risk.py`
Steps:
- [x] Add failing pytest coverage for context city extraction, item-id dedupe, and text-value dedupe helpers.
- [x] Add pure helper functions in `expense_claim_platform_context_tools.py`.
- [x] Route route-risk and platform-risk consumers through the shared helpers.
- [x] Run targeted platform-risk tests inside the active app container.
Validation commands:
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_expense_claim_platform_context_tools.py server/tests/test_expense_claim_platform_risk_stage.py
```
### Task 7: Frontend Protocol Constants And Test Helpers
**Files:**
- Create: `web/src/constants/documentProtocol.js`
- Create: `web/tests/helpers/sourceSurface.mjs`
- Create: `web/tests/document-protocol-constants.test.mjs`
- Modify: `web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js`
- Migrate one high-churn source-surface test to the helper.
Steps:
- [x] Add failing Node tests for status labels, document type constants, and reusable source-surface loading.
- [x] Move duplicated status labels into `documentProtocol.js`.
- [x] Reuse constants in application-preview model and request/document-center model code.
- [x] Migrate one source-surface test helper to reduce brittle boilerplate.
Validation commands:
```bash
node --test web/tests/document-protocol-constants.test.mjs web/tests/workbench-ai-mode-switch.test.mjs
```
### Task 8: Backend Test Fixture Consolidation
**Files:**
- Create: `server/src/app/test_helpers/db.py`
- Create: `server/src/app/test_helpers/__init__.py`
- Create: `server/tests/test_db_test_helpers.py`
- Modify: `server/tests/test_expense_claim_platform_risk_stage.py`
Steps:
- [x] Identify an existing small test file with duplicated session/client/OCR helpers.
- [x] Add shared DB helper while preserving its current behavior.
- [x] Migrate one test file only.
- [x] Run the migrated test plus adjacent coverage inside the active app container.
Validation commands:
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_db_test_helpers.py server/tests/test_expense_claim_platform_risk_stage.py
```
### Task 9: Steward Planner Module Split
**Files:**
- Create: `server/src/app/services/steward_planner_shared.py`
- Create: `server/src/app/services/steward_planner_fallback.py`
- Create: `server/src/app/services/steward_planner_extraction.py`
- Modify: `server/src/app/services/steward_planner.py`
Steps:
- [x] Move shared constants and `PlannedTaskDraft` into a shared planner module.
- [x] Move off-topic, pending-flow, and rule-fallback planning into `steward_planner_fallback.py`.
- [x] Move task extraction, ontology normalization, attachment grouping, and summary helpers into `steward_planner_extraction.py`.
- [x] Keep `steward_planner.py` as the service orchestration entrypoint.
- [x] Run planner/fact resolver tests inside the active app container.
Validation commands:
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_application_fact_resolver.py server/tests/test_steward_planner.py
```
### Task 10: App Shell Dynamic Business Chunk Loading
**Files:**
- Create: `web/src/views/scripts/appShellAsyncViews.js`
- Create: `web/src/components/shared/AppViewLoadingState.vue`
- Create: `web/src/components/shared/AppModalLoadingState.vue`
- Modify: `web/src/views/AppShellRouteView.vue`
- Modify: `web/src/components/layout/SidebarRail.vue`
- Modify: `web/src/components/layout/AiSidebarRail.vue`
- Modify: `web/tests/app-shell-route-loading.test.mjs`
- Modify: `web/tests/ai-sidebar-rail-mode.test.mjs`
Steps:
- [x] Keep top-level shell routes eager so login/setup/app layout does not blank during route navigation.
- [x] Move heavy business views behind `defineAsyncComponent` loaders.
- [x] Add an in-workarea loading state for slow business chunks.
- [x] Add a modal loading state for the smart reimbursement assistant chunk.
- [x] Preload likely next views on sidebar hover/focus and during browser idle time.
- [x] Preserve existing Vite manual vendor chunks, including `vendor-echarts`.
- [x] Run targeted frontend tests and production build.
Validation commands:
```bash
node --test web/tests/app-shell-route-loading.test.mjs web/tests/ai-sidebar-rail-mode.test.mjs web/tests/sidebar-document-unread-dot.test.mjs web/tests/workbench-ai-mode-switch.test.mjs web/tests/workbench-ai-reimbursement-association-gate.test.mjs web/tests/travel-reimbursement-review-drawer-switch.test.mjs web/tests/documents-center-status-filter.test.mjs
npm --prefix web run build
```
Result:
- App shell entry chunk after split: `index-vWyUfHfm.js` 223.75 kB, gzip 74.11 kB.
- Large `vendor-echarts` chunk remains isolated at 598.67 kB, gzip 204.84 kB; it is no longer part of the app-shell entry chunk.
- Build still reports the existing Rollup `#__PURE__` annotation warnings from Element Plus / VueUse.
## Guardrails
- Do not touch unrelated dirty files.
- Do not change renderer output intentionally in this slice.
- Do not move backend logic until frontend helper extraction is green.
- Backend tests must run through Docker:
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q <path>
```

View File

@@ -0,0 +1,141 @@
from __future__ import annotations
import re
from datetime import date, timedelta
CITY_NAMES = (
"北京",
"上海",
"广州",
"深圳",
"杭州",
"南京",
"苏州",
"成都",
"重庆",
"天津",
"武汉",
"西安",
"长沙",
"郑州",
"青岛",
"厦门",
"福州",
"合肥",
"济南",
"沈阳",
"大连",
"宁波",
"无锡",
)
MONTH_DAY_PATTERN = re.compile(r"(?P<month>\d{1,2})\s*月\s*(?P<day>\d{1,2})\s*(?:日|号)?")
ISO_DATE_PATTERN = re.compile(
r"(?P<year>\d{4})[-/年](?P<month>\d{1,2})[-/月](?P<day>\d{1,2})(?:日)?"
)
class ApplicationFactResolver:
@staticmethod
def infer_expense_type(segment: str, task_type: str) -> str:
compact = re.sub(r"\s+", "", segment)
if re.search(r"招待|接待|餐饮|宴请|客户吃饭|业务餐", compact):
return "entertainment"
if re.search(r"出差|差旅|住宿|酒店|机票|航班|高铁|火车", compact):
return "travel"
if re.search(r"交通|出租车|的士|网约车|打车|地铁|公交", compact):
return "transport" if task_type == "reimbursement" else "travel"
return "travel" if task_type == "expense_application" else "other"
@staticmethod
def extract_time_range(segment: str, base_date: date) -> str:
compact = re.sub(r"\s+", "", segment)
if "昨天" in compact:
return (base_date - timedelta(days=1)).isoformat()
if "前天" in compact:
return (base_date - timedelta(days=2)).isoformat()
if "明天" in compact:
return (base_date + timedelta(days=1)).isoformat()
if "后天" in compact:
return (base_date + timedelta(days=2)).isoformat()
iso_match = ISO_DATE_PATTERN.search(compact)
if iso_match:
return ApplicationFactResolver.safe_date(
int(iso_match.group("year")),
int(iso_match.group("month")),
int(iso_match.group("day")),
)
month_day = MONTH_DAY_PATTERN.search(compact)
if month_day:
return ApplicationFactResolver.safe_date(
base_date.year,
int(month_day.group("month")),
int(month_day.group("day")),
)
return ""
@staticmethod
def safe_date(year: int, month: int, day: int) -> str:
try:
return date(year, month, day).isoformat()
except ValueError:
return ""
@staticmethod
def extract_location(segment: str) -> str:
compact = re.sub(r"\s+", "", segment)
for prefix in ("", "", "", "前往"):
match = re.search(fr"{prefix}({'|'.join(CITY_NAMES)})", compact)
if match:
return match.group(1)
for city in CITY_NAMES:
if city in compact:
return city
return ""
@staticmethod
def extract_reason(segment: str, task_type: str) -> str:
cleaned = re.sub(r"\s+", "", segment).strip(",。;; ")
if task_type == "expense_application":
match = re.search(r"(辅助|支持|协助|支撑|参加|拜访|调研|实施|部署|审核).+", cleaned)
if match:
return strip_trailing_connectors(match.group(0))
reason = re.sub(r"^.*?(?:出差|差旅)", "", cleaned).strip(",。;;的费用")
return strip_trailing_connectors(reason) or cleaned
cleaned = re.sub(r"^(?:我想要|我想|我要|还需要|需要|请帮我|帮我)?报销", "", cleaned)
if not cleaned or cleaned in {"费用", "报销单", "报销流程"}:
return ""
cleaned = re.sub(r"^(?:昨天|前天|明天|后天|\d{1,2}月\d{1,2}(?:日|号)?)的?", "", cleaned)
return cleaned.strip(",。;; ")
@staticmethod
def extract_transport_mode(segment: str) -> str:
compact = re.sub(r"\s+", "", segment)
if re.search(r"高铁|动车|火车", compact):
return "train"
if re.search(r"飞机|机票|航班", compact):
return "flight"
if re.search(r"出租车|的士|网约车|打车", compact):
return "taxi"
if "交通" in compact:
return "other"
return ""
def strip_trailing_connectors(value: str) -> str:
cleaned = str(value or "").strip(",。;; ")
return re.sub(r"(?:并且|而且|同时|另外|还需要|需要)$", "", cleaned).strip(",。;; ")
def resolve_application_facts(segment: str, task_type: str, base_date: date) -> dict[str, str]:
fields = {
"expense_type": ApplicationFactResolver.infer_expense_type(segment, task_type),
"time_range": ApplicationFactResolver.extract_time_range(segment, base_date),
"location": ApplicationFactResolver.extract_location(segment),
"reason": ApplicationFactResolver.extract_reason(segment, task_type),
"transport_mode": ApplicationFactResolver.extract_transport_mode(segment),
}
return {key: value for key, value in fields.items() if value}

View File

@@ -6,6 +6,18 @@ from typing import Any
from app.services.expense_rule_runtime import RuntimeTravelPolicy from app.services.expense_rule_runtime import RuntimeTravelPolicy
def unique_text_values(values: list[Any]) -> list[str]:
normalized_values: list[str] = []
seen: set[str] = set()
for value in list(values or []):
normalized = str(value or "").strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
normalized_values.append(normalized)
return normalized_values
def count_values(values: list[str]) -> dict[str, int]: def count_values(values: list[str]) -> dict[str, int]:
counts: dict[str, int] = {} counts: dict[str, int] = {}
for value in values: for value in values:
@@ -51,21 +63,35 @@ def collect_attachment_cities(
) -> list[str]: ) -> list[str]:
cities: list[str] = [] cities: list[str] = []
for context in contexts: for context in contexts:
document_info = context.get("document_info") or {} for city in collect_context_cities(context, policy):
parts = [
str(context.get("ocr_summary") or ""),
str(context.get("ocr_text") or ""),
str(context.get("item").item_location if context.get("item") is not None else ""),
]
for field in list(document_info.get("fields") or []):
if isinstance(field, dict):
parts.append(str(field.get("value") or ""))
for city in extract_known_cities_from_text(" ".join(parts), policy):
if city not in cities: if city not in cities:
cities.append(city) cities.append(city)
return cities return cities
def collect_context_cities(
context: dict[str, Any],
policy: RuntimeTravelPolicy,
*,
include_item_reason: bool = False,
) -> list[str]:
if not isinstance(context, dict):
return []
document_info = context.get("document_info") or {}
item = context.get("item")
parts = [
str(context.get("ocr_summary") or ""),
str(context.get("ocr_text") or ""),
str(getattr(item, "item_location", "") or ""),
]
if include_item_reason:
parts.append(str(getattr(item, "item_reason", "") or ""))
for field in list(document_info.get("fields") or []):
if isinstance(field, dict):
parts.append(str(field.get("value") or ""))
return extract_known_cities_from_text(" ".join(parts), policy)
def extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> list[str]: def extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> list[str]:
normalized = str(text or "").strip() normalized = str(text or "").strip()
if not normalized: if not normalized:
@@ -77,6 +103,11 @@ def extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> li
return cities return cities
def extract_first_known_city_from_text(text: str, policy: RuntimeTravelPolicy) -> str:
cities = extract_known_cities_from_text(text, policy)
return cities[0] if cities else ""
def resolve_first_document_field_value( def resolve_first_document_field_value(
document_info: dict[str, Any], document_info: dict[str, Any],
*, *,
@@ -95,3 +126,15 @@ def resolve_first_document_field_value(
if field_key in normalized_keys or any(token in label for token in labels): if field_key in normalized_keys or any(token in label for token in labels):
return value return value
return "" return ""
def collect_context_item_ids(contexts: list[dict[str, Any]]) -> list[str]:
item_ids: list[str] = []
seen: set[str] = set()
for context in list(contexts or []):
item = context.get("item") if isinstance(context, dict) else None
item_id = str(getattr(item, "id", "") or "").strip()
if item_id and item_id not in seen:
seen.add(item_id)
item_ids.append(item_id)
return item_ids

View File

@@ -13,6 +13,7 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.budget import BudgetService from app.services.budget import BudgetService
from app.services.expense_claim_platform_context_tools import ( from app.services.expense_claim_platform_context_tools import (
collect_attachment_cities, collect_attachment_cities,
collect_context_item_ids,
collect_invoice_keys_from_contexts, collect_invoice_keys_from_contexts,
collect_invoice_keys_from_document_info, collect_invoice_keys_from_document_info,
count_values, count_values,
@@ -768,15 +769,7 @@ class ExpenseClaimPlatformRiskMixin:
@staticmethod @staticmethod
def _context_item_ids(contexts: list[dict[str, Any]]) -> list[str]: def _context_item_ids(contexts: list[dict[str, Any]]) -> list[str]:
item_ids: list[str] = [] return collect_context_item_ids(contexts)
seen: set[str] = set()
for context in list(contexts or []):
item = context.get("item") if isinstance(context, dict) else None
item_id = str(getattr(item, "id", "") or "").strip()
if item_id and item_id not in seen:
seen.add(item_id)
item_ids.append(item_id)
return item_ids
@staticmethod @staticmethod
def _with_related_item_ids(flag: dict[str, Any], item_ids: list[str]) -> dict[str, Any]: def _with_related_item_ids(flag: dict[str, Any], item_ids: list[str]) -> dict[str, Any]:

View File

@@ -3,6 +3,13 @@ from __future__ import annotations
from typing import Any from typing import Any
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_platform_context_tools import (
collect_context_cities,
collect_context_item_ids,
extract_first_known_city_from_text,
resolve_first_document_field_value,
unique_text_values,
)
from app.services.expense_rule_runtime import RuntimeTravelPolicy from app.services.expense_rule_runtime import RuntimeTravelPolicy
@@ -13,16 +20,16 @@ def resolve_multi_city_related_item_ids(
) -> tuple[list[str], list[str]]: ) -> tuple[list[str], list[str]]:
segments = _collect_travel_route_segments(contexts, policy) segments = _collect_travel_route_segments(contexts, policy)
if not segments: if not segments:
return _context_item_ids(contexts), [] return collect_context_item_ids(contexts), []
first_origin = str(segments[0].get("origin") or "").strip() first_origin = str(segments[0].get("origin") or "").strip()
first_destination = str(segments[0].get("destination") or "").strip() first_destination = str(segments[0].get("destination") or "").strip()
expected_destination = _resolve_expected_travel_city(claim, contexts, policy) expected_destination = _resolve_expected_travel_city(claim, contexts, policy)
baseline_cities = _unique_text_values( baseline_cities = unique_text_values(
[first_origin, expected_destination or first_destination] [first_origin, expected_destination or first_destination]
) )
destination_cities = _unique_text_values( destination_cities = unique_text_values(
[str(segment.get("destination") or "") for segment in segments] [str(segment.get("destination") or "") for segment in segments]
) )
extra_cities = [ extra_cities = [
@@ -31,7 +38,7 @@ def resolve_multi_city_related_item_ids(
if city and city not in set(baseline_cities) if city and city not in set(baseline_cities)
] ]
if not extra_cities: if not extra_cities:
route_cities = _unique_text_values( route_cities = unique_text_values(
[ [
city city
for segment in segments for segment in segments
@@ -86,7 +93,7 @@ def _resolve_expected_travel_city(
contexts: list[dict[str, Any]], contexts: list[dict[str, Any]],
policy: RuntimeTravelPolicy, policy: RuntimeTravelPolicy,
) -> str: ) -> str:
claim_city = _extract_first_known_city(str(claim.location or ""), policy) claim_city = extract_first_known_city_from_text(str(claim.location or ""), policy)
if claim_city: if claim_city:
return claim_city return claim_city
@@ -96,7 +103,7 @@ def _resolve_expected_travel_city(
scene_code = str(document_info.get("scene_code") or "").strip().lower() scene_code = str(document_info.get("scene_code") or "").strip().lower()
if document_type != "hotel_invoice" and scene_code != "hotel": if document_type != "hotel_invoice" and scene_code != "hotel":
continue continue
for city in _extract_context_cities(context, policy): for city in collect_context_cities(context, policy, include_item_reason=True):
return city return city
return "" return ""
@@ -107,7 +114,7 @@ def _extract_route_segment(
) -> tuple[str, str] | None: ) -> tuple[str, str] | None:
document_info = context.get("document_info") or {} document_info = context.get("document_info") or {}
item = context.get("item") item = context.get("item")
route_value = _resolve_document_field_value( route_value = resolve_first_document_field_value(
document_info, document_info,
keys={"route", "route_cities", "routecities", "travel_route", "trip_route"}, keys={"route", "route_cities", "routecities", "travel_route", "trip_route"},
labels={"路线", "行程", "起讫", "起终", "始发", "到达"}, labels={"路线", "行程", "起讫", "起终", "始发", "到达"},
@@ -130,8 +137,8 @@ def _extract_route_segment(
segment.strip() segment.strip()
for segment in normalized.split(separator, 1) for segment in normalized.split(separator, 1)
] ]
origin = _extract_first_known_city(origin_text, policy) origin = extract_first_known_city_from_text(origin_text, policy)
destination = _extract_first_known_city(destination_text, policy) destination = extract_first_known_city_from_text(destination_text, policy)
if origin and destination and origin != destination: if origin and destination and origin != destination:
return origin, destination return origin, destination
return None return None
@@ -154,91 +161,11 @@ def _is_long_distance_context(
) )
def _extract_context_cities(
context: dict[str, Any],
policy: RuntimeTravelPolicy,
) -> list[str]:
document_info = context.get("document_info") or {}
item = context.get("item")
parts = [
str(context.get("ocr_summary") or ""),
str(context.get("ocr_text") or ""),
str(getattr(item, "item_location", "") or ""),
str(getattr(item, "item_reason", "") or ""),
]
for field in list(document_info.get("fields") or []):
if isinstance(field, dict):
parts.append(str(field.get("value") or ""))
return _extract_known_cities_from_text(" ".join(parts), policy)
def _extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> list[str]:
normalized = str(text or "").strip()
if not normalized:
return []
cities: list[str] = []
for city in sorted(policy.city_tiers.keys(), key=lambda item: len(item), reverse=True):
if city in normalized and city not in cities:
cities.append(city)
return cities
def _extract_first_known_city(text: str, policy: RuntimeTravelPolicy) -> str:
cities = _extract_known_cities_from_text(text, policy)
return cities[0] if cities else ""
def _resolve_document_field_value(
document_info: dict[str, Any],
*,
keys: set[str],
labels: set[str],
) -> str:
normalized_keys = {key.replace("_", "").lower() for key in keys}
for field in list(document_info.get("fields") or []):
if not isinstance(field, dict):
continue
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
value = str(field.get("value") or "").strip()
if not value:
continue
if field_key in normalized_keys or any(token in label for token in labels):
return value
return ""
def _route_segment_item_ids(segments: list[dict[str, Any]]) -> list[str]: def _route_segment_item_ids(segments: list[dict[str, Any]]) -> list[str]:
item_ids: list[str] = [] return collect_context_item_ids(
seen: set[str] = set() [
for segment in list(segments or []): {"item": segment.get("item")}
item = segment.get("item") if isinstance(segment, dict) else None for segment in list(segments or [])
item_id = str(getattr(item, "id", "") or "").strip() if isinstance(segment, dict)
if item_id and item_id not in seen: ]
seen.add(item_id) )
item_ids.append(item_id)
return item_ids
def _context_item_ids(contexts: list[dict[str, Any]]) -> list[str]:
item_ids: list[str] = []
seen: set[str] = set()
for context in list(contexts or []):
item = context.get("item") if isinstance(context, dict) else None
item_id = str(getattr(item, "id", "") or "").strip()
if item_id and item_id not in seen:
seen.add(item_id)
item_ids.append(item_id)
return item_ids
def _unique_text_values(values: list[str]) -> list[str]:
normalized_values: list[str] = []
seen: set[str] = set()
for value in list(values or []):
normalized = str(value or "").strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
normalized_values.append(normalized)
return normalized_values

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,578 @@
from __future__ import annotations
import re
from datetime import UTC, date, datetime
from typing import Any
from app.schemas.steward import (
StewardAttachmentGroup,
StewardAttachmentInput,
StewardConfirmationAction,
StewardPlanRequest,
StewardTask,
StewardThinkingEvent,
)
from app.services.application_fact_resolver import ApplicationFactResolver
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.steward_constants import BUSINESS_CANONICAL_FIELDS
from app.services.steward_planner_shared import (
BUSINESS_FIELD_LABELS,
CITY_NAMES,
EXPENSE_TYPE_LABELS,
PlannedTaskDraft,
REIMBURSEMENT_PATTERN,
TRANSPORT_MODE_LABELS,
)
class StewardPlannerExtractionMixin:
def _has_multiple_financial_demands(self, message: str) -> bool:
task_drafts = self._extract_task_drafts(message)
if len(task_drafts) > 1:
return True
compact = re.sub(r"\s+", "", message)
if not compact:
return False
application_signal = self._looks_like_application(compact) or self._looks_like_future_travel_application(compact)
reimbursement_signal = self._find_first_reimbursement_index(compact) >= 0
if application_signal and reimbursement_signal:
return True
connector_signal = re.search(r"并且|同时|另外|还有|还要|以及|再", compact)
repeated_reimbursement_signal = len(list(REIMBURSEMENT_PATTERN.finditer(compact))) > 1
return bool(connector_signal and repeated_reimbursement_signal)
@staticmethod
def _find_first_reimbursement_index(message: str) -> int:
candidates = [message.find(item) for item in ("我要报销", "还需要报销", "需要报销", "报销")]
positives = [item for item in candidates if item >= 0]
return min(positives) if positives else -1
@staticmethod
def _looks_like_application(text: str) -> bool:
compact = re.sub(r"\s+", "", text)
return bool(compact) and "申请" in compact and bool(re.search(r"出差|差旅|费用|交通|住宿|采购|会务|会议", compact))
@staticmethod
def _looks_like_future_travel_application(text: str) -> bool:
compact = re.sub(r"\s+", "", text)
if not compact or "报销" in compact:
return False
business_signal = re.search(
r"出差|差旅|客户现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收",
compact,
)
route_signal = re.search(
fr"(?:去|到|赴|前往)({'|'.join(CITY_NAMES)})",
compact,
)
time_signal = re.search(
r"明天|后天|下周|下月|近期|月底|\d{1,2}月\d{1,2}(?:日|号)?|"
r"\d{4}[-/年]\d{1,2}[-/月]\d{1,2}(?:日)?|[0-9一二两三四五六七八九十]+天",
compact,
)
planned_route_signal = re.search(
r"(?:去|到|赴|前往).{0,24}(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)|"
r"(?:出差|差旅).{0,24}(?:[0-9一二两三四五六七八九十]+天|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)",
compact,
)
return bool((business_signal or route_signal) and (time_signal or planned_route_signal))
def _looks_like_ambiguous_travel_flow(
self,
text: str,
base_date: date,
request: StewardPlanRequest,
) -> bool:
compact = re.sub(r"\s+", "", text)
if not compact or request.attachments:
return False
if re.search(r"申请|报销|草稿|提交|审批|保存|发起|创建", compact):
return False
if not re.search(r"出差|差旅|客户现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收", compact):
return False
if not self._extract_time_range(compact, base_date):
return False
if not self._extract_location(compact):
return False
return not self._is_future_or_current_time_range(compact, base_date)
def _is_future_or_current_time_range(self, segment: str, base_date: date) -> bool:
normalized = self._extract_time_range(segment, base_date)
if not normalized:
return False
try:
parsed = date.fromisoformat(normalized)
except ValueError:
return False
return parsed >= base_date
def _build_task(
self,
draft: PlannedTaskDraft,
base_date: date,
request: StewardPlanRequest,
) -> StewardTask:
fields = self._extract_ontology_fields(draft.segment, draft.task_type, base_date, request)
missing_fields = self._resolve_missing_fields(draft.task_type, fields)
task_id = f"task_{'app' if draft.task_type == 'expense_application' else 'reim'}_{draft.index:03d}"
assigned_agent = (
"application_assistant"
if draft.task_type == "expense_application"
else "reimbursement_assistant"
)
title_prefix = "费用申请" if draft.task_type == "expense_application" else "费用报销"
title = self._build_task_title(title_prefix, fields, draft.index)
return StewardTask(
task_id=task_id,
task_type=draft.task_type, # type: ignore[arg-type]
assigned_agent=assigned_agent, # type: ignore[arg-type]
title=title,
summary=self._build_task_summary(draft.segment, fields),
status="needs_confirmation",
confidence=self._resolve_task_confidence(draft.segment, fields, draft.task_type),
ontology_fields=fields,
missing_fields=missing_fields,
confirmation_required=True,
)
def _build_fallback_task(
self,
message: str,
base_date: date,
request: StewardPlanRequest,
) -> StewardTask:
task_type = "reimbursement" if "报销" in message or request.attachments else "expense_application"
draft = PlannedTaskDraft(task_type=task_type, segment=message, index=1)
task = self._build_task(draft, base_date, request)
return task.model_copy(update={"confidence": min(task.confidence, 0.58)})
def _extract_ontology_fields(
self,
segment: str,
task_type: str,
base_date: date,
request: StewardPlanRequest,
) -> dict[str, str]:
normalized_context = normalize_ontology_form_values(request.context_json.get("review_form_values"))
fields: dict[str, str] = {
key: value
for key, value in normalized_context.items()
if key in BUSINESS_CANONICAL_FIELDS and str(value or "").strip()
}
expense_type = self._infer_expense_type(segment, task_type)
if expense_type and not fields.get("expense_type"):
fields["expense_type"] = expense_type
time_range = self._extract_time_range(segment, base_date)
if time_range and not fields.get("time_range"):
fields["time_range"] = time_range
location = self._extract_location(segment)
if location and not fields.get("location"):
fields["location"] = location
reason = self._extract_reason(segment, task_type)
if reason and not fields.get("reason"):
fields["reason"] = reason
transport_mode = self._extract_transport_mode(segment)
if transport_mode and not fields.get("transport_mode"):
fields["transport_mode"] = transport_mode
if request.attachments:
fields["attachments"] = "".join(item.name for item in request.attachments if item.name)
return {key: value for key, value in fields.items() if key in BUSINESS_CANONICAL_FIELDS and value}
@staticmethod
def _infer_expense_type(segment: str, task_type: str) -> str:
return ApplicationFactResolver.infer_expense_type(segment, task_type)
def _extract_time_range(self, segment: str, base_date: date) -> str:
return ApplicationFactResolver.extract_time_range(segment, base_date)
@staticmethod
def _safe_date(year: int, month: int, day: int) -> str:
return ApplicationFactResolver.safe_date(year, month, day)
@staticmethod
def _extract_location(segment: str) -> str:
return ApplicationFactResolver.extract_location(segment)
@staticmethod
def _extract_reason(segment: str, task_type: str) -> str:
return ApplicationFactResolver.extract_reason(segment, task_type)
@staticmethod
def _extract_transport_mode(segment: str) -> str:
return ApplicationFactResolver.extract_transport_mode(segment)
@staticmethod
def _resolve_missing_fields(task_type: str, fields: dict[str, str]) -> list[str]:
required = ["expense_type", "time_range", "reason"]
if task_type == "expense_application":
required.append("location")
if fields.get("expense_type") in {"travel", "transport"}:
required.append("transport_mode")
return [key for key in required if not str(fields.get(key) or "").strip()]
@staticmethod
def _resolve_task_confidence(segment: str, fields: dict[str, str], task_type: str) -> float:
compact = re.sub(r"\s+", "", segment)
if task_type == "expense_application":
intent_score = 1.0 if (
"申请" in compact or StewardPlannerExtractionMixin._looks_like_future_travel_application(compact)
) else 0.45
else:
intent_score = 1.0 if "报销" in compact else 0.45
time_score = 1.0 if fields.get("time_range") else 0.0
location_score = 1.0 if fields.get("location") else 0.2
scene_score = 1.0 if fields.get("expense_type") and fields["expense_type"] != "other" else 0.35
confidence = min(1.0, 0.35 * intent_score + 0.25 * time_score + 0.2 * location_score + 0.2 * scene_score)
return round(max(0.45, confidence), 2)
def _build_attachment_groups(
self,
attachments: list[StewardAttachmentInput],
tasks: list[StewardTask],
) -> list[StewardAttachmentGroup]:
if not attachments:
return []
classified = [(item, self._classify_attachment(item)) for item in attachments if item.name]
travel_related = [item.name for item, scene in classified if scene in {"travel", "transport"}]
excluded = [item.name for item, scene in classified if scene not in {"travel", "transport"}]
target_task = self._resolve_attachment_target_task(tasks)
groups: list[StewardAttachmentGroup] = []
if travel_related:
confidence = 0.72 + min(0.18, len(travel_related) * 0.04)
groups.append(
StewardAttachmentGroup(
group_id="ag_travel_001",
target_task_id=target_task.task_id if target_task else None,
scene="travel",
scene_label="差旅相关费用",
attachment_names=travel_related,
excluded_attachment_names=excluded,
confidence=round(confidence, 2),
rationale="附件名称或 OCR 摘要中包含差旅、交通、住宿、火车、机票等线索。",
confirmation_required=True,
)
)
elif excluded:
groups.append(
StewardAttachmentGroup(
group_id="ag_other_001",
target_task_id=None,
scene="other",
scene_label="待人工确认费用",
attachment_names=excluded,
excluded_attachment_names=[],
confidence=0.5,
rationale="当前附件缺少可稳定归属到申请或报销任务的差旅线索。",
confirmation_required=True,
)
)
return groups
@staticmethod
def _resolve_attachment_target_task(tasks: list[StewardTask]) -> StewardTask | None:
reimbursement_tasks = [item for item in tasks if item.task_type == "reimbursement"]
for task in reimbursement_tasks:
if task.ontology_fields.get("expense_type") == "travel":
return task
return reimbursement_tasks[0] if reimbursement_tasks else None
@staticmethod
def _classify_attachment(attachment: StewardAttachmentInput) -> str:
text = " ".join(
[
attachment.name,
attachment.media_type,
attachment.ocr_summary,
" ".join(f"{key}:{value}" for key, value in attachment.ocr_fields.items()),
]
)
compact = re.sub(r"\s+", "", text).lower()
if re.search(r"招待|接待|餐饮|宴请|客户|meal|entertainment", compact):
return "entertainment"
if re.search(r"酒店|住宿|差旅|出差|高铁|火车|动车|机票|航班|train|flight|hotel|travel", compact):
return "travel"
if re.search(r"出租车|的士|网约车|打车|交通|taxi|transport", compact):
return "transport"
return "other"
def _build_confirmation_actions(
self,
tasks: list[StewardTask],
attachment_groups: list[StewardAttachmentGroup],
) -> list[StewardConfirmationAction]:
actions: list[StewardConfirmationAction] = []
for task in tasks:
if task.task_type == "expense_application":
action_type = "confirm_create_application"
label = "确认创建申请单"
else:
action_type = "confirm_create_reimbursement_draft"
label = "确认创建报销草稿"
actions.append(
StewardConfirmationAction(
confirmation_id=f"confirm_{task.task_id}",
action_type=action_type,
label=label,
description=f"确认后把“{task.title}”交给{self._agent_label(task.assigned_agent)}继续核对。",
target_task_id=task.task_id,
payload={
"task_id": task.task_id,
"task_type": task.task_type,
"assigned_agent": task.assigned_agent,
"ontology_fields": task.ontology_fields,
},
)
)
for group in attachment_groups:
actions.append(
StewardConfirmationAction(
confirmation_id=f"confirm_{group.group_id}",
action_type="confirm_attachment_group",
label="确认附件归集",
description=f"确认后将 {len(group.attachment_names)} 份附件按“{group.scene_label}”归集。",
target_task_id=group.target_task_id,
attachment_group_id=group.group_id,
payload={
"attachment_group_id": group.group_id,
"target_task_id": group.target_task_id,
"attachment_names": group.attachment_names,
"excluded_attachment_names": group.excluded_attachment_names,
},
)
)
return actions
@staticmethod
def _agent_label(assigned_agent: str) -> str:
return "申请助手" if assigned_agent == "application_assistant" else "报销助手"
def _build_thinking_events(
self,
tasks: list[StewardTask],
attachment_groups: list[StewardAttachmentGroup],
attachments: list[StewardAttachmentInput],
) -> list[StewardThinkingEvent]:
application_count = sum(1 for item in tasks if item.task_type == "expense_application")
reimbursement_count = sum(1 for item in tasks if item.task_type == "reimbursement")
task_intent_summary = self._summarize_task_intents(tasks)
ontology_summary = self._summarize_ontology_coverage(tasks)
delegation_summary = self._summarize_delegation_targets(tasks)
events = [
StewardThinkingEvent(
event_id="intent_agent_entry",
stage="intent_agent",
title="意图识别智能体接管",
content=(
f"检测到复合财务话术,当前不是单一助手会话;"
f"已进入小财管家编排模式,候选任务共 {len(tasks)} 个。"
),
),
StewardThinkingEvent(
event_id="intent_task_split",
stage="task_split",
title=f"拆分申请 {application_count} 个、报销 {reimbursement_count}",
content=task_intent_summary,
),
StewardThinkingEvent(
event_id="intent_ontology_mapping",
stage="ontology_mapping",
title="核对业务要素",
content=ontology_summary,
),
]
gap_event = self._build_business_gap_thinking_event(tasks)
if gap_event:
events.append(gap_event)
if attachments:
events.append(
StewardThinkingEvent(
event_id="intent_attachment_correlation",
stage="attachment_correlation",
title="关联附件与任务线索",
content=self._summarize_attachment_correlation(attachment_groups, len(attachments)),
)
)
events.append(
StewardThinkingEvent(
event_id="intent_delegation_gate",
stage="delegation_gate",
title="生成确认点并准备分派",
content=f"{delegation_summary} 创建单据、生成草稿、绑定附件和提交审批都会等待用户确认。",
)
)
return events
@staticmethod
def _summarize_task_intents(tasks: list[StewardTask]) -> str:
if not tasks:
return "当前输入尚未形成稳定任务,先保留为待确认财务事项。"
parts = []
for task in tasks:
task_label = "申请" if task.task_type == "expense_application" else "报销"
fields = task.ontology_fields
anchors = []
if fields.get("time_range"):
anchors.append(fields["time_range"])
if fields.get("location"):
anchors.append(fields["location"])
if fields.get("expense_type"):
anchors.append(StewardPlannerExtractionMixin._format_business_field_value("expense_type", fields["expense_type"]))
anchor_text = "".join(anchors) if anchors else "待补充关键字段"
parts.append(f"{task_label}{task.title}{anchor_text}")
return "".join(parts)
@staticmethod
def _summarize_ontology_coverage(tasks: list[StewardTask]) -> str:
mapped_labels = []
missing_labels = []
for task in tasks:
mapped_labels.extend(StewardPlannerExtractionMixin._business_field_label(key) for key in task.ontology_fields.keys())
missing_labels.extend(StewardPlannerExtractionMixin._business_field_label(key) for key in task.missing_fields)
mapped = "".join(dict.fromkeys(label for label in mapped_labels if label)) or "暂无稳定业务要素"
missing = ";还缺少:" + "".join(dict.fromkeys(label for label in missing_labels if label)) if missing_labels else ""
return f"已把用户输入归一为业务要素:{mapped}{missing}。后续执行仍会先让用户确认。"
@staticmethod
def _build_business_gap_thinking_event(tasks: list[StewardTask]) -> StewardThinkingEvent | None:
gap_lines = []
for task in tasks:
if not task.missing_fields:
continue
missing_labels = [
StewardPlannerExtractionMixin._business_field_label(key)
for key in task.missing_fields
if key
]
if not missing_labels:
continue
if task.task_type == "expense_application" and "transport_mode" in task.missing_fields:
gap_lines.append(
(
f"{task.title}已识别到{StewardPlannerExtractionMixin._summarize_known_business_points(task)}"
"但用户没有说明出行方式;出行方式会影响交通费用测算,进入申请单核对后需要先追问火车、飞机或轮船。"
)
)
else:
gap_lines.append(
(
f"{task.title}还缺少{''.join(dict.fromkeys(missing_labels))}"
"需要在对应步骤里继续向用户确认,不能直接执行入库或提交。"
)
)
if not gap_lines:
return None
return StewardThinkingEvent(
event_id="intent_business_gap_check",
stage="business_gap_check",
title="判断待补充信息",
content="".join(gap_lines),
)
@staticmethod
def _summarize_known_business_points(task: StewardTask) -> str:
parts = []
for key in ("time_range", "location", "reason", "expense_type"):
value = str(task.ontology_fields.get(key) or "").strip()
if value:
parts.append(
f"{StewardPlannerExtractionMixin._business_field_label(key)}"
f"{StewardPlannerExtractionMixin._format_business_field_value(key, value)}"
)
return "".join(parts) or "部分业务要素"
@staticmethod
def _business_field_label(key: str) -> str:
return BUSINESS_FIELD_LABELS.get(str(key or "").strip(), str(key or "").strip())
@staticmethod
def _format_business_field_value(key: str, value: str) -> str:
cleaned = str(value or "").strip()
if key == "expense_type":
return EXPENSE_TYPE_LABELS.get(cleaned, cleaned)
if key == "transport_mode":
return TRANSPORT_MODE_LABELS.get(cleaned, cleaned)
return cleaned
@staticmethod
def _summarize_attachment_correlation(
attachment_groups: list[StewardAttachmentGroup],
total_attachment_count: int,
) -> str:
grouped_names = []
excluded_names = []
for group in attachment_groups:
grouped_names.extend(group.attachment_names)
excluded_names.extend(group.excluded_attachment_names)
grouped_text = "".join(grouped_names) if grouped_names else "暂无可稳定归集附件"
excluded_text = ";排除或单独确认:" + "".join(excluded_names) if excluded_names else ""
return f"已核对 {total_attachment_count} 份附件,建议归集:{grouped_text}{excluded_text}"
@staticmethod
def _summarize_delegation_targets(tasks: list[StewardTask]) -> str:
application_count = sum(1 for item in tasks if item.assigned_agent == "application_assistant")
reimbursement_count = sum(1 for item in tasks if item.assigned_agent == "reimbursement_assistant")
parts = []
if application_count:
parts.append(f"{application_count} 个申请任务交给申请助手")
if reimbursement_count:
parts.append(f"{reimbursement_count} 个报销任务交给报销助手")
return "".join(parts) + "" if parts else "尚无可分派任务。"
@staticmethod
def _build_summary(tasks: list[StewardTask], attachment_groups: list[StewardAttachmentGroup]) -> str:
parts = [f"我识别到 {len(tasks)} 个待处理任务"]
if attachment_groups:
grouped = sum(len(item.attachment_names) for item in attachment_groups)
parts.append(f"并形成 {grouped} 份附件的归集建议")
parts.append(",请确认后我再分派给对应助手执行。")
return "".join(parts)
@staticmethod
def _build_task_title(prefix: str, fields: dict[str, str], index: int) -> str:
location = fields.get("location", "")
time_range = fields.get("time_range", "")
expense_type = fields.get("expense_type", "")
subject = location or {"travel": "差旅", "transport": "交通", "entertainment": "招待"}.get(expense_type, "")
if subject and time_range:
return f"{prefix} {time_range} {subject}"
if subject:
return f"{prefix} {subject}"
return f"{prefix} {index}"
@staticmethod
def _build_task_summary(segment: str, fields: dict[str, str]) -> str:
field_parts = []
for key, label in (
("time_range", "时间"),
("location", "地点"),
("expense_type", "费用类型"),
("reason", "事由"),
("transport_mode", "交通方式"),
):
value = fields.get(key)
if value:
field_parts.append(f"{label}{value}")
return "".join(field_parts) or segment
@staticmethod
def _resolve_base_date(client_now_iso: str | None, context_json: dict[str, Any]) -> date:
raw_value = client_now_iso or str(context_json.get("client_now_iso") or "").strip()
if raw_value:
try:
parsed = datetime.fromisoformat(raw_value.replace("Z", "+00:00"))
return parsed.date()
except ValueError:
pass
return datetime.now(UTC).date()
@staticmethod
def _clean_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()

View File

@@ -0,0 +1,438 @@
from __future__ import annotations
import re
import uuid
from datetime import date
from typing import Any
from app.schemas.steward import (
StewardCandidateFlow,
StewardConfirmationAction,
StewardPendingFlowConfirmation,
StewardPlanRequest,
StewardPlanResponse,
StewardTask,
StewardThinkingEvent,
)
from app.services.steward_planner_shared import (
APPLICATION_SPLIT_PATTERN,
BUSINESS_FIELD_LABELS,
PlannedTaskDraft,
REIMBURSEMENT_PATTERN,
STEWARD_BUSINESS_SIGNAL_KEYWORDS,
STEWARD_GREETING_KEYWORDS,
STEWARD_OFF_TOPIC_SCENARIO_GREETING,
STEWARD_OFF_TOPIC_SCENARIO_MEANINGLESS,
STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS,
)
class StewardPlannerFallbackMixin:
def _should_use_model_intent_recognition(
self,
message: str,
base_date: date,
request: StewardPlanRequest,
) -> bool:
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
return False
return self._has_multiple_financial_demands(message)
@staticmethod
def _is_business_irrelevant_input(message: str, request: StewardPlanRequest) -> bool:
"""判断输入是否与小财管家支持的财务事项完全无关(向后兼容包装)。
判定规则:消息去除所有空白后不含任何业务信号关键词,且没有上传附件。
实际判定逻辑由 _classify_irrelevant_input 负责,命中任何场景即视为业务无关。
"""
return StewardPlannerFallbackMixin._classify_irrelevant_input(message, request) is not None
@staticmethod
def _classify_irrelevant_input(message: str, request: StewardPlanRequest) -> str | None:
"""把业务无关输入细分为三个场景,便于给出更贴切的引导。
返回值:
- "greeting":礼貌问候("你好"等),无业务关键词
- "meaningless":完全无意义内容(纯数字、纯标点、单字符重复、纯字母数字乱码)
- "off_business":有意义但与财务无关(问天气、聊生活等)
- None消息与业务相关无需走 off_topic 路径
"""
if request.attachments:
return None
compact = re.sub(r"\s+", "", message)
if not compact:
return None
if any(keyword in compact for keyword in STEWARD_BUSINESS_SIGNAL_KEYWORDS):
return None
if StewardPlannerFallbackMixin._looks_like_greeting(compact):
return STEWARD_OFF_TOPIC_SCENARIO_GREETING
if StewardPlannerFallbackMixin._looks_like_meaningless(compact):
return STEWARD_OFF_TOPIC_SCENARIO_MEANINGLESS
return STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS
@staticmethod
def _looks_like_greeting(compact_message: str) -> bool:
"""判断消息是否只是礼貌问候(无其他有意义内容)。"""
normalized = compact_message.lower()
for keyword in STEWARD_GREETING_KEYWORDS:
if normalized == keyword.lower() or normalized.startswith(keyword.lower()):
# 整句只是问候词(允许少量标点)
tail = normalized[len(keyword.lower()):]
if not tail or re.fullmatch(r"[!。.?,~\s]+", tail):
return True
return False
@staticmethod
def _looks_like_meaningless(compact_message: str) -> bool:
"""判断消息是否完全没有语义价值(纯数字、纯标点、单字符重复等)。"""
if re.fullmatch(r"\d+", compact_message):
return True
# 纯标点
if re.fullmatch(r"[\W_]+", compact_message):
return True
# 单字符重复(例如 "啊啊啊啊啊"
if len(compact_message) >= 2 and len(set(compact_message)) == 1:
return True
# 短字母数字组合但没有任何业务意义,例如 "abc"、"test123"
# 注意:必须排除已经被关键词命中的情况(前面的判定已保证不命中关键词)
if re.fullmatch(r"[a-zA-Z0-9]+", compact_message) and len(compact_message) <= 12:
return True
return False
def _build_off_topic_plan(
self,
request: StewardPlanRequest,
*,
scenario: str,
) -> StewardPlanResponse:
"""业务无关输入的兜底计划根据场景给出对应引导off_business 场景可由 LLM 增强。"""
base_summary = self._default_off_topic_summary(scenario)
thinking_event = self._build_off_topic_thinking_event(scenario)
suggested_prompts = self._off_topic_suggested_prompts(scenario)
model_call_traces: list[dict[str, Any]] = []
# 仅对 off_business 场景尝试让 LLM 生成多样化引导;问候/无意义场景用规则模板即可。
if (
scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS
and self.off_topic_agent is not None
):
try:
llm_result = self.off_topic_agent.generate(request, scenario=scenario)
if llm_result is not None and llm_result.response_text:
base_summary = llm_result.response_text
model_call_traces = llm_result.model_call_traces
except Exception:
# 失败时静默回退到规则模板
pass
return StewardPlanResponse(
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
plan_status="off_topic",
planning_source="rule_fallback",
next_action="none",
summary=base_summary,
thinking_events=[thinking_event],
tasks=[],
attachment_groups=[],
confirmation_groups=[],
candidate_flows=[],
suggested_prompts=suggested_prompts,
model_call_traces=model_call_traces,
)
@staticmethod
def _default_off_topic_summary(scenario: str) -> str:
"""off_topic 场景的默认引导文案LLM 不可用时使用。"""
if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING:
return (
"### 您好主人,很高兴为您服务\n\n"
"请问您今天要办理什么业务?目前小财管家能帮您整理"
"**费用申请**和**费用报销**这两类事项。\n\n"
"要不您换种说法告诉我:"
)
if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS:
return (
"### 抱歉主人,这句话我暂时帮不上忙\n\n"
"我看了您刚才说的这句话,里面聊的不是财务事项。"
"小财管家目前只能帮您整理**费用申请**和**费用报销**这两类业务。\n\n"
"要不您换种说法告诉我:"
)
# meaningless
return (
"### 这句话我暂时没识别到财务事项\n\n"
"很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**这两类事项。\n\n"
"要不您换种说法告诉我:"
)
@staticmethod
def _build_off_topic_thinking_event(scenario: str) -> StewardThinkingEvent:
"""off_topic 场景下向用户展示的思考过程摘要。"""
if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING:
return StewardThinkingEvent(
event_id="intent_agent_off_topic_greeting",
stage="off_topic",
title="先回应主人的问候",
content="主人向我打了个招呼,我先礼貌回应一下,再引导他/她说出具体想办什么业务。",
)
if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS:
return StewardThinkingEvent(
event_id="intent_agent_off_topic_non_business",
stage="off_topic",
title="这句话不在服务范围内",
content="我看了您刚才说的这句话,里面聊的不是财务事项。小财管家目前只能帮您整理费用申请和费用报销。",
)
return StewardThinkingEvent(
event_id="intent_agent_off_topic_meaningless",
stage="off_topic",
title="未识别到财务事项",
content=(
"我仔细看了看您刚才说的这句话,里面好像没有出现"
"费用申请、报销、出差、交通、招待这些财务关键词。"
),
)
@staticmethod
def _off_topic_suggested_prompts(scenario: str) -> list[str]:
"""off_topic 场景下展示给用户的推荐话术。"""
if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING:
return [
"我想要申请明天去北京出差3天支撑客户现场实施",
"我要报销昨天的交通费",
"我上周出差去上海的费用需要报销",
]
if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS:
return [
"我想要申请明天去北京出差3天支撑客户现场实施",
"我要报销昨天的交通费",
"我需要整理上周出差的发票",
]
# meaningless
return [
"我想要申请明天去北京出差3天支撑客户现场实施",
"我要报销昨天的交通费",
"我上周出差去上海的费用需要报销",
]
def _build_rule_fallback_plan(
self,
request: StewardPlanRequest,
*,
base_date: date,
model_call_traces: list[dict[str, Any]] | None = None,
fallback_reason: str = "",
) -> StewardPlanResponse:
message = self._clean_text(request.message)
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
return self._build_pending_flow_fallback_plan(
request,
base_date=base_date,
model_call_traces=model_call_traces,
fallback_reason=fallback_reason,
)
task_drafts = self._extract_task_drafts(message)
tasks = [self._build_task(draft, base_date, request) for draft in task_drafts]
if not tasks:
tasks = [self._build_fallback_task(message, base_date, request)]
attachment_groups = self._build_attachment_groups(request.attachments, tasks)
confirmation_groups = self._build_confirmation_actions(tasks, attachment_groups)
thinking_events = self._build_thinking_events(tasks, attachment_groups, request.attachments)
if fallback_reason:
thinking_events.insert(
0,
StewardThinkingEvent(
event_id="intent_agent_rule_fallback",
stage="rule_fallback",
title="意图识别智能体进入兜底模式",
content=fallback_reason,
),
)
plan_id = f"steward_plan_{uuid.uuid4().hex[:12]}"
return StewardPlanResponse(
plan_id=plan_id,
plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate",
planning_source="rule_fallback",
next_action="confirm_task" if confirmation_groups else "delegate_task",
summary=self._build_summary(tasks, attachment_groups),
thinking_events=thinking_events,
tasks=tasks,
attachment_groups=attachment_groups,
confirmation_groups=confirmation_groups,
model_call_traces=model_call_traces or [],
)
def _build_pending_flow_fallback_plan(
self,
request: StewardPlanRequest,
*,
base_date: date,
model_call_traces: list[dict[str, Any]] | None = None,
fallback_reason: str = "",
planning_source: str = "rule_fallback",
) -> StewardPlanResponse:
candidates = self._build_rule_candidate_flows(request, base_date)
gate = self._resolve_required_application_gate(request, "travel")
pending_reason = self._build_pending_flow_reason(gate)
pending = StewardPendingFlowConfirmation(
status="pending",
source_message=request.message,
reason=pending_reason,
candidate_flows=candidates,
)
thinking_events = []
if fallback_reason:
thinking_events.append(
StewardThinkingEvent(
event_id="intent_agent_rule_fallback",
stage="rule_fallback",
title="意图识别智能体进入兜底模式",
content=fallback_reason,
)
)
thinking_events.append(
StewardThinkingEvent(
event_id="intent_pending_flow_confirmation",
stage="flow_confirmation",
title="需要确认流程方向",
content=pending_reason,
)
)
return StewardPlanResponse(
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
plan_status="needs_flow_confirmation",
planning_source=planning_source, # type: ignore[arg-type]
next_action="confirm_flow",
summary=self._build_pending_flow_summary(gate),
thinking_events=thinking_events,
pending_flow_confirmation=pending,
candidate_flows=candidates,
model_call_traces=model_call_traces or [],
)
def _build_rule_candidate_flows(
self,
request: StewardPlanRequest,
base_date: date,
) -> list[StewardCandidateFlow]:
application_fields = self._extract_ontology_fields(
request.message,
"expense_application",
base_date,
request,
)
reimbursement_fields = self._extract_ontology_fields(
request.message,
"reimbursement",
base_date,
request,
)
gate = self._resolve_required_application_gate(request, "travel")
if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0:
return [
StewardCandidateFlow(
flow_id="travel_application",
label="先发起出差申请",
confidence=0.86,
reason="已先查询你名下可关联的差旅申请单,暂未查到可关联单据,因此应先申请单据。",
ontology_fields=application_fields,
missing_fields=self._resolve_missing_fields("expense_application", application_fields),
)
]
reimbursement_label = "发起费用报销"
reimbursement_reason = "用户描述的也可能是已发生出差事项,需要进入报销材料整理。"
if gate.get("checked"):
candidate_count = int(gate.get("candidate_count") or 0)
reimbursement_label = "关联已有申请单并发起报销"
reimbursement_reason = f"已先查到 {candidate_count} 个可关联申请单,选择后会先请你关联具体单据。"
return [
StewardCandidateFlow(
flow_id="travel_application",
label="补办出差申请",
confidence=0.52,
reason="用户描述了出差时间、地点和事由,但没有明确说要报销。",
ontology_fields=application_fields,
missing_fields=self._resolve_missing_fields("expense_application", application_fields),
),
StewardCandidateFlow(
flow_id="travel_reimbursement",
label=reimbursement_label,
confidence=0.48,
reason=reimbursement_reason,
ontology_fields=reimbursement_fields,
missing_fields=self._resolve_missing_fields("reimbursement", reimbursement_fields),
),
]
@staticmethod
def _resolve_required_application_gate(
request: StewardPlanRequest,
expense_type: str,
) -> dict[str, Any]:
context = request.context_json if isinstance(request.context_json, dict) else {}
gates = context.get("required_application_gate")
if not isinstance(gates, dict):
return {}
gate = gates.get(expense_type)
if not isinstance(gate, dict) or not gate.get("checked"):
return {}
try:
candidate_count = max(0, int(gate.get("candidate_count") or 0))
except (TypeError, ValueError):
candidate_count = 0
return {
**gate,
"candidate_count": candidate_count,
"checked": True,
}
@staticmethod
def _build_pending_flow_reason(gate: dict[str, Any]) -> str:
if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0:
return "我已经先查询你名下可关联的差旅申请单,未查到可关联单据,所以当前应先申请单据。"
if gate.get("checked"):
candidate_count = int(gate.get("candidate_count") or 0)
return f"我已经先查询你名下的差旅申请单,查到 {candidate_count} 个可关联申请单,需要你确认是否关联单据后发起报销。"
return "当前话术描述了出差事项,但没有明确说明要补办申请还是发起报销。"
@staticmethod
def _build_pending_flow_summary(gate: dict[str, Any]) -> str:
if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0:
return "我已先查询可关联申请单,暂未查到可关联单据;这次应先申请单据,再进入后续报销。"
if gate.get("checked"):
candidate_count = int(gate.get("candidate_count") or 0)
return (
f"我已先查询可关联申请单,查到 {candidate_count} 个可关联申请单;"
"你可以选择关联已有申请单发起报销,或改为补办新的出差申请。"
)
return (
"我识别到这是一次出差事项,但还不能确定你要做的是"
"**补办出差申请**还是**发起费用报销**。请先选择一个方向。"
)
def _extract_task_drafts(self, message: str) -> list[PlannedTaskDraft]:
drafts: list[PlannedTaskDraft] = []
first_reimbursement = self._find_first_reimbursement_index(message)
application_source = message[:first_reimbursement] if first_reimbursement >= 0 else message
if self._looks_like_application(application_source) or self._looks_like_future_travel_application(application_source):
drafts.append(
PlannedTaskDraft(
task_type="expense_application",
segment=application_source.strip(",。;; "),
index=len(drafts) + 1,
)
)
for match in REIMBURSEMENT_PATTERN.finditer(message):
segment = f"报销{match.group(1)}"
drafts.append(
PlannedTaskDraft(
task_type="reimbursement",
segment=segment.strip(",。;; "),
index=len(drafts) + 1,
)
)
return drafts

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
import re
from dataclasses import dataclass
CITY_NAMES = (
"北京",
"上海",
"广州",
"深圳",
"杭州",
"南京",
"苏州",
"成都",
"重庆",
"天津",
"武汉",
"西安",
"长沙",
"郑州",
"青岛",
"厦门",
"福州",
"合肥",
"济南",
"沈阳",
"大连",
"宁波",
"无锡",
)
# 业务信号关键词:用于判定输入是否与小财管家支持的财务事项相关。
# 只要清洗后的消息命中其中任意一个关键词,就视为业务相关;否则进入 off_topic 拦截。
STEWARD_BUSINESS_SIGNAL_KEYWORDS: tuple[str, ...] = (
# 动作词
"申请", "报销", "草稿", "提交", "审批", "保存", "发起", "创建", "核对", "归集",
# 差旅场景
"出差", "差旅", "费用", "交通", "住宿", "招待", "酒店", "机票", "航班", "高铁",
"动车", "火车", "出租车", "的士", "网约车", "打车", "地铁", "公交", "用餐", "餐饮", "宴请",
# 票据/凭证
"票据", "发票", "凭证", "行程单", "付款截图", "付款", "小票", "收据",
# 业务对象
"客户", "项目", "拜访", "会议", "培训", "部署", "实施", "支撑", "支持", "协助",
"调研", "驻场", "上线", "验收", "审核",
# 时间信号
"昨天", "前天", "明天", "后天", "下周", "下月", "近期", "月底", "今天", "上周", "上月",
# 金额/数量("天"用于"出差3天"等表达)
"金额", "", "", "", "", "",
# 复用城市名信号
*CITY_NAMES,
)
# 业务无关输入的场景分类
STEWARD_OFF_TOPIC_SCENARIO_GREETING = "greeting"
STEWARD_OFF_TOPIC_SCENARIO_MEANINGLESS = "meaningless"
STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS = "off_business"
# 问候词:用于将"你好"等礼貌问候单独归类为 greeting 场景
STEWARD_GREETING_KEYWORDS: tuple[str, ...] = (
"你好", "您好", "hi", "hello", "hey", "", "哈喽",
"早上好", "上午好", "中午好", "下午好", "晚上好", "早安", "晚安",
"您好呀", "你好呀", "在吗", "在么", "在不在",
)
APPLICATION_SPLIT_PATTERN = re.compile(r"(?:^|[,。;;])[^,。;;]*?(?:申请|出差申请|差旅申请)[^,。;;]*")
REIMBURSEMENT_PATTERN = re.compile(r"(?:我要报销|还需要报销|需要报销|报销)([^,。;;?!\n]+)")
BUSINESS_FIELD_LABELS = {
"expense_type": "费用类型",
"time_range": "时间",
"location": "地点",
"reason": "事由",
"amount": "金额",
"transport_mode": "出行方式",
"attachments": "附件/凭证",
"customer_name": "客户或项目对象",
"merchant_name": "商户/开票方",
"department_name": "所属部门",
"employee_name": "申请人",
"employee_no": "员工编号",
}
EXPENSE_TYPE_LABELS = {
"travel": "差旅",
"transport": "交通费",
"entertainment": "业务招待费",
"office": "办公用品",
"meeting": "会议费",
"training": "培训费",
"other": "其他费用",
}
TRANSPORT_MODE_LABELS = {
"train": "火车/高铁",
"flight": "飞机",
"taxi": "出租车/网约车",
"subway": "地铁",
"other": "其他交通方式",
}
@dataclass(frozen=True)
class PlannedTaskDraft:
task_type: str
segment: str
index: int

View File

@@ -0,0 +1 @@
"""Shared utilities for backend tests."""

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
def build_in_memory_session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
def build_in_memory_session() -> Session:
return build_in_memory_session_factory()()

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from datetime import date
from app.services.application_fact_resolver import (
ApplicationFactResolver,
resolve_application_facts,
)
def test_application_fact_resolver_extracts_travel_application_fields() -> None:
facts = resolve_application_facts(
"明天去上海出差3天辅助国网仿生产环境部署高铁往返",
"expense_application",
date(2026, 6, 23),
)
assert facts["expense_type"] == "travel"
assert facts["time_range"] == "2026-06-24"
assert facts["location"] == "上海"
assert facts["reason"] == "辅助国网仿生产环境部署,高铁往返"
assert facts["transport_mode"] == "train"
def test_application_fact_resolver_preserves_reimbursement_transport_semantics() -> None:
facts = resolve_application_facts(
"报销昨天去北京客户现场沟通产生的出租车费用",
"reimbursement",
date(2026, 6, 23),
)
assert facts["expense_type"] == "transport"
assert facts["time_range"] == "2026-06-22"
assert facts["location"] == "北京"
assert facts["reason"] == "去北京客户现场沟通产生的出租车费用"
assert facts["transport_mode"] == "taxi"
def test_application_fact_resolver_keeps_static_wrapper_api() -> None:
assert ApplicationFactResolver.infer_expense_type("打车去客户现场", "expense_application") == "travel"
assert ApplicationFactResolver.infer_expense_type("打车去客户现场", "reimbursement") == "transport"
assert ApplicationFactResolver.extract_time_range("2026-07-01 去深圳", date(2026, 6, 23)) == "2026-07-01"

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from app.models.financial_record import ExpenseClaim
from app.test_helpers.db import build_in_memory_session
def test_build_in_memory_session_creates_isolated_sqlite_schema() -> None:
with build_in_memory_session() as db:
db.add(
ExpenseClaim(
claim_no="CLM-HELPER-001",
employee_name="张三",
department_name="研发部",
expense_type="travel",
reason="测试共享内存数据库夹具",
location="上海",
amount=Decimal("10.00"),
occurred_at=datetime(2026, 6, 23, tzinfo=UTC),
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
)
db.commit()
assert db.query(ExpenseClaim).count() == 1

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from types import SimpleNamespace
from app.services.expense_claim_platform_context_tools import (
collect_context_cities,
collect_context_item_ids,
extract_first_known_city_from_text,
unique_text_values,
)
from app.services.expense_rule_runtime_models import build_default_expense_rule_catalog
def test_unique_text_values_trims_and_preserves_order() -> None:
assert unique_text_values([" 上海 ", "", "上海", "北京", None, "北京"]) == [
"上海",
"北京",
]
def test_collect_context_item_ids_skips_empty_and_dedupes() -> None:
contexts = [
{"item": SimpleNamespace(id="item-1")},
{"item": SimpleNamespace(id="item-1")},
{"item": SimpleNamespace(id=" item-2 ")},
{"item": SimpleNamespace(id="")},
{},
]
assert collect_context_item_ids(contexts) == ["item-1", "item-2"]
def test_collect_context_cities_can_include_item_reason() -> None:
policy = build_default_expense_rule_catalog().travel_policy
assert policy is not None
context = {
"ocr_summary": "火车票;武汉-上海",
"ocr_text": "",
"document_info": {
"fields": [
{"key": "route", "label": "路线", "value": "上海-深圳"},
]
},
"item": SimpleNamespace(item_location="", item_reason="深圳-上海"),
}
assert set(collect_context_cities(context, policy, include_item_reason=True)) == {
"武汉",
"上海",
"深圳",
}
assert extract_first_known_city_from_text("预计前往北京客户现场", policy) == "北京"

View File

@@ -5,13 +5,10 @@ from datetime import UTC, date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from sqlalchemy import create_engine from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.db.base import Base
from app.models.agent_asset import AgentAsset from app.models.agent_asset import AgentAsset
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
@@ -19,17 +16,10 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
from app.test_helpers.db import build_in_memory_session
def build_session() -> Session: build_session = build_in_memory_session
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def _build_rule_payload( def _build_rule_payload(

View File

@@ -148,10 +148,10 @@
.ai-quick-btn { .ai-quick-btn {
min-height: 48px; min-height: 48px;
display: grid; display: grid;
grid-template-columns: 28px minmax(0, 1fr); grid-template-columns: 32px minmax(0, 1fr);
align-items: center; align-items: center;
gap: 10px; gap: 12px;
padding: 0 4px; padding: 7px 10px;
color: #111827; color: #111827;
font-size: 14px; font-size: 14px;
font-weight: 780; font-weight: 780;
@@ -160,13 +160,21 @@
box-shadow: none; box-shadow: none;
} }
.ai-quick-btn i { .ai-quick-icon {
width: 28px; width: 32px;
display: inline-flex; height: 32px;
justify-content: center; display: inline-grid;
place-items: center;
color: #536277; color: #536277;
font-size: 18px; }
line-height: 1;
.ai-sidebar-tabler-icon {
width: 20px;
height: 20px;
display: block;
overflow: visible;
flex: none;
stroke-width: 1.85;
} }
.ai-quick-btn.primary { .ai-quick-btn.primary {
@@ -175,17 +183,10 @@
box-shadow: none; box-shadow: none;
} }
.ai-quick-btn.active { .ai-quick-btn.primary .ai-quick-icon {
color: #173d78;
background: rgba(45, 114, 217, 0.055);
border-color: rgba(45, 114, 217, 0.12);
}
.ai-quick-btn.primary i {
color: var(--ai-rail-amber); color: var(--ai-rail-amber);
} }
.ai-nav-btn:hover,
.ai-recent-item:hover, .ai-recent-item:hover,
.ai-user-action:hover { .ai-user-action:hover {
background: rgba(255, 255, 255, 0.78); background: rgba(255, 255, 255, 0.78);
@@ -194,7 +195,10 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.ai-quick-btn:hover { .ai-quick-btn:hover,
.ai-quick-btn.active,
.ai-nav-btn:hover,
.ai-nav-btn.active {
color: #0f172a; color: #0f172a;
background: rgba(15, 23, 42, 0.035); background: rgba(15, 23, 42, 0.035);
border-color: transparent; border-color: transparent;
@@ -202,11 +206,14 @@
transform: translateX(2px); transform: translateX(2px);
} }
.ai-quick-btn:hover i { .ai-quick-btn:hover .ai-quick-icon,
.ai-quick-btn.active .ai-quick-icon,
.ai-nav-btn:hover .ai-nav-icon,
.ai-nav-btn.active .ai-nav-icon {
color: var(--ai-rail-accent); color: var(--ai-rail-accent);
} }
.ai-quick-btn.primary:hover i { .ai-quick-btn.primary:hover .ai-quick-icon {
color: var(--ai-rail-amber); color: var(--ai-rail-amber);
} }
@@ -215,10 +222,10 @@
min-height: 48px; min-height: 48px;
height: 48px; height: 48px;
display: grid; display: grid;
grid-template-columns: 28px minmax(0, 1fr) 28px; grid-template-columns: 32px minmax(0, 1fr) 28px;
align-items: center; align-items: center;
gap: 4px; gap: 8px;
padding: 0 6px 0 4px; padding: 0 6px 0 10px;
border: 1px solid rgba(45, 114, 217, 0.14); border: 1px solid rgba(45, 114, 217, 0.14);
border-radius: 12px; border-radius: 12px;
background: rgba(255, 255, 255, 0.7); background: rgba(255, 255, 255, 0.7);
@@ -308,41 +315,6 @@
box-shadow: none; box-shadow: none;
} }
.ai-nav-btn::before {
content: "";
position: absolute;
left: 0;
width: 3px;
height: 22px;
border-radius: 999px;
background: transparent;
transition:
background 180ms var(--ease),
opacity 180ms var(--ease);
opacity: 0;
}
.ai-nav-btn.active {
border-color: rgba(45, 114, 217, 0.13);
background:
linear-gradient(90deg, rgba(45, 114, 217, 0.095), rgba(255, 255, 255, 0.74)),
var(--ai-rail-panel);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.78),
0 8px 18px rgba(45, 114, 217, 0.045);
color: #173d78;
}
.ai-nav-btn.active::before {
background: linear-gradient(180deg, var(--ai-rail-accent), var(--ai-rail-green));
opacity: 1;
}
.ai-nav-btn:not(.active):hover::before {
background: rgba(45, 114, 217, 0.36);
opacity: 1;
}
.ai-nav-icon { .ai-nav-icon {
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -357,17 +329,9 @@
box-shadow 180ms var(--ease); box-shadow 180ms var(--ease);
} }
.ai-nav-btn.active .ai-nav-icon { .ai-nav-icon .ai-sidebar-tabler-icon {
background: width: 19px;
linear-gradient(145deg, rgba(45, 114, 217, 0.12), rgba(255, 255, 255, 0.72)), height: 19px;
rgba(255, 255, 255, 0.52);
color: var(--ai-rail-accent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
}
.ai-nav-icon i {
font-size: 18px;
line-height: 1;
} }
.ai-nav-copy { .ai-nav-copy {
@@ -386,10 +350,6 @@
white-space: nowrap; white-space: nowrap;
} }
.ai-nav-btn.active .ai-nav-copy strong {
font-weight: 820;
}
.ai-recent-desc { .ai-recent-desc {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -621,8 +581,7 @@
box-shadow: none; box-shadow: none;
} }
.ai-rail.rail-collapsed .ai-nav-list::before, .ai-rail.rail-collapsed .ai-nav-list::before {
.ai-rail.rail-collapsed .ai-nav-btn::before {
display: none; display: none;
} }
@@ -636,12 +595,6 @@
padding: 8px; padding: 8px;
} }
.ai-rail.rail-collapsed .ai-nav-btn.active {
grid-column: auto;
min-height: 44px;
grid-template-columns: 1fr;
}
.ai-rail.rail-collapsed .ai-quick-btn span, .ai-rail.rail-collapsed .ai-quick-btn span,
.ai-rail.rail-collapsed .ai-conversation-search, .ai-rail.rail-collapsed .ai-conversation-search,
.ai-rail.rail-collapsed .ai-brand-copy, .ai-rail.rail-collapsed .ai-brand-copy,
@@ -653,7 +606,7 @@
display: none; display: none;
} }
.ai-rail.rail-collapsed .ai-quick-btn i, .ai-rail.rail-collapsed .ai-quick-icon,
.ai-rail.rail-collapsed .ai-brand-logo, .ai-rail.rail-collapsed .ai-brand-logo,
.ai-rail.rail-collapsed .ai-nav-icon, .ai-rail.rail-collapsed .ai-nav-icon,
.ai-rail.rail-collapsed .ai-user-avatar { .ai-rail.rail-collapsed .ai-user-avatar {

View File

@@ -475,42 +475,30 @@
top: calc(100% + 12px); top: calc(100% + 12px);
right: 0; right: 0;
z-index: 60; z-index: 60;
width: 380px; width: 428px;
max-width: calc(100vw - 24px); max-width: calc(100vw - 24px);
max-height: min(520px, calc(100vh - 96px)); max-height: min(560px, calc(100vh - 68px));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.06); border: 1px solid #dbe4ef;
border-radius: 12px; border-radius: 10px;
background: #ffffff; background: #ffffff;
box-shadow: box-shadow:
0 16px 36px rgba(0, 0, 0, 0.08), 0 18px 42px rgba(15, 23, 42, 0.13),
0 4px 12px rgba(0, 0, 0, 0.03), 0 4px 12px rgba(15, 23, 42, 0.06);
0 0 1px rgba(0, 0, 0, 0.1);
overscroll-behavior-y: contain; overscroll-behavior-y: contain;
} }
.notification-popover::before {
content: "";
display: block;
height: 3px;
background: linear-gradient(
90deg,
var(--theme-primary-active) 0%,
var(--theme-primary-light-3, #7eb3d4) 100%
);
}
.notification-head { .notification-head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 12px 14px 10px; padding: 14px 16px 12px;
border-bottom: 1px solid #edf2f7; border-bottom: 1px solid #e6edf6;
background: #fafbfd; background: #ffffff;
} }
.notification-head-brand { .notification-head-brand {
@@ -522,14 +510,14 @@
} }
.notification-head-icon { .notification-head-icon {
width: 32px; width: 34px;
height: 32px; height: 34px;
flex: 0 0 auto; flex: 0 0 auto;
display: grid; display: grid;
place-items: center; place-items: center;
border: 1px solid var(--theme-primary-light-6); border: 1px solid #dbeafe;
border-radius: 4px; border-radius: 4px;
background: #fff; background: #f8fbff;
color: var(--theme-primary-active); color: var(--theme-primary-active);
font-size: 17px; font-size: 17px;
} }
@@ -561,22 +549,25 @@
} }
.notification-clear-btn { .notification-clear-btn {
height: 28px; height: 30px;
padding: 0 10px; padding: 0 10px;
border: 0; border: 1px solid transparent;
border-radius: 4px; border-radius: 4px;
background: transparent; background: transparent;
color: var(--theme-primary-active); color: #475569;
font-size: 12px; font-size: 12px;
font-weight: 750; font-weight: 750;
white-space: nowrap; white-space: nowrap;
transition: transition:
background 160ms var(--ease), background 160ms var(--ease),
border-color 160ms var(--ease),
color 160ms var(--ease); color 160ms var(--ease);
} }
.notification-clear-btn:hover:not(:disabled) { .notification-clear-btn:hover:not(:disabled) {
background: var(--theme-primary-light-9); border-color: #bfdbfe;
background: #eff6ff;
color: var(--theme-primary-active);
} }
.notification-clear-btn:disabled { .notification-clear-btn:disabled {
@@ -585,8 +576,8 @@
} }
.notification-close-btn { .notification-close-btn {
width: 28px; width: 30px;
height: 28px; height: 30px;
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
border: 0; border: 0;
@@ -608,15 +599,15 @@
display: flex; display: flex;
align-items: stretch; align-items: stretch;
gap: 0; gap: 0;
padding: 0 14px; padding: 0 16px;
border-bottom: 1px solid #edf2f7; border-bottom: 1px solid #e6edf6;
background: #fff; background: #f8fafc;
} }
.notification-tabs button { .notification-tabs button {
position: relative; position: relative;
flex: 1 1 0; flex: 1 1 0;
height: 38px; height: 40px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -666,10 +657,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
max-height: min(336px, calc(100vh - 226px)); max-height: min(420px, calc(100vh - 166px));
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
padding: 4px 0 12px; padding: 10px 0 14px;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f8fafc; scrollbar-color: #cbd5e1 #f8fafc;
overscroll-behavior-y: contain; overscroll-behavior-y: contain;
@@ -691,179 +682,151 @@
.notification-row { .notification-row {
display: grid; display: grid;
grid-template-columns: 34px minmax(0, 1fr) 16px; grid-template-columns: 52px minmax(0, 1fr);
align-items: center; align-items: start;
gap: 12px; gap: 16px;
min-height: 68px; min-height: 104px;
padding: 12px 16px; padding: 20px 22px;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
background: #ffffff; background: #ffffff;
flex-shrink: 0; flex-shrink: 0;
cursor: pointer;
text-align: left; text-align: left;
transition: transition:
background 180ms var(--ease), background 180ms var(--ease),
border-color 180ms var(--ease); transform 180ms var(--ease);
} }
.notification-row + .notification-row { .notification-row + .notification-row {
border-top: 1px solid #f1f5f9; border-top: 1px solid #f4f6fb;
} }
.notification-row.unread { .notification-row.unread {
background: #f8fafc; background: #ffffff;
position: relative;
}
.notification-row.unread::before {
content: '';
position: absolute;
left: 0;
top: 16px;
bottom: 16px;
width: 3px;
border-radius: 0 4px 4px 0;
background: var(--theme-primary-active);
} }
.notification-row:hover { .notification-row:hover {
background: #f1f5f9; background: #f8fafc;
} }
.notification-row.unread:hover { .notification-row.unread:hover {
background: #f1f5f9; background: #f8fafc;
} }
.notification-type-icon { .notification-avatar {
width: 34px; position: relative;
height: 34px; width: 52px;
height: 52px;
display: grid; display: grid;
place-items: center; place-items: center;
border: 1px solid rgba(0,0,0,0.04); border: 1px solid #dbeafe;
border-radius: 8px; border-radius: 999px;
background: #ffffff; background: #eff6ff;
color: var(--theme-primary-active); color: #2563eb;
font-size: 16px; font-size: 18px;
box-shadow: 0 1.5px 4px rgba(0,0,0,0.03); font-weight: 800;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
} }
.notification-type-icon.danger { .notification-avatar.danger {
border-color: #fecaca; border-color: #fecaca;
background: #fff5f5; background: #fff1f2;
color: #dc2626; color: #be123c;
} }
.notification-type-icon.warning { .notification-avatar.warning {
border-color: #fde68a; border-color: #fde68a;
background: #fffbeb; background: #fffbeb;
color: #d97706; color: #b45309;
} }
.notification-type-icon.success { .notification-avatar.success {
border-color: #bbf7d0; border-color: #bbf7d0;
background: #f0fdf4; background: #f0fdf4;
color: #16a34a; color: #15803d;
} }
.notification-type-icon.info { .notification-avatar.info {
border-color: #bfdbfe; border-color: #bfdbfe;
background: #eff6ff; background: #eff6ff;
color: #2563eb; color: #2563eb;
} }
.notification-copy { .notification-avatar-label {
min-width: 0; line-height: 1;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 1px;
padding-bottom: 1px;
} }
.notification-title-line { .notification-avatar-badge {
min-width: 0; position: absolute;
display: flex; top: -2px;
align-items: center; right: -2px;
gap: 8px;
}
.notification-copy strong {
min-width: 0;
color: #0f172a;
font-size: 13.5px;
font-weight: 750;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-title-line b {
flex: 0 0 auto;
min-width: 18px; min-width: 18px;
height: 18px; height: 18px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0 5px; padding: 0 4px;
border-radius: 4px; border: 2px solid #ffffff;
background: #dc2626; border-radius: 999px;
color: #fff; background: #ef4444;
color: #ffffff;
font-size: 10px; font-size: 10px;
font-weight: 800; font-weight: 800;
line-height: 1; line-height: 1;
} }
.notification-copy small { .notification-row-content {
color: #64748b; min-width: 0;
font-size: 12px; display: grid;
line-height: 1.4; gap: 8px;
padding-top: 2px;
}
.notification-row-top {
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.notification-row-title {
min-width: 0;
color: #111827;
font-size: 15px;
font-weight: 800;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-row.unread .notification-row-title {
color: #0f172a;
}
.notification-preview {
max-width: 100%;
color: #8a94a6;
font-size: 13.5px;
line-height: 1.55;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
margin-bottom: 2px;
} }
.notification-meta { .notification-time {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 2px;
}
.notification-meta em,
.notification-meta time {
min-width: 0;
overflow: hidden;
color: #94a3b8;
font-size: 11px;
font-style: normal;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-meta em {
display: none;
}
.notification-meta time {
flex: 0 0 auto; flex: 0 0 auto;
} color: #b4bcc8;
font-size: 13px;
.notification-row-arrow { font-style: normal;
color: #cbd5e1; font-weight: 700;
font-size: 18px; font-variant-numeric: tabular-nums;
transition: color 160ms var(--ease), transform 160ms var(--ease); line-height: 1.3;
} white-space: nowrap;
.notification-row:hover .notification-row-arrow {
color: var(--theme-primary-active);
transform: translateX(2px);
} }
.notification-empty { .notification-empty {
@@ -1573,7 +1536,24 @@
} }
.notification-row { .notification-row {
padding: 9px 12px; grid-template-columns: 44px minmax(0, 1fr);
gap: 12px;
min-height: 90px;
padding: 16px 14px;
}
.notification-avatar {
width: 44px;
height: 44px;
font-size: 16px;
}
.notification-row-top {
gap: 8px;
}
.notification-time {
font-size: 12px;
} }
.company-switcher { .company-switcher {
@@ -1641,7 +1621,7 @@
} }
.notification-head-icon, .notification-head-icon,
.notification-type-icon { .notification-avatar {
width: 30px; width: 30px;
height: 30px; height: 30px;
} }
@@ -1653,10 +1633,26 @@
.notification-row { .notification-row {
grid-template-columns: 30px minmax(0, 1fr); grid-template-columns: 30px minmax(0, 1fr);
gap: 8px; gap: 8px;
min-height: 82px;
padding: 13px 10px;
} }
.notification-row-arrow { .notification-avatar {
display: none; font-size: 13px;
}
.notification-avatar-badge {
min-width: 16px;
height: 16px;
font-size: 9px;
}
.notification-row-title {
font-size: 13.5px;
}
.notification-preview {
font-size: 12.5px;
} }
.topbar.detail-mode { .topbar.detail-mode {

View File

@@ -28,183 +28,11 @@
<p>您可以直接向我提问,或选择下方推荐主题,快速完成费用相关事务</p> <p>您可以直接向我提问,或选择下方推荐主题,快速完成费用相关事务</p>
</div> </div>
<form class="workbench-ai-composer" @submit.prevent="submitAiModePrompt"> <WorkbenchAiComposer
<div class="workbench-ai-composer-field"> :runtime="workbenchAiRuntime"
<div v-if="workbenchDateTagLabel" class="workbench-ai-date-chip"> placeholder="今天我能帮您做点什么?"
<i class="mdi mdi-calendar-check-outline"></i>
<span>{{ workbenchDateTagLabel }}</span>
<button type="button" aria-label="移除日期" :disabled="isAiModeInputLocked" @click="removeWorkbenchDateTag">
<i class="mdi mdi-close"></i>
</button>
</div>
<textarea
ref="assistantInputRef"
v-model="assistantDraft"
maxlength="1000"
rows="3"
:placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '今天我能帮您做点什么?'"
:disabled="isAiModeInputLocked"
@keydown.enter.exact.prevent="submitAiModePrompt"
></textarea>
</div>
<div class="workbench-ai-composer-toolbar">
<div class="workbench-ai-tool-buttons">
<div class="workbench-date-anchor workbench-ai-date-anchor">
<button
type="button"
class="workbench-ai-icon-btn"
:class="{ active: workbenchDatePickerOpen || workbenchDateTagLabel }"
title="选择日期"
aria-label="选择日期"
:aria-expanded="workbenchDatePickerOpen"
:disabled="isAiModeInputLocked"
@click.stop="toggleWorkbenchDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="workbenchDatePickerOpen"
class="workbench-ai-date-popover"
role="dialog"
aria-label="选择业务日期"
@click.stop
>
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
<button
type="button"
:class="{ active: workbenchDateMode === 'single' }"
:disabled="isAiModeInputLocked"
@click="setWorkbenchDateMode('single')"
>
单日
</button>
<button
type="button"
:class="{ active: workbenchDateMode === 'range' }"
:disabled="isAiModeInputLocked"
@click="setWorkbenchDateMode('range')"
>
范围
</button>
</div>
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
<span>业务日期</span>
<input
v-model="workbenchSingleDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('single')"
/> />
</label> <WorkbenchAiFileStrip :runtime="workbenchAiRuntime" />
<div v-else class="workbench-ai-date-range">
<label class="workbench-ai-date-field">
<span>开始日期</span>
<input
v-model="workbenchRangeStartDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('range-start')"
/>
</label>
<label class="workbench-ai-date-field">
<span>结束日期</span>
<input
v-model="workbenchRangeEndDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('range-end')"
/>
</label>
</div>
<div class="workbench-ai-date-actions">
<button type="button" class="ghost" :disabled="isAiModeInputLocked" @click="clearWorkbenchDateSelection">清除</button>
<button
type="button"
class="primary"
:disabled="!workbenchCanApplyDateSelection || isAiModeInputLocked"
@click="applyWorkbenchDateSelection"
>
完成
</button>
</div>
</div>
</div>
<button
type="button"
class="workbench-ai-icon-btn"
title="上传附件"
aria-label="上传附件"
:disabled="isAiModeInputLocked"
@click="triggerAiModeFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
<button
type="button"
class="workbench-ai-icon-btn"
title="语音输入"
aria-label="语音输入"
:disabled="isAiModeInputLocked"
@click="handleVoiceInput"
>
<i class="mdi mdi-microphone-outline"></i>
</button>
</div>
<div class="workbench-ai-composer-right">
<div class="workbench-ai-model-selector" :title="modelSelectorTitle">
<span>{{ displayModelName }}</span>
<i class="mdi mdi-chevron-down"></i>
</div>
<button
type="submit"
class="workbench-ai-send-btn"
:disabled="!canSubmitAiModePrompt || sending || isAiModeInputLocked"
aria-label="发送给小财管家"
>
<i class="mdi mdi-arrow-up"></i>
</button>
</div>
</div>
</form>
<div v-if="selectedFileCards.length" class="workbench-ai-file-strip" aria-label="已选择附件">
<article v-for="file in selectedFileCards" :key="file.key" class="workbench-ai-file-card">
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
<i :class="file.icon"></i>
</span>
<span class="workbench-ai-file-card__body">
<strong :title="file.name">{{ file.name }}</strong>
<small>{{ file.typeLabel }}</small>
<small
v-if="file.ocrState?.label"
class="workbench-ai-file-card__ocr"
:class="`is-${file.ocrState.status || 'idle'}`"
:title="file.ocrState.title || file.ocrState.label"
>
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
<span>{{ file.ocrState.label }}</span>
</small>
</span>
<button
type="button"
class="workbench-ai-file-card__remove"
:disabled="isAiModeInputLocked"
:aria-label="`移除附件 ${file.name}`"
@click="removeAiModeFile(file.key)"
>
<i class="mdi mdi-close"></i>
</button>
</article>
</div>
<div class="workbench-ai-quick-start-section"> <div class="workbench-ai-quick-start-section">
<h3 class="workbench-ai-quick-start-title">快速开始</h3> <h3 class="workbench-ai-quick-start-title">快速开始</h3>
@@ -565,183 +393,12 @@
</div> </div>
<div class="workbench-ai-conversation-bottom"> <div class="workbench-ai-conversation-bottom">
<div v-if="selectedFileCards.length" class="workbench-ai-file-strip inline" aria-label="已选择附件"> <WorkbenchAiFileStrip inline :runtime="workbenchAiRuntime" />
<article v-for="file in selectedFileCards" :key="file.key" class="workbench-ai-file-card"> <WorkbenchAiComposer
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true"> inline
<i :class="file.icon"></i> :runtime="workbenchAiRuntime"
</span> placeholder="继续和小财管家对话..."
<span class="workbench-ai-file-card__body">
<strong :title="file.name">{{ file.name }}</strong>
<small>{{ file.typeLabel }}</small>
<small
v-if="file.ocrState?.label"
class="workbench-ai-file-card__ocr"
:class="`is-${file.ocrState.status || 'idle'}`"
:title="file.ocrState.title || file.ocrState.label"
>
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
<span>{{ file.ocrState.label }}</span>
</small>
</span>
<button
type="button"
class="workbench-ai-file-card__remove"
:disabled="isAiModeInputLocked"
:aria-label="`移除附件 ${file.name}`"
@click="removeAiModeFile(file.key)"
>
<i class="mdi mdi-close"></i>
</button>
</article>
</div>
<form class="workbench-ai-composer workbench-ai-composer--inline" @submit.prevent="submitAiModePrompt">
<div class="workbench-ai-composer-field">
<div v-if="workbenchDateTagLabel" class="workbench-ai-date-chip">
<i class="mdi mdi-calendar-check-outline"></i>
<span>{{ workbenchDateTagLabel }}</span>
<button type="button" aria-label="移除日期" :disabled="isAiModeInputLocked" @click="removeWorkbenchDateTag">
<i class="mdi mdi-close"></i>
</button>
</div>
<textarea
ref="assistantInputRef"
v-model="assistantDraft"
maxlength="1000"
rows="3"
:placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '继续和小财管家对话...'"
:disabled="isAiModeInputLocked"
@keydown.enter.exact.prevent="submitAiModePrompt"
></textarea>
</div>
<div class="workbench-ai-composer-toolbar">
<div class="workbench-ai-tool-buttons">
<div class="workbench-date-anchor workbench-ai-date-anchor">
<button
type="button"
class="workbench-ai-icon-btn"
:class="{ active: workbenchDatePickerOpen || workbenchDateTagLabel }"
title="选择日期"
aria-label="选择日期"
:aria-expanded="workbenchDatePickerOpen"
:disabled="isAiModeInputLocked"
@click.stop="toggleWorkbenchDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="workbenchDatePickerOpen"
class="workbench-ai-date-popover"
role="dialog"
aria-label="选择业务日期"
@click.stop
>
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
<button
type="button"
:class="{ active: workbenchDateMode === 'single' }"
:disabled="isAiModeInputLocked"
@click="setWorkbenchDateMode('single')"
>
单日
</button>
<button
type="button"
:class="{ active: workbenchDateMode === 'range' }"
:disabled="isAiModeInputLocked"
@click="setWorkbenchDateMode('range')"
>
范围
</button>
</div>
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
<span>业务日期</span>
<input
v-model="workbenchSingleDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('single')"
/> />
</label>
<div v-else class="workbench-ai-date-range">
<label class="workbench-ai-date-field">
<span>开始日期</span>
<input
v-model="workbenchRangeStartDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('range-start')"
/>
</label>
<label class="workbench-ai-date-field">
<span>结束日期</span>
<input
v-model="workbenchRangeEndDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('range-end')"
/>
</label>
</div>
<div class="workbench-ai-date-actions">
<button type="button" class="ghost" :disabled="isAiModeInputLocked" @click="clearWorkbenchDateSelection">清除</button>
<button
type="button"
class="primary"
:disabled="!workbenchCanApplyDateSelection || isAiModeInputLocked"
@click="applyWorkbenchDateSelection"
>
完成
</button>
</div>
</div>
</div>
<button
type="button"
class="workbench-ai-icon-btn"
title="上传附件"
aria-label="上传附件"
:disabled="isAiModeInputLocked"
@click="triggerAiModeFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
<button
type="button"
class="workbench-ai-icon-btn"
title="语音输入"
aria-label="语音输入"
:disabled="isAiModeInputLocked"
@click="handleVoiceInput"
>
<i class="mdi mdi-microphone-outline"></i>
</button>
</div>
<div class="workbench-ai-composer-right">
<div class="workbench-ai-model-selector" :title="modelSelectorTitle">
<span>{{ displayModelName }}</span>
<i class="mdi mdi-chevron-down"></i>
</div>
<button
type="submit"
class="workbench-ai-send-btn"
:disabled="!canSubmitAiModePrompt || sending || isAiModeInputLocked"
aria-label="发送给小财管家"
>
<i class="mdi mdi-arrow-up"></i>
</button>
</div>
</div>
</form>
<p class="workbench-ai-disclaimer">小财管家可能会出错,重要费用事项请核对单据、制度与审批结果。</p> <p class="workbench-ai-disclaimer">小财管家可能会出错,重要费用事项请核对单据、制度与审批结果。</p>
</div> </div>

View File

@@ -1,7 +1,10 @@
<template src="./PersonalWorkbenchAiMode.template.html"></template> <template src="./PersonalWorkbenchAiMode.template.html"></template>
<script setup> <script setup>
import { proxyRefs } from 'vue'
import orbIcon from '../../assets/workbench-ai-mode-orb-icon.gif' import orbIcon from '../../assets/workbench-ai-mode-orb-icon.gif'
import WorkbenchAiComposer from './workbench-ai/WorkbenchAiComposer.vue'
import WorkbenchAiFileStrip from './workbench-ai/WorkbenchAiFileStrip.vue'
import { usePersonalWorkbenchAiMode } from '../../composables/workbenchAiMode/usePersonalWorkbenchAiMode.js' import { usePersonalWorkbenchAiMode } from '../../composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'
const props = defineProps({ const props = defineProps({
@@ -9,9 +12,12 @@ const props = defineProps({
}) })
const emit = defineEmits(['conversation-change', 'conversation-history-change', 'open-document', 'request-updated']) const emit = defineEmits(['conversation-change', 'conversation-history-change', 'open-document', 'request-updated'])
const aiModeRuntime = usePersonalWorkbenchAiMode(props, emit)
const workbenchAiRuntime = proxyRefs(aiModeRuntime)
const { const {
activeConversationTitle, aiModeActionItems, applicationPreviewEditor, applicationSubmitConfirmOpen, assistantDraft, assistantInputRef, canShowInlineSuggestedActions, canSubmitAiModePrompt, cancelDeleteConversation, cancelInlineApplicationSubmitConfirm, clearWorkbenchDateSelection, commitInlineApplicationPreviewEditor, confirmDeleteConversation, confirmInlineApplicationSubmit, conversationMessages, conversationScrollRef, conversationStarted, deleteDialogOpen, displayModelName, displayUserName, fileInputRef, handleAiAnswerMarkdownClick, handleAiModeFilesChange, handleInlineApplicationPreviewEditorKeydown, handleInlineConversationScroll, handleInlineSuggestedAction, handleVoiceInput, hasInlineAttachmentOcrDetails, hasInlineThinking, isAiModeInputLocked, isApplicationPreviewEditing, isApplicationPreviewEstimatePending, isInlineAttachmentOcrExpanded, isInlineSuggestedActionDisabled, isInlineThinkingExpanded, markInlineMessageFeedback, modelSelectorTitle, openApplicationPreviewEditor, quoteInlineMessage, regenerateLastReply, removeAiModeFile, removeWorkbenchDateTag, renderInlineConversationHtml, requestDeleteCurrentConversation, resolveApplicationPreviewEditorOptions, resolveInlineApplicationPreviewEditorControl, resolveInlineApplicationPreviewEditorDateMax, resolveInlineApplicationPreviewEditorDateMin, resolveInlineApplicationPreviewMissingFields, resolveInlineApplicationPreviewRows, resolveInlineAttachmentOcrDocuments, resolveInlineAttachmentOcrFileCount, resolveInlineThinkingEvents, runAiModeAction, scrollInlineConversationToTop, selectedFileCards, sending, setWorkbenchDateMode, submitAiModePrompt, toggleInlineAttachmentOcrDetails, toggleInlineThinking, toggleWorkbenchDatePicker, triggerAiModeFileUpload, workbenchCanApplyDateSelection, workbenchDateMode, workbenchDatePickerOpen, workbenchDateTagLabel, workbenchRangeEndDate, workbenchRangeStartDate, workbenchSingleDate, applyWorkbenchDateSelection, buildInlineApplicationPreviewFooterText, copyInlineMessage, formatMessageTime, handleWorkbenchDateInputChange activeConversationTitle, aiModeActionItems, applicationPreviewEditor, applicationSubmitConfirmOpen, assistantDraft, assistantInputRef, canShowInlineSuggestedActions, canSubmitAiModePrompt, cancelDeleteConversation, cancelInlineApplicationSubmitConfirm, clearWorkbenchDateSelection, commitInlineApplicationPreviewEditor, confirmDeleteConversation, confirmInlineApplicationSubmit, conversationMessages, conversationScrollRef, conversationStarted, deleteDialogOpen, displayModelName, displayUserName, fileInputRef, handleAiAnswerMarkdownClick, handleAiModeFilesChange, handleInlineApplicationPreviewEditorKeydown, handleInlineConversationScroll, handleInlineSuggestedAction, handleVoiceInput, hasInlineAttachmentOcrDetails, hasInlineThinking, isAiModeInputLocked, isApplicationPreviewEditing, isApplicationPreviewEstimatePending, isInlineAttachmentOcrExpanded, isInlineSuggestedActionDisabled, isInlineThinkingExpanded, markInlineMessageFeedback, modelSelectorTitle, openApplicationPreviewEditor, quoteInlineMessage, regenerateLastReply, removeAiModeFile, removeWorkbenchDateTag, renderInlineConversationHtml, requestDeleteCurrentConversation, resolveApplicationPreviewEditorOptions, resolveInlineApplicationPreviewEditorControl, resolveInlineApplicationPreviewEditorDateMax, resolveInlineApplicationPreviewEditorDateMin, resolveInlineApplicationPreviewMissingFields, resolveInlineApplicationPreviewRows, resolveInlineAttachmentOcrDocuments, resolveInlineAttachmentOcrFileCount, resolveInlineThinkingEvents, runAiModeAction, scrollInlineConversationToTop, selectedFileCards, sending, setWorkbenchDateMode, submitAiModePrompt, toggleInlineAttachmentOcrDetails, toggleInlineThinking, toggleWorkbenchDatePicker, triggerAiModeFileUpload, workbenchCanApplyDateSelection, workbenchDateMode, workbenchDatePickerOpen, workbenchDateTagLabel, workbenchRangeEndDate, workbenchRangeStartDate, workbenchSingleDate, applyWorkbenchDateSelection, buildInlineApplicationPreviewFooterText, copyInlineMessage, formatMessageTime, handleWorkbenchDateInputChange
} = usePersonalWorkbenchAiMode(props, emit) } = aiModeRuntime
</script> </script>
<style scoped src="../../assets/styles/components/personal-workbench-ai-mode.css"></style> <style scoped src="../../assets/styles/components/personal-workbench-ai-mode.css"></style>

View File

@@ -12,6 +12,17 @@
<span class="workbench-ai-file-card__body"> <span class="workbench-ai-file-card__body">
<strong :title="file.name">{{ file.name }}</strong> <strong :title="file.name">{{ file.name }}</strong>
<small>{{ file.typeLabel }}</small> <small>{{ file.typeLabel }}</small>
<small
v-if="file.ocrState?.label"
class="workbench-ai-file-card__ocr"
:class="`is-${file.ocrState.status || 'idle'}`"
:title="file.ocrState.title || file.ocrState.label"
>
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
<span>{{ file.ocrState.label }}</span>
</small>
</span> </span>
<button <button
type="button" type="button"

View File

@@ -43,7 +43,23 @@
:class="{ primary: action.primary }" :class="{ primary: action.primary }"
@click="handleQuickAction(action.event)" @click="handleQuickAction(action.event)"
> >
<i :class="action.icon" aria-hidden="true"></i> <span class="ai-quick-icon" aria-hidden="true">
<svg
class="ai-sidebar-tabler-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
v-for="(path, pathIndex) in action.iconPaths"
:key="`${action.event}-${pathIndex}`"
:d="path"
/>
</svg>
</span>
<span>{{ action.label }}</span> <span>{{ action.label }}</span>
</button> </button>
</template> </template>
@@ -60,10 +76,26 @@
class="ai-nav-btn" class="ai-nav-btn"
:class="{ active: activeView === item.id }" :class="{ active: activeView === item.id }"
:aria-current="activeView === item.id ? 'page' : undefined" :aria-current="activeView === item.id ? 'page' : undefined"
@mouseenter="emit('prefetch-view', item.id)"
@focus="emit('prefetch-view', item.id)"
@click="emit('navigate', item.id)" @click="emit('navigate', item.id)"
> >
<span class="ai-nav-icon" aria-hidden="true"> <span class="ai-nav-icon" aria-hidden="true">
<i :class="item.aiIcon"></i> <svg
class="ai-sidebar-tabler-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
v-for="(path, pathIndex) in item.aiIconPaths"
:key="`${item.id}-${pathIndex}`"
:d="path"
/>
</svg>
</span> </span>
<span class="ai-nav-copy"> <span class="ai-nav-copy">
<strong>{{ item.displayLabel }}</strong> <strong>{{ item.displayLabel }}</strong>
@@ -155,7 +187,7 @@ const props = defineProps({
conversationHistory: { type: Array, default: () => [] } conversationHistory: { type: Array, default: () => [] }
}) })
const emit = defineEmits(['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout']) const emit = defineEmits(['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout', 'prefetch-view'])
const conversationSearchOpen = ref(false) const conversationSearchOpen = ref(false)
const conversationSearchQuery = ref('') const conversationSearchQuery = ref('')
const conversationSearchInputRef = ref(null) const conversationSearchInputRef = ref(null)
@@ -164,16 +196,78 @@ const editingConversationTitle = ref('')
const editingTitleInputRef = ref(null) const editingTitleInputRef = ref(null)
let recentClickTimer = null let recentClickTimer = null
const tablerIconPaths = {
plus: [
'M12 5l0 14',
'M5 12l14 0'
],
search: [
'M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0',
'M21 21l-6 -6'
],
fileText: [
'M14 3v4a1 1 0 0 0 1 1h4',
'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2',
'M9 9l1 0',
'M9 13l6 0',
'M9 17l6 0'
],
folder: [
'M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2'
],
book2: [
'M19 4v16h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12',
'M19 16h-12a2 2 0 0 0 -2 2',
'M9 8h6'
],
chartLine: [
'M4 19l16 0',
'M4 15l4 -6l4 2l4 -5l4 4'
],
chartDonut: [
'M10 3.2a9 9 0 1 0 10.8 10.8a1 1 0 0 0 -1 -1h-6.8a2 2 0 0 1 -2 -2v-6.8a1 1 0 0 0 -1 -1',
'M15 3.5a9 9 0 0 1 5.5 5.5h-4.5a1 1 0 0 1 -1 -1v-4.5'
],
shieldCheck: [
'M11.46 20.846a12 12 0 0 1 -7.46 -10.846v-4l8 -3l8 3v4c0 1.122 -.154 2.203 -.441 3.226',
'M15 19l2 2l4 -4'
],
robot: [
'M7 7h10a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7a2 2 0 0 1 2 -2',
'M9 11l.01 0',
'M15 11l.01 0',
'M9 15h6',
'M12 7v-4'
],
users: [
'M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0',
'M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2',
'M16 3.13a4 4 0 0 1 0 7.75',
'M21 21v-2a4 4 0 0 0 -3 -3.85'
],
sliders: [
'M4 6h16',
'M4 12h10',
'M4 18h14',
'M8 6v.01',
'M14 12v.01',
'M18 18v.01'
],
circle: [
'M12 12m-8 0a8 8 0 1 0 16 0a8 8 0 1 0 -16 0'
]
}
const quickActions = [ const quickActions = [
{ {
label: '新建对话', label: '新建对话',
icon: 'mdi mdi-plus', iconPaths: tablerIconPaths.plus,
event: 'new-chat', event: 'new-chat',
primary: true primary: true
}, },
{ {
label: '查询对话', label: '查询对话',
icon: 'mdi mdi-magnify', iconPaths: tablerIconPaths.search,
event: 'search' event: 'search'
} }
] ]
@@ -181,15 +275,15 @@ const quickActions = [
const displayBrandName = computed(() => String(props.brandName || '易财费控').trim() || '易财费控') const displayBrandName = computed(() => String(props.brandName || '易财费控').trim() || '易财费控')
const sidebarMeta = { const sidebarMeta = {
overview: { label: '分析看板', icon: 'mdi mdi-chart-line-variant' }, overview: { label: '分析看板', iconPaths: tablerIconPaths.chartLine },
documents: { label: '单据中心', icon: 'mdi mdi-file-document-outline' }, documents: { label: '单据中心', iconPaths: tablerIconPaths.fileText },
receiptFolder: { label: '票据夹', icon: 'mdi mdi-receipt-text-outline' }, receiptFolder: { label: '票据夹', iconPaths: tablerIconPaths.folder },
budget: { label: '预算管理', icon: 'mdi mdi-chart-donut' }, budget: { label: '预算管理', iconPaths: tablerIconPaths.chartDonut },
policies: { label: '财务政策', icon: 'mdi mdi-book-open-page-variant-outline' }, policies: { label: '财务政策', iconPaths: tablerIconPaths.book2 },
audit: { label: '规则管理', icon: 'mdi mdi-shield-check-outline' }, audit: { label: '规则管理', iconPaths: tablerIconPaths.shieldCheck },
digitalEmployees: { label: '数字员工', icon: 'mdi mdi-robot-outline' }, digitalEmployees: { label: '数字员工', iconPaths: tablerIconPaths.robot },
employees: { label: '员工管理', icon: 'mdi mdi-account-group-outline' }, employees: { label: '员工管理', iconPaths: tablerIconPaths.users },
settings: { label: '系统设置', icon: 'mdi mdi-tune-vertical' } settings: { label: '系统设置', iconPaths: tablerIconPaths.sliders }
} }
const aiBusinessViewIds = computed(() => new Set(resolveAiSidebarBusinessViewIds(props.currentUser))) const aiBusinessViewIds = computed(() => new Set(resolveAiSidebarBusinessViewIds(props.currentUser)))
@@ -200,7 +294,7 @@ const businessNavItems = computed(() =>
.map((item) => ({ .map((item) => ({
...item, ...item,
displayLabel: sidebarMeta[item.id]?.label ?? item.label, displayLabel: sidebarMeta[item.id]?.label ?? item.label,
aiIcon: sidebarMeta[item.id]?.icon ?? 'mdi mdi-circle-outline' aiIconPaths: sidebarMeta[item.id]?.iconPaths ?? tablerIconPaths.circle
})) }))
) )

View File

@@ -47,6 +47,8 @@
class="nav-btn" class="nav-btn"
:class="{ active: activeView === item.id }" :class="{ active: activeView === item.id }"
type="button" type="button"
@mouseenter="emit('prefetch-view', item.id)"
@focus="emit('prefetch-view', item.id)"
@click="emit('navigate', item.id)" @click="emit('navigate', item.id)"
> >
<span class="nav-icon" v-html="item.icon"></span> <span class="nav-icon" v-html="item.icon"></span>
@@ -100,7 +102,7 @@ const props = defineProps({
} }
}) })
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse']) const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse', 'prefetch-view'])
const sidebarMeta = { const sidebarMeta = {
overview: { label: '分析看板' }, overview: { label: '分析看板' },

View File

@@ -153,10 +153,10 @@
<button <button
class="notification-clear-btn" class="notification-clear-btn"
type="button" type="button"
:disabled="notificationItems.length === 0" :disabled="notificationBulkActionDisabled"
@click="clearAllNotifications" @click="handleNotificationBulkAction"
> >
清空通知 {{ notificationBulkActionLabel }}
</button> </button>
<button <button
class="notification-close-btn" class="notification-close-btn"
@@ -201,24 +201,16 @@
:class="{ unread: item.unread }" :class="{ unread: item.unread }"
@click="openNotification(item)" @click="openNotification(item)"
> >
<span class="notification-type-icon" :class="item.tone"> <span class="notification-avatar" :class="item.tone" aria-hidden="true">
<i :class="resolveNotificationIcon(item)"></i> <span class="notification-avatar-label">{{ item.avatarLabel }}</span>
<span v-if="item.badge" class="notification-avatar-badge">{{ item.badge }}</span>
</span> </span>
<span class="notification-row-main"> <span class="notification-row-content">
<span class="notification-row-head"> <span class="notification-row-top">
<span class="notification-title-line">
<strong class="notification-row-title">{{ item.title }}</strong> <strong class="notification-row-title">{{ item.title }}</strong>
<b v-if="item.badge">{{ item.badge }}</b>
</span>
<span class="notification-row-action" aria-hidden="true">
<i class="mdi mdi-chevron-right"></i>
</span>
</span>
<small class="notification-context">{{ item.description }}</small>
<span class="notification-row-foot">
<span class="notification-category-pill">{{ item.category || '系统通知' }}</span>
<time class="notification-time">{{ item.timeLabel || item.time }}</time> <time class="notification-time">{{ item.timeLabel || item.time }}</time>
</span> </span>
<small class="notification-preview">{{ item.description }}</small>
</span> </span>
</button> </button>
</div> </div>
@@ -512,7 +504,8 @@ const {
isNotificationHidden, isNotificationHidden,
isNotificationRead, isNotificationRead,
loadNotificationStates, loadNotificationStates,
markNotificationStateRead markNotificationStateRead,
markNotificationStatesRead
} = useTopBarNotificationStates() } = useTopBarNotificationStates()
const notificationTab = ref('unread') const notificationTab = ref('unread')
@@ -565,6 +558,36 @@ function resolveDocumentNotificationDescription(row) {
].filter(Boolean).join(' · ') || '单据中心有新的单据状态' ].filter(Boolean).join(' · ') || '单据中心有新的单据状态'
} }
function resolveNotificationAvatarLabel(item) {
const raw = String(
item?.avatarLabel
|| item?.initiatorName
|| item?.applicantName
|| item?.employeeName
|| item?.category
|| item?.title
|| '通'
).trim()
if (!raw) {
return '通'
}
return raw.replace(/\s+/g, '').slice(0, 1).toUpperCase()
}
function resolveDocumentNotificationAvatarLabel(row) {
return resolveNotificationAvatarLabel({
avatarLabel:
row?.initiatorName
|| row?.applicantName
|| row?.employeeName
|| row?.sourceLabel
|| row?.documentTypeLabel
|| row?.title
})
}
function resolveWorkbenchNotificationId(item, index) { function resolveWorkbenchNotificationId(item, index) {
return normalizeNotificationId(`workbench:${item?.id || [item?.title, item?.description, item?.time, index].join('|')}`) return normalizeNotificationId(`workbench:${item?.id || [item?.title, item?.description, item?.time, index].join('|')}`)
} }
@@ -583,12 +606,12 @@ const documentNotificationItems = computed(() =>
kind: 'document', kind: 'document',
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`, title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
description: resolveDocumentNotificationDescription(row), description: resolveDocumentNotificationDescription(row),
avatarLabel: resolveDocumentNotificationAvatarLabel(row),
time: row.updatedAt || row.createdAt, time: row.updatedAt || row.createdAt,
timeLabel: formatNotificationTimeLabel(row.updatedAt || row.createdAt), timeLabel: formatNotificationTimeLabel(row.updatedAt || row.createdAt),
category: row.sourceLabel || '单据中心', category: row.sourceLabel || '单据中心',
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }), tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
unread, unread,
icon: row.source === 'approval' ? 'mdi mdi-clipboard-text-clock-outline' : 'mdi mdi-file-document-outline',
badge: unread ? '新' : '', badge: unread ? '新' : '',
target: { target: {
type: 'document', type: 'document',
@@ -615,10 +638,10 @@ const workbenchNotificationItems = computed(() => (
id, id,
kind: 'workbench', kind: 'workbench',
category: item.category || '个人工作台', category: item.category || '个人工作台',
avatarLabel: resolveNotificationAvatarLabel(item),
time: notificationTime, time: notificationTime,
timeLabel: formatNotificationTimeLabel(item.time || item.updatedAt || item.due), timeLabel: formatNotificationTimeLabel(item.time || item.updatedAt || item.due),
unread: Boolean(item.unread) && !readNotificationIds.value.has(id), unread: Boolean(item.unread) && !readNotificationIds.value.has(id)
icon: item.icon || resolveNotificationIcon(item)
} }
}).filter(Boolean) }).filter(Boolean)
: [] : []
@@ -631,6 +654,14 @@ const readNotifications = computed(() => notificationItems.value.filter((item) =
const activeNotifications = computed(() => ( const activeNotifications = computed(() => (
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
)) ))
const notificationBulkActionLabel = computed(() => (
notificationTab.value === 'unread' ? '全部已读' : '删除已读'
))
const notificationBulkActionDisabled = computed(() => (
notificationTab.value === 'unread'
? unreadNotifications.value.length === 0
: readNotifications.value.length === 0
))
const topbarNotificationCount = computed(() => { const topbarNotificationCount = computed(() => {
const count = unreadNotifications.value.length const count = unreadNotifications.value.length
return count > 0 ? Math.min(count, 99) : 0 return count > 0 ? Math.min(count, 99) : 0
@@ -659,26 +690,6 @@ function scheduleDocumentInboxInitialRefresh() {
}, props.activeView === 'workbench' ? 1200 : 6000) }, props.activeView === 'workbench' ? 1200 : 6000)
} }
function resolveNotificationIcon(item) {
if (item?.icon) {
return item.icon
}
if (item?.tone === 'danger') {
return 'mdi mdi-alert-circle-outline'
}
if (item?.tone === 'warning') {
return 'mdi mdi-alert-outline'
}
if (item?.tone === 'success') {
return 'mdi mdi-check-circle-outline'
}
return 'mdi mdi-bell-outline'
}
function markNotificationRead(item) { function markNotificationRead(item) {
if (!item?.id || !item.unread) { if (!item?.id || !item.unread) {
return return
@@ -691,8 +702,8 @@ function markNotificationRead(item) {
void markNotificationStateRead(item) void markNotificationStateRead(item)
} }
function clearAllNotifications() { function markUnreadNotificationsRead() {
const currentItems = notificationItems.value const currentItems = unreadNotifications.value
if (!currentItems.length) { if (!currentItems.length) {
return return
} }
@@ -705,8 +716,29 @@ function clearAllNotifications() {
markDocumentInboxRowsRead(documentRows) markDocumentInboxRowsRead(documentRows)
} }
void markNotificationStatesRead(currentItems)
}
function deleteReadNotifications() {
const currentItems = readNotifications.value
if (!currentItems.length) {
return
}
void hideNotificationStates(currentItems) void hideNotificationStates(currentItems)
notificationTab.value = 'unread' }
function handleNotificationBulkAction() {
if (notificationBulkActionDisabled.value) {
return
}
if (notificationTab.value === 'unread') {
markUnreadNotificationsRead()
return
}
deleteReadNotifications()
} }
function openNotification(item) { function openNotification(item) {

View File

@@ -0,0 +1,74 @@
<template>
<Teleport to="body">
<div class="app-modal-loading-backdrop" role="status" aria-live="polite">
<section class="app-modal-loading-panel" aria-label="正在打开智能工作台">
<span class="app-modal-loading-spinner" aria-hidden="true"></span>
<div>
<strong>正在打开智能工作台</strong>
<p>基础页面已就绪正在载入助手模块</p>
</div>
</section>
</div>
</Teleport>
</template>
<style scoped>
.app-modal-loading-backdrop {
position: fixed;
inset: 0;
z-index: 3000;
display: grid;
place-items: center;
padding: 24px;
background: rgba(15, 23, 42, 0.36);
backdrop-filter: blur(6px);
}
.app-modal-loading-panel {
display: flex;
align-items: center;
gap: 14px;
width: min(420px, 100%);
padding: 20px 22px;
border: 1px solid rgba(255, 255, 255, 0.68);
border-radius: 8px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18);
color: #111827;
}
.app-modal-loading-panel strong {
display: block;
font-size: 15px;
line-height: 1.35;
}
.app-modal-loading-panel p {
margin: 4px 0 0;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.app-modal-loading-spinner {
width: 24px;
height: 24px;
flex: 0 0 auto;
border: 2px solid rgba(59, 130, 246, 0.18);
border-top-color: #2563eb;
border-radius: 999px;
animation: app-modal-loading-spin 0.8s linear infinite;
}
@keyframes app-modal-loading-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.app-modal-loading-spinner {
animation: none;
}
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<section class="app-view-loading-state" aria-live="polite">
<div class="app-view-loading-copy">
<span class="app-view-loading-spinner" aria-hidden="true"></span>
<div>
<strong>正在加载页面内容</strong>
<p>页面框架已就绪正在载入当前模块的数据和控件</p>
</div>
</div>
<div class="app-view-loading-skeleton" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</section>
</template>
<style scoped>
.app-view-loading-state {
display: grid;
gap: 16px;
width: min(760px, 100%);
margin: 24px auto;
padding: 24px;
border: 1px solid rgba(148, 163, 184, 0.26);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.06);
}
.app-view-loading-copy {
display: flex;
align-items: center;
gap: 12px;
color: #1f2937;
}
.app-view-loading-copy strong {
display: block;
font-size: 15px;
line-height: 1.35;
}
.app-view-loading-copy p {
margin: 4px 0 0;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.app-view-loading-spinner {
width: 22px;
height: 22px;
flex: 0 0 auto;
border: 2px solid rgba(59, 130, 246, 0.18);
border-top-color: #2563eb;
border-radius: 999px;
animation: app-view-loading-spin 0.8s linear infinite;
}
.app-view-loading-skeleton {
display: grid;
gap: 10px;
}
.app-view-loading-skeleton span {
display: block;
height: 12px;
border-radius: 999px;
background: linear-gradient(90deg, #e5e7eb 0%, #f8fafc 48%, #e5e7eb 100%);
background-size: 200% 100%;
animation: app-view-loading-shimmer 1.4s ease-in-out infinite;
}
.app-view-loading-skeleton span:nth-child(2) {
width: 86%;
}
.app-view-loading-skeleton span:nth-child(3) {
width: 68%;
}
@keyframes app-view-loading-spin {
to {
transform: rotate(360deg);
}
}
@keyframes app-view-loading-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.app-view-loading-spinner,
.app-view-loading-skeleton span {
animation: none;
}
}
</style>

View File

@@ -1,5 +1,11 @@
import { isApplicationDocumentNo } from '../../utils/documentClassification.js' import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../../utils/riskFlags.js' import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../../utils/riskFlags.js'
import {
DOCUMENT_TYPE_APPLICATION,
DOCUMENT_TYPE_EXPENSE_APPLICATION,
DOCUMENT_TYPE_REIMBURSEMENT,
resolveDocumentTypeLabel
} from '../../constants/documentProtocol.js'
const EXPENSE_TYPE_LABELS = { const EXPENSE_TYPE_LABELS = {
travel: '差旅费', travel: '差旅费',
@@ -49,8 +55,6 @@ const DOCUMENT_BACKED_EXPENSE_TYPES = new Set([
'hotel_ticket', 'hotel_ticket',
'ride_ticket' 'ride_ticket'
]) ])
const DOCUMENT_TYPE_APPLICATION = 'application'
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
const RELATED_APPLICATION_STEP_LABEL = '关联单据' const RELATED_APPLICATION_STEP_LABEL = '关联单据'
const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态' const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态'
const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档' const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档'
@@ -179,14 +183,14 @@ function resolveDocumentTypeMeta(claim, typeCode) {
const normalizedType = String(typeCode || '').trim() const normalizedType = String(typeCode || '').trim()
const isApplication = const isApplication =
explicitType === DOCUMENT_TYPE_APPLICATION explicitType === DOCUMENT_TYPE_APPLICATION
|| explicitType === 'expense_application' || explicitType === DOCUMENT_TYPE_EXPENSE_APPLICATION
|| isApplicationDocumentNo(claimNo) || isApplicationDocumentNo(claimNo)
|| normalizedType === 'application' || normalizedType === 'application'
|| normalizedType.endsWith('_application') || normalizedType.endsWith('_application')
return isApplication return isApplication
? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: '申请单' } ? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: resolveDocumentTypeLabel(DOCUMENT_TYPE_APPLICATION) }
: { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: '报销单' } : { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: resolveDocumentTypeLabel(DOCUMENT_TYPE_REIMBURSEMENT) }
} }
function normalizeExpenseType(typeCode) { function normalizeExpenseType(typeCode) {

View File

@@ -139,7 +139,14 @@ export function useTopBarNotificationStates() {
} }
function markNotificationStateRead(item) { function markNotificationStateRead(item) {
return syncNotificationPatches([buildPatch(item, { read: true })]) return markNotificationStatesRead([item])
}
function markNotificationStatesRead(items) {
const patches = (Array.isArray(items) ? items : [])
.map((item) => buildPatch(item, { read: true, hidden: false }))
.filter(Boolean)
return syncNotificationPatches(patches)
} }
function hideNotificationStates(items) { function hideNotificationStates(items) {
@@ -158,6 +165,7 @@ export function useTopBarNotificationStates() {
isNotificationHidden, isNotificationHidden,
isNotificationRead, isNotificationRead,
loadNotificationStates, loadNotificationStates,
markNotificationStateRead markNotificationStateRead,
markNotificationStatesRead
} }
} }

View File

@@ -24,8 +24,7 @@ import {
} from './workbenchAiComposerModel.js' } from './workbenchAiComposerModel.js'
import { import {
createWorkbenchAiMessageRuntime, createWorkbenchAiMessageRuntime,
formatMessageTime, formatMessageTime
normalizeInlineAttachmentOcrDetails
} from './workbenchAiMessageModel.js' } from './workbenchAiMessageModel.js'
import { useWorkbenchAiActionRouter } from './useWorkbenchAiActionRouter.js' import { useWorkbenchAiActionRouter } from './useWorkbenchAiActionRouter.js'
import { useWorkbenchAiAttachmentAssociationFlow } from './useWorkbenchAiAttachmentAssociationFlow.js' import { useWorkbenchAiAttachmentAssociationFlow } from './useWorkbenchAiAttachmentAssociationFlow.js'
@@ -34,8 +33,10 @@ import { useWorkbenchAiComposerFiles } from './useWorkbenchAiComposerFiles.js'
import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js' import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js'
import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js' import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js'
import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js' import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js'
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js' import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js' import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
import { isReimbursementCreationIntent } from './workbenchAiApplicationGateModel.js'
const AI_SEARCH_CONVERSATION_ID = 'ai-search' const AI_SEARCH_CONVERSATION_ID = 'ai-search'
const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6 const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
@@ -174,6 +175,23 @@ export function usePersonalWorkbenchAiMode(props, emit) {
...card, ...card,
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index]) ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
}))) })))
const {
hasInlineAttachmentOcrDetails,
hasInlineThinking,
isInlineAttachmentOcrExpanded,
isInlineThinkingExpanded,
resolveInlineAttachmentOcrDocuments,
resolveInlineAttachmentOcrFileCount,
resolveInlineThinkingEvents,
toggleInlineAttachmentOcrDetails,
toggleInlineThinking
} = useWorkbenchAiMessageExpansion({
attachmentOcrExpandedMessageIds,
inlineConversationAutoScrollPinned,
scrollInlineConversationToBottom,
thinkingCollapsedMessageIds,
thinkingExpandedMessageIds
})
const applicationFlow = useWorkbenchAiApplicationPreviewFlow({ const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
activateInlineConversation, activateInlineConversation,
@@ -324,6 +342,10 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}) })
} }
function setAssistantInputRef(element) {
assistantInputRef.value = element
}
function isInlineConversationNearBottom() { function isInlineConversationNearBottom() {
const el = conversationScrollRef.value const el = conversationScrollRef.value
if (!el) { if (!el) {
@@ -499,78 +521,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
return renderAiConversationHtml(content) return renderAiConversationHtml(content)
} }
function resolveInlineThinkingEvents(message) {
return Array.isArray(message?.stewardPlan?.thinkingEvents) ? message.stewardPlan.thinkingEvents : []
}
function hasInlineThinking(message) {
return resolveInlineThinkingEvents(message).length > 0
}
function isInlineThinkingExpanded(message) {
if (!message?.id) {
return Boolean(message?.pending)
}
if (thinkingCollapsedMessageIds.value.has(message.id)) {
return false
}
return Boolean(message.pending || thinkingExpandedMessageIds.value.has(message.id))
}
function toggleInlineThinking(message) {
if (!message?.id) {
return
}
const nextExpandedIds = new Set(thinkingExpandedMessageIds.value)
const nextCollapsedIds = new Set(thinkingCollapsedMessageIds.value)
if (isInlineThinkingExpanded(message)) {
nextExpandedIds.delete(message.id)
nextCollapsedIds.add(message.id)
} else {
nextCollapsedIds.delete(message.id)
nextExpandedIds.add(message.id)
}
thinkingExpandedMessageIds.value = nextExpandedIds
thinkingCollapsedMessageIds.value = nextCollapsedIds
}
function hasInlineAttachmentOcrDetails(message = {}) {
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
return Boolean(details?.documents?.length || details?.fileNames?.length)
}
function resolveInlineAttachmentOcrDocuments(message = {}) {
return normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)?.documents || []
}
function resolveInlineAttachmentOcrFileCount(message = {}) {
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
return Math.max(details?.documents?.length || 0, details?.fileNames?.length || 0)
}
function isInlineAttachmentOcrExpanded(message = {}) {
return Boolean(message?.id && attachmentOcrExpandedMessageIds.value.has(message.id))
}
function toggleInlineAttachmentOcrDetails(message = {}, forceExpanded = null) {
if (!message?.id || !hasInlineAttachmentOcrDetails(message)) {
return
}
const nextExpandedIds = new Set(attachmentOcrExpandedMessageIds.value)
const shouldExpand = forceExpanded === null
? !nextExpandedIds.has(message.id)
: Boolean(forceExpanded)
if (shouldExpand) {
nextExpandedIds.add(message.id)
} else {
nextExpandedIds.delete(message.id)
}
attachmentOcrExpandedMessageIds.value = nextExpandedIds
nextTick(() => {
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
})
}
function buildInlinePromptText(rawPrompt, files = []) { function buildInlinePromptText(rawPrompt, files = []) {
const prompt = buildWorkbenchPromptText(rawPrompt) const prompt = buildWorkbenchPromptText(rawPrompt)
if (prompt) { if (prompt) {
@@ -579,20 +529,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
return files.length ? '请帮我处理已上传的附件。' : '' return files.length ? '请帮我处理已上传的附件。' : ''
} }
function isReimbursementCreationIntent(prompt = '') {
const compact = String(prompt || '').replace(/\s+/g, '')
if (!compact || !/报销|报账/.test(compact)) {
return false
}
if (/标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗|查一下|查询|状态|进度|多少钱|多少/.test(compact)) {
return false
}
return (
/^(我要|我想|我需要|帮我|请帮我|需要|发起|新建|创建|提交|办理|走).{0,16}(报销|报账)/.test(compact) ||
/^(报销|报账)(一下|一笔|单|流程)?$/.test(compact)
)
}
function handleAiAnswerMarkdownClick(event) { function handleAiAnswerMarkdownClick(event) {
const target = event?.target const target = event?.target
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]') const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
@@ -800,6 +736,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
scrollInlineConversationToTop, scrollInlineConversationToTop,
selectedFileCards, selectedFileCards,
sending, sending,
setAssistantInputRef,
setWorkbenchDateMode, setWorkbenchDateMode,
submitAiModePrompt, submitAiModePrompt,
toggleInlineAttachmentOcrDetails, toggleInlineAttachmentOcrDetails,

View File

@@ -24,10 +24,15 @@ import {
buildInlineApplicationSubmitPrecheckPayload, buildInlineApplicationSubmitPrecheckPayload,
buildInlineApplicationSubmitThinkingEvents, buildInlineApplicationSubmitThinkingEvents,
completeInlineThinkingEvents, completeInlineThinkingEvents,
extractInlineApplicationDraftPayload, extractInlineApplicationDraftPayload
resolveInlineApplicationPreviewActionFromText
} from './workbenchAiApplicationPreviewModel.js' } from './workbenchAiApplicationPreviewModel.js'
import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js' import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js'
import {
isOrphanInlineApplicationPreviewMessage,
resolveInlineApplicationPreviewTextAction,
resolveLatestApplicationPreviewMessage,
resolveLatestOrphanApplicationPreviewMessage
} from './workbenchAiApplicationGateModel.js'
function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) { function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) {
const fields = normalizeApplicationPreview(applicationPreview).fields || {} const fields = normalizeApplicationPreview(applicationPreview).fields || {}
@@ -197,23 +202,12 @@ export function useWorkbenchAiApplicationPreviewFlow({
].join('\n\n') ].join('\n\n')
} }
function resolveLatestApplicationPreviewMessage() { function resolveLatestInlineApplicationPreviewMessage() {
return [...conversationMessages.value] return resolveLatestApplicationPreviewMessage(conversationMessages.value)
.reverse()
.find((message) => message.role === 'assistant' && message.applicationPreview)
} }
function isOrphanInlineApplicationPreviewMessage(message = {}) { function resolveLatestOrphanInlineApplicationPreviewMessage() {
if (message?.applicationPreview || message?.role !== 'assistant') { return resolveLatestOrphanApplicationPreviewMessage(conversationMessages.value)
return false
}
return /下方表格|申请核对表|点击对应行即可直接编辑/.test(String(message.content || message.text || ''))
}
function resolveLatestOrphanApplicationPreviewMessage() {
return [...conversationMessages.value]
.reverse()
.find((message) => isOrphanInlineApplicationPreviewMessage(message))
} }
function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) { function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) {
@@ -310,7 +304,7 @@ export function useWorkbenchAiApplicationPreviewFlow({
} }
async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) { async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) {
const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestApplicationPreviewMessage() const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestInlineApplicationPreviewMessage()
if (!targetMessage?.applicationPreview) { if (!targetMessage?.applicationPreview) {
toast('当前没有可提交的申请表。') toast('当前没有可提交的申请表。')
return false return false
@@ -446,12 +440,12 @@ export function useWorkbenchAiApplicationPreviewFlow({
toast('请等待费用测算完成后再继续操作。') toast('请等待费用测算完成后再继续操作。')
return true return true
} }
const actionType = resolveInlineApplicationPreviewActionFromText(prompt) const actionType = resolveInlineApplicationPreviewTextAction(prompt)
if (!actionType) { if (!actionType) {
return false return false
} }
if (!resolveLatestApplicationPreviewMessage()) { if (!resolveLatestInlineApplicationPreviewMessage()) {
const orphanPreviewMessage = resolveLatestOrphanApplicationPreviewMessage() const orphanPreviewMessage = resolveLatestOrphanInlineApplicationPreviewMessage()
if (!orphanPreviewMessage) { if (!orphanPreviewMessage) {
return false return false
} }

View File

@@ -0,0 +1,95 @@
import { nextTick } from 'vue'
import { normalizeInlineAttachmentOcrDetails } from './workbenchAiMessageModel.js'
export function useWorkbenchAiMessageExpansion({
attachmentOcrExpandedMessageIds,
inlineConversationAutoScrollPinned,
scrollInlineConversationToBottom,
thinkingCollapsedMessageIds,
thinkingExpandedMessageIds
}) {
function resolveInlineThinkingEvents(message) {
return Array.isArray(message?.stewardPlan?.thinkingEvents) ? message.stewardPlan.thinkingEvents : []
}
function hasInlineThinking(message) {
return resolveInlineThinkingEvents(message).length > 0
}
function isInlineThinkingExpanded(message) {
if (!message?.id) {
return Boolean(message?.pending)
}
if (thinkingCollapsedMessageIds.value.has(message.id)) {
return false
}
return Boolean(message.pending || thinkingExpandedMessageIds.value.has(message.id))
}
function toggleInlineThinking(message) {
if (!message?.id) {
return
}
const nextExpandedIds = new Set(thinkingExpandedMessageIds.value)
const nextCollapsedIds = new Set(thinkingCollapsedMessageIds.value)
if (isInlineThinkingExpanded(message)) {
nextExpandedIds.delete(message.id)
nextCollapsedIds.add(message.id)
} else {
nextCollapsedIds.delete(message.id)
nextExpandedIds.add(message.id)
}
thinkingExpandedMessageIds.value = nextExpandedIds
thinkingCollapsedMessageIds.value = nextCollapsedIds
}
function hasInlineAttachmentOcrDetails(message = {}) {
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
return Boolean(details?.documents?.length || details?.fileNames?.length)
}
function resolveInlineAttachmentOcrDocuments(message = {}) {
return normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)?.documents || []
}
function resolveInlineAttachmentOcrFileCount(message = {}) {
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
return Math.max(details?.documents?.length || 0, details?.fileNames?.length || 0)
}
function isInlineAttachmentOcrExpanded(message = {}) {
return Boolean(message?.id && attachmentOcrExpandedMessageIds.value.has(message.id))
}
function toggleInlineAttachmentOcrDetails(message = {}, forceExpanded = null) {
if (!message?.id || !hasInlineAttachmentOcrDetails(message)) {
return
}
const nextExpandedIds = new Set(attachmentOcrExpandedMessageIds.value)
const shouldExpand = forceExpanded === null
? !nextExpandedIds.has(message.id)
: Boolean(forceExpanded)
if (shouldExpand) {
nextExpandedIds.add(message.id)
} else {
nextExpandedIds.delete(message.id)
}
attachmentOcrExpandedMessageIds.value = nextExpandedIds
nextTick(() => {
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
})
}
return {
hasInlineAttachmentOcrDetails,
hasInlineThinking,
isInlineAttachmentOcrExpanded,
isInlineThinkingExpanded,
resolveInlineAttachmentOcrDocuments,
resolveInlineAttachmentOcrFileCount,
resolveInlineThinkingEvents,
toggleInlineAttachmentOcrDetails,
toggleInlineThinking
}
}

View File

@@ -0,0 +1,51 @@
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT
} from '../../services/aiApplicationPreviewActions.js'
export function isReimbursementCreationIntent(prompt = '') {
const compact = String(prompt || '').replace(/\s+/g, '')
if (!compact || !/报销|报账/.test(compact)) {
return false
}
if (/标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗|查一下|查询|状态|进度|多少钱|多少/.test(compact)) {
return false
}
return (
/^(我要|我想|我需要|帮我|请帮我|需要|发起|新建|创建|提交|办理|走).{0,16}(报销|报账)/.test(compact) ||
/^(报销|报账)(一下|一笔|单|流程)?$/.test(compact)
)
}
export function resolveInlineApplicationPreviewTextAction(text = '') {
const normalized = String(text || '').replace(/\s+/g, '').trim()
if (!normalized) {
return ''
}
if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) {
return AI_APPLICATION_ACTION_SAVE_DRAFT
}
if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) {
return AI_APPLICATION_ACTION_SUBMIT
}
return ''
}
export function resolveLatestApplicationPreviewMessage(messages = []) {
return [...(Array.isArray(messages) ? messages : [])]
.reverse()
.find((message) => message?.role === 'assistant' && message.applicationPreview) || null
}
export function isOrphanInlineApplicationPreviewMessage(message = {}) {
if (message?.applicationPreview || message?.role !== 'assistant') {
return false
}
return /下方表格|申请核对表|点击对应行即可直接编辑/.test(String(message.content || message.text || ''))
}
export function resolveLatestOrphanApplicationPreviewMessage(messages = []) {
return [...(Array.isArray(messages) ? messages : [])]
.reverse()
.find((message) => isOrphanInlineApplicationPreviewMessage(message)) || null
}

View File

@@ -13,19 +13,8 @@ import {
AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT AI_APPLICATION_ACTION_SUBMIT
} from '../../services/aiApplicationPreviewActions.js' } from '../../services/aiApplicationPreviewActions.js'
import { INLINE_APPLICATION_STATUS_LABELS } from '../../constants/documentProtocol.js'
const INLINE_APPLICATION_STATUS_LABELS = { import { resolveInlineApplicationPreviewTextAction } from './workbenchAiApplicationGateModel.js'
draft: '草稿',
submitted: '审批中',
pending: '待处理',
approved: '已审批',
completed: '已完成',
archived: '已归档',
returned: '已退回',
rejected: '已驳回',
pending_payment: '待付款',
paid: '已付款'
}
function normalizeInlineApplicationResultTableCell(value, fallback = '-') { function normalizeInlineApplicationResultTableCell(value, fallback = '-') {
const text = String(value || '') const text = String(value || '')
@@ -201,17 +190,7 @@ export function buildInlineApplicationDetailAction(draftPayload = {}) {
} }
export function resolveInlineApplicationPreviewActionFromText(text = '') { export function resolveInlineApplicationPreviewActionFromText(text = '') {
const normalized = String(text || '').replace(/\s+/g, '').trim() return resolveInlineApplicationPreviewTextAction(text)
if (!normalized) {
return ''
}
if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) {
return AI_APPLICATION_ACTION_SAVE_DRAFT
}
if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) {
return AI_APPLICATION_ACTION_SUBMIT
}
return ''
} }
export function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback = '费用申请') { export function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback = '费用申请') {

View File

@@ -0,0 +1,28 @@
export const DOCUMENT_TYPE_ALL = 'all'
export const DOCUMENT_TYPE_APPLICATION = 'application'
export const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
export const DOCUMENT_TYPE_EXPENSE_APPLICATION = 'expense_application'
export const DOCUMENT_TYPE_LABELS = {
[DOCUMENT_TYPE_APPLICATION]: '申请单',
[DOCUMENT_TYPE_EXPENSE_APPLICATION]: '申请单',
[DOCUMENT_TYPE_REIMBURSEMENT]: '报销单'
}
export const INLINE_APPLICATION_STATUS_LABELS = {
draft: '草稿',
submitted: '审批中',
pending: '待处理',
approved: '已审批',
completed: '已完成',
archived: '已归档',
returned: '已退回',
rejected: '已驳回',
pending_payment: '待付款',
paid: '已付款'
}
export function resolveDocumentTypeLabel(typeCode, fallback = '报销单') {
const normalized = String(typeCode || '').trim().toLowerCase()
return DOCUMENT_TYPE_LABELS[normalized] || fallback
}

View File

@@ -1,49 +1,14 @@
import { renderLegacyAttachmentAssociationHtml } from './aiConversationLegacyAttachmentRenderer.js' import { renderLegacyAttachmentAssociationHtml } from './aiConversationLegacyAttachmentRenderer.js'
import { parseTableRow, renderTable } from './aiConversationTableRenderer.js' import { parseTableRow, renderTable } from './aiConversationTableRenderer.js'
import {
const ALLOWED_COLON_HEADING_TITLES = new Set([ DOCUMENT_DETAIL_HREF_PREFIX,
'基础信息识别结果', extractTrustedHtmlBlocks,
'报销测算参考', normalizeConversationText,
'补充信息' restoreTrustedHtmlBlocks
]) } from './conversationTrustedHtml.js'
const BUSINESS_FIELD_LABELS = new Set([
'时间',
'地点',
'事由',
'金额',
'费用类型',
'报销类型',
'商户',
'商户/开票方',
'客户',
'客户/项目对象',
'附件',
'附件/凭证',
'出行方式'
])
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:' const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:' const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
const TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
const TRUSTED_HTML_ALLOWED_TAGS = new Set([
'section',
'article',
'header',
'footer',
'div',
'span',
'strong',
'a'
])
const TRUSTED_HTML_ALLOWED_ATTRS = new Set([
'aria-label',
'class',
'data-ai-action',
'href'
])
function escapeHtml(value = '') { function escapeHtml(value = '') {
return String(value) return String(value)
@@ -146,150 +111,6 @@ function renderInlineHtml(value = '') {
return html return html
} }
function splitColonHeadingLine(line) {
const rawLine = String(line || '')
const trimmed = rawLine.trim()
if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) {
return [rawLine]
}
const chineseColonIndex = trimmed.indexOf('')
const asciiColonIndex = trimmed.indexOf(':')
const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0)
if (!colonIndexes.length) {
return [rawLine]
}
const colonIndex = Math.min(...colonIndexes)
const title = trimmed.slice(0, colonIndex)
const body = trimmed.slice(colonIndex + 1).trim()
if (!ALLOWED_COLON_HEADING_TITLES.has(title)) {
return [rawLine]
}
return body ? [`### ${title}`, '', body] : [`### ${title}`]
}
function normalizeBusinessFieldLine(line) {
const rawLine = String(line || '')
const trimmed = rawLine.trim()
if (
!trimmed ||
trimmed.startsWith('|') ||
/^[-*+]\s/.test(trimmed) ||
/^#{1,6}\s/.test(trimmed)
) {
return rawLine
}
const match = trimmed.match(/^([^:\n]{1,16})[:]\s*(.+)$/u)
if (!match) {
return rawLine
}
const label = match[1].trim()
const value = match[2].trim()
if (!BUSINESS_FIELD_LABELS.has(label) || !value) {
return rawLine
}
return `- **${label}**${value}`
}
function normalizeConversationText(text = '') {
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n')
const normalizedLines = []
let inFence = false
lines.forEach((line) => {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence
normalizedLines.push(line)
return
}
if (inFence) {
normalizedLines.push(line)
return
}
const nextLines = splitColonHeadingLine(line)
if (nextLines[0]?.startsWith('### ') && normalizedLines.length) {
const previousLine = normalizedLines[normalizedLines.length - 1]
if (String(previousLine || '').trim()) {
normalizedLines.push('')
}
}
normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine)))
})
return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n').trim()
}
function hasOnlyTrustedHtmlTags(html = '') {
const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi
let match = tagPattern.exec(html)
while (match) {
const tagName = String(match[1] || '').toLowerCase()
if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) {
return false
}
const attrText = String(match[2] || '')
const attrPattern = /\s([:@\w-]+)\s*=/g
let attrMatch = attrPattern.exec(attrText)
while (attrMatch) {
const attrName = String(attrMatch[1] || '').toLowerCase()
if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) {
return false
}
attrMatch = attrPattern.exec(attrText)
}
match = tagPattern.exec(html)
}
return true
}
function sanitizeTrustedHtmlBlock(html = '') {
const value = String(html || '').trim()
if (!value || !value.includes('class="ai-document-card-list"')) {
return ''
}
if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) {
return ''
}
if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) {
return ''
}
if (!hasOnlyTrustedHtmlTags(value)) {
return ''
}
const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim())
if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) {
return ''
}
return value
}
function extractTrustedHtmlBlocks(text = '') {
const trustedHtmlBlocks = []
const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => {
const sanitizedHtml = sanitizeTrustedHtmlBlock(html)
if (!sanitizedHtml) {
return ''
}
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}`
trustedHtmlBlocks.push(sanitizedHtml)
return `\n\n${placeholder}\n\n`
})
return { content, trustedHtmlBlocks }
}
function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = []) {
return trustedHtmlBlocks.reduce((nextHtml, block, index) => {
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}`
const paragraphPattern = new RegExp(`<p class="ai-html-paragraph">${placeholder}</p>`, 'g')
return nextHtml
.replace(paragraphPattern, block)
.replaceAll(placeholder, block)
}, html)
}
function isFenceLine(line = '') { function isFenceLine(line = '') {
return /^\s*(```|~~~)/.test(String(line || '')) return /^\s*(```|~~~)/.test(String(line || ''))
} }
@@ -501,7 +322,7 @@ export function renderAiConversationHtml(content = '') {
} }
const extracted = extractTrustedHtmlBlocks(content) const extracted = extractTrustedHtmlBlocks(content)
const normalized = normalizeConversationText(extracted.content) const normalized = normalizeConversationText(extracted.content, { trim: true })
if (!normalized) { if (!normalized) {
return '' return ''
} }
@@ -628,6 +449,7 @@ export function renderAiConversationHtml(content = '') {
return restoreTrustedHtmlBlocks( return restoreTrustedHtmlBlocks(
`<div class="ai-html-flow">${blocks.filter(Boolean).join('')}</div>`, `<div class="ai-html-flow">${blocks.filter(Boolean).join('')}</div>`,
extracted.trustedHtmlBlocks extracted.trustedHtmlBlocks,
{ paragraphClass: 'ai-html-paragraph' }
) )
} }

View File

@@ -0,0 +1,191 @@
export const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
const ALLOWED_COLON_HEADING_TITLES = new Set([
'基础信息识别结果',
'报销测算参考',
'补充信息'
])
const BUSINESS_FIELD_LABELS = new Set([
'时间',
'地点',
'事由',
'金额',
'费用类型',
'报销类型',
'商户',
'商户/开票方',
'客户',
'客户/项目对象',
'附件',
'附件/凭证',
'出行方式'
])
const TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
const TRUSTED_HTML_ALLOWED_TAGS = new Set([
'section',
'article',
'header',
'footer',
'div',
'span',
'strong',
'a'
])
const TRUSTED_HTML_ALLOWED_ATTRS = new Set([
'aria-label',
'class',
'data-ai-action',
'href'
])
function splitColonHeadingLine(line) {
const rawLine = String(line || '')
const trimmed = rawLine.trim()
if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) {
return [rawLine]
}
const chineseColonIndex = trimmed.indexOf('')
const asciiColonIndex = trimmed.indexOf(':')
const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0)
if (!colonIndexes.length) {
return [rawLine]
}
const colonIndex = Math.min(...colonIndexes)
const title = trimmed.slice(0, colonIndex)
const body = trimmed.slice(colonIndex + 1).trim()
if (!ALLOWED_COLON_HEADING_TITLES.has(title)) {
return [rawLine]
}
return body ? [`### ${title}`, '', body] : [`### ${title}`]
}
function normalizeBusinessFieldLine(line) {
const rawLine = String(line || '')
const trimmed = rawLine.trim()
if (
!trimmed ||
trimmed.startsWith('|') ||
/^[-*+]\s/.test(trimmed) ||
/^#{1,6}\s/.test(trimmed)
) {
return rawLine
}
const match = trimmed.match(/^([^:\n]{1,16})[:]\s*(.+)$/u)
if (!match) {
return rawLine
}
const label = match[1].trim()
const value = match[2].trim()
if (!BUSINESS_FIELD_LABELS.has(label) || !value) {
return rawLine
}
return `- **${label}**${value}`
}
function hasOnlyTrustedHtmlTags(html = '') {
const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi
let match = tagPattern.exec(html)
while (match) {
const tagName = String(match[1] || '').toLowerCase()
if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) {
return false
}
const attrText = String(match[2] || '')
const attrPattern = /\s([:@\w-]+)\s*=/g
let attrMatch = attrPattern.exec(attrText)
while (attrMatch) {
const attrName = String(attrMatch[1] || '').toLowerCase()
if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) {
return false
}
attrMatch = attrPattern.exec(attrText)
}
match = tagPattern.exec(html)
}
return true
}
function sanitizeTrustedHtmlBlock(html = '') {
const value = String(html || '').trim()
if (!value || !value.includes('class="ai-document-card-list"')) {
return ''
}
if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) {
return ''
}
if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) {
return ''
}
if (!hasOnlyTrustedHtmlTags(value)) {
return ''
}
const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim())
if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) {
return ''
}
return value
}
export function normalizeConversationText(text = '', options = {}) {
const shouldTrim = Boolean(options.trim)
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n')
const normalizedLines = []
let inFence = false
lines.forEach((line) => {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence
normalizedLines.push(line)
return
}
if (inFence) {
normalizedLines.push(line)
return
}
const nextLines = splitColonHeadingLine(line)
if (nextLines[0]?.startsWith('### ') && normalizedLines.length) {
const previousLine = normalizedLines[normalizedLines.length - 1]
if (String(previousLine || '').trim()) {
normalizedLines.push('')
}
}
normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine)))
})
const normalized = normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n')
return shouldTrim ? normalized.trim() : normalized
}
export function extractTrustedHtmlBlocks(text = '') {
const trustedHtmlBlocks = []
const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => {
const sanitizedHtml = sanitizeTrustedHtmlBlock(html)
if (!sanitizedHtml) {
return ''
}
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}`
trustedHtmlBlocks.push(sanitizedHtml)
return `\n\n${placeholder}\n\n`
})
return { content, trustedHtmlBlocks }
}
export function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = [], options = {}) {
const paragraphClass = String(options.paragraphClass || '').trim()
return trustedHtmlBlocks.reduce((nextHtml, block, index) => {
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}`
const paragraphPattern = paragraphClass
? new RegExp(`<p class="${paragraphClass}">${placeholder}</p>\\n?`, 'g')
: new RegExp(`<p>${placeholder}</p>\\n?`, 'g')
return nextHtml
.replace(paragraphPattern, block)
.replaceAll(placeholder, block)
}, html)
}

View File

@@ -2,6 +2,12 @@ import { countClaimRisks, resolveArchiveRiskTone } from './archiveCenterListFilt
import { isNewDocument } from './documentCenterNewState.js' import { isNewDocument } from './documentCenterNewState.js'
import { isArchivedDocumentRow } from './documentCenterRows.js' import { isArchivedDocumentRow } from './documentCenterRows.js'
import { sortDocumentRowsByLatestTime } from './documentCenterSort.js' import { sortDocumentRowsByLatestTime } from './documentCenterSort.js'
import {
DOCUMENT_TYPE_ALL,
DOCUMENT_TYPE_APPLICATION,
DOCUMENT_TYPE_REIMBURSEMENT,
resolveDocumentTypeLabel
} from '../constants/documentProtocol.js'
import { import {
extractDateText, extractDateText,
formatDocumentListTime, formatDocumentListTime,
@@ -10,9 +16,11 @@ import {
} from './documentCenterTime.js' } from './documentCenterTime.js'
import { normalizeRequestForUi } from './requestViewModel.js' import { normalizeRequestForUi } from './requestViewModel.js'
export const DOCUMENT_TYPE_ALL = 'all' export {
export const DOCUMENT_TYPE_APPLICATION = 'application' DOCUMENT_TYPE_ALL,
export const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement' DOCUMENT_TYPE_APPLICATION,
DOCUMENT_TYPE_REIMBURSEMENT
}
export const SCENE_ALL = 'all' export const SCENE_ALL = 'all'
export const DOCUMENT_SCOPE_ALL = '全部' export const DOCUMENT_SCOPE_ALL = '全部'
export const DOCUMENT_SCOPE_APPLICATION = '申请单' export const DOCUMENT_SCOPE_APPLICATION = '申请单'
@@ -129,7 +137,7 @@ export function buildDocumentRow(request, options = {}) {
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
const documentTypeLabel = const documentTypeLabel =
normalized.documentTypeLabel normalized.documentTypeLabel
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单') || resolveDocumentTypeLabel(documentTypeCode)
const initiatorName = String( const initiatorName = String(
normalized.person normalized.person
|| normalized.employeeName || normalized.employeeName

View File

@@ -1,4 +1,10 @@
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import {
DOCUMENT_DETAIL_HREF_PREFIX,
extractTrustedHtmlBlocks,
normalizeConversationText,
restoreTrustedHtmlBlocks
} from './conversationTrustedHtml.js'
const markdown = new MarkdownIt({ const markdown = new MarkdownIt({
html: false, html: false,
@@ -25,25 +31,6 @@ const ACTION_LINK_CLASS_BY_HREF = {
'#review-quick-edit': 'markdown-action-link-edit', '#review-quick-edit': 'markdown-action-link-edit',
'#review-risk-panel': 'markdown-action-link-risk' '#review-risk-panel': 'markdown-action-link-risk'
} }
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
const TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
const TRUSTED_HTML_ALLOWED_TAGS = new Set([
'section',
'article',
'header',
'footer',
'div',
'span',
'strong',
'a'
])
const TRUSTED_HTML_ALLOWED_ATTRS = new Set([
'aria-label',
'class',
'data-ai-action',
'href'
])
function escapeHtml(text) { function escapeHtml(text) {
return String(text || '') return String(text || '')
@@ -136,176 +123,8 @@ markdown.renderer.rules.table_close = (tokens, idx, options, env, self) => (
`${defaultTableClose ? defaultTableClose(tokens, idx, options, env, self) : '</table>'}</div>` `${defaultTableClose ? defaultTableClose(tokens, idx, options, env, self) : '</table>'}</div>`
) )
const ALLOWED_COLON_HEADING_TITLES = new Set([
'基础信息识别结果',
'报销测算参考',
'补充信息'
])
const BUSINESS_FIELD_LABELS = new Set([
'时间',
'地点',
'事由',
'金额',
'费用类型',
'报销类型',
'商户',
'商户/开票方',
'客户',
'客户/项目对象',
'附件',
'附件/凭证',
'出行方式'
])
function splitColonHeadingLine(line) {
const rawLine = String(line || '')
const trimmed = rawLine.trim()
if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) {
return [rawLine]
}
const chineseColonIndex = trimmed.indexOf('')
const asciiColonIndex = trimmed.indexOf(':')
const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0)
if (!colonIndexes.length) {
return [rawLine]
}
const colonIndex = Math.min(...colonIndexes)
const title = trimmed.slice(0, colonIndex + 1)
const titleText = title.slice(0, -1)
const body = trimmed.slice(colonIndex + 1).trim()
if (!ALLOWED_COLON_HEADING_TITLES.has(titleText)) {
return [rawLine]
}
return body ? [`### ${titleText}`, '', body] : [`### ${titleText}`]
}
function normalizeBusinessFieldLine(line) {
const rawLine = String(line || '')
const trimmed = rawLine.trim()
if (
!trimmed ||
trimmed.startsWith('|') ||
/^[-*+]\s/.test(trimmed) ||
/^#{1,6}\s/.test(trimmed)
) {
return rawLine
}
const match = trimmed.match(/^([^:\n]{1,16})[:]\s*(.+)$/u)
if (!match) {
return rawLine
}
const label = match[1].trim()
const value = match[2].trim()
if (!BUSINESS_FIELD_LABELS.has(label) || !value) {
return rawLine
}
return `- **${label}**${value}`
}
function normalizeColonHeadings(text) {
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n')
const normalizedLines = []
let inFence = false
lines.forEach((line) => {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence
normalizedLines.push(line)
return
}
if (inFence) {
normalizedLines.push(line)
return
}
const nextLines = splitColonHeadingLine(line)
if (nextLines[0]?.startsWith('### ') && normalizedLines.length) {
const previousLine = normalizedLines[normalizedLines.length - 1]
if (String(previousLine || '').trim()) {
normalizedLines.push('')
}
}
normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine)))
})
return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n')
}
function hasOnlyTrustedHtmlTags(html = '') {
const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi
let match = tagPattern.exec(html)
while (match) {
const tagName = String(match[1] || '').toLowerCase()
if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) {
return false
}
const attrText = String(match[2] || '')
const attrPattern = /\s([:@\w-]+)\s*=/g
let attrMatch = attrPattern.exec(attrText)
while (attrMatch) {
const attrName = String(attrMatch[1] || '').toLowerCase()
if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) {
return false
}
attrMatch = attrPattern.exec(attrText)
}
match = tagPattern.exec(html)
}
return true
}
function sanitizeTrustedHtmlBlock(html = '') {
const value = String(html || '').trim()
if (!value || !value.includes('class="ai-document-card-list"')) {
return ''
}
if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) {
return ''
}
if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) {
return ''
}
if (!hasOnlyTrustedHtmlTags(value)) {
return ''
}
const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim())
if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) {
return ''
}
return value
}
function extractTrustedHtmlBlocks(text = '') {
const trustedHtmlBlocks = []
const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => {
const sanitizedHtml = sanitizeTrustedHtmlBlock(html)
if (!sanitizedHtml) {
return ''
}
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}`
trustedHtmlBlocks.push(sanitizedHtml)
return `\n\n${placeholder}\n\n`
})
return { content, trustedHtmlBlocks }
}
function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = []) {
return trustedHtmlBlocks.reduce((nextHtml, block, index) => {
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}`
const paragraphPattern = new RegExp(`<p>${placeholder}</p>\\n?`, 'g')
return nextHtml
.replace(paragraphPattern, block)
.replaceAll(placeholder, block)
}, html)
}
export function renderMarkdown(text = '') { export function renderMarkdown(text = '') {
const { content, trustedHtmlBlocks } = extractTrustedHtmlBlocks(text) const { content, trustedHtmlBlocks } = extractTrustedHtmlBlocks(text)
const normalized = normalizeColonHeadings(content).trim() const normalized = normalizeConversationText(content).trim()
return normalized ? restoreTrustedHtmlBlocks(markdown.render(normalized), trustedHtmlBlocks) : '' return normalized ? restoreTrustedHtmlBlocks(markdown.render(normalized), trustedHtmlBlocks) : ''
} }

View File

@@ -34,6 +34,7 @@
@new-chat="openAiSidebarNewChat" @new-chat="openAiSidebarNewChat"
@open-recent="openAiSidebarRecent" @open-recent="openAiSidebarRecent"
@rename-conversation="handleAiConversationRename" @rename-conversation="handleAiConversationRename"
@prefetch-view="prefetchAppView"
@logout="handleLogout" @logout="handleLogout"
/> />
<SidebarRail <SidebarRail
@@ -49,6 +50,7 @@
@logout="handleLogout" @logout="handleLogout"
@toggle-collapse="toggleSidebarCollapsed" @toggle-collapse="toggleSidebarCollapsed"
@navigate="handleNavigateWithMobileClose" @navigate="handleNavigateWithMobileClose"
@prefetch-view="prefetchAppView"
/> />
</Transition> </Transition>
</div> </div>
@@ -243,24 +245,18 @@ import AiSidebarRail from '../components/layout/AiSidebarRail.vue'
import SidebarRail from '../components/layout/SidebarRail.vue' import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue' import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue' import FilterBar from '../components/layout/FilterBar.vue'
import AuditView from './AuditView.vue'
import BudgetCenterView from './BudgetCenterView.vue'
import DigitalEmployeesView from './DigitalEmployeesView.vue'
import DocumentsCenterView from './DocumentsCenterView.vue'
import EmployeeManagementView from './EmployeeManagementView.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
import PoliciesView from './PoliciesView.vue'
import ReceiptFolderView from './ReceiptFolderView.vue'
import SettingsView from './SettingsView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import { useAppShell } from '../composables/useAppShell.js' import { useAppShell } from '../composables/useAppShell.js'
import { useSystemState } from '../composables/useSystemState.js' import { useSystemState } from '../composables/useSystemState.js'
import { filterNavItemsByAccess, isPlatformAdminUser } from '../utils/accessControl.js' import { filterNavItemsByAccess, isPlatformAdminUser } from '../utils/accessControl.js'
import { isBusinessDocumentReference } from '../utils/aiDocumentDetailReference.js' import { isBusinessDocumentReference } from '../utils/aiDocumentDetailReference.js'
import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js' import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js'
import {
defineAsyncModalView,
defineAsyncRouteView,
preloadAppView,
scheduleRelatedAppViewPreload
} from './scripts/appShellAsyncViews.js'
const employeeSummary = ref(null) const employeeSummary = ref(null)
const knowledgeSummary = ref(null) const knowledgeSummary = ref(null)
@@ -300,6 +296,18 @@ const aiSidebarCommandSeq = ref(0)
const aiSidebarCommand = ref({ seq: 0, type: '', payload: null }) const aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
const aiActiveConversationId = ref('') const aiActiveConversationId = ref('')
const aiConversationHistory = ref([]) const aiConversationHistory = ref([])
const AuditView = defineAsyncRouteView('audit')
const BudgetCenterView = defineAsyncRouteView('budget')
const DigitalEmployeesView = defineAsyncRouteView('digitalEmployees')
const DocumentsCenterView = defineAsyncRouteView('documents')
const EmployeeManagementView = defineAsyncRouteView('employees')
const OverviewView = defineAsyncRouteView('overview')
const PersonalWorkbenchView = defineAsyncRouteView('workbench')
const PoliciesView = defineAsyncRouteView('policies')
const ReceiptFolderView = defineAsyncRouteView('receiptFolder')
const SettingsView = defineAsyncRouteView('settings')
const TravelReimbursementCreateView = defineAsyncModalView('travelCreate')
const TravelRequestDetailView = defineAsyncRouteView('travelDetail')
function toggleSidebarCollapsed() { function toggleSidebarCollapsed() {
sidebarCollapsed.value = !sidebarCollapsed.value sidebarCollapsed.value = !sidebarCollapsed.value
@@ -310,6 +318,10 @@ function handleNavigateWithMobileClose(viewId) {
mobileSidebarOpen.value = false mobileSidebarOpen.value = false
} }
function prefetchAppView(viewId) {
void preloadAppView(viewId).catch(() => {})
}
function toggleWorkbenchMode() { function toggleWorkbenchMode() {
const nextMode = workbenchMode.value === 'ai' ? 'traditional' : 'ai' const nextMode = workbenchMode.value === 'ai' ? 'traditional' : 'ai'
if (nextMode === 'ai') { if (nextMode === 'ai') {
@@ -580,4 +592,12 @@ watch(
}, },
{ immediate: true } { immediate: true }
) )
watch(
() => activeView.value,
(view) => {
scheduleRelatedAppViewPreload(view)
},
{ immediate: true }
)
</script> </script>

View File

@@ -0,0 +1,86 @@
import { defineAsyncComponent } from 'vue'
import AppModalLoadingState from '../../components/shared/AppModalLoadingState.vue'
import AppViewLoadingState from '../../components/shared/AppViewLoadingState.vue'
const appViewLoaders = {
audit: () => import('../AuditView.vue'),
budget: () => import('../BudgetCenterView.vue'),
digitalEmployees: () => import('../DigitalEmployeesView.vue'),
documents: () => import('../DocumentsCenterView.vue'),
employees: () => import('../EmployeeManagementView.vue'),
overview: () => import('../OverviewView.vue'),
policies: () => import('../PoliciesView.vue'),
receiptFolder: () => import('../ReceiptFolderView.vue'),
settings: () => import('../SettingsView.vue'),
travelCreate: () => import('../TravelReimbursementCreateView.vue'),
travelDetail: () => import('../TravelRequestDetailView.vue'),
workbench: () => import('../PersonalWorkbenchView.vue')
}
const appViewPreloadCache = new Map()
const relatedPreloadViews = {
audit: ['documents', 'policies'],
budget: ['documents', 'overview'],
digitalEmployees: ['overview', 'settings'],
documents: ['travelDetail', 'workbench'],
employees: ['settings', 'overview'],
overview: ['workbench', 'documents'],
policies: ['audit', 'documents'],
receiptFolder: ['documents', 'workbench'],
settings: ['digitalEmployees', 'employees'],
workbench: ['documents', 'receiptFolder']
}
export function preloadAppView(viewId) {
const normalizedViewId = String(viewId || '').trim()
const loader = appViewLoaders[normalizedViewId]
if (!loader) {
return Promise.resolve(null)
}
if (!appViewPreloadCache.has(normalizedViewId)) {
appViewPreloadCache.set(
normalizedViewId,
loader().catch((error) => {
appViewPreloadCache.delete(normalizedViewId)
throw error
})
)
}
return appViewPreloadCache.get(normalizedViewId)
}
export function defineAsyncRouteView(viewId, options = {}) {
return defineAsyncComponent({
loader: () => preloadAppView(viewId),
loadingComponent: options.loadingComponent || AppViewLoadingState,
delay: options.delay ?? 160,
timeout: options.timeout ?? 30000,
suspensible: false
})
}
export function defineAsyncModalView(viewId) {
return defineAsyncRouteView(viewId, {
loadingComponent: AppModalLoadingState,
delay: 80
})
}
export function scheduleRelatedAppViewPreload(viewId) {
const views = relatedPreloadViews[String(viewId || '').trim()] || []
if (!views.length || typeof window === 'undefined') {
return
}
const runPreload = () => {
for (const view of views.slice(0, 2)) {
void preloadAppView(view).catch(() => {})
}
}
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(runPreload, { timeout: 1600 })
return
}
window.setTimeout(runPreload, 360)
}

View File

@@ -1,319 +1,14 @@
import { export {
DATE_INPUT_FORMAT, buildBusinessTimeContextFromReviewValues,
buildReviewAttachmentStatus, buildReviewCorrectionMessage,
cloneReviewEditFields, buildReviewFactCards,
createEmptyInlineReviewState, buildReviewFormContextFromPayload,
formatAmountDisplay, buildReviewMainMessageText,
formatReviewSceneDisplayValue, buildReviewRiskConversationText,
normalizeReviewRiskLevel, buildReviewRiskItems,
shouldShowReviewFactCard, canExposeReviewPanelScope,
buildBusinessTimeContextFromReviewValues as buildBusinessTimeContextFromReviewValuesModel, isTravelReviewPayload,
buildReviewFormContextFromPayload as buildReviewFormContextFromPayloadModel, normalizeReviewPanelScope,
isTravelReviewPayload as isTravelReviewPayloadModel, resolveReviewRiskBriefs,
resolveReviewTravelTransportType as resolveReviewTravelTransportTypeModel resolveReviewTravelTransportType
} from './travelReimbursementReviewModel.js' } from './travelReimbursementReviewPanelModel.js'
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
const REVIEW_PANEL_SCOPE_RISK = 'risk'
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要你确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g
const REVIEW_RISK_LEVEL_META = {
high: {
label: '高风险',
icon: 'mdi mdi-alert-octagon-outline',
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
},
medium: {
label: '中风险',
icon: 'mdi mdi-alert-circle-outline',
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
},
info: {
label: '提示',
icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
},
low: {
label: '低风险',
icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
}
}
export function normalizeReviewPanelScope(scope) {
const normalized = String(scope || '').trim()
return [REVIEW_PANEL_SCOPE_OVERVIEW, REVIEW_PANEL_SCOPE_DOCUMENTS, REVIEW_PANEL_SCOPE_RISK].includes(normalized)
? normalized
: ''
}
export function canExposeReviewPanelScope(scope) {
return Boolean(normalizeReviewPanelScope(scope))
}
export function buildBusinessTimeContextFromReviewValues(values = {}) {
return buildBusinessTimeContextFromReviewValuesModel(values)
}
export function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) {
return buildReviewFormContextFromPayloadModel(reviewPayload, inlineState)
}
export function buildReviewCorrectionMessage(fields) {
const lines = ['请按以下核对后的报销信息更新当前识别结果:']
for (const item of cloneReviewEditFields(fields)) {
if (!item.label || (!item.value && !item.required)) {
continue
}
lines.push(`${item.label}${String(item.value || '').trim() || '待补充'}`)
}
return lines.join('\n')
}
export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
return isTravelReviewPayloadModel(reviewPayload, inlineState)
}
export function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
return resolveReviewTravelTransportTypeModel(reviewPayload, fallbackText)
}
export function resolveReviewRiskBriefs(reviewPayload) {
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
return reviewPayload.risk_briefs.filter((item) => {
const title = String(item?.title || '').trim()
return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword))
})
}
export function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) {
const pendingAttachmentCount = Math.max(0, Number(inlineState.pending_attachment_count || 0))
const totalAttachmentCount = Math.max(0, Number(inlineState.attachment_count || 0))
const existingAttachmentCount = Math.max(0, totalAttachmentCount - pendingAttachmentCount)
const attachmentStatus =
pendingAttachmentCount > 0
? existingAttachmentCount > 0
? `已上传 ${existingAttachmentCount} 份,待新增 ${pendingAttachmentCount}`
: `待保存 ${pendingAttachmentCount}`
: totalAttachmentCount > 0
? `已上传 ${totalAttachmentCount}`
: buildReviewAttachmentStatus(reviewPayload)
if (isTravelReviewPayload(reviewPayload, inlineState)) {
return [
{
key: 'occurred_date',
label: '发生时间',
value: String(inlineState.occurred_date || '').trim() || '待补充',
icon: 'mdi mdi-calendar-month-outline',
editor: 'date',
modelKey: 'occurred_date',
placeholder: `例如 ${DATE_INPUT_FORMAT}`
},
{
key: 'amount',
label: '金额',
value: formatAmountDisplay(inlineState.amount) || '待补充',
icon: 'mdi mdi-cash',
editor: 'amount',
modelKey: 'amount',
placeholder: '例如 200.00'
},
{
key: 'transport_type',
label: '交通类型',
value: String(inlineState.transport_type || '').trim() || '待确认',
icon: 'mdi mdi-train-car',
editor: 'text',
modelKey: 'transport_type',
placeholder: '例如 火车/高铁、飞机'
},
{
key: 'hotel_name',
label: '酒店名称',
value: String(inlineState.merchant_name || '').trim() || '待补充',
icon: 'mdi mdi-bed-outline',
editor: 'text',
modelKey: 'merchant_name',
placeholder: '请输入酒店名称'
},
{
key: 'travel_purpose',
label: '出差事宜',
value: String(inlineState.reason_value || '').trim() || '待补充',
icon: 'mdi mdi-briefcase-edit-outline',
editor: 'textarea',
modelKey: 'reason_value',
placeholder: '请填写本次出差的具体工作内容或业务意图',
wide: true
}
]
}
const cards = [
{
key: 'occurred_date',
label: '发生时间',
value: String(inlineState.occurred_date || '').trim() || '待补充',
icon: 'mdi mdi-calendar-month-outline',
editor: 'date',
modelKey: 'occurred_date',
placeholder: `例如 ${DATE_INPUT_FORMAT}`
},
{
key: 'amount',
label: '金额',
value: formatAmountDisplay(inlineState.amount) || '待补充',
icon: 'mdi mdi-cash',
editor: 'amount',
modelKey: 'amount',
placeholder: '例如 200.00'
},
{
key: 'scene',
label: '场景 / 事由',
value: formatReviewSceneDisplayValue(inlineState),
icon: 'mdi mdi-silverware-fork-knife',
editor: 'select',
modelKey: 'scene_label',
placeholder: '请选择场景'
},
{
key: 'attachments',
label: '票据状态',
value: attachmentStatus,
icon: 'mdi mdi-file-document-outline',
editor: 'upload',
modelKey: 'attachment_names',
placeholder: ''
}
]
if (shouldShowReviewFactCard(reviewPayload, 'customer_name', inlineState.customer_name)) {
cards.splice(cards.length - 1, 0, {
key: 'customer_name',
label: '关联客户',
value: String(inlineState.customer_name || '').trim() || '待补充',
icon: 'mdi mdi-domain',
editor: 'text',
modelKey: 'customer_name',
placeholder: '请输入客户名称'
})
}
if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) {
cards.splice(cards.length - 1, 0, {
key: 'location',
label: '业务地点',
value: String(inlineState.location || '').trim() || '待补充',
icon: 'mdi mdi-map-marker-outline',
editor: 'text',
modelKey: 'location',
placeholder: '请输入业务地点'
})
}
if (shouldShowReviewFactCard(reviewPayload, 'merchant_name', inlineState.merchant_name)) {
cards.splice(cards.length - 1, 0, {
key: 'merchant_name',
label: '酒店/商户',
value: String(inlineState.merchant_name || '').trim() || '待补充',
icon: 'mdi mdi-storefront-outline',
editor: 'text',
modelKey: 'merchant_name',
placeholder: '请输入酒店或商户名称'
})
}
if (shouldShowReviewFactCard(reviewPayload, 'participants', inlineState.participants)) {
cards.splice(cards.length - 1, 0, {
key: 'participants',
label: '同行人员',
value: String(inlineState.participants || '').trim() || '待补充',
icon: 'mdi mdi-account-group-outline',
editor: 'text',
modelKey: 'participants',
placeholder: '例如 客户 2 人,我方 1 人'
})
}
return cards
}
function normalizeReviewRiskTitle(title, fallbackTitle) {
const normalized = String(title || '').trim()
const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示'
if (!normalized) return fallback
const cleaned = normalized
.replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示')
.replace(/(高风险|中风险|低风险)/g, '')
.replace(/^[:\-—\s]+|[:\-—\s]+$/g, '')
.trim()
return cleaned || fallback
}
export function buildReviewRiskItems(reviewPayload) {
return resolveReviewRiskBriefs(reviewPayload)
.map((brief, index) => {
const title = String(brief?.title || '').trim()
const content = String(brief?.content || '').trim()
const detail = String(brief?.detail || '').trim()
const suggestion = String(brief?.suggestion || '').trim()
const level = normalizeReviewRiskLevel(brief?.level)
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle)
const summary = content || normalizedTitle
if (!normalizedTitle && !summary) return null
return {
key: `${level}-${normalizedTitle}-${index}`,
title: normalizedTitle,
summary,
detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。',
level,
levelLabel: meta.label,
icon: meta.icon,
sourceLabel: meta.label,
suggestion: suggestion || meta.suggestion
}
})
.filter(Boolean)
}
export function buildReviewRiskConversationText(item, detailTarget = {}) {
const title = String(item?.title || '风险提示').trim()
const summary = String(item?.summary || '').trim()
const detail = String(item?.detail || '').trim()
const suggestion = String(item?.suggestion || '').trim()
const isInfo = String(item?.level || '').trim() === 'info'
const detailHref = String(detailTarget?.href || '').trim()
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
const lines = [`${title}`]
if (summary) {
lines.push('', `${isInfo ? '提示内容' : '风险点'}${summary}`)
}
if (detail && detail !== summary) {
lines.push('', `规则依据:${detail}`)
}
if (suggestion) {
lines.push('', `${isInfo ? '处理建议' : '修改建议'}${suggestion}`)
}
if (detailHref) {
lines.push('', `[${detailLabel}](${detailHref})`)
}
return lines.join('\n')
}
export function buildReviewMainMessageText(message) {
const text = String(message?.text || '')
if (!message?.reviewPayload) {
return text
}
return text
.replace(REVIEW_PENDING_SUMMARY_PATTERN, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}

View File

@@ -97,7 +97,7 @@ test('AI sidebar has quick actions, business navigation and conversation history
assert.match(aiSidebar, /class="ai-nav-list"/) assert.match(aiSidebar, /class="ai-nav-list"/)
assert.match(aiSidebar, /v-for="item in businessNavItems"/) assert.match(aiSidebar, /v-for="item in businessNavItems"/)
assert.match(aiSidebar, /ai-nav-copy/) assert.match(aiSidebar, /ai-nav-copy/)
assert.match(aiSidebar, /item\.aiIcon/) assert.match(aiSidebar, /item\.aiIconPaths/)
assert.match(aiSidebar, /aria-current/) assert.match(aiSidebar, /aria-current/)
assert.doesNotMatch(aiSidebar, /displayHint/) assert.doesNotMatch(aiSidebar, /displayHint/)
assert.doesNotMatch(aiSidebar, /个人工作台/) assert.doesNotMatch(aiSidebar, /个人工作台/)
@@ -136,7 +136,7 @@ test('AI sidebar has quick actions, business navigation and conversation history
assert.match(aiSidebar, /displayUser\.subtitle/) assert.match(aiSidebar, /displayUser\.subtitle/)
assert.match(aiSidebar, /aria-label="用户操作"/) assert.match(aiSidebar, /aria-label="用户操作"/)
assert.match(aiSidebar, /emit\('logout'\)/) assert.match(aiSidebar, /emit\('logout'\)/)
assert.match(aiSidebar, /defineEmits\(\['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout'\]\)/) assert.match(aiSidebar, /defineEmits\(\['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout', 'prefetch-view'\]\)/)
assert.doesNotMatch(aiSidebar, /search-chat/) assert.doesNotMatch(aiSidebar, /search-chat/)
assert.doesNotMatch(aiSidebar, /打开系统设置/) assert.doesNotMatch(aiSidebar, /打开系统设置/)
assert.doesNotMatch(aiSidebar, /mdi-chevron-up/) assert.doesNotMatch(aiSidebar, /mdi-chevron-up/)
@@ -154,10 +154,21 @@ test('AI sidebar visual treatment keeps a restrained three-layer workspace', ()
assert.match(aiSidebarStyles, /\.ai-rail-brand\s*\{[\s\S]*min-height:\s*74px;[\s\S]*grid-template-columns:\s*42px minmax\(0,\s*1fr\);/) assert.match(aiSidebarStyles, /\.ai-rail-brand\s*\{[\s\S]*min-height:\s*74px;[\s\S]*grid-template-columns:\s*42px minmax\(0,\s*1fr\);/)
assert.match(aiSidebarStyles, /\.ai-brand-logo\s*\{[\s\S]*width:\s*42px;[\s\S]*height:\s*42px;/) assert.match(aiSidebarStyles, /\.ai-brand-logo\s*\{[\s\S]*width:\s*42px;[\s\S]*height:\s*42px;/)
assert.match(aiSidebarStyles, /\.ai-brand-logo svg\s*\{[\s\S]*width:\s*26px;[\s\S]*height:\s*26px;/) assert.match(aiSidebarStyles, /\.ai-brand-logo svg\s*\{[\s\S]*width:\s*26px;[\s\S]*height:\s*26px;/)
assert.match(aiSidebar, /icon:\s*'mdi mdi-plus'/) assert.match(aiSidebar, /const tablerIconPaths = \{/)
assert.match(aiSidebar, /plus:\s*\[/)
assert.match(aiSidebar, /search:\s*\[/)
assert.match(aiSidebar, /fileText:\s*\[/)
assert.match(aiSidebar, /book2:\s*\[/)
assert.match(aiSidebar, /iconPaths:\s*tablerIconPaths\.plus/)
assert.match(aiSidebar, /aiIconPaths:\s*sidebarMeta\[item\.id\]\?\.iconPaths/)
assert.doesNotMatch(aiSidebar, /icon:\s*'mdi mdi-plus'/)
assert.doesNotMatch(aiSidebar, /mdi mdi-file-document-outline/)
assert.match(aiSidebarStyles, /\.ai-sidebar-tabler-icon\s*\{[\s\S]*stroke-width:\s*1\.85;/)
assert.match(aiSidebarStyles, /\.ai-rail-quick\s*\{[\s\S]*gap:\s*6px;[\s\S]*padding:\s*8px 18px 12px;/) assert.match(aiSidebarStyles, /\.ai-rail-quick\s*\{[\s\S]*gap:\s*6px;[\s\S]*padding:\s*8px 18px 12px;/)
assert.match(quickButtonBlock, /min-height:\s*48px;/) assert.match(quickButtonBlock, /min-height:\s*48px;/)
assert.match(quickButtonBlock, /grid-template-columns:\s*28px minmax\(0,\s*1fr\);/) assert.match(quickButtonBlock, /grid-template-columns:\s*32px minmax\(0,\s*1fr\);/)
assert.match(quickButtonBlock, /gap:\s*12px;/)
assert.match(quickButtonBlock, /padding:\s*7px 10px;/)
assert.match(quickButtonBlock, /background:\s*transparent;/) assert.match(quickButtonBlock, /background:\s*transparent;/)
assert.match(quickButtonBlock, /border-color:\s*transparent;/) assert.match(quickButtonBlock, /border-color:\s*transparent;/)
assert.match(quickButtonBlock, /box-shadow:\s*none;/) assert.match(quickButtonBlock, /box-shadow:\s*none;/)
@@ -170,7 +181,12 @@ test('AI sidebar visual treatment keeps a restrained three-layer workspace', ()
assert.doesNotMatch(navListBlock, /box-shadow:/) assert.doesNotMatch(navListBlock, /box-shadow:/)
assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*min-height:\s*48px;/) assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*min-height:\s*48px;/)
assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*grid-template-columns:\s*32px minmax\(0,\s*1fr\);/) assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*grid-template-columns:\s*32px minmax\(0,\s*1fr\);/)
assert.match(aiSidebarStyles, /\.ai-nav-btn\.active\s*\{[\s\S]*background:[\s\S]*linear-gradient\(90deg,\s*rgba\(45,\s*114,\s*217,\s*0\.095\)/) assert.match(aiSidebarStyles, /\.ai-quick-btn:hover,\s*\.ai-quick-btn\.active,\s*\.ai-nav-btn:hover,\s*\.ai-nav-btn\.active\s*\{[\s\S]*background:\s*rgba\(15,\s*23,\s*42,\s*0\.035\);/)
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn::before/)
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn\.active::before/)
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn\.active \.ai-nav-copy/)
assert.doesNotMatch(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-nav-btn\.active/)
assert.doesNotMatch(aiSidebarStyles, /linear-gradient\(90deg,\s*rgba\(45,\s*114,\s*217,\s*0\.095\)/)
assert.match(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-nav-list\s*\{[\s\S]*grid-template-columns:\s*1fr;/) assert.match(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-nav-list\s*\{[\s\S]*grid-template-columns:\s*1fr;/)
assert.match(aiSidebarStyles, /\.ai-rail-recents\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);/) assert.match(aiSidebarStyles, /\.ai-rail-recents\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);/)
assert.match(aiSidebarStyles, /\.ai-recents-list\s*\{[\s\S]*overflow-y:\s*auto;[\s\S]*scrollbar-width:\s*none;/) assert.match(aiSidebarStyles, /\.ai-recents-list\s*\{[\s\S]*overflow-y:\s*auto;[\s\S]*scrollbar-width:\s*none;/)

View File

@@ -8,23 +8,65 @@ const shell = readFileSync(
'utf8' 'utf8'
) )
const router = readFileSync(fileURLToPath(new URL('../src/router/index.js', import.meta.url)), 'utf8') const router = readFileSync(fileURLToPath(new URL('../src/router/index.js', import.meta.url)), 'utf8')
const sidebarRail = readFileSync(
fileURLToPath(new URL('../src/components/layout/SidebarRail.vue', import.meta.url)),
'utf8'
)
const aiSidebarRail = readFileSync(
fileURLToPath(new URL('../src/components/layout/AiSidebarRail.vue', import.meta.url)),
'utf8'
)
test('app shell main route views are eagerly imported', () => { test('app shell lazily loads heavy business views with an in-workarea loading state', () => {
assert.doesNotMatch(shell, /defineAsyncRouteView/) assert.match(shell, /defineAsyncRouteView\('audit'\)/)
assert.doesNotMatch(shell, /defineAsyncComponent/) assert.match(shell, /defineAsyncRouteView\('documents'\)/)
assert.doesNotMatch(shell, /loadingComponent:/) assert.match(shell, /defineAsyncRouteView\('workbench'\)/)
assert.doesNotMatch(shell, /\u9875\u9762\u5207\u6362\u4e2d/) assert.match(shell, /defineAsyncModalView\('travelCreate'\)/)
assert.doesNotMatch(shell, /floating:\s*true/) assert.match(shell, /function prefetchAppView\(viewId\)/)
assert.doesNotMatch(shell, /blocking:\s*true/) assert.match(shell, /@prefetch-view="prefetchAppView"/)
assert.match(shell, /import AuditView from '\.\/AuditView\.vue'/) assert.doesNotMatch(shell, /import AuditView from '\.\/AuditView\.vue'/)
assert.match(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/) assert.doesNotMatch(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/)
assert.match(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/) assert.doesNotMatch(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/)
assert.match(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/) assert.doesNotMatch(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/)
assert.match(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/) assert.doesNotMatch(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/)
}) })
test('top-level app routes are eagerly imported', () => { test('app view preloading is triggered from both standard and AI sidebars', () => {
assert.doesNotMatch(router, /\(\)\s*=>\s*import\(/) assert.match(sidebarRail, /'prefetch-view'/)
assert.match(sidebarRail, /@mouseenter="emit\('prefetch-view', item\.id\)"/)
assert.match(sidebarRail, /@focus="emit\('prefetch-view', item\.id\)"/)
assert.match(aiSidebarRail, /'prefetch-view'/)
assert.match(aiSidebarRail, /@mouseenter="emit\('prefetch-view', item\.id\)"/)
assert.match(aiSidebarRail, /@focus="emit\('prefetch-view', item\.id\)"/)
})
test('async app view loader keeps transitions nonblocking and visible', () => {
const asyncViews = readFileSync(
fileURLToPath(new URL('../src/views/scripts/appShellAsyncViews.js', import.meta.url)),
'utf8'
)
const loadingState = readFileSync(
fileURLToPath(new URL('../src/components/shared/AppViewLoadingState.vue', import.meta.url)),
'utf8'
)
const modalLoadingState = readFileSync(
fileURLToPath(new URL('../src/components/shared/AppModalLoadingState.vue', import.meta.url)),
'utf8'
)
assert.match(asyncViews, /defineAsyncComponent/)
assert.match(asyncViews, /loadingComponent:\s*options\.loadingComponent \|\| AppViewLoadingState/)
assert.match(asyncViews, /loadingComponent:\s*AppModalLoadingState/)
assert.match(asyncViews, /suspensible:\s*false/)
assert.match(asyncViews, /requestIdleCallback/)
assert.match(loadingState, /正在加载页面内容/)
assert.match(loadingState, /app-view-loading-skeleton/)
assert.match(modalLoadingState, /Teleport to="body"/)
assert.match(modalLoadingState, /正在打开智能工作台/)
})
test('top-level shell routes stay eager so the layout does not blank during navigation', () => {
assert.doesNotMatch(router, /component:\s*\(\)\s*=>\s*import\(\s*'\.\.\/views\/AppShellRouteView\.vue'/)
assert.match(router, /import AppShellRouteView from '\.\.\/views\/AppShellRouteView\.vue'/) assert.match(router, /import AppShellRouteView from '\.\.\/views\/AppShellRouteView\.vue'/)
assert.match(router, /import LoginRouteView from '\.\.\/views\/LoginRouteView\.vue'/) assert.match(router, /import LoginRouteView from '\.\.\/views\/LoginRouteView\.vue'/)
assert.match(router, /import SetupRouteView from '\.\.\/views\/SetupRouteView\.vue'/) assert.match(router, /import SetupRouteView from '\.\.\/views\/SetupRouteView\.vue'/)

View File

@@ -0,0 +1,61 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
extractTrustedHtmlBlocks,
normalizeConversationText,
restoreTrustedHtmlBlocks
} from '../src/utils/conversationTrustedHtml.js'
test('conversation trusted html helper preserves valid document cards', () => {
const trustedBlock = [
'<!-- ai-trusted-html:start -->',
'<section class="ai-document-card-list" aria-label="单据结果">',
'<article class="ai-document-card" aria-label="单据详情">',
'<strong>差旅申请</strong>',
'<a class="ai-html-action-link" data-ai-action="open-document-detail" href="#ai-open-document-detail:CL-1">查看</a>',
'</article>',
'</section>',
'<!-- ai-trusted-html:end -->'
].join('')
const extracted = extractTrustedHtmlBlocks(`结果如下:\n\n${trustedBlock}`)
assert.equal(extracted.trustedHtmlBlocks.length, 1)
assert.match(extracted.content, /AI_TRUSTED_HTML_BLOCK_0/)
const restored = restoreTrustedHtmlBlocks(
'<p>AI_TRUSTED_HTML_BLOCK_0</p>\n',
extracted.trustedHtmlBlocks
)
assert.match(restored, /class="ai-document-card-list"/)
assert.match(restored, /href="#ai-open-document-detail:CL-1"/)
assert.doesNotMatch(restored, /AI_TRUSTED_HTML_BLOCK_0/)
})
test('conversation trusted html helper rejects unsafe trusted blocks', () => {
const extracted = extractTrustedHtmlBlocks([
'<!-- ai-trusted-html:start -->',
'<section class="ai-document-card-list">',
'<a href="javascript:alert(1)" onclick="alert(1)">危险</a>',
'</section>',
'<!-- ai-trusted-html:end -->'
].join(''))
assert.equal(extracted.trustedHtmlBlocks.length, 0)
assert.equal(extracted.content.trim(), '')
})
test('conversation trusted html helper normalizes business copy outside fences', () => {
const normalized = normalizeConversationText([
'基础信息识别结果:请核对',
'时间2026-02-20',
'',
'```',
'金额:不要改代码块',
'```'
].join('\n'), { trim: true })
assert.match(normalized, /### 基础信息识别结果/)
assert.match(normalized, /- \*\*时间\*\*2026-02-20/)
assert.match(normalized, /```\n金额不要改代码块\n```/)
})

View File

@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
DOCUMENT_TYPE_APPLICATION,
DOCUMENT_TYPE_LABELS,
DOCUMENT_TYPE_REIMBURSEMENT,
INLINE_APPLICATION_STATUS_LABELS,
resolveDocumentTypeLabel
} from '../src/constants/documentProtocol.js'
import { readSourceFile, readSourceSurface } from './helpers/sourceSurface.mjs'
test('document protocol constants centralize document types and labels', () => {
assert.equal(DOCUMENT_TYPE_APPLICATION, 'application')
assert.equal(DOCUMENT_TYPE_REIMBURSEMENT, 'reimbursement')
assert.equal(DOCUMENT_TYPE_LABELS[DOCUMENT_TYPE_APPLICATION], '申请单')
assert.equal(DOCUMENT_TYPE_LABELS[DOCUMENT_TYPE_REIMBURSEMENT], '报销单')
assert.equal(resolveDocumentTypeLabel('application'), '申请单')
assert.equal(resolveDocumentTypeLabel('expense_application'), '申请单')
assert.equal(resolveDocumentTypeLabel('unknown', '单据'), '单据')
})
test('inline application status labels live in the shared document protocol', () => {
assert.equal(INLINE_APPLICATION_STATUS_LABELS.draft, '草稿')
assert.equal(INLINE_APPLICATION_STATUS_LABELS.submitted, '审批中')
assert.equal(INLINE_APPLICATION_STATUS_LABELS.pending_payment, '待付款')
})
test('source surface helper loads one or more source files for source assertions', () => {
const model = readSourceFile('constants/documentProtocol.js')
assert.match(model, /DOCUMENT_TYPE_APPLICATION/)
const combined = readSourceSurface([
'constants/documentProtocol.js',
'composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
])
assert.match(combined, /INLINE_APPLICATION_STATUS_LABELS/)
assert.match(combined, /normalizeInlineApplicationStatusLabel/)
})

View File

@@ -1,38 +1,19 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url'
const documentsCenterView = readFileSync( import { readSourceFile, readSourceSurface } from './helpers/sourceSurface.mjs'
fileURLToPath(new URL('../src/views/DocumentsCenterView.vue', import.meta.url)),
'utf8'
)
const documentsCenterViewModel = readFileSync(
fileURLToPath(new URL('../src/utils/documentCenterViewModel.js', import.meta.url)),
'utf8'
)
const documentsCenterLogic = `${documentsCenterView}\n${documentsCenterViewModel}`
const documentsCenterStyles = readFileSync( const documentsCenterView = readSourceFile('views/DocumentsCenterView.vue')
fileURLToPath(new URL('../src/assets/styles/views/documents-center-view.css', import.meta.url)), const documentsCenterViewModel = readSourceFile('utils/documentCenterViewModel.js')
'utf8' const documentsCenterLogic = readSourceSurface([
) 'views/DocumentsCenterView.vue',
const documentListSharedStyles = readFileSync( 'utils/documentCenterViewModel.js'
fileURLToPath(new URL('../src/assets/styles/components/document-list-shared.css', import.meta.url)), ])
'utf8' const documentsCenterStyles = readSourceFile('assets/styles/views/documents-center-view.css')
) const documentListSharedStyles = readSourceFile('assets/styles/components/document-list-shared.css')
const tableLoadingState = readFileSync( const tableLoadingState = readSourceFile('components/shared/TableLoadingState.vue')
fileURLToPath(new URL('../src/components/shared/TableLoadingState.vue', import.meta.url)), const reimbursementService = readSourceFile('services/reimbursements.js')
'utf8' const requestsComposable = readSourceFile('composables/useRequests.js')
)
const reimbursementService = readFileSync(
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
'utf8'
)
const requestsComposable = readFileSync(
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
'utf8'
)
test('documents center keeps only the top scope tabs and renders risk level as a dropdown filter', () => { test('documents center keeps only the top scope tabs and renders risk level as a dropdown filter', () => {
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/) assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
@@ -145,7 +126,7 @@ test('documents center preserves application document type from mapped requests'
) )
assert.match( assert.match(
documentsCenterLogic, documentsCenterLogic,
/documentTypeCode === DOCUMENT_TYPE_APPLICATION \? '申请单' : '报销单'/ /resolveDocumentTypeLabel\(documentTypeCode\)/
) )
assert.doesNotMatch( assert.doesNotMatch(
documentsCenterLogic, documentsCenterLogic,

View File

@@ -0,0 +1,13 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
export function readSourceFile(relativePath) {
return readFileSync(
fileURLToPath(new URL(`../../src/${relativePath}`, import.meta.url)),
'utf8'
)
}
export function readSourceSurface(relativePaths = []) {
return relativePaths.map((relativePath) => readSourceFile(relativePath)).join('\n')
}

View File

@@ -82,37 +82,64 @@ test('topbar bell owns document center unread notifications', () => {
assert.match(topbar, /startDocumentInboxPolling\(\)/) assert.match(topbar, /startDocumentInboxPolling\(\)/)
assert.match(topbar, /stopDocumentInboxPolling\(\)/) assert.match(topbar, /stopDocumentInboxPolling\(\)/)
assert.match(topbar, /class="notification-clear-btn"/) assert.match(topbar, /class="notification-clear-btn"/)
assert.match(topbar, /function clearAllNotifications\(\)/) assert.match(topbar, /notificationBulkActionLabel/)
assert.match(topbar, /notificationBulkActionDisabled/)
assert.match(topbar, /function handleNotificationBulkAction\(\)/)
assert.match(topbar, /function markUnreadNotificationsRead\(\)/)
assert.match(topbar, /function deleteReadNotifications\(\)/)
assert.match(topbar, /function markNotificationRead\(item\)/) assert.match(topbar, /function markNotificationRead\(item\)/)
assert.match(topbar, /class="notification-type-icon" :class="item\.tone"/) assert.match(topbar, /class="notification-avatar" :class="item\.tone"/)
assert.match(topbarStyles, /\.notification-head-copy\s*\{[\s\S]*display:\s*grid;/) assert.match(topbarStyles, /\.notification-head-copy\s*\{[\s\S]*display:\s*grid;/)
assert.match(topbarStyles, /\.notification-head-actions\s*\{[\s\S]*display:\s*inline-flex;/) assert.match(topbarStyles, /\.notification-head-actions\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(topbarStyles, /\.notification-popover\s*\{[\s\S]*max-height:\s*min\(520px,\s*calc\(100vh - 96px\)\);/) assert.match(topbarStyles, /\.notification-popover\s*\{[\s\S]*max-height:\s*min\(560px,\s*calc\(100vh - 68px\)\);/)
assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*min\(336px,\s*calc\(100vh - 226px\)\);[\s\S]*overflow-y:\s*auto;/) assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*min\(420px,\s*calc\(100vh - 166px\)\);[\s\S]*overflow-y:\s*auto;/)
assert.match(topbarStyles, /\.notification-copy small\s*\{[\s\S]*-webkit-line-clamp:\s*2;/) assert.match(topbarStyles, /\.notification-preview\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
assert.match(topbarStyles, /@media \(max-width: 640px\)[\s\S]*\.notification-popover\s*\{[\s\S]*position:\s*fixed;/) assert.match(topbarStyles, /@media \(max-width: 640px\)[\s\S]*\.notification-popover\s*\{[\s\S]*position:\s*fixed;/)
assert.match(topbarStyles, /\.notification-tabs button em\s*\{[\s\S]*border-radius:\s*4px;/) assert.match(topbarStyles, /\.notification-tabs button em\s*\{[\s\S]*border-radius:\s*4px;/)
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 16px;/) assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*52px minmax\(0,\s*1fr\);/)
assert.doesNotMatch(topbarStyles, /\.notification-dot/) assert.doesNotMatch(topbarStyles, /\.notification-dot/)
}) })
test('topbar notification popover uses inbox-style rows with formatted time labels', () => { test('topbar notification bulk action label follows active tab semantics', () => {
assert.match(topbar, /class="notification-row-main"/) assert.match(topbar, />\s*\{\{ notificationBulkActionLabel \}\}\s*<\/button>/)
assert.match(topbar, /class="notification-row-head"/) assert.match(topbar, /:disabled="notificationBulkActionDisabled"/)
assert.match(topbar, /@click="handleNotificationBulkAction"/)
assert.match(topbar, /const notificationBulkActionLabel = computed\(\(\) => \(\s*notificationTab\.value === 'unread' \? '全部已读' : '删除已读'\s*\)\)/)
assert.match(topbar, /const notificationBulkActionDisabled = computed\(\(\) => \(\s*notificationTab\.value === 'unread'\s*\? unreadNotifications\.value\.length === 0\s*: readNotifications\.value\.length === 0\s*\)\)/)
assert.match(topbar, /function handleNotificationBulkAction\(\) \{[\s\S]*if \(notificationTab\.value === 'unread'\) \{[\s\S]*markUnreadNotificationsRead\(\)[\s\S]*return[\s\S]*deleteReadNotifications\(\)[\s\S]*\}/)
assert.match(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*const currentItems = unreadNotifications\.value[\s\S]*markDocumentInboxRowsRead\(documentRows\)[\s\S]*markNotificationStatesRead\(currentItems\)/)
assert.match(topbar, /function deleteReadNotifications\(\) \{[\s\S]*const currentItems = readNotifications\.value[\s\S]*hideNotificationStates\(currentItems\)/)
assert.doesNotMatch(topbar, />\s*清空通知\s*<\/button>/)
})
test('topbar notification popover uses reference-style avatar message rows', () => {
assert.match(topbar, /class="notification-avatar" :class="item\.tone"/)
assert.match(topbar, /class="notification-avatar-label"/)
assert.match(topbar, /class="notification-avatar-badge"/)
assert.match(topbar, /class="notification-row-content"/)
assert.match(topbar, /class="notification-row-top"/)
assert.match(topbar, /class="notification-row-title"/) assert.match(topbar, /class="notification-row-title"/)
assert.match(topbar, /class="notification-context"/) assert.match(topbar, /class="notification-preview"/)
assert.match(topbar, /class="notification-row-foot"/)
assert.match(topbar, /class="notification-category-pill"/)
assert.match(topbar, /class="notification-time"/) assert.match(topbar, /class="notification-time"/)
assert.match(topbar, /class="notification-row-action"/) assert.match(topbar, /avatarLabel: resolveDocumentNotificationAvatarLabel\(row\)/)
assert.match(topbar, /avatarLabel: resolveNotificationAvatarLabel\(item\)/)
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(row\.updatedAt \|\| row\.createdAt\)/) assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(row\.updatedAt \|\| row\.createdAt\)/)
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(item\.time \|\| item\.updatedAt \|\| item\.due\)/) assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(item\.time \|\| item\.updatedAt \|\| item\.due\)/)
assert.doesNotMatch(topbar, /class="notification-category-pill"/)
assert.doesNotMatch(topbar, /class="notification-row-action"/)
assert.doesNotMatch(topbar, /<time>\{\{ item\.time \}\}<\/time>/) assert.doesNotMatch(topbar, /<time>\{\{ item\.time \}\}<\/time>/)
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*36px minmax\(0,\s*1fr\);/) assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*52px minmax\(0,\s*1fr\);/)
assert.match(topbarStyles, /\.notification-context\s*\{[\s\S]*-webkit-line-clamp:\s*2;/) assert.match(topbarStyles, /\.notification-avatar\s*\{[\s\S]*border-radius:\s*999px;/)
assert.match(topbarStyles, /\.notification-row-foot\s*\{[\s\S]*justify-content:\s*space-between;/) assert.match(topbarStyles, /\.notification-preview\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
assert.match(topbarStyles, /\.notification-row-top\s*\{[\s\S]*justify-content:\s*space-between;/)
assert.match(topbarStyles, /\.notification-time\s*\{[\s\S]*font-variant-numeric:\s*tabular-nums;/) assert.match(topbarStyles, /\.notification-time\s*\{[\s\S]*font-variant-numeric:\s*tabular-nums;/)
assert.match(topbarStyles, /\.notification-row-action\s*\{[\s\S]*width:\s*28px;[\s\S]*height:\s*28px;/) assert.doesNotMatch(topbarStyles, /\.notification-category-pill/)
assert.doesNotMatch(topbarStyles, /\.notification-row-action/)
})
test('topbar notification popover does not render a top accent line', () => {
assert.doesNotMatch(topbarStyles, /\.notification-popover::before/)
assert.doesNotMatch(topbarStyles, /height:\s*2px;[\s\S]*background:\s*var\(--theme-primary-active\)/)
}) })
test('topbar notification state is persisted through backend API with local fallback', () => { test('topbar notification state is persisted through backend API with local fallback', () => {
@@ -124,9 +151,20 @@ test('topbar notification state is persisted through backend API with local fall
assert.match(topbarNotificationStates, /NOTIFICATION_HIDDEN_STORAGE_KEY/) assert.match(topbarNotificationStates, /NOTIFICATION_HIDDEN_STORAGE_KEY/)
assert.match(topbarNotificationStates, /applyRemoteStates/) assert.match(topbarNotificationStates, /applyRemoteStates/)
assert.match(topbarNotificationStates, /markNotificationStateRead/) assert.match(topbarNotificationStates, /markNotificationStateRead/)
assert.match(topbarNotificationStates, /markNotificationStatesRead/)
assert.match(topbarNotificationStates, /hideNotificationStates/) assert.match(topbarNotificationStates, /hideNotificationStates/)
}) })
test('topbar notification bulk actions are wired to backend state API', () => {
assert.match(topbar, /markNotificationStatesRead/)
assert.match(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*const currentItems = unreadNotifications\.value[\s\S]*markDocumentInboxRowsRead\(documentRows\)[\s\S]*markNotificationStatesRead\(currentItems\)/)
assert.doesNotMatch(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*currentItems\.forEach\(\(item\) => \{[\s\S]*markNotificationStateRead\(item\)/)
assert.match(topbar, /function deleteReadNotifications\(\) \{[\s\S]*const currentItems = readNotifications\.value[\s\S]*hideNotificationStates\(currentItems\)/)
assert.match(topbarNotificationStates, /function markNotificationStatesRead\(items\) \{[\s\S]*buildPatch\(item, \{ read: true, hidden: false \}\)[\s\S]*return syncNotificationPatches\(patches\)/)
assert.match(topbarNotificationStates, /function hideNotificationStates\(items\) \{[\s\S]*buildPatch\(item, \{ read: true, hidden: true \}\)[\s\S]*return syncNotificationPatches\(patches\)/)
assert.match(notificationStatesService, /apiRequest\('\/notification-states',\s*\{[\s\S]*method:\s*'POST'[\s\S]*body:\s*JSON\.stringify\(\{ states: batch \}\)/)
})
test('document inbox reuses document center viewed-key state', () => { test('document inbox reuses document center viewed-key state', () => {
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/) assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
assert.match(documentInbox, /fetchNotificationStates/) assert.match(documentInbox, /fetchNotificationStates/)

View File

@@ -40,6 +40,10 @@ const reviewPanelModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementReviewPanelModel.js', import.meta.url)), fileURLToPath(new URL('../src/views/scripts/travelReimbursementReviewPanelModel.js', import.meta.url)),
'utf8' 'utf8'
) )
const createReviewModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementCreateReviewModel.js', import.meta.url)),
'utf8'
)
const messageItemTemplate = readFileSync( const messageItemTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)), fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
'utf8' 'utf8'
@@ -130,6 +134,13 @@ test('review drawer tools expose the default review tab before conditional docum
) )
}) })
test('create review model remains a thin compatibility layer over review panel model', () => {
assert.match(createReviewModelScript, /export \{[\s\S]*buildReviewFactCards[\s\S]*buildReviewRiskItems[\s\S]*\} from '\.\/travelReimbursementReviewPanelModel\.js'/)
assert.doesNotMatch(createReviewModelScript, /function buildReviewFactCards/)
assert.doesNotMatch(createReviewModelScript, /function buildReviewRiskItems/)
assert.doesNotMatch(createReviewModelScript, /const REVIEW_RISK_LEVEL_META/)
})
test('review drawer tool buttons switch modes instead of toggling the active mode closed', () => { test('review drawer tool buttons switch modes instead of toggling the active mode closed', () => {
assert.match(createViewScriptSurface, /const isReviewOverviewDrawer = computed\(\(\) => reviewDrawerMode\.value === REVIEW_DRAWER_MODE_REVIEW\)/) assert.match(createViewScriptSurface, /const isReviewOverviewDrawer = computed\(\(\) => reviewDrawerMode\.value === REVIEW_DRAWER_MODE_REVIEW\)/)
assert.match(createViewScriptSurface, /function switchReviewDrawerMode\(mode\) \{[\s\S]*if \(reviewDrawerMode\.value === mode\) \{[\s\S]*return[\s\S]*\}/) assert.match(createViewScriptSurface, /function switchReviewDrawerMode\(mode\) \{[\s\S]*if \(reviewDrawerMode\.value === mode\) \{[\s\S]*return[\s\S]*\}/)

View File

@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
isOrphanInlineApplicationPreviewMessage,
isReimbursementCreationIntent,
resolveInlineApplicationPreviewTextAction,
resolveLatestApplicationPreviewMessage,
resolveLatestOrphanApplicationPreviewMessage
} from '../src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT
} from '../src/services/aiApplicationPreviewActions.js'
test('workbench application gate detects reimbursement creation without catching policy questions', () => {
assert.equal(isReimbursementCreationIntent('我要报销'), true)
assert.equal(isReimbursementCreationIntent('帮我新建一笔报账'), true)
assert.equal(isReimbursementCreationIntent('报销一下'), true)
assert.equal(isReimbursementCreationIntent('报销制度是什么'), false)
assert.equal(isReimbursementCreationIntent('帮我查询报销进度'), false)
assert.equal(isReimbursementCreationIntent('这张票能不能报销'), false)
})
test('workbench application gate resolves save and submit text actions consistently', () => {
assert.equal(resolveInlineApplicationPreviewTextAction('保存草稿'), AI_APPLICATION_ACTION_SAVE_DRAFT)
assert.equal(resolveInlineApplicationPreviewTextAction(' 先保存 '), AI_APPLICATION_ACTION_SAVE_DRAFT)
assert.equal(resolveInlineApplicationPreviewTextAction('确认提交'), AI_APPLICATION_ACTION_SUBMIT)
assert.equal(resolveInlineApplicationPreviewTextAction('直接提交'), AI_APPLICATION_ACTION_SUBMIT)
assert.equal(resolveInlineApplicationPreviewTextAction('继续修改'), '')
})
test('workbench application gate resolves latest live or orphan preview message', () => {
const messages = [
{ id: 'user-1', role: 'user', content: '2月去上海出差' },
{ id: 'assistant-orphan', role: 'assistant', content: '这是申请核对表,下方表格点击对应行即可直接编辑。' },
{ id: 'assistant-other', role: 'assistant', content: '普通回复' }
]
assert.equal(isOrphanInlineApplicationPreviewMessage(messages[1]), true)
assert.equal(resolveLatestApplicationPreviewMessage(messages), null)
assert.equal(resolveLatestOrphanApplicationPreviewMessage(messages)?.id, 'assistant-orphan')
messages.push({
id: 'assistant-preview',
role: 'assistant',
content: '申请核对表',
applicationPreview: { fields: { location: '上海' } }
})
assert.equal(resolveLatestApplicationPreviewMessage(messages)?.id, 'assistant-preview')
})

View File

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
function readSource(path) {
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
}
const aiModeComponent = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html')
const composerComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue')
const fileStripComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
const aiModeRuntime = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js')
function countOccurrences(source, pattern) {
return source.match(pattern)?.length || 0
}
test('personal workbench AI mode reuses shared composer and file strip components', () => {
assert.match(aiModeComponent, /import \{ proxyRefs \} from 'vue'/)
assert.match(aiModeComponent, /import WorkbenchAiComposer from '\.\/workbench-ai\/WorkbenchAiComposer\.vue'/)
assert.match(aiModeComponent, /import WorkbenchAiFileStrip from '\.\/workbench-ai\/WorkbenchAiFileStrip\.vue'/)
assert.match(aiModeComponent, /const aiModeRuntime = usePersonalWorkbenchAiMode\(props, emit\)/)
assert.match(aiModeComponent, /const workbenchAiRuntime = proxyRefs\(aiModeRuntime\)/)
assert.equal(countOccurrences(aiModeTemplate, /<WorkbenchAiComposer\b/g), 2)
assert.equal(countOccurrences(aiModeTemplate, /<WorkbenchAiFileStrip\b/g), 2)
assert.doesNotMatch(aiModeTemplate, /<form class="workbench-ai-composer"/)
assert.doesNotMatch(aiModeTemplate, /<article v-for="file in selectedFileCards"/)
})
test('shared workbench composer keeps the parent input focus ref writable', () => {
assert.match(composerComponent, /:ref="runtime\.setAssistantInputRef"/)
assert.match(aiModeRuntime, /function setAssistantInputRef\(element\)/)
assert.match(aiModeRuntime, /assistantInputRef\.value = element/)
assert.match(aiModeRuntime, /setAssistantInputRef,/)
})
test('shared workbench file strip preserves OCR status badges', () => {
assert.match(fileStripComponent, /file\.ocrState\?\.label/)
assert.match(fileStripComponent, /class="workbench-ai-file-card__ocr"/)
assert.match(fileStripComponent, /file\.ocrState\.status === 'recognizing'/)
assert.match(fileStripComponent, /mdi mdi-text-recognition/)
assert.match(fileStripComponent, /:title="file\.ocrState\.title \|\| file\.ocrState\.label"/)
})

View File

@@ -156,13 +156,15 @@ const appShell = readSource('../src/views/AppShellRouteView.vue')
const workbenchView = readSource('../src/views/PersonalWorkbenchView.vue') const workbenchView = readSource('../src/views/PersonalWorkbenchView.vue')
const aiMode = readSource('../src/components/business/PersonalWorkbenchAiMode.vue') const aiMode = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html') const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html')
const aiModeComposer = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue')
const aiModeFileStrip = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url)) const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url))
const aiModeRuntime = readdirSync(aiModeRuntimeDir) const aiModeRuntime = readdirSync(aiModeRuntimeDir)
.filter((file) => file.endsWith('.js')) .filter((file) => file.endsWith('.js'))
.sort() .sort()
.map((file) => readFileSync(new URL(`../src/composables/workbenchAiMode/${file}`, import.meta.url), 'utf8')) .map((file) => readFileSync(new URL(`../src/composables/workbenchAiMode/${file}`, import.meta.url), 'utf8'))
.join('\n') .join('\n')
const aiModeSurface = `${aiMode}\n${aiModeTemplate}\n${aiModeRuntime}` const aiModeSurface = `${aiMode}\n${aiModeTemplate}\n${aiModeComposer}\n${aiModeFileStrip}\n${aiModeRuntime}`
const aiModeStyles = readSource('../src/assets/styles/components/personal-workbench-ai-mode.css') const aiModeStyles = readSource('../src/assets/styles/components/personal-workbench-ai-mode.css')
const workbenchViewStyles = readSource('../src/assets/styles/views/personal-workbench-view.css') const workbenchViewStyles = readSource('../src/assets/styles/views/personal-workbench-view.css')
const appStyles = readSource('../src/assets/styles/app.css') const appStyles = readSource('../src/assets/styles/app.css')
@@ -228,7 +230,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /费用测算中,请稍等/) assert.match(aiModeSurface, /费用测算中,请稍等/)
assert.match(aiModeSurface, /rows="3"/) assert.match(aiModeSurface, /rows="3"/)
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/) assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
assert.match(aiModeSurface, /<article v-for="file in selectedFileCards"[\s\S]*class="workbench-ai-file-card"/) assert.match(aiModeSurface, /<article v-for="file in runtime\.selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
assert.match(aiModeSurface, /class="workbench-ai-file-card__ocr"/) assert.match(aiModeSurface, /class="workbench-ai-file-card__ocr"/)
assert.match(aiModeSurface, /file\.ocrState\?\.label/) assert.match(aiModeSurface, /file\.ocrState\?\.label/)
assert.match(aiModeSurface, /mdi mdi-text-recognition/) assert.match(aiModeSurface, /mdi mdi-text-recognition/)
@@ -301,8 +303,9 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /解释制度/) assert.match(aiModeSurface, /解释制度/)
assert.match(aiModeSurface, /催办审批/) assert.match(aiModeSurface, /催办审批/)
assert.match(aiModeSurface, /<Transition name="workbench-ai-panel-swap" mode="out-in" appear>/) assert.match(aiModeSurface, /<Transition name="workbench-ai-panel-swap" mode="out-in" appear>/)
assert.match(aiModeSurface, /@submit\.prevent="submitAiModePrompt"/) assert.match(aiModeSurface, /@submit\.prevent="runtime\.submitAiModePrompt"/)
assert.equal((aiModeSurface.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 2) assert.equal((aiModeSurface.match(/<WorkbenchAiComposer\b/g) || []).length, 2)
assert.equal((aiModeSurface.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 1)
assert.match(aiModeSurface, /class="workbench-ai-conversation"/) assert.match(aiModeSurface, /class="workbench-ai-conversation"/)
assert.match(aiModeSurface, /class="workbench-ai-thread"[\s\S]*@scroll\.passive="handleInlineConversationScroll"/) assert.match(aiModeSurface, /class="workbench-ai-thread"[\s\S]*@scroll\.passive="handleInlineConversationScroll"/)
assert.match(aiModeSurface, /workbench-ai-answer-card/) assert.match(aiModeSurface, /workbench-ai-answer-card/)
@@ -393,6 +396,14 @@ test('AI mode screen follows the approved reference structure', () => {
aiModeSurface, aiModeSurface,
/suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\([\s\S]*targetMessage\.applicationPreview/ /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\([\s\S]*targetMessage\.applicationPreview/
) )
assert.match(aiModeSurface, /function isOrphanInlineApplicationPreviewMessage\(message = \{\}\)/)
assert.match(aiModeSurface, /function resolveLatestOrphanApplicationPreviewMessage\(messages = \[\]\)/)
assert.match(aiModeSurface, /function resolveLatestOrphanInlineApplicationPreviewMessage\(\)/)
assert.match(aiModeSurface, /当前申请核对表状态不完整,我先重新生成可编辑表格。/)
assert.match(
aiModeSurface,
/const previewSourceText = resolveLatestInlineUserPrompt\(\)[\s\S]*pushInlineApplicationActionUserMessage\(prompt\)[\s\S]*startAiApplicationPreview\('travel', '差旅费', previewSourceText/
)
assert.doesNotMatch(aiModeSurface, /\*\*申请单号:\*\*/) assert.doesNotMatch(aiModeSurface, /\*\*申请单号:\*\*/)
assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/) assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/) assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/)

View File

@@ -314,7 +314,10 @@ test('linked application selection can create reimbursement draft from associati
}) })
test('personal workbench routes reimbursement creation intent to association gate before steward', () => { test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
assert.match(personalWorkbenchAiMode, /function isReimbursementCreationIntent\(prompt = ''\)/) assert.match(
personalWorkbenchAiMode,
/import \{ isReimbursementCreationIntent \} from '\.\/workbenchAiApplicationGateModel\.js'/
)
const startConversationIndex = personalWorkbenchAiMode.indexOf('function startInlineConversation') const startConversationIndex = personalWorkbenchAiMode.indexOf('function startInlineConversation')
const gateIndex = personalWorkbenchAiMode.indexOf('expenseFlow.startAiReimbursementAssociationGate(cleanPrompt', startConversationIndex) const gateIndex = personalWorkbenchAiMode.indexOf('expenseFlow.startAiReimbursementAssociationGate(cleanPrompt', startConversationIndex)
const stewardIndex = personalWorkbenchAiMode.indexOf('stewardFlow.requestInlineAssistantReply(cleanPrompt', startConversationIndex) const stewardIndex = personalWorkbenchAiMode.indexOf('stewardFlow.requestInlineAssistantReply(cleanPrompt', startConversationIndex)