refactor: consolidate finance workflow modules
This commit is contained in:
410
docs/superpowers/plans/2026-06-23-code-deduplication-refactor.md
Normal file
410
docs/superpowers/plans/2026-06-23-code-deduplication-refactor.md
Normal 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>
|
||||
```
|
||||
141
server/src/app/services/application_fact_resolver.py
Normal file
141
server/src/app/services/application_fact_resolver.py
Normal 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}
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
578
server/src/app/services/steward_planner_extraction.py
Normal file
578
server/src/app/services/steward_planner_extraction.py
Normal 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()
|
||||
|
||||
438
server/src/app/services/steward_planner_fallback.py
Normal file
438
server/src/app/services/steward_planner_fallback.py
Normal 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
|
||||
|
||||
110
server/src/app/services/steward_planner_shared.py
Normal file
110
server/src/app/services/steward_planner_shared.py
Normal 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
|
||||
|
||||
|
||||
1
server/src/app/test_helpers/__init__.py
Normal file
1
server/src/app/test_helpers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared utilities for backend tests."""
|
||||
21
server/src/app/test_helpers/db.py
Normal file
21
server/src/app/test_helpers/db.py
Normal 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()()
|
||||
42
server/tests/test_application_fact_resolver.py
Normal file
42
server/tests/test_application_fact_resolver.py
Normal 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"
|
||||
29
server/tests/test_db_test_helpers.py
Normal file
29
server/tests/test_db_test_helpers.py
Normal 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
|
||||
52
server/tests/test_expense_claim_platform_context_tools.py
Normal file
52
server/tests/test_expense_claim_platform_context_tools.py
Normal 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) == "北京"
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
)
|
||||
|
||||
|
||||
@@ -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: '分析看板' },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
74
web/src/components/shared/AppModalLoadingState.vue
Normal file
74
web/src/components/shared/AppModalLoadingState.vue
Normal 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>
|
||||
104
web/src/components/shared/AppViewLoadingState.vue
Normal file
104
web/src/components/shared/AppViewLoadingState.vue
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = '费用申请') {
|
||||
|
||||
28
web/src/constants/documentProtocol.js
Normal file
28
web/src/constants/documentProtocol.js
Normal 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
|
||||
}
|
||||
@@ -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' }
|
||||
)
|
||||
}
|
||||
|
||||
191
web/src/utils/conversationTrustedHtml.js
Normal file
191
web/src/utils/conversationTrustedHtml.js
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) : ''
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
86
web/src/views/scripts/appShellAsyncViews.js
Normal file
86
web/src/views/scripts/appShellAsyncViews.js
Normal 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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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;/)
|
||||
|
||||
@@ -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'/)
|
||||
|
||||
61
web/tests/conversation-trusted-html.test.mjs
Normal file
61
web/tests/conversation-trusted-html.test.mjs
Normal 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```/)
|
||||
})
|
||||
39
web/tests/document-protocol-constants.test.mjs
Normal file
39
web/tests/document-protocol-constants.test.mjs
Normal 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/)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
13
web/tests/helpers/sourceSurface.mjs
Normal file
13
web/tests/helpers/sourceSurface.mjs
Normal 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')
|
||||
}
|
||||
@@ -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/)
|
||||
|
||||
@@ -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]*\}/)
|
||||
|
||||
52
web/tests/workbench-ai-application-gate-model.test.mjs
Normal file
52
web/tests/workbench-ai-application-gate-model.test.mjs
Normal 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')
|
||||
})
|
||||
46
web/tests/workbench-ai-composer-components.test.mjs
Normal file
46
web/tests/workbench-ai-composer-components.test.mjs
Normal 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"/)
|
||||
})
|
||||
@@ -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\(/)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user