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
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]:
counts: dict[str, int] = {}
for value in values:
@@ -51,21 +63,35 @@ def collect_attachment_cities(
) -> list[str]:
cities: list[str] = []
for context in contexts:
document_info = context.get("document_info") or {}
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):
for city in collect_context_cities(context, policy):
if city not in cities:
cities.append(city)
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]:
normalized = str(text or "").strip()
if not normalized:
@@ -77,6 +103,11 @@ def extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> li
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(
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):
return value
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.expense_claim_platform_context_tools import (
collect_attachment_cities,
collect_context_item_ids,
collect_invoice_keys_from_contexts,
collect_invoice_keys_from_document_info,
count_values,
@@ -768,15 +769,7 @@ class ExpenseClaimPlatformRiskMixin:
@staticmethod
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
return collect_context_item_ids(contexts)
@staticmethod
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 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
@@ -13,16 +20,16 @@ def resolve_multi_city_related_item_ids(
) -> tuple[list[str], list[str]]:
segments = _collect_travel_route_segments(contexts, policy)
if not segments:
return _context_item_ids(contexts), []
return collect_context_item_ids(contexts), []
first_origin = str(segments[0].get("origin") or "").strip()
first_destination = str(segments[0].get("destination") or "").strip()
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]
)
destination_cities = _unique_text_values(
destination_cities = unique_text_values(
[str(segment.get("destination") or "") for segment in segments]
)
extra_cities = [
@@ -31,7 +38,7 @@ def resolve_multi_city_related_item_ids(
if city and city not in set(baseline_cities)
]
if not extra_cities:
route_cities = _unique_text_values(
route_cities = unique_text_values(
[
city
for segment in segments
@@ -86,7 +93,7 @@ def _resolve_expected_travel_city(
contexts: list[dict[str, Any]],
policy: RuntimeTravelPolicy,
) -> 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:
return claim_city
@@ -96,7 +103,7 @@ def _resolve_expected_travel_city(
scene_code = str(document_info.get("scene_code") or "").strip().lower()
if document_type != "hotel_invoice" and scene_code != "hotel":
continue
for city in _extract_context_cities(context, policy):
for city in collect_context_cities(context, policy, include_item_reason=True):
return city
return ""
@@ -107,7 +114,7 @@ def _extract_route_segment(
) -> tuple[str, str] | None:
document_info = context.get("document_info") or {}
item = context.get("item")
route_value = _resolve_document_field_value(
route_value = resolve_first_document_field_value(
document_info,
keys={"route", "route_cities", "routecities", "travel_route", "trip_route"},
labels={"路线", "行程", "起讫", "起终", "始发", "到达"},
@@ -130,8 +137,8 @@ def _extract_route_segment(
segment.strip()
for segment in normalized.split(separator, 1)
]
origin = _extract_first_known_city(origin_text, policy)
destination = _extract_first_known_city(destination_text, policy)
origin = extract_first_known_city_from_text(origin_text, policy)
destination = extract_first_known_city_from_text(destination_text, policy)
if origin and destination and origin != destination:
return origin, destination
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]:
item_ids: list[str] = []
seen: set[str] = set()
for segment in list(segments or []):
item = segment.get("item") if isinstance(segment, 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 _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
return collect_context_item_ids(
[
{"item": segment.get("item")}
for segment in list(segments or [])
if isinstance(segment, dict)
]
)

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 typing import Any
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
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.financial_record import ExpenseClaim, ExpenseClaimItem
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_claims import ExpenseClaimService
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:
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()
build_session = build_in_memory_session
def _build_rule_payload(

View File

@@ -148,10 +148,10 @@
.ai-quick-btn {
min-height: 48px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
grid-template-columns: 32px minmax(0, 1fr);
align-items: center;
gap: 10px;
padding: 0 4px;
gap: 12px;
padding: 7px 10px;
color: #111827;
font-size: 14px;
font-weight: 780;
@@ -160,13 +160,21 @@
box-shadow: none;
}
.ai-quick-btn i {
width: 28px;
display: inline-flex;
justify-content: center;
.ai-quick-icon {
width: 32px;
height: 32px;
display: inline-grid;
place-items: center;
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 {
@@ -175,17 +183,10 @@
box-shadow: none;
}
.ai-quick-btn.active {
color: #173d78;
background: rgba(45, 114, 217, 0.055);
border-color: rgba(45, 114, 217, 0.12);
}
.ai-quick-btn.primary i {
.ai-quick-btn.primary .ai-quick-icon {
color: var(--ai-rail-amber);
}
.ai-nav-btn:hover,
.ai-recent-item:hover,
.ai-user-action:hover {
background: rgba(255, 255, 255, 0.78);
@@ -194,7 +195,10 @@
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;
background: rgba(15, 23, 42, 0.035);
border-color: transparent;
@@ -202,11 +206,14 @@
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);
}
.ai-quick-btn.primary:hover i {
.ai-quick-btn.primary:hover .ai-quick-icon {
color: var(--ai-rail-amber);
}
@@ -215,10 +222,10 @@
min-height: 48px;
height: 48px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 28px;
grid-template-columns: 32px minmax(0, 1fr) 28px;
align-items: center;
gap: 4px;
padding: 0 6px 0 4px;
gap: 8px;
padding: 0 6px 0 10px;
border: 1px solid rgba(45, 114, 217, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
@@ -308,41 +315,6 @@
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 {
width: 32px;
height: 32px;
@@ -357,17 +329,9 @@
box-shadow 180ms var(--ease);
}
.ai-nav-btn.active .ai-nav-icon {
background:
linear-gradient(145deg, rgba(45, 114, 217, 0.12), rgba(255, 255, 255, 0.72)),
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-icon .ai-sidebar-tabler-icon {
width: 19px;
height: 19px;
}
.ai-nav-copy {
@@ -386,10 +350,6 @@
white-space: nowrap;
}
.ai-nav-btn.active .ai-nav-copy strong {
font-weight: 820;
}
.ai-recent-desc {
min-width: 0;
overflow: hidden;
@@ -621,8 +581,7 @@
box-shadow: none;
}
.ai-rail.rail-collapsed .ai-nav-list::before,
.ai-rail.rail-collapsed .ai-nav-btn::before {
.ai-rail.rail-collapsed .ai-nav-list::before {
display: none;
}
@@ -636,12 +595,6 @@
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-conversation-search,
.ai-rail.rail-collapsed .ai-brand-copy,
@@ -653,7 +606,7 @@
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-nav-icon,
.ai-rail.rail-collapsed .ai-user-avatar {

View File

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

View File

@@ -28,183 +28,11 @@
<p>您可以直接向我提问,或选择下方推荐主题,快速完成费用相关事务</p>
</div>
<form class="workbench-ai-composer" @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')"
<WorkbenchAiComposer
:runtime="workbenchAiRuntime"
placeholder="今天我能帮您做点什么?"
/>
</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>
<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>
<WorkbenchAiFileStrip :runtime="workbenchAiRuntime" />
<div class="workbench-ai-quick-start-section">
<h3 class="workbench-ai-quick-start-title">快速开始</h3>
@@ -565,183 +393,12 @@
</div>
<div class="workbench-ai-conversation-bottom">
<div v-if="selectedFileCards.length" class="workbench-ai-file-strip inline" 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>
<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')"
<WorkbenchAiFileStrip inline :runtime="workbenchAiRuntime" />
<WorkbenchAiComposer
inline
:runtime="workbenchAiRuntime"
placeholder="继续和小财管家对话..."
/>
</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>
</div>

View File

@@ -1,7 +1,10 @@
<template src="./PersonalWorkbenchAiMode.template.html"></template>
<script setup>
import { proxyRefs } from 'vue'
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'
const props = defineProps({
@@ -9,9 +12,12 @@ const props = defineProps({
})
const emit = defineEmits(['conversation-change', 'conversation-history-change', 'open-document', 'request-updated'])
const aiModeRuntime = usePersonalWorkbenchAiMode(props, emit)
const workbenchAiRuntime = proxyRefs(aiModeRuntime)
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
} = usePersonalWorkbenchAiMode(props, emit)
} = aiModeRuntime
</script>
<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">
<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"

View File

@@ -43,7 +43,23 @@
:class="{ primary: action.primary }"
@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>
</button>
</template>
@@ -60,10 +76,26 @@
class="ai-nav-btn"
:class="{ active: activeView === item.id }"
:aria-current="activeView === item.id ? 'page' : undefined"
@mouseenter="emit('prefetch-view', item.id)"
@focus="emit('prefetch-view', item.id)"
@click="emit('navigate', item.id)"
>
<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 class="ai-nav-copy">
<strong>{{ item.displayLabel }}</strong>
@@ -155,7 +187,7 @@ const props = defineProps({
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 conversationSearchQuery = ref('')
const conversationSearchInputRef = ref(null)
@@ -164,16 +196,78 @@ const editingConversationTitle = ref('')
const editingTitleInputRef = ref(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 = [
{
label: '新建对话',
icon: 'mdi mdi-plus',
iconPaths: tablerIconPaths.plus,
event: 'new-chat',
primary: true
},
{
label: '查询对话',
icon: 'mdi mdi-magnify',
iconPaths: tablerIconPaths.search,
event: 'search'
}
]
@@ -181,15 +275,15 @@ const quickActions = [
const displayBrandName = computed(() => String(props.brandName || '易财费控').trim() || '易财费控')
const sidebarMeta = {
overview: { label: '分析看板', icon: 'mdi mdi-chart-line-variant' },
documents: { label: '单据中心', icon: 'mdi mdi-file-document-outline' },
receiptFolder: { label: '票据夹', icon: 'mdi mdi-receipt-text-outline' },
budget: { label: '预算管理', icon: 'mdi mdi-chart-donut' },
policies: { label: '财务政策', icon: 'mdi mdi-book-open-page-variant-outline' },
audit: { label: '规则管理', icon: 'mdi mdi-shield-check-outline' },
digitalEmployees: { label: '数字员工', icon: 'mdi mdi-robot-outline' },
employees: { label: '员工管理', icon: 'mdi mdi-account-group-outline' },
settings: { label: '系统设置', icon: 'mdi mdi-tune-vertical' }
overview: { label: '分析看板', iconPaths: tablerIconPaths.chartLine },
documents: { label: '单据中心', iconPaths: tablerIconPaths.fileText },
receiptFolder: { label: '票据夹', iconPaths: tablerIconPaths.folder },
budget: { label: '预算管理', iconPaths: tablerIconPaths.chartDonut },
policies: { label: '财务政策', iconPaths: tablerIconPaths.book2 },
audit: { label: '规则管理', iconPaths: tablerIconPaths.shieldCheck },
digitalEmployees: { label: '数字员工', iconPaths: tablerIconPaths.robot },
employees: { label: '员工管理', iconPaths: tablerIconPaths.users },
settings: { label: '系统设置', iconPaths: tablerIconPaths.sliders }
}
const aiBusinessViewIds = computed(() => new Set(resolveAiSidebarBusinessViewIds(props.currentUser)))
@@ -200,7 +294,7 @@ const businessNavItems = computed(() =>
.map((item) => ({
...item,
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="{ active: activeView === item.id }"
type="button"
@mouseenter="emit('prefetch-view', item.id)"
@focus="emit('prefetch-view', item.id)"
@click="emit('navigate', item.id)"
>
<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 = {
overview: { label: '分析看板' },

View File

@@ -153,10 +153,10 @@
<button
class="notification-clear-btn"
type="button"
:disabled="notificationItems.length === 0"
@click="clearAllNotifications"
:disabled="notificationBulkActionDisabled"
@click="handleNotificationBulkAction"
>
清空通知
{{ notificationBulkActionLabel }}
</button>
<button
class="notification-close-btn"
@@ -201,24 +201,16 @@
:class="{ unread: item.unread }"
@click="openNotification(item)"
>
<span class="notification-type-icon" :class="item.tone">
<i :class="resolveNotificationIcon(item)"></i>
<span class="notification-avatar" :class="item.tone" aria-hidden="true">
<span class="notification-avatar-label">{{ item.avatarLabel }}</span>
<span v-if="item.badge" class="notification-avatar-badge">{{ item.badge }}</span>
</span>
<span class="notification-row-main">
<span class="notification-row-head">
<span class="notification-title-line">
<span class="notification-row-content">
<span class="notification-row-top">
<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>
</span>
<small class="notification-preview">{{ item.description }}</small>
</span>
</button>
</div>
@@ -512,7 +504,8 @@ const {
isNotificationHidden,
isNotificationRead,
loadNotificationStates,
markNotificationStateRead
markNotificationStateRead,
markNotificationStatesRead
} = useTopBarNotificationStates()
const notificationTab = ref('unread')
@@ -565,6 +558,36 @@ function resolveDocumentNotificationDescription(row) {
].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) {
return normalizeNotificationId(`workbench:${item?.id || [item?.title, item?.description, item?.time, index].join('|')}`)
}
@@ -583,12 +606,12 @@ const documentNotificationItems = computed(() =>
kind: 'document',
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
description: resolveDocumentNotificationDescription(row),
avatarLabel: resolveDocumentNotificationAvatarLabel(row),
time: row.updatedAt || row.createdAt,
timeLabel: formatNotificationTimeLabel(row.updatedAt || row.createdAt),
category: row.sourceLabel || '单据中心',
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
unread,
icon: row.source === 'approval' ? 'mdi mdi-clipboard-text-clock-outline' : 'mdi mdi-file-document-outline',
badge: unread ? '新' : '',
target: {
type: 'document',
@@ -615,10 +638,10 @@ const workbenchNotificationItems = computed(() => (
id,
kind: 'workbench',
category: item.category || '个人工作台',
avatarLabel: resolveNotificationAvatarLabel(item),
time: notificationTime,
timeLabel: formatNotificationTimeLabel(item.time || item.updatedAt || item.due),
unread: Boolean(item.unread) && !readNotificationIds.value.has(id),
icon: item.icon || resolveNotificationIcon(item)
unread: Boolean(item.unread) && !readNotificationIds.value.has(id)
}
}).filter(Boolean)
: []
@@ -631,6 +654,14 @@ const readNotifications = computed(() => notificationItems.value.filter((item) =
const activeNotifications = computed(() => (
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 count = unreadNotifications.value.length
return count > 0 ? Math.min(count, 99) : 0
@@ -659,26 +690,6 @@ function scheduleDocumentInboxInitialRefresh() {
}, 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) {
if (!item?.id || !item.unread) {
return
@@ -691,8 +702,8 @@ function markNotificationRead(item) {
void markNotificationStateRead(item)
}
function clearAllNotifications() {
const currentItems = notificationItems.value
function markUnreadNotificationsRead() {
const currentItems = unreadNotifications.value
if (!currentItems.length) {
return
}
@@ -705,8 +716,29 @@ function clearAllNotifications() {
markDocumentInboxRowsRead(documentRows)
}
void markNotificationStatesRead(currentItems)
}
function deleteReadNotifications() {
const currentItems = readNotifications.value
if (!currentItems.length) {
return
}
void hideNotificationStates(currentItems)
notificationTab.value = 'unread'
}
function handleNotificationBulkAction() {
if (notificationBulkActionDisabled.value) {
return
}
if (notificationTab.value === 'unread') {
markUnreadNotificationsRead()
return
}
deleteReadNotifications()
}
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 { 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 = {
travel: '差旅费',
@@ -49,8 +55,6 @@ const DOCUMENT_BACKED_EXPENSE_TYPES = new Set([
'hotel_ticket',
'ride_ticket'
])
const DOCUMENT_TYPE_APPLICATION = 'application'
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态'
const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档'
@@ -179,14 +183,14 @@ function resolveDocumentTypeMeta(claim, typeCode) {
const normalizedType = String(typeCode || '').trim()
const isApplication =
explicitType === DOCUMENT_TYPE_APPLICATION
|| explicitType === 'expense_application'
|| explicitType === DOCUMENT_TYPE_EXPENSE_APPLICATION
|| isApplicationDocumentNo(claimNo)
|| normalizedType === 'application'
|| normalizedType.endsWith('_application')
return isApplication
? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: '申请单' }
: { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: '报销单' }
? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: resolveDocumentTypeLabel(DOCUMENT_TYPE_APPLICATION) }
: { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: resolveDocumentTypeLabel(DOCUMENT_TYPE_REIMBURSEMENT) }
}
function normalizeExpenseType(typeCode) {

View File

@@ -139,7 +139,14 @@ export function useTopBarNotificationStates() {
}
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) {
@@ -158,6 +165,7 @@ export function useTopBarNotificationStates() {
isNotificationHidden,
isNotificationRead,
loadNotificationStates,
markNotificationStateRead
markNotificationStateRead,
markNotificationStatesRead
}
}

View File

@@ -24,8 +24,7 @@ import {
} from './workbenchAiComposerModel.js'
import {
createWorkbenchAiMessageRuntime,
formatMessageTime,
normalizeInlineAttachmentOcrDetails
formatMessageTime
} from './workbenchAiMessageModel.js'
import { useWorkbenchAiActionRouter } from './useWorkbenchAiActionRouter.js'
import { useWorkbenchAiAttachmentAssociationFlow } from './useWorkbenchAiAttachmentAssociationFlow.js'
@@ -34,8 +33,10 @@ import { useWorkbenchAiComposerFiles } from './useWorkbenchAiComposerFiles.js'
import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js'
import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js'
import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js'
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
import { isReimbursementCreationIntent } from './workbenchAiApplicationGateModel.js'
const AI_SEARCH_CONVERSATION_ID = 'ai-search'
const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
@@ -174,6 +175,23 @@ export function usePersonalWorkbenchAiMode(props, emit) {
...card,
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({
activateInlineConversation,
@@ -324,6 +342,10 @@ export function usePersonalWorkbenchAiMode(props, emit) {
})
}
function setAssistantInputRef(element) {
assistantInputRef.value = element
}
function isInlineConversationNearBottom() {
const el = conversationScrollRef.value
if (!el) {
@@ -499,78 +521,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
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 = []) {
const prompt = buildWorkbenchPromptText(rawPrompt)
if (prompt) {
@@ -579,20 +529,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
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) {
const target = event?.target
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,
selectedFileCards,
sending,
setAssistantInputRef,
setWorkbenchDateMode,
submitAiModePrompt,
toggleInlineAttachmentOcrDetails,

View File

@@ -24,10 +24,15 @@ import {
buildInlineApplicationSubmitPrecheckPayload,
buildInlineApplicationSubmitThinkingEvents,
completeInlineThinkingEvents,
extractInlineApplicationDraftPayload,
resolveInlineApplicationPreviewActionFromText
extractInlineApplicationDraftPayload
} from './workbenchAiApplicationPreviewModel.js'
import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js'
import {
isOrphanInlineApplicationPreviewMessage,
resolveInlineApplicationPreviewTextAction,
resolveLatestApplicationPreviewMessage,
resolveLatestOrphanApplicationPreviewMessage
} from './workbenchAiApplicationGateModel.js'
function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) {
const fields = normalizeApplicationPreview(applicationPreview).fields || {}
@@ -197,23 +202,12 @@ export function useWorkbenchAiApplicationPreviewFlow({
].join('\n\n')
}
function resolveLatestApplicationPreviewMessage() {
return [...conversationMessages.value]
.reverse()
.find((message) => message.role === 'assistant' && message.applicationPreview)
function resolveLatestInlineApplicationPreviewMessage() {
return resolveLatestApplicationPreviewMessage(conversationMessages.value)
}
function isOrphanInlineApplicationPreviewMessage(message = {}) {
if (message?.applicationPreview || message?.role !== 'assistant') {
return false
}
return /下方表格|申请核对表|点击对应行即可直接编辑/.test(String(message.content || message.text || ''))
}
function resolveLatestOrphanApplicationPreviewMessage() {
return [...conversationMessages.value]
.reverse()
.find((message) => isOrphanInlineApplicationPreviewMessage(message))
function resolveLatestOrphanInlineApplicationPreviewMessage() {
return resolveLatestOrphanApplicationPreviewMessage(conversationMessages.value)
}
function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) {
@@ -310,7 +304,7 @@ export function useWorkbenchAiApplicationPreviewFlow({
}
async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) {
const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestApplicationPreviewMessage()
const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestInlineApplicationPreviewMessage()
if (!targetMessage?.applicationPreview) {
toast('当前没有可提交的申请表。')
return false
@@ -446,12 +440,12 @@ export function useWorkbenchAiApplicationPreviewFlow({
toast('请等待费用测算完成后再继续操作。')
return true
}
const actionType = resolveInlineApplicationPreviewActionFromText(prompt)
const actionType = resolveInlineApplicationPreviewTextAction(prompt)
if (!actionType) {
return false
}
if (!resolveLatestApplicationPreviewMessage()) {
const orphanPreviewMessage = resolveLatestOrphanApplicationPreviewMessage()
if (!resolveLatestInlineApplicationPreviewMessage()) {
const orphanPreviewMessage = resolveLatestOrphanInlineApplicationPreviewMessage()
if (!orphanPreviewMessage) {
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_SUBMIT
} from '../../services/aiApplicationPreviewActions.js'
const INLINE_APPLICATION_STATUS_LABELS = {
draft: '草稿',
submitted: '审批中',
pending: '待处理',
approved: '已审批',
completed: '已完成',
archived: '已归档',
returned: '已退回',
rejected: '已驳回',
pending_payment: '待付款',
paid: '已付款'
}
import { INLINE_APPLICATION_STATUS_LABELS } from '../../constants/documentProtocol.js'
import { resolveInlineApplicationPreviewTextAction } from './workbenchAiApplicationGateModel.js'
function normalizeInlineApplicationResultTableCell(value, fallback = '-') {
const text = String(value || '')
@@ -201,17 +190,7 @@ export function buildInlineApplicationDetailAction(draftPayload = {}) {
}
export function resolveInlineApplicationPreviewActionFromText(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 ''
return resolveInlineApplicationPreviewTextAction(text)
}
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 { parseTableRow, renderTable } from './aiConversationTableRenderer.js'
const ALLOWED_COLON_HEADING_TITLES = new Set([
'基础信息识别结果',
'报销测算参考',
'补充信息'
])
const BUSINESS_FIELD_LABELS = new Set([
'时间',
'地点',
'事由',
'金额',
'费用类型',
'报销类型',
'商户',
'商户/开票方',
'客户',
'客户/项目对象',
'附件',
'附件/凭证',
'出行方式'
])
import {
DOCUMENT_DETAIL_HREF_PREFIX,
extractTrustedHtmlBlocks,
normalizeConversationText,
restoreTrustedHtmlBlocks
} from './conversationTrustedHtml.js'
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-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 = '') {
return String(value)
@@ -146,150 +111,6 @@ function renderInlineHtml(value = '') {
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 = '') {
return /^\s*(```|~~~)/.test(String(line || ''))
}
@@ -501,7 +322,7 @@ export function renderAiConversationHtml(content = '') {
}
const extracted = extractTrustedHtmlBlocks(content)
const normalized = normalizeConversationText(extracted.content)
const normalized = normalizeConversationText(extracted.content, { trim: true })
if (!normalized) {
return ''
}
@@ -628,6 +449,7 @@ export function renderAiConversationHtml(content = '') {
return restoreTrustedHtmlBlocks(
`<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 { isArchivedDocumentRow } from './documentCenterRows.js'
import { sortDocumentRowsByLatestTime } from './documentCenterSort.js'
import {
DOCUMENT_TYPE_ALL,
DOCUMENT_TYPE_APPLICATION,
DOCUMENT_TYPE_REIMBURSEMENT,
resolveDocumentTypeLabel
} from '../constants/documentProtocol.js'
import {
extractDateText,
formatDocumentListTime,
@@ -10,9 +16,11 @@ import {
} from './documentCenterTime.js'
import { normalizeRequestForUi } from './requestViewModel.js'
export const DOCUMENT_TYPE_ALL = 'all'
export const DOCUMENT_TYPE_APPLICATION = 'application'
export const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
export {
DOCUMENT_TYPE_ALL,
DOCUMENT_TYPE_APPLICATION,
DOCUMENT_TYPE_REIMBURSEMENT
}
export const SCENE_ALL = 'all'
export const DOCUMENT_SCOPE_ALL = '全部'
export const DOCUMENT_SCOPE_APPLICATION = '申请单'
@@ -129,7 +137,7 @@ export function buildDocumentRow(request, options = {}) {
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
const documentTypeLabel =
normalized.documentTypeLabel
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
|| resolveDocumentTypeLabel(documentTypeCode)
const initiatorName = String(
normalized.person
|| normalized.employeeName

View File

@@ -1,4 +1,10 @@
import MarkdownIt from 'markdown-it'
import {
DOCUMENT_DETAIL_HREF_PREFIX,
extractTrustedHtmlBlocks,
normalizeConversationText,
restoreTrustedHtmlBlocks
} from './conversationTrustedHtml.js'
const markdown = new MarkdownIt({
html: false,
@@ -25,25 +31,6 @@ const ACTION_LINK_CLASS_BY_HREF = {
'#review-quick-edit': 'markdown-action-link-edit',
'#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) {
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>`
)
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 = '') {
const { content, trustedHtmlBlocks } = extractTrustedHtmlBlocks(text)
const normalized = normalizeColonHeadings(content).trim()
const normalized = normalizeConversationText(content).trim()
return normalized ? restoreTrustedHtmlBlocks(markdown.render(normalized), trustedHtmlBlocks) : ''
}

View File

@@ -34,6 +34,7 @@
@new-chat="openAiSidebarNewChat"
@open-recent="openAiSidebarRecent"
@rename-conversation="handleAiConversationRename"
@prefetch-view="prefetchAppView"
@logout="handleLogout"
/>
<SidebarRail
@@ -49,6 +50,7 @@
@logout="handleLogout"
@toggle-collapse="toggleSidebarCollapsed"
@navigate="handleNavigateWithMobileClose"
@prefetch-view="prefetchAppView"
/>
</Transition>
</div>
@@ -243,24 +245,18 @@ import AiSidebarRail from '../components/layout/AiSidebarRail.vue'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.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 { useSystemState } from '../composables/useSystemState.js'
import { filterNavItemsByAccess, isPlatformAdminUser } from '../utils/accessControl.js'
import { isBusinessDocumentReference } from '../utils/aiDocumentDetailReference.js'
import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js'
import {
defineAsyncModalView,
defineAsyncRouteView,
preloadAppView,
scheduleRelatedAppViewPreload
} from './scripts/appShellAsyncViews.js'
const employeeSummary = ref(null)
const knowledgeSummary = ref(null)
@@ -300,6 +296,18 @@ const aiSidebarCommandSeq = ref(0)
const aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
const aiActiveConversationId = 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() {
sidebarCollapsed.value = !sidebarCollapsed.value
@@ -310,6 +318,10 @@ function handleNavigateWithMobileClose(viewId) {
mobileSidebarOpen.value = false
}
function prefetchAppView(viewId) {
void preloadAppView(viewId).catch(() => {})
}
function toggleWorkbenchMode() {
const nextMode = workbenchMode.value === 'ai' ? 'traditional' : 'ai'
if (nextMode === 'ai') {
@@ -580,4 +592,12 @@ watch(
},
{ immediate: true }
)
watch(
() => activeView.value,
(view) => {
scheduleRelatedAppViewPreload(view)
},
{ immediate: true }
)
</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 {
DATE_INPUT_FORMAT,
buildReviewAttachmentStatus,
cloneReviewEditFields,
createEmptyInlineReviewState,
formatAmountDisplay,
formatReviewSceneDisplayValue,
normalizeReviewRiskLevel,
shouldShowReviewFactCard,
buildBusinessTimeContextFromReviewValues as buildBusinessTimeContextFromReviewValuesModel,
buildReviewFormContextFromPayload as buildReviewFormContextFromPayloadModel,
isTravelReviewPayload as isTravelReviewPayloadModel,
resolveReviewTravelTransportType as resolveReviewTravelTransportTypeModel
} from './travelReimbursementReviewModel.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()
}
export {
buildBusinessTimeContextFromReviewValues,
buildReviewCorrectionMessage,
buildReviewFactCards,
buildReviewFormContextFromPayload,
buildReviewMainMessageText,
buildReviewRiskConversationText,
buildReviewRiskItems,
canExposeReviewPanelScope,
isTravelReviewPayload,
normalizeReviewPanelScope,
resolveReviewRiskBriefs,
resolveReviewTravelTransportType
} from './travelReimbursementReviewPanelModel.js'

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, /v-for="item in businessNavItems"/)
assert.match(aiSidebar, /ai-nav-copy/)
assert.match(aiSidebar, /item\.aiIcon/)
assert.match(aiSidebar, /item\.aiIconPaths/)
assert.match(aiSidebar, /aria-current/)
assert.doesNotMatch(aiSidebar, /displayHint/)
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, /aria-label="用户操作"/)
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, /打开系统设置/)
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-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(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(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, /border-color:\s*transparent;/)
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.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\.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-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;/)

View File

@@ -8,23 +8,65 @@ const shell = readFileSync(
'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', () => {
assert.doesNotMatch(shell, /defineAsyncRouteView/)
assert.doesNotMatch(shell, /defineAsyncComponent/)
assert.doesNotMatch(shell, /loadingComponent:/)
assert.doesNotMatch(shell, /\u9875\u9762\u5207\u6362\u4e2d/)
assert.doesNotMatch(shell, /floating:\s*true/)
assert.doesNotMatch(shell, /blocking:\s*true/)
assert.match(shell, /import AuditView from '\.\/AuditView\.vue'/)
assert.match(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/)
assert.match(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/)
assert.match(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/)
assert.match(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/)
test('app shell lazily loads heavy business views with an in-workarea loading state', () => {
assert.match(shell, /defineAsyncRouteView\('audit'\)/)
assert.match(shell, /defineAsyncRouteView\('documents'\)/)
assert.match(shell, /defineAsyncRouteView\('workbench'\)/)
assert.match(shell, /defineAsyncModalView\('travelCreate'\)/)
assert.match(shell, /function prefetchAppView\(viewId\)/)
assert.match(shell, /@prefetch-view="prefetchAppView"/)
assert.doesNotMatch(shell, /import AuditView from '\.\/AuditView\.vue'/)
assert.doesNotMatch(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/)
assert.doesNotMatch(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/)
assert.doesNotMatch(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/)
assert.doesNotMatch(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/)
})
test('top-level app routes are eagerly imported', () => {
assert.doesNotMatch(router, /\(\)\s*=>\s*import\(/)
test('app view preloading is triggered from both standard and AI sidebars', () => {
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 LoginRouteView from '\.\.\/views\/LoginRouteView\.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 { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const documentsCenterView = readFileSync(
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}`
import { readSourceFile, readSourceSurface } from './helpers/sourceSurface.mjs'
const documentsCenterStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/documents-center-view.css', import.meta.url)),
'utf8'
)
const documentListSharedStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/document-list-shared.css', import.meta.url)),
'utf8'
)
const tableLoadingState = readFileSync(
fileURLToPath(new URL('../src/components/shared/TableLoadingState.vue', import.meta.url)),
'utf8'
)
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'
)
const documentsCenterView = readSourceFile('views/DocumentsCenterView.vue')
const documentsCenterViewModel = readSourceFile('utils/documentCenterViewModel.js')
const documentsCenterLogic = readSourceSurface([
'views/DocumentsCenterView.vue',
'utils/documentCenterViewModel.js'
])
const documentsCenterStyles = readSourceFile('assets/styles/views/documents-center-view.css')
const documentListSharedStyles = readSourceFile('assets/styles/components/document-list-shared.css')
const tableLoadingState = readSourceFile('components/shared/TableLoadingState.vue')
const reimbursementService = readSourceFile('services/reimbursements.js')
const requestsComposable = readSourceFile('composables/useRequests.js')
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"/)
@@ -145,7 +126,7 @@ test('documents center preserves application document type from mapped requests'
)
assert.match(
documentsCenterLogic,
/documentTypeCode === DOCUMENT_TYPE_APPLICATION \? '申请单' : '报销单'/
/resolveDocumentTypeLabel\(documentTypeCode\)/
)
assert.doesNotMatch(
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, /stopDocumentInboxPolling\(\)/)
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, /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-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-list\s*\{[\s\S]*max-height:\s*min\(336px,\s*calc\(100vh - 226px\)\);[\s\S]*overflow-y:\s*auto;/)
assert.match(topbarStyles, /\.notification-copy small\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
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\(420px,\s*calc\(100vh - 166px\)\);[\s\S]*overflow-y:\s*auto;/)
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, /\.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/)
})
test('topbar notification popover uses inbox-style rows with formatted time labels', () => {
assert.match(topbar, /class="notification-row-main"/)
assert.match(topbar, /class="notification-row-head"/)
test('topbar notification bulk action label follows active tab semantics', () => {
assert.match(topbar, />\s*\{\{ notificationBulkActionLabel \}\}\s*<\/button>/)
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-context"/)
assert.match(topbar, /class="notification-row-foot"/)
assert.match(topbar, /class="notification-category-pill"/)
assert.match(topbar, /class="notification-preview"/)
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\(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.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*36px minmax\(0,\s*1fr\);/)
assert.match(topbarStyles, /\.notification-context\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
assert.match(topbarStyles, /\.notification-row-foot\s*\{[\s\S]*justify-content:\s*space-between;/)
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*52px minmax\(0,\s*1fr\);/)
assert.match(topbarStyles, /\.notification-avatar\s*\{[\s\S]*border-radius:\s*999px;/)
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-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', () => {
@@ -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, /applyRemoteStates/)
assert.match(topbarNotificationStates, /markNotificationStateRead/)
assert.match(topbarNotificationStates, /markNotificationStatesRead/)
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', () => {
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
assert.match(documentInbox, /fetchNotificationStates/)

View File

@@ -40,6 +40,10 @@ const reviewPanelModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementReviewPanelModel.js', import.meta.url)),
'utf8'
)
const createReviewModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementCreateReviewModel.js', import.meta.url)),
'utf8'
)
const messageItemTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
'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', () => {
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]*\}/)

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 aiMode = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
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 aiModeRuntime = readdirSync(aiModeRuntimeDir)
.filter((file) => file.endsWith('.js'))
.sort()
.map((file) => readFileSync(new URL(`../src/composables/workbenchAiMode/${file}`, import.meta.url), 'utf8'))
.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 workbenchViewStyles = readSource('../src/assets/styles/views/personal-workbench-view.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, /rows="3"/)
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, /file\.ocrState\?\.label/)
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, /<Transition name="workbench-ai-panel-swap" mode="out-in" appear>/)
assert.match(aiModeSurface, /@submit\.prevent="submitAiModePrompt"/)
assert.equal((aiModeSurface.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 2)
assert.match(aiModeSurface, /@submit\.prevent="runtime\.submitAiModePrompt"/)
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-thread"[\s\S]*@scroll\.passive="handleInlineConversationScroll"/)
assert.match(aiModeSurface, /workbench-ai-answer-card/)
@@ -393,6 +396,14 @@ test('AI mode screen follows the approved reference structure', () => {
aiModeSurface,
/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, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
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', () => {
assert.match(personalWorkbenchAiMode, /function isReimbursementCreationIntent\(prompt = ''\)/)
assert.match(
personalWorkbenchAiMode,
/import \{ isReimbursementCreationIntent \} from '\.\/workbenchAiApplicationGateModel\.js'/
)
const startConversationIndex = personalWorkbenchAiMode.indexOf('function startInlineConversation')
const gateIndex = personalWorkbenchAiMode.indexOf('expenseFlow.startAiReimbursementAssociationGate(cleanPrompt', startConversationIndex)
const stewardIndex = personalWorkbenchAiMode.indexOf('stewardFlow.requestInlineAssistantReply(cleanPrompt', startConversationIndex)