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
|
from app.services.expense_rule_runtime import RuntimeTravelPolicy
|
||||||
|
|
||||||
|
|
||||||
|
def unique_text_values(values: list[Any]) -> list[str]:
|
||||||
|
normalized_values: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for value in list(values or []):
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized or normalized in seen:
|
||||||
|
continue
|
||||||
|
seen.add(normalized)
|
||||||
|
normalized_values.append(normalized)
|
||||||
|
return normalized_values
|
||||||
|
|
||||||
|
|
||||||
def count_values(values: list[str]) -> dict[str, int]:
|
def count_values(values: list[str]) -> dict[str, int]:
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
for value in values:
|
for value in values:
|
||||||
@@ -51,21 +63,35 @@ def collect_attachment_cities(
|
|||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
cities: list[str] = []
|
cities: list[str] = []
|
||||||
for context in contexts:
|
for context in contexts:
|
||||||
document_info = context.get("document_info") or {}
|
for city in collect_context_cities(context, policy):
|
||||||
parts = [
|
|
||||||
str(context.get("ocr_summary") or ""),
|
|
||||||
str(context.get("ocr_text") or ""),
|
|
||||||
str(context.get("item").item_location if context.get("item") is not None else ""),
|
|
||||||
]
|
|
||||||
for field in list(document_info.get("fields") or []):
|
|
||||||
if isinstance(field, dict):
|
|
||||||
parts.append(str(field.get("value") or ""))
|
|
||||||
for city in extract_known_cities_from_text(" ".join(parts), policy):
|
|
||||||
if city not in cities:
|
if city not in cities:
|
||||||
cities.append(city)
|
cities.append(city)
|
||||||
return cities
|
return cities
|
||||||
|
|
||||||
|
|
||||||
|
def collect_context_cities(
|
||||||
|
context: dict[str, Any],
|
||||||
|
policy: RuntimeTravelPolicy,
|
||||||
|
*,
|
||||||
|
include_item_reason: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
if not isinstance(context, dict):
|
||||||
|
return []
|
||||||
|
document_info = context.get("document_info") or {}
|
||||||
|
item = context.get("item")
|
||||||
|
parts = [
|
||||||
|
str(context.get("ocr_summary") or ""),
|
||||||
|
str(context.get("ocr_text") or ""),
|
||||||
|
str(getattr(item, "item_location", "") or ""),
|
||||||
|
]
|
||||||
|
if include_item_reason:
|
||||||
|
parts.append(str(getattr(item, "item_reason", "") or ""))
|
||||||
|
for field in list(document_info.get("fields") or []):
|
||||||
|
if isinstance(field, dict):
|
||||||
|
parts.append(str(field.get("value") or ""))
|
||||||
|
return extract_known_cities_from_text(" ".join(parts), policy)
|
||||||
|
|
||||||
|
|
||||||
def extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> list[str]:
|
def extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> list[str]:
|
||||||
normalized = str(text or "").strip()
|
normalized = str(text or "").strip()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
@@ -77,6 +103,11 @@ def extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> li
|
|||||||
return cities
|
return cities
|
||||||
|
|
||||||
|
|
||||||
|
def extract_first_known_city_from_text(text: str, policy: RuntimeTravelPolicy) -> str:
|
||||||
|
cities = extract_known_cities_from_text(text, policy)
|
||||||
|
return cities[0] if cities else ""
|
||||||
|
|
||||||
|
|
||||||
def resolve_first_document_field_value(
|
def resolve_first_document_field_value(
|
||||||
document_info: dict[str, Any],
|
document_info: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
@@ -95,3 +126,15 @@ def resolve_first_document_field_value(
|
|||||||
if field_key in normalized_keys or any(token in label for token in labels):
|
if field_key in normalized_keys or any(token in label for token in labels):
|
||||||
return value
|
return value
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def collect_context_item_ids(contexts: list[dict[str, Any]]) -> list[str]:
|
||||||
|
item_ids: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for context in list(contexts or []):
|
||||||
|
item = context.get("item") if isinstance(context, dict) else None
|
||||||
|
item_id = str(getattr(item, "id", "") or "").strip()
|
||||||
|
if item_id and item_id not in seen:
|
||||||
|
seen.add(item_id)
|
||||||
|
item_ids.append(item_id)
|
||||||
|
return item_ids
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
|||||||
from app.services.budget import BudgetService
|
from app.services.budget import BudgetService
|
||||||
from app.services.expense_claim_platform_context_tools import (
|
from app.services.expense_claim_platform_context_tools import (
|
||||||
collect_attachment_cities,
|
collect_attachment_cities,
|
||||||
|
collect_context_item_ids,
|
||||||
collect_invoice_keys_from_contexts,
|
collect_invoice_keys_from_contexts,
|
||||||
collect_invoice_keys_from_document_info,
|
collect_invoice_keys_from_document_info,
|
||||||
count_values,
|
count_values,
|
||||||
@@ -768,15 +769,7 @@ class ExpenseClaimPlatformRiskMixin:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _context_item_ids(contexts: list[dict[str, Any]]) -> list[str]:
|
def _context_item_ids(contexts: list[dict[str, Any]]) -> list[str]:
|
||||||
item_ids: list[str] = []
|
return collect_context_item_ids(contexts)
|
||||||
seen: set[str] = set()
|
|
||||||
for context in list(contexts or []):
|
|
||||||
item = context.get("item") if isinstance(context, dict) else None
|
|
||||||
item_id = str(getattr(item, "id", "") or "").strip()
|
|
||||||
if item_id and item_id not in seen:
|
|
||||||
seen.add(item_id)
|
|
||||||
item_ids.append(item_id)
|
|
||||||
return item_ids
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _with_related_item_ids(flag: dict[str, Any], item_ids: list[str]) -> dict[str, Any]:
|
def _with_related_item_ids(flag: dict[str, Any], item_ids: list[str]) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
|
from app.services.expense_claim_platform_context_tools import (
|
||||||
|
collect_context_cities,
|
||||||
|
collect_context_item_ids,
|
||||||
|
extract_first_known_city_from_text,
|
||||||
|
resolve_first_document_field_value,
|
||||||
|
unique_text_values,
|
||||||
|
)
|
||||||
from app.services.expense_rule_runtime import RuntimeTravelPolicy
|
from app.services.expense_rule_runtime import RuntimeTravelPolicy
|
||||||
|
|
||||||
|
|
||||||
@@ -13,16 +20,16 @@ def resolve_multi_city_related_item_ids(
|
|||||||
) -> tuple[list[str], list[str]]:
|
) -> tuple[list[str], list[str]]:
|
||||||
segments = _collect_travel_route_segments(contexts, policy)
|
segments = _collect_travel_route_segments(contexts, policy)
|
||||||
if not segments:
|
if not segments:
|
||||||
return _context_item_ids(contexts), []
|
return collect_context_item_ids(contexts), []
|
||||||
|
|
||||||
first_origin = str(segments[0].get("origin") or "").strip()
|
first_origin = str(segments[0].get("origin") or "").strip()
|
||||||
first_destination = str(segments[0].get("destination") or "").strip()
|
first_destination = str(segments[0].get("destination") or "").strip()
|
||||||
expected_destination = _resolve_expected_travel_city(claim, contexts, policy)
|
expected_destination = _resolve_expected_travel_city(claim, contexts, policy)
|
||||||
baseline_cities = _unique_text_values(
|
baseline_cities = unique_text_values(
|
||||||
[first_origin, expected_destination or first_destination]
|
[first_origin, expected_destination or first_destination]
|
||||||
)
|
)
|
||||||
|
|
||||||
destination_cities = _unique_text_values(
|
destination_cities = unique_text_values(
|
||||||
[str(segment.get("destination") or "") for segment in segments]
|
[str(segment.get("destination") or "") for segment in segments]
|
||||||
)
|
)
|
||||||
extra_cities = [
|
extra_cities = [
|
||||||
@@ -31,7 +38,7 @@ def resolve_multi_city_related_item_ids(
|
|||||||
if city and city not in set(baseline_cities)
|
if city and city not in set(baseline_cities)
|
||||||
]
|
]
|
||||||
if not extra_cities:
|
if not extra_cities:
|
||||||
route_cities = _unique_text_values(
|
route_cities = unique_text_values(
|
||||||
[
|
[
|
||||||
city
|
city
|
||||||
for segment in segments
|
for segment in segments
|
||||||
@@ -86,7 +93,7 @@ def _resolve_expected_travel_city(
|
|||||||
contexts: list[dict[str, Any]],
|
contexts: list[dict[str, Any]],
|
||||||
policy: RuntimeTravelPolicy,
|
policy: RuntimeTravelPolicy,
|
||||||
) -> str:
|
) -> str:
|
||||||
claim_city = _extract_first_known_city(str(claim.location or ""), policy)
|
claim_city = extract_first_known_city_from_text(str(claim.location or ""), policy)
|
||||||
if claim_city:
|
if claim_city:
|
||||||
return claim_city
|
return claim_city
|
||||||
|
|
||||||
@@ -96,7 +103,7 @@ def _resolve_expected_travel_city(
|
|||||||
scene_code = str(document_info.get("scene_code") or "").strip().lower()
|
scene_code = str(document_info.get("scene_code") or "").strip().lower()
|
||||||
if document_type != "hotel_invoice" and scene_code != "hotel":
|
if document_type != "hotel_invoice" and scene_code != "hotel":
|
||||||
continue
|
continue
|
||||||
for city in _extract_context_cities(context, policy):
|
for city in collect_context_cities(context, policy, include_item_reason=True):
|
||||||
return city
|
return city
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -107,7 +114,7 @@ def _extract_route_segment(
|
|||||||
) -> tuple[str, str] | None:
|
) -> tuple[str, str] | None:
|
||||||
document_info = context.get("document_info") or {}
|
document_info = context.get("document_info") or {}
|
||||||
item = context.get("item")
|
item = context.get("item")
|
||||||
route_value = _resolve_document_field_value(
|
route_value = resolve_first_document_field_value(
|
||||||
document_info,
|
document_info,
|
||||||
keys={"route", "route_cities", "routecities", "travel_route", "trip_route"},
|
keys={"route", "route_cities", "routecities", "travel_route", "trip_route"},
|
||||||
labels={"路线", "行程", "起讫", "起终", "始发", "到达"},
|
labels={"路线", "行程", "起讫", "起终", "始发", "到达"},
|
||||||
@@ -130,8 +137,8 @@ def _extract_route_segment(
|
|||||||
segment.strip()
|
segment.strip()
|
||||||
for segment in normalized.split(separator, 1)
|
for segment in normalized.split(separator, 1)
|
||||||
]
|
]
|
||||||
origin = _extract_first_known_city(origin_text, policy)
|
origin = extract_first_known_city_from_text(origin_text, policy)
|
||||||
destination = _extract_first_known_city(destination_text, policy)
|
destination = extract_first_known_city_from_text(destination_text, policy)
|
||||||
if origin and destination and origin != destination:
|
if origin and destination and origin != destination:
|
||||||
return origin, destination
|
return origin, destination
|
||||||
return None
|
return None
|
||||||
@@ -154,91 +161,11 @@ def _is_long_distance_context(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _extract_context_cities(
|
|
||||||
context: dict[str, Any],
|
|
||||||
policy: RuntimeTravelPolicy,
|
|
||||||
) -> list[str]:
|
|
||||||
document_info = context.get("document_info") or {}
|
|
||||||
item = context.get("item")
|
|
||||||
parts = [
|
|
||||||
str(context.get("ocr_summary") or ""),
|
|
||||||
str(context.get("ocr_text") or ""),
|
|
||||||
str(getattr(item, "item_location", "") or ""),
|
|
||||||
str(getattr(item, "item_reason", "") or ""),
|
|
||||||
]
|
|
||||||
for field in list(document_info.get("fields") or []):
|
|
||||||
if isinstance(field, dict):
|
|
||||||
parts.append(str(field.get("value") or ""))
|
|
||||||
return _extract_known_cities_from_text(" ".join(parts), policy)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> list[str]:
|
|
||||||
normalized = str(text or "").strip()
|
|
||||||
if not normalized:
|
|
||||||
return []
|
|
||||||
cities: list[str] = []
|
|
||||||
for city in sorted(policy.city_tiers.keys(), key=lambda item: len(item), reverse=True):
|
|
||||||
if city in normalized and city not in cities:
|
|
||||||
cities.append(city)
|
|
||||||
return cities
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_first_known_city(text: str, policy: RuntimeTravelPolicy) -> str:
|
|
||||||
cities = _extract_known_cities_from_text(text, policy)
|
|
||||||
return cities[0] if cities else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_document_field_value(
|
|
||||||
document_info: dict[str, Any],
|
|
||||||
*,
|
|
||||||
keys: set[str],
|
|
||||||
labels: set[str],
|
|
||||||
) -> str:
|
|
||||||
normalized_keys = {key.replace("_", "").lower() for key in keys}
|
|
||||||
for field in list(document_info.get("fields") or []):
|
|
||||||
if not isinstance(field, dict):
|
|
||||||
continue
|
|
||||||
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
|
|
||||||
label = str(field.get("label") or "").replace(" ", "")
|
|
||||||
value = str(field.get("value") or "").strip()
|
|
||||||
if not value:
|
|
||||||
continue
|
|
||||||
if field_key in normalized_keys or any(token in label for token in labels):
|
|
||||||
return value
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _route_segment_item_ids(segments: list[dict[str, Any]]) -> list[str]:
|
def _route_segment_item_ids(segments: list[dict[str, Any]]) -> list[str]:
|
||||||
item_ids: list[str] = []
|
return collect_context_item_ids(
|
||||||
seen: set[str] = set()
|
[
|
||||||
for segment in list(segments or []):
|
{"item": segment.get("item")}
|
||||||
item = segment.get("item") if isinstance(segment, dict) else None
|
for segment in list(segments or [])
|
||||||
item_id = str(getattr(item, "id", "") or "").strip()
|
if isinstance(segment, dict)
|
||||||
if item_id and item_id not in seen:
|
]
|
||||||
seen.add(item_id)
|
)
|
||||||
item_ids.append(item_id)
|
|
||||||
return item_ids
|
|
||||||
|
|
||||||
|
|
||||||
def _context_item_ids(contexts: list[dict[str, Any]]) -> list[str]:
|
|
||||||
item_ids: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for context in list(contexts or []):
|
|
||||||
item = context.get("item") if isinstance(context, dict) else None
|
|
||||||
item_id = str(getattr(item, "id", "") or "").strip()
|
|
||||||
if item_id and item_id not in seen:
|
|
||||||
seen.add(item_id)
|
|
||||||
item_ids.append(item_id)
|
|
||||||
return item_ids
|
|
||||||
|
|
||||||
|
|
||||||
def _unique_text_values(values: list[str]) -> list[str]:
|
|
||||||
normalized_values: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for value in list(values or []):
|
|
||||||
normalized = str(value or "").strip()
|
|
||||||
if not normalized or normalized in seen:
|
|
||||||
continue
|
|
||||||
seen.add(normalized)
|
|
||||||
normalized_values.append(normalized)
|
|
||||||
return normalized_values
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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 decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
|
||||||
from sqlalchemy.pool import StaticPool
|
|
||||||
|
|
||||||
from app.api.deps import CurrentUserContext
|
from app.api.deps import CurrentUserContext
|
||||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||||
from app.db.base import Base
|
|
||||||
from app.models.agent_asset import AgentAsset
|
from app.models.agent_asset import AgentAsset
|
||||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||||
@@ -19,17 +16,10 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
|||||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
|
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
|
||||||
|
from app.test_helpers.db import build_in_memory_session
|
||||||
|
|
||||||
|
|
||||||
def build_session() -> Session:
|
build_session = build_in_memory_session
|
||||||
engine = create_engine(
|
|
||||||
"sqlite+pysqlite:///:memory:",
|
|
||||||
connect_args={"check_same_thread": False},
|
|
||||||
poolclass=StaticPool,
|
|
||||||
)
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
|
||||||
return session_factory()
|
|
||||||
|
|
||||||
|
|
||||||
def _build_rule_payload(
|
def _build_rule_payload(
|
||||||
|
|||||||
@@ -148,10 +148,10 @@
|
|||||||
.ai-quick-btn {
|
.ai-quick-btn {
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 28px minmax(0, 1fr);
|
grid-template-columns: 32px minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
padding: 0 4px;
|
padding: 7px 10px;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 780;
|
font-weight: 780;
|
||||||
@@ -160,13 +160,21 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-quick-btn i {
|
.ai-quick-icon {
|
||||||
width: 28px;
|
width: 32px;
|
||||||
display: inline-flex;
|
height: 32px;
|
||||||
justify-content: center;
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
color: #536277;
|
color: #536277;
|
||||||
font-size: 18px;
|
}
|
||||||
line-height: 1;
|
|
||||||
|
.ai-sidebar-tabler-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
flex: none;
|
||||||
|
stroke-width: 1.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-quick-btn.primary {
|
.ai-quick-btn.primary {
|
||||||
@@ -175,17 +183,10 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-quick-btn.active {
|
.ai-quick-btn.primary .ai-quick-icon {
|
||||||
color: #173d78;
|
|
||||||
background: rgba(45, 114, 217, 0.055);
|
|
||||||
border-color: rgba(45, 114, 217, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-quick-btn.primary i {
|
|
||||||
color: var(--ai-rail-amber);
|
color: var(--ai-rail-amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-nav-btn:hover,
|
|
||||||
.ai-recent-item:hover,
|
.ai-recent-item:hover,
|
||||||
.ai-user-action:hover {
|
.ai-user-action:hover {
|
||||||
background: rgba(255, 255, 255, 0.78);
|
background: rgba(255, 255, 255, 0.78);
|
||||||
@@ -194,7 +195,10 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-quick-btn:hover {
|
.ai-quick-btn:hover,
|
||||||
|
.ai-quick-btn.active,
|
||||||
|
.ai-nav-btn:hover,
|
||||||
|
.ai-nav-btn.active {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
background: rgba(15, 23, 42, 0.035);
|
background: rgba(15, 23, 42, 0.035);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
@@ -202,11 +206,14 @@
|
|||||||
transform: translateX(2px);
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-quick-btn:hover i {
|
.ai-quick-btn:hover .ai-quick-icon,
|
||||||
|
.ai-quick-btn.active .ai-quick-icon,
|
||||||
|
.ai-nav-btn:hover .ai-nav-icon,
|
||||||
|
.ai-nav-btn.active .ai-nav-icon {
|
||||||
color: var(--ai-rail-accent);
|
color: var(--ai-rail-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-quick-btn.primary:hover i {
|
.ai-quick-btn.primary:hover .ai-quick-icon {
|
||||||
color: var(--ai-rail-amber);
|
color: var(--ai-rail-amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,10 +222,10 @@
|
|||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 28px minmax(0, 1fr) 28px;
|
grid-template-columns: 32px minmax(0, 1fr) 28px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
padding: 0 6px 0 4px;
|
padding: 0 6px 0 10px;
|
||||||
border: 1px solid rgba(45, 114, 217, 0.14);
|
border: 1px solid rgba(45, 114, 217, 0.14);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: rgba(255, 255, 255, 0.7);
|
||||||
@@ -308,41 +315,6 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-nav-btn::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
width: 3px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: transparent;
|
|
||||||
transition:
|
|
||||||
background 180ms var(--ease),
|
|
||||||
opacity 180ms var(--ease);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-nav-btn.active {
|
|
||||||
border-color: rgba(45, 114, 217, 0.13);
|
|
||||||
background:
|
|
||||||
linear-gradient(90deg, rgba(45, 114, 217, 0.095), rgba(255, 255, 255, 0.74)),
|
|
||||||
var(--ai-rail-panel);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.78),
|
|
||||||
0 8px 18px rgba(45, 114, 217, 0.045);
|
|
||||||
color: #173d78;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-nav-btn.active::before {
|
|
||||||
background: linear-gradient(180deg, var(--ai-rail-accent), var(--ai-rail-green));
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-nav-btn:not(.active):hover::before {
|
|
||||||
background: rgba(45, 114, 217, 0.36);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-nav-icon {
|
.ai-nav-icon {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -357,17 +329,9 @@
|
|||||||
box-shadow 180ms var(--ease);
|
box-shadow 180ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-nav-btn.active .ai-nav-icon {
|
.ai-nav-icon .ai-sidebar-tabler-icon {
|
||||||
background:
|
width: 19px;
|
||||||
linear-gradient(145deg, rgba(45, 114, 217, 0.12), rgba(255, 255, 255, 0.72)),
|
height: 19px;
|
||||||
rgba(255, 255, 255, 0.52);
|
|
||||||
color: var(--ai-rail-accent);
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-nav-icon i {
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-nav-copy {
|
.ai-nav-copy {
|
||||||
@@ -386,10 +350,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-nav-btn.active .ai-nav-copy strong {
|
|
||||||
font-weight: 820;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-recent-desc {
|
.ai-recent-desc {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -621,8 +581,7 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-rail.rail-collapsed .ai-nav-list::before,
|
.ai-rail.rail-collapsed .ai-nav-list::before {
|
||||||
.ai-rail.rail-collapsed .ai-nav-btn::before {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,12 +595,6 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-rail.rail-collapsed .ai-nav-btn.active {
|
|
||||||
grid-column: auto;
|
|
||||||
min-height: 44px;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-rail.rail-collapsed .ai-quick-btn span,
|
.ai-rail.rail-collapsed .ai-quick-btn span,
|
||||||
.ai-rail.rail-collapsed .ai-conversation-search,
|
.ai-rail.rail-collapsed .ai-conversation-search,
|
||||||
.ai-rail.rail-collapsed .ai-brand-copy,
|
.ai-rail.rail-collapsed .ai-brand-copy,
|
||||||
@@ -653,7 +606,7 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-rail.rail-collapsed .ai-quick-btn i,
|
.ai-rail.rail-collapsed .ai-quick-icon,
|
||||||
.ai-rail.rail-collapsed .ai-brand-logo,
|
.ai-rail.rail-collapsed .ai-brand-logo,
|
||||||
.ai-rail.rail-collapsed .ai-nav-icon,
|
.ai-rail.rail-collapsed .ai-nav-icon,
|
||||||
.ai-rail.rail-collapsed .ai-user-avatar {
|
.ai-rail.rail-collapsed .ai-user-avatar {
|
||||||
|
|||||||
@@ -475,42 +475,30 @@
|
|||||||
top: calc(100% + 12px);
|
top: calc(100% + 12px);
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
width: 380px;
|
width: 428px;
|
||||||
max-width: calc(100vw - 24px);
|
max-width: calc(100vw - 24px);
|
||||||
max-height: min(520px, calc(100vh - 96px));
|
max-height: min(560px, calc(100vh - 68px));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
border: 1px solid #dbe4ef;
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 16px 36px rgba(0, 0, 0, 0.08),
|
0 18px 42px rgba(15, 23, 42, 0.13),
|
||||||
0 4px 12px rgba(0, 0, 0, 0.03),
|
0 4px 12px rgba(15, 23, 42, 0.06);
|
||||||
0 0 1px rgba(0, 0, 0, 0.1);
|
|
||||||
overscroll-behavior-y: contain;
|
overscroll-behavior-y: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-popover::before {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
height: 3px;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--theme-primary-active) 0%,
|
|
||||||
var(--theme-primary-light-3, #7eb3d4) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-head {
|
.notification-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 14px 10px;
|
padding: 14px 16px 12px;
|
||||||
border-bottom: 1px solid #edf2f7;
|
border-bottom: 1px solid #e6edf6;
|
||||||
background: #fafbfd;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-head-brand {
|
.notification-head-brand {
|
||||||
@@ -522,14 +510,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-head-icon {
|
.notification-head-icon {
|
||||||
width: 32px;
|
width: 34px;
|
||||||
height: 32px;
|
height: 34px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid var(--theme-primary-light-6);
|
border: 1px solid #dbeafe;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #fff;
|
background: #f8fbff;
|
||||||
color: var(--theme-primary-active);
|
color: var(--theme-primary-active);
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
@@ -561,22 +549,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-clear-btn {
|
.notification-clear-btn {
|
||||||
height: 28px;
|
height: 30px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: 0;
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--theme-primary-active);
|
color: #475569;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition:
|
transition:
|
||||||
background 160ms var(--ease),
|
background 160ms var(--ease),
|
||||||
|
border-color 160ms var(--ease),
|
||||||
color 160ms var(--ease);
|
color 160ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-clear-btn:hover:not(:disabled) {
|
.notification-clear-btn:hover:not(:disabled) {
|
||||||
background: var(--theme-primary-light-9);
|
border-color: #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: var(--theme-primary-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-clear-btn:disabled {
|
.notification-clear-btn:disabled {
|
||||||
@@ -585,8 +576,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-close-btn {
|
.notification-close-btn {
|
||||||
width: 28px;
|
width: 30px;
|
||||||
height: 28px;
|
height: 30px;
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -608,15 +599,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
padding: 0 14px;
|
padding: 0 16px;
|
||||||
border-bottom: 1px solid #edf2f7;
|
border-bottom: 1px solid #e6edf6;
|
||||||
background: #fff;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-tabs button {
|
.notification-tabs button {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
height: 38px;
|
height: 40px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -666,10 +657,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
max-height: min(336px, calc(100vh - 226px));
|
max-height: min(420px, calc(100vh - 166px));
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 0 12px;
|
padding: 10px 0 14px;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #cbd5e1 #f8fafc;
|
scrollbar-color: #cbd5e1 #f8fafc;
|
||||||
overscroll-behavior-y: contain;
|
overscroll-behavior-y: contain;
|
||||||
@@ -691,179 +682,151 @@
|
|||||||
|
|
||||||
.notification-row {
|
.notification-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 34px minmax(0, 1fr) 16px;
|
grid-template-columns: 52px minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: start;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
min-height: 68px;
|
min-height: 104px;
|
||||||
padding: 12px 16px;
|
padding: 20px 22px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition:
|
transition:
|
||||||
background 180ms var(--ease),
|
background 180ms var(--ease),
|
||||||
border-color 180ms var(--ease);
|
transform 180ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-row + .notification-row {
|
.notification-row + .notification-row {
|
||||||
border-top: 1px solid #f1f5f9;
|
border-top: 1px solid #f4f6fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-row.unread {
|
.notification-row.unread {
|
||||||
background: #f8fafc;
|
background: #ffffff;
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-row.unread::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 16px;
|
|
||||||
bottom: 16px;
|
|
||||||
width: 3px;
|
|
||||||
border-radius: 0 4px 4px 0;
|
|
||||||
background: var(--theme-primary-active);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-row:hover {
|
.notification-row:hover {
|
||||||
background: #f1f5f9;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-row.unread:hover {
|
.notification-row.unread:hover {
|
||||||
background: #f1f5f9;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-type-icon {
|
.notification-avatar {
|
||||||
width: 34px;
|
position: relative;
|
||||||
height: 34px;
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid rgba(0,0,0,0.04);
|
border: 1px solid #dbeafe;
|
||||||
border-radius: 8px;
|
border-radius: 999px;
|
||||||
background: #ffffff;
|
background: #eff6ff;
|
||||||
color: var(--theme-primary-active);
|
color: #2563eb;
|
||||||
font-size: 16px;
|
font-size: 18px;
|
||||||
box-shadow: 0 1.5px 4px rgba(0,0,0,0.03);
|
font-weight: 800;
|
||||||
|
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-type-icon.danger {
|
.notification-avatar.danger {
|
||||||
border-color: #fecaca;
|
border-color: #fecaca;
|
||||||
background: #fff5f5;
|
background: #fff1f2;
|
||||||
color: #dc2626;
|
color: #be123c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-type-icon.warning {
|
.notification-avatar.warning {
|
||||||
border-color: #fde68a;
|
border-color: #fde68a;
|
||||||
background: #fffbeb;
|
background: #fffbeb;
|
||||||
color: #d97706;
|
color: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-type-icon.success {
|
.notification-avatar.success {
|
||||||
border-color: #bbf7d0;
|
border-color: #bbf7d0;
|
||||||
background: #f0fdf4;
|
background: #f0fdf4;
|
||||||
color: #16a34a;
|
color: #15803d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-type-icon.info {
|
.notification-avatar.info {
|
||||||
border-color: #bfdbfe;
|
border-color: #bfdbfe;
|
||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-copy {
|
.notification-avatar-label {
|
||||||
min-width: 0;
|
line-height: 1;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding-top: 1px;
|
|
||||||
padding-bottom: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-title-line {
|
.notification-avatar-badge {
|
||||||
min-width: 0;
|
position: absolute;
|
||||||
display: flex;
|
top: -2px;
|
||||||
align-items: center;
|
right: -2px;
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-copy strong {
|
|
||||||
min-width: 0;
|
|
||||||
color: #0f172a;
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 750;
|
|
||||||
line-height: 1.3;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-title-line b {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
min-width: 18px;
|
min-width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0 5px;
|
padding: 0 4px;
|
||||||
border-radius: 4px;
|
border: 2px solid #ffffff;
|
||||||
background: #dc2626;
|
border-radius: 999px;
|
||||||
color: #fff;
|
background: #ef4444;
|
||||||
|
color: #ffffff;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-copy small {
|
.notification-row-content {
|
||||||
color: #64748b;
|
min-width: 0;
|
||||||
font-size: 12px;
|
display: grid;
|
||||||
line-height: 1.4;
|
gap: 8px;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row-top {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row-title {
|
||||||
|
min-width: 0;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row.unread .notification-row-title {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.55;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-meta {
|
.notification-time {
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-meta em,
|
|
||||||
.notification-meta time {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 11px;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-meta em {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-meta time {
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
color: #b4bcc8;
|
||||||
|
font-size: 13px;
|
||||||
.notification-row-arrow {
|
font-style: normal;
|
||||||
color: #cbd5e1;
|
font-weight: 700;
|
||||||
font-size: 18px;
|
font-variant-numeric: tabular-nums;
|
||||||
transition: color 160ms var(--ease), transform 160ms var(--ease);
|
line-height: 1.3;
|
||||||
}
|
white-space: nowrap;
|
||||||
|
|
||||||
.notification-row:hover .notification-row-arrow {
|
|
||||||
color: var(--theme-primary-active);
|
|
||||||
transform: translateX(2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-empty {
|
.notification-empty {
|
||||||
@@ -1573,7 +1536,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-row {
|
.notification-row {
|
||||||
padding: 9px 12px;
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 90px;
|
||||||
|
padding: 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row-top {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-time {
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.company-switcher {
|
.company-switcher {
|
||||||
@@ -1641,7 +1621,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-head-icon,
|
.notification-head-icon,
|
||||||
.notification-type-icon {
|
.notification-avatar {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
@@ -1653,10 +1633,26 @@
|
|||||||
.notification-row {
|
.notification-row {
|
||||||
grid-template-columns: 30px minmax(0, 1fr);
|
grid-template-columns: 30px minmax(0, 1fr);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
min-height: 82px;
|
||||||
|
padding: 13px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-row-arrow {
|
.notification-avatar {
|
||||||
display: none;
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-avatar-badge {
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row-title {
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-preview {
|
||||||
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar.detail-mode {
|
.topbar.detail-mode {
|
||||||
|
|||||||
@@ -28,183 +28,11 @@
|
|||||||
<p>您可以直接向我提问,或选择下方推荐主题,快速完成费用相关事务</p>
|
<p>您可以直接向我提问,或选择下方推荐主题,快速完成费用相关事务</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="workbench-ai-composer" @submit.prevent="submitAiModePrompt">
|
<WorkbenchAiComposer
|
||||||
<div class="workbench-ai-composer-field">
|
:runtime="workbenchAiRuntime"
|
||||||
<div v-if="workbenchDateTagLabel" class="workbench-ai-date-chip">
|
placeholder="今天我能帮您做点什么?"
|
||||||
<i class="mdi mdi-calendar-check-outline"></i>
|
|
||||||
<span>{{ workbenchDateTagLabel }}</span>
|
|
||||||
<button type="button" aria-label="移除日期" :disabled="isAiModeInputLocked" @click="removeWorkbenchDateTag">
|
|
||||||
<i class="mdi mdi-close"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
ref="assistantInputRef"
|
|
||||||
v-model="assistantDraft"
|
|
||||||
maxlength="1000"
|
|
||||||
rows="3"
|
|
||||||
:placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '今天我能帮您做点什么?'"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@keydown.enter.exact.prevent="submitAiModePrompt"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="workbench-ai-composer-toolbar">
|
|
||||||
<div class="workbench-ai-tool-buttons">
|
|
||||||
<div class="workbench-date-anchor workbench-ai-date-anchor">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="workbench-ai-icon-btn"
|
|
||||||
:class="{ active: workbenchDatePickerOpen || workbenchDateTagLabel }"
|
|
||||||
title="选择日期"
|
|
||||||
aria-label="选择日期"
|
|
||||||
:aria-expanded="workbenchDatePickerOpen"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@click.stop="toggleWorkbenchDatePicker"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-calendar-range"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="workbenchDatePickerOpen"
|
|
||||||
class="workbench-ai-date-popover"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="选择业务日期"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:class="{ active: workbenchDateMode === 'single' }"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@click="setWorkbenchDateMode('single')"
|
|
||||||
>
|
|
||||||
单日
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:class="{ active: workbenchDateMode === 'range' }"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@click="setWorkbenchDateMode('range')"
|
|
||||||
>
|
|
||||||
范围
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
|
|
||||||
<span>业务日期</span>
|
|
||||||
<input
|
|
||||||
v-model="workbenchSingleDate"
|
|
||||||
type="date"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@change="handleWorkbenchDateInputChange('single')"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
<WorkbenchAiFileStrip :runtime="workbenchAiRuntime" />
|
||||||
|
|
||||||
<div v-else class="workbench-ai-date-range">
|
|
||||||
<label class="workbench-ai-date-field">
|
|
||||||
<span>开始日期</span>
|
|
||||||
<input
|
|
||||||
v-model="workbenchRangeStartDate"
|
|
||||||
type="date"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@change="handleWorkbenchDateInputChange('range-start')"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="workbench-ai-date-field">
|
|
||||||
<span>结束日期</span>
|
|
||||||
<input
|
|
||||||
v-model="workbenchRangeEndDate"
|
|
||||||
type="date"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@change="handleWorkbenchDateInputChange('range-end')"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="workbench-ai-date-actions">
|
|
||||||
<button type="button" class="ghost" :disabled="isAiModeInputLocked" @click="clearWorkbenchDateSelection">清除</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="primary"
|
|
||||||
:disabled="!workbenchCanApplyDateSelection || isAiModeInputLocked"
|
|
||||||
@click="applyWorkbenchDateSelection"
|
|
||||||
>
|
|
||||||
完成
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="workbench-ai-icon-btn"
|
|
||||||
title="上传附件"
|
|
||||||
aria-label="上传附件"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@click="triggerAiModeFileUpload"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-paperclip"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="workbench-ai-icon-btn"
|
|
||||||
title="语音输入"
|
|
||||||
aria-label="语音输入"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@click="handleVoiceInput"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-microphone-outline"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="workbench-ai-composer-right">
|
|
||||||
<div class="workbench-ai-model-selector" :title="modelSelectorTitle">
|
|
||||||
<span>{{ displayModelName }}</span>
|
|
||||||
<i class="mdi mdi-chevron-down"></i>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="workbench-ai-send-btn"
|
|
||||||
:disabled="!canSubmitAiModePrompt || sending || isAiModeInputLocked"
|
|
||||||
aria-label="发送给小财管家"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-arrow-up"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div v-if="selectedFileCards.length" class="workbench-ai-file-strip" aria-label="已选择附件">
|
|
||||||
<article v-for="file in selectedFileCards" :key="file.key" class="workbench-ai-file-card">
|
|
||||||
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
|
|
||||||
<i :class="file.icon"></i>
|
|
||||||
</span>
|
|
||||||
<span class="workbench-ai-file-card__body">
|
|
||||||
<strong :title="file.name">{{ file.name }}</strong>
|
|
||||||
<small>{{ file.typeLabel }}</small>
|
|
||||||
<small
|
|
||||||
v-if="file.ocrState?.label"
|
|
||||||
class="workbench-ai-file-card__ocr"
|
|
||||||
:class="`is-${file.ocrState.status || 'idle'}`"
|
|
||||||
:title="file.ocrState.title || file.ocrState.label"
|
|
||||||
>
|
|
||||||
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
|
|
||||||
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
|
|
||||||
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
|
|
||||||
<span>{{ file.ocrState.label }}</span>
|
|
||||||
</small>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="workbench-ai-file-card__remove"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
:aria-label="`移除附件 ${file.name}`"
|
|
||||||
@click="removeAiModeFile(file.key)"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-close"></i>
|
|
||||||
</button>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="workbench-ai-quick-start-section">
|
<div class="workbench-ai-quick-start-section">
|
||||||
<h3 class="workbench-ai-quick-start-title">快速开始</h3>
|
<h3 class="workbench-ai-quick-start-title">快速开始</h3>
|
||||||
@@ -565,183 +393,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="workbench-ai-conversation-bottom">
|
<div class="workbench-ai-conversation-bottom">
|
||||||
<div v-if="selectedFileCards.length" class="workbench-ai-file-strip inline" aria-label="已选择附件">
|
<WorkbenchAiFileStrip inline :runtime="workbenchAiRuntime" />
|
||||||
<article v-for="file in selectedFileCards" :key="file.key" class="workbench-ai-file-card">
|
<WorkbenchAiComposer
|
||||||
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
|
inline
|
||||||
<i :class="file.icon"></i>
|
:runtime="workbenchAiRuntime"
|
||||||
</span>
|
placeholder="继续和小财管家对话..."
|
||||||
<span class="workbench-ai-file-card__body">
|
|
||||||
<strong :title="file.name">{{ file.name }}</strong>
|
|
||||||
<small>{{ file.typeLabel }}</small>
|
|
||||||
<small
|
|
||||||
v-if="file.ocrState?.label"
|
|
||||||
class="workbench-ai-file-card__ocr"
|
|
||||||
:class="`is-${file.ocrState.status || 'idle'}`"
|
|
||||||
:title="file.ocrState.title || file.ocrState.label"
|
|
||||||
>
|
|
||||||
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
|
|
||||||
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
|
|
||||||
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
|
|
||||||
<span>{{ file.ocrState.label }}</span>
|
|
||||||
</small>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="workbench-ai-file-card__remove"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
:aria-label="`移除附件 ${file.name}`"
|
|
||||||
@click="removeAiModeFile(file.key)"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-close"></i>
|
|
||||||
</button>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="workbench-ai-composer workbench-ai-composer--inline" @submit.prevent="submitAiModePrompt">
|
|
||||||
<div class="workbench-ai-composer-field">
|
|
||||||
<div v-if="workbenchDateTagLabel" class="workbench-ai-date-chip">
|
|
||||||
<i class="mdi mdi-calendar-check-outline"></i>
|
|
||||||
<span>{{ workbenchDateTagLabel }}</span>
|
|
||||||
<button type="button" aria-label="移除日期" :disabled="isAiModeInputLocked" @click="removeWorkbenchDateTag">
|
|
||||||
<i class="mdi mdi-close"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
ref="assistantInputRef"
|
|
||||||
v-model="assistantDraft"
|
|
||||||
maxlength="1000"
|
|
||||||
rows="3"
|
|
||||||
:placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '继续和小财管家对话...'"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@keydown.enter.exact.prevent="submitAiModePrompt"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="workbench-ai-composer-toolbar">
|
|
||||||
<div class="workbench-ai-tool-buttons">
|
|
||||||
<div class="workbench-date-anchor workbench-ai-date-anchor">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="workbench-ai-icon-btn"
|
|
||||||
:class="{ active: workbenchDatePickerOpen || workbenchDateTagLabel }"
|
|
||||||
title="选择日期"
|
|
||||||
aria-label="选择日期"
|
|
||||||
:aria-expanded="workbenchDatePickerOpen"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@click.stop="toggleWorkbenchDatePicker"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-calendar-range"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="workbenchDatePickerOpen"
|
|
||||||
class="workbench-ai-date-popover"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="选择业务日期"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:class="{ active: workbenchDateMode === 'single' }"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@click="setWorkbenchDateMode('single')"
|
|
||||||
>
|
|
||||||
单日
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:class="{ active: workbenchDateMode === 'range' }"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@click="setWorkbenchDateMode('range')"
|
|
||||||
>
|
|
||||||
范围
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
|
|
||||||
<span>业务日期</span>
|
|
||||||
<input
|
|
||||||
v-model="workbenchSingleDate"
|
|
||||||
type="date"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@change="handleWorkbenchDateInputChange('single')"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
|
|
||||||
<div v-else class="workbench-ai-date-range">
|
|
||||||
<label class="workbench-ai-date-field">
|
|
||||||
<span>开始日期</span>
|
|
||||||
<input
|
|
||||||
v-model="workbenchRangeStartDate"
|
|
||||||
type="date"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@change="handleWorkbenchDateInputChange('range-start')"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="workbench-ai-date-field">
|
|
||||||
<span>结束日期</span>
|
|
||||||
<input
|
|
||||||
v-model="workbenchRangeEndDate"
|
|
||||||
type="date"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@change="handleWorkbenchDateInputChange('range-end')"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="workbench-ai-date-actions">
|
|
||||||
<button type="button" class="ghost" :disabled="isAiModeInputLocked" @click="clearWorkbenchDateSelection">清除</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="primary"
|
|
||||||
:disabled="!workbenchCanApplyDateSelection || isAiModeInputLocked"
|
|
||||||
@click="applyWorkbenchDateSelection"
|
|
||||||
>
|
|
||||||
完成
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="workbench-ai-icon-btn"
|
|
||||||
title="上传附件"
|
|
||||||
aria-label="上传附件"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@click="triggerAiModeFileUpload"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-paperclip"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="workbench-ai-icon-btn"
|
|
||||||
title="语音输入"
|
|
||||||
aria-label="语音输入"
|
|
||||||
:disabled="isAiModeInputLocked"
|
|
||||||
@click="handleVoiceInput"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-microphone-outline"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="workbench-ai-composer-right">
|
|
||||||
<div class="workbench-ai-model-selector" :title="modelSelectorTitle">
|
|
||||||
<span>{{ displayModelName }}</span>
|
|
||||||
<i class="mdi mdi-chevron-down"></i>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="workbench-ai-send-btn"
|
|
||||||
:disabled="!canSubmitAiModePrompt || sending || isAiModeInputLocked"
|
|
||||||
aria-label="发送给小财管家"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-arrow-up"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="workbench-ai-disclaimer">小财管家可能会出错,重要费用事项请核对单据、制度与审批结果。</p>
|
<p class="workbench-ai-disclaimer">小财管家可能会出错,重要费用事项请核对单据、制度与审批结果。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<template src="./PersonalWorkbenchAiMode.template.html"></template>
|
<template src="./PersonalWorkbenchAiMode.template.html"></template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { proxyRefs } from 'vue'
|
||||||
import orbIcon from '../../assets/workbench-ai-mode-orb-icon.gif'
|
import orbIcon from '../../assets/workbench-ai-mode-orb-icon.gif'
|
||||||
|
import WorkbenchAiComposer from './workbench-ai/WorkbenchAiComposer.vue'
|
||||||
|
import WorkbenchAiFileStrip from './workbench-ai/WorkbenchAiFileStrip.vue'
|
||||||
import { usePersonalWorkbenchAiMode } from '../../composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'
|
import { usePersonalWorkbenchAiMode } from '../../composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -9,9 +12,12 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
const emit = defineEmits(['conversation-change', 'conversation-history-change', 'open-document', 'request-updated'])
|
const emit = defineEmits(['conversation-change', 'conversation-history-change', 'open-document', 'request-updated'])
|
||||||
|
|
||||||
|
const aiModeRuntime = usePersonalWorkbenchAiMode(props, emit)
|
||||||
|
const workbenchAiRuntime = proxyRefs(aiModeRuntime)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeConversationTitle, aiModeActionItems, applicationPreviewEditor, applicationSubmitConfirmOpen, assistantDraft, assistantInputRef, canShowInlineSuggestedActions, canSubmitAiModePrompt, cancelDeleteConversation, cancelInlineApplicationSubmitConfirm, clearWorkbenchDateSelection, commitInlineApplicationPreviewEditor, confirmDeleteConversation, confirmInlineApplicationSubmit, conversationMessages, conversationScrollRef, conversationStarted, deleteDialogOpen, displayModelName, displayUserName, fileInputRef, handleAiAnswerMarkdownClick, handleAiModeFilesChange, handleInlineApplicationPreviewEditorKeydown, handleInlineConversationScroll, handleInlineSuggestedAction, handleVoiceInput, hasInlineAttachmentOcrDetails, hasInlineThinking, isAiModeInputLocked, isApplicationPreviewEditing, isApplicationPreviewEstimatePending, isInlineAttachmentOcrExpanded, isInlineSuggestedActionDisabled, isInlineThinkingExpanded, markInlineMessageFeedback, modelSelectorTitle, openApplicationPreviewEditor, quoteInlineMessage, regenerateLastReply, removeAiModeFile, removeWorkbenchDateTag, renderInlineConversationHtml, requestDeleteCurrentConversation, resolveApplicationPreviewEditorOptions, resolveInlineApplicationPreviewEditorControl, resolveInlineApplicationPreviewEditorDateMax, resolveInlineApplicationPreviewEditorDateMin, resolveInlineApplicationPreviewMissingFields, resolveInlineApplicationPreviewRows, resolveInlineAttachmentOcrDocuments, resolveInlineAttachmentOcrFileCount, resolveInlineThinkingEvents, runAiModeAction, scrollInlineConversationToTop, selectedFileCards, sending, setWorkbenchDateMode, submitAiModePrompt, toggleInlineAttachmentOcrDetails, toggleInlineThinking, toggleWorkbenchDatePicker, triggerAiModeFileUpload, workbenchCanApplyDateSelection, workbenchDateMode, workbenchDatePickerOpen, workbenchDateTagLabel, workbenchRangeEndDate, workbenchRangeStartDate, workbenchSingleDate, applyWorkbenchDateSelection, buildInlineApplicationPreviewFooterText, copyInlineMessage, formatMessageTime, handleWorkbenchDateInputChange
|
activeConversationTitle, aiModeActionItems, applicationPreviewEditor, applicationSubmitConfirmOpen, assistantDraft, assistantInputRef, canShowInlineSuggestedActions, canSubmitAiModePrompt, cancelDeleteConversation, cancelInlineApplicationSubmitConfirm, clearWorkbenchDateSelection, commitInlineApplicationPreviewEditor, confirmDeleteConversation, confirmInlineApplicationSubmit, conversationMessages, conversationScrollRef, conversationStarted, deleteDialogOpen, displayModelName, displayUserName, fileInputRef, handleAiAnswerMarkdownClick, handleAiModeFilesChange, handleInlineApplicationPreviewEditorKeydown, handleInlineConversationScroll, handleInlineSuggestedAction, handleVoiceInput, hasInlineAttachmentOcrDetails, hasInlineThinking, isAiModeInputLocked, isApplicationPreviewEditing, isApplicationPreviewEstimatePending, isInlineAttachmentOcrExpanded, isInlineSuggestedActionDisabled, isInlineThinkingExpanded, markInlineMessageFeedback, modelSelectorTitle, openApplicationPreviewEditor, quoteInlineMessage, regenerateLastReply, removeAiModeFile, removeWorkbenchDateTag, renderInlineConversationHtml, requestDeleteCurrentConversation, resolveApplicationPreviewEditorOptions, resolveInlineApplicationPreviewEditorControl, resolveInlineApplicationPreviewEditorDateMax, resolveInlineApplicationPreviewEditorDateMin, resolveInlineApplicationPreviewMissingFields, resolveInlineApplicationPreviewRows, resolveInlineAttachmentOcrDocuments, resolveInlineAttachmentOcrFileCount, resolveInlineThinkingEvents, runAiModeAction, scrollInlineConversationToTop, selectedFileCards, sending, setWorkbenchDateMode, submitAiModePrompt, toggleInlineAttachmentOcrDetails, toggleInlineThinking, toggleWorkbenchDatePicker, triggerAiModeFileUpload, workbenchCanApplyDateSelection, workbenchDateMode, workbenchDatePickerOpen, workbenchDateTagLabel, workbenchRangeEndDate, workbenchRangeStartDate, workbenchSingleDate, applyWorkbenchDateSelection, buildInlineApplicationPreviewFooterText, copyInlineMessage, formatMessageTime, handleWorkbenchDateInputChange
|
||||||
} = usePersonalWorkbenchAiMode(props, emit)
|
} = aiModeRuntime
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../../assets/styles/components/personal-workbench-ai-mode.css"></style>
|
<style scoped src="../../assets/styles/components/personal-workbench-ai-mode.css"></style>
|
||||||
|
|||||||
@@ -12,6 +12,17 @@
|
|||||||
<span class="workbench-ai-file-card__body">
|
<span class="workbench-ai-file-card__body">
|
||||||
<strong :title="file.name">{{ file.name }}</strong>
|
<strong :title="file.name">{{ file.name }}</strong>
|
||||||
<small>{{ file.typeLabel }}</small>
|
<small>{{ file.typeLabel }}</small>
|
||||||
|
<small
|
||||||
|
v-if="file.ocrState?.label"
|
||||||
|
class="workbench-ai-file-card__ocr"
|
||||||
|
:class="`is-${file.ocrState.status || 'idle'}`"
|
||||||
|
:title="file.ocrState.title || file.ocrState.label"
|
||||||
|
>
|
||||||
|
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
|
||||||
|
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
|
||||||
|
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
|
||||||
|
<span>{{ file.ocrState.label }}</span>
|
||||||
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -43,7 +43,23 @@
|
|||||||
:class="{ primary: action.primary }"
|
:class="{ primary: action.primary }"
|
||||||
@click="handleQuickAction(action.event)"
|
@click="handleQuickAction(action.event)"
|
||||||
>
|
>
|
||||||
<i :class="action.icon" aria-hidden="true"></i>
|
<span class="ai-quick-icon" aria-hidden="true">
|
||||||
|
<svg
|
||||||
|
class="ai-sidebar-tabler-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
v-for="(path, pathIndex) in action.iconPaths"
|
||||||
|
:key="`${action.event}-${pathIndex}`"
|
||||||
|
:d="path"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span>{{ action.label }}</span>
|
<span>{{ action.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -60,10 +76,26 @@
|
|||||||
class="ai-nav-btn"
|
class="ai-nav-btn"
|
||||||
:class="{ active: activeView === item.id }"
|
:class="{ active: activeView === item.id }"
|
||||||
:aria-current="activeView === item.id ? 'page' : undefined"
|
:aria-current="activeView === item.id ? 'page' : undefined"
|
||||||
|
@mouseenter="emit('prefetch-view', item.id)"
|
||||||
|
@focus="emit('prefetch-view', item.id)"
|
||||||
@click="emit('navigate', item.id)"
|
@click="emit('navigate', item.id)"
|
||||||
>
|
>
|
||||||
<span class="ai-nav-icon" aria-hidden="true">
|
<span class="ai-nav-icon" aria-hidden="true">
|
||||||
<i :class="item.aiIcon"></i>
|
<svg
|
||||||
|
class="ai-sidebar-tabler-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
v-for="(path, pathIndex) in item.aiIconPaths"
|
||||||
|
:key="`${item.id}-${pathIndex}`"
|
||||||
|
:d="path"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="ai-nav-copy">
|
<span class="ai-nav-copy">
|
||||||
<strong>{{ item.displayLabel }}</strong>
|
<strong>{{ item.displayLabel }}</strong>
|
||||||
@@ -155,7 +187,7 @@ const props = defineProps({
|
|||||||
conversationHistory: { type: Array, default: () => [] }
|
conversationHistory: { type: Array, default: () => [] }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout'])
|
const emit = defineEmits(['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout', 'prefetch-view'])
|
||||||
const conversationSearchOpen = ref(false)
|
const conversationSearchOpen = ref(false)
|
||||||
const conversationSearchQuery = ref('')
|
const conversationSearchQuery = ref('')
|
||||||
const conversationSearchInputRef = ref(null)
|
const conversationSearchInputRef = ref(null)
|
||||||
@@ -164,16 +196,78 @@ const editingConversationTitle = ref('')
|
|||||||
const editingTitleInputRef = ref(null)
|
const editingTitleInputRef = ref(null)
|
||||||
let recentClickTimer = null
|
let recentClickTimer = null
|
||||||
|
|
||||||
|
const tablerIconPaths = {
|
||||||
|
plus: [
|
||||||
|
'M12 5l0 14',
|
||||||
|
'M5 12l14 0'
|
||||||
|
],
|
||||||
|
search: [
|
||||||
|
'M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0',
|
||||||
|
'M21 21l-6 -6'
|
||||||
|
],
|
||||||
|
fileText: [
|
||||||
|
'M14 3v4a1 1 0 0 0 1 1h4',
|
||||||
|
'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2',
|
||||||
|
'M9 9l1 0',
|
||||||
|
'M9 13l6 0',
|
||||||
|
'M9 17l6 0'
|
||||||
|
],
|
||||||
|
folder: [
|
||||||
|
'M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2'
|
||||||
|
],
|
||||||
|
book2: [
|
||||||
|
'M19 4v16h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12',
|
||||||
|
'M19 16h-12a2 2 0 0 0 -2 2',
|
||||||
|
'M9 8h6'
|
||||||
|
],
|
||||||
|
chartLine: [
|
||||||
|
'M4 19l16 0',
|
||||||
|
'M4 15l4 -6l4 2l4 -5l4 4'
|
||||||
|
],
|
||||||
|
chartDonut: [
|
||||||
|
'M10 3.2a9 9 0 1 0 10.8 10.8a1 1 0 0 0 -1 -1h-6.8a2 2 0 0 1 -2 -2v-6.8a1 1 0 0 0 -1 -1',
|
||||||
|
'M15 3.5a9 9 0 0 1 5.5 5.5h-4.5a1 1 0 0 1 -1 -1v-4.5'
|
||||||
|
],
|
||||||
|
shieldCheck: [
|
||||||
|
'M11.46 20.846a12 12 0 0 1 -7.46 -10.846v-4l8 -3l8 3v4c0 1.122 -.154 2.203 -.441 3.226',
|
||||||
|
'M15 19l2 2l4 -4'
|
||||||
|
],
|
||||||
|
robot: [
|
||||||
|
'M7 7h10a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7a2 2 0 0 1 2 -2',
|
||||||
|
'M9 11l.01 0',
|
||||||
|
'M15 11l.01 0',
|
||||||
|
'M9 15h6',
|
||||||
|
'M12 7v-4'
|
||||||
|
],
|
||||||
|
users: [
|
||||||
|
'M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0',
|
||||||
|
'M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2',
|
||||||
|
'M16 3.13a4 4 0 0 1 0 7.75',
|
||||||
|
'M21 21v-2a4 4 0 0 0 -3 -3.85'
|
||||||
|
],
|
||||||
|
sliders: [
|
||||||
|
'M4 6h16',
|
||||||
|
'M4 12h10',
|
||||||
|
'M4 18h14',
|
||||||
|
'M8 6v.01',
|
||||||
|
'M14 12v.01',
|
||||||
|
'M18 18v.01'
|
||||||
|
],
|
||||||
|
circle: [
|
||||||
|
'M12 12m-8 0a8 8 0 1 0 16 0a8 8 0 1 0 -16 0'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
const quickActions = [
|
const quickActions = [
|
||||||
{
|
{
|
||||||
label: '新建对话',
|
label: '新建对话',
|
||||||
icon: 'mdi mdi-plus',
|
iconPaths: tablerIconPaths.plus,
|
||||||
event: 'new-chat',
|
event: 'new-chat',
|
||||||
primary: true
|
primary: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '查询对话',
|
label: '查询对话',
|
||||||
icon: 'mdi mdi-magnify',
|
iconPaths: tablerIconPaths.search,
|
||||||
event: 'search'
|
event: 'search'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -181,15 +275,15 @@ const quickActions = [
|
|||||||
const displayBrandName = computed(() => String(props.brandName || '易财费控').trim() || '易财费控')
|
const displayBrandName = computed(() => String(props.brandName || '易财费控').trim() || '易财费控')
|
||||||
|
|
||||||
const sidebarMeta = {
|
const sidebarMeta = {
|
||||||
overview: { label: '分析看板', icon: 'mdi mdi-chart-line-variant' },
|
overview: { label: '分析看板', iconPaths: tablerIconPaths.chartLine },
|
||||||
documents: { label: '单据中心', icon: 'mdi mdi-file-document-outline' },
|
documents: { label: '单据中心', iconPaths: tablerIconPaths.fileText },
|
||||||
receiptFolder: { label: '票据夹', icon: 'mdi mdi-receipt-text-outline' },
|
receiptFolder: { label: '票据夹', iconPaths: tablerIconPaths.folder },
|
||||||
budget: { label: '预算管理', icon: 'mdi mdi-chart-donut' },
|
budget: { label: '预算管理', iconPaths: tablerIconPaths.chartDonut },
|
||||||
policies: { label: '财务政策', icon: 'mdi mdi-book-open-page-variant-outline' },
|
policies: { label: '财务政策', iconPaths: tablerIconPaths.book2 },
|
||||||
audit: { label: '规则管理', icon: 'mdi mdi-shield-check-outline' },
|
audit: { label: '规则管理', iconPaths: tablerIconPaths.shieldCheck },
|
||||||
digitalEmployees: { label: '数字员工', icon: 'mdi mdi-robot-outline' },
|
digitalEmployees: { label: '数字员工', iconPaths: tablerIconPaths.robot },
|
||||||
employees: { label: '员工管理', icon: 'mdi mdi-account-group-outline' },
|
employees: { label: '员工管理', iconPaths: tablerIconPaths.users },
|
||||||
settings: { label: '系统设置', icon: 'mdi mdi-tune-vertical' }
|
settings: { label: '系统设置', iconPaths: tablerIconPaths.sliders }
|
||||||
}
|
}
|
||||||
|
|
||||||
const aiBusinessViewIds = computed(() => new Set(resolveAiSidebarBusinessViewIds(props.currentUser)))
|
const aiBusinessViewIds = computed(() => new Set(resolveAiSidebarBusinessViewIds(props.currentUser)))
|
||||||
@@ -200,7 +294,7 @@ const businessNavItems = computed(() =>
|
|||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
|
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
|
||||||
aiIcon: sidebarMeta[item.id]?.icon ?? 'mdi mdi-circle-outline'
|
aiIconPaths: sidebarMeta[item.id]?.iconPaths ?? tablerIconPaths.circle
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@
|
|||||||
class="nav-btn"
|
class="nav-btn"
|
||||||
:class="{ active: activeView === item.id }"
|
:class="{ active: activeView === item.id }"
|
||||||
type="button"
|
type="button"
|
||||||
|
@mouseenter="emit('prefetch-view', item.id)"
|
||||||
|
@focus="emit('prefetch-view', item.id)"
|
||||||
@click="emit('navigate', item.id)"
|
@click="emit('navigate', item.id)"
|
||||||
>
|
>
|
||||||
<span class="nav-icon" v-html="item.icon"></span>
|
<span class="nav-icon" v-html="item.icon"></span>
|
||||||
@@ -100,7 +102,7 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse'])
|
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse', 'prefetch-view'])
|
||||||
|
|
||||||
const sidebarMeta = {
|
const sidebarMeta = {
|
||||||
overview: { label: '分析看板' },
|
overview: { label: '分析看板' },
|
||||||
|
|||||||
@@ -153,10 +153,10 @@
|
|||||||
<button
|
<button
|
||||||
class="notification-clear-btn"
|
class="notification-clear-btn"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="notificationItems.length === 0"
|
:disabled="notificationBulkActionDisabled"
|
||||||
@click="clearAllNotifications"
|
@click="handleNotificationBulkAction"
|
||||||
>
|
>
|
||||||
清空通知
|
{{ notificationBulkActionLabel }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="notification-close-btn"
|
class="notification-close-btn"
|
||||||
@@ -201,24 +201,16 @@
|
|||||||
:class="{ unread: item.unread }"
|
:class="{ unread: item.unread }"
|
||||||
@click="openNotification(item)"
|
@click="openNotification(item)"
|
||||||
>
|
>
|
||||||
<span class="notification-type-icon" :class="item.tone">
|
<span class="notification-avatar" :class="item.tone" aria-hidden="true">
|
||||||
<i :class="resolveNotificationIcon(item)"></i>
|
<span class="notification-avatar-label">{{ item.avatarLabel }}</span>
|
||||||
|
<span v-if="item.badge" class="notification-avatar-badge">{{ item.badge }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="notification-row-main">
|
<span class="notification-row-content">
|
||||||
<span class="notification-row-head">
|
<span class="notification-row-top">
|
||||||
<span class="notification-title-line">
|
|
||||||
<strong class="notification-row-title">{{ item.title }}</strong>
|
<strong class="notification-row-title">{{ item.title }}</strong>
|
||||||
<b v-if="item.badge">{{ item.badge }}</b>
|
|
||||||
</span>
|
|
||||||
<span class="notification-row-action" aria-hidden="true">
|
|
||||||
<i class="mdi mdi-chevron-right"></i>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<small class="notification-context">{{ item.description }}</small>
|
|
||||||
<span class="notification-row-foot">
|
|
||||||
<span class="notification-category-pill">{{ item.category || '系统通知' }}</span>
|
|
||||||
<time class="notification-time">{{ item.timeLabel || item.time }}</time>
|
<time class="notification-time">{{ item.timeLabel || item.time }}</time>
|
||||||
</span>
|
</span>
|
||||||
|
<small class="notification-preview">{{ item.description }}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -512,7 +504,8 @@ const {
|
|||||||
isNotificationHidden,
|
isNotificationHidden,
|
||||||
isNotificationRead,
|
isNotificationRead,
|
||||||
loadNotificationStates,
|
loadNotificationStates,
|
||||||
markNotificationStateRead
|
markNotificationStateRead,
|
||||||
|
markNotificationStatesRead
|
||||||
} = useTopBarNotificationStates()
|
} = useTopBarNotificationStates()
|
||||||
const notificationTab = ref('unread')
|
const notificationTab = ref('unread')
|
||||||
|
|
||||||
@@ -565,6 +558,36 @@ function resolveDocumentNotificationDescription(row) {
|
|||||||
].filter(Boolean).join(' · ') || '单据中心有新的单据状态'
|
].filter(Boolean).join(' · ') || '单据中心有新的单据状态'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveNotificationAvatarLabel(item) {
|
||||||
|
const raw = String(
|
||||||
|
item?.avatarLabel
|
||||||
|
|| item?.initiatorName
|
||||||
|
|| item?.applicantName
|
||||||
|
|| item?.employeeName
|
||||||
|
|| item?.category
|
||||||
|
|| item?.title
|
||||||
|
|| '通'
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return '通'
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw.replace(/\s+/g, '').slice(0, 1).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDocumentNotificationAvatarLabel(row) {
|
||||||
|
return resolveNotificationAvatarLabel({
|
||||||
|
avatarLabel:
|
||||||
|
row?.initiatorName
|
||||||
|
|| row?.applicantName
|
||||||
|
|| row?.employeeName
|
||||||
|
|| row?.sourceLabel
|
||||||
|
|| row?.documentTypeLabel
|
||||||
|
|| row?.title
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function resolveWorkbenchNotificationId(item, index) {
|
function resolveWorkbenchNotificationId(item, index) {
|
||||||
return normalizeNotificationId(`workbench:${item?.id || [item?.title, item?.description, item?.time, index].join('|')}`)
|
return normalizeNotificationId(`workbench:${item?.id || [item?.title, item?.description, item?.time, index].join('|')}`)
|
||||||
}
|
}
|
||||||
@@ -583,12 +606,12 @@ const documentNotificationItems = computed(() =>
|
|||||||
kind: 'document',
|
kind: 'document',
|
||||||
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
|
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
|
||||||
description: resolveDocumentNotificationDescription(row),
|
description: resolveDocumentNotificationDescription(row),
|
||||||
|
avatarLabel: resolveDocumentNotificationAvatarLabel(row),
|
||||||
time: row.updatedAt || row.createdAt,
|
time: row.updatedAt || row.createdAt,
|
||||||
timeLabel: formatNotificationTimeLabel(row.updatedAt || row.createdAt),
|
timeLabel: formatNotificationTimeLabel(row.updatedAt || row.createdAt),
|
||||||
category: row.sourceLabel || '单据中心',
|
category: row.sourceLabel || '单据中心',
|
||||||
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
|
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
|
||||||
unread,
|
unread,
|
||||||
icon: row.source === 'approval' ? 'mdi mdi-clipboard-text-clock-outline' : 'mdi mdi-file-document-outline',
|
|
||||||
badge: unread ? '新' : '',
|
badge: unread ? '新' : '',
|
||||||
target: {
|
target: {
|
||||||
type: 'document',
|
type: 'document',
|
||||||
@@ -615,10 +638,10 @@ const workbenchNotificationItems = computed(() => (
|
|||||||
id,
|
id,
|
||||||
kind: 'workbench',
|
kind: 'workbench',
|
||||||
category: item.category || '个人工作台',
|
category: item.category || '个人工作台',
|
||||||
|
avatarLabel: resolveNotificationAvatarLabel(item),
|
||||||
time: notificationTime,
|
time: notificationTime,
|
||||||
timeLabel: formatNotificationTimeLabel(item.time || item.updatedAt || item.due),
|
timeLabel: formatNotificationTimeLabel(item.time || item.updatedAt || item.due),
|
||||||
unread: Boolean(item.unread) && !readNotificationIds.value.has(id),
|
unread: Boolean(item.unread) && !readNotificationIds.value.has(id)
|
||||||
icon: item.icon || resolveNotificationIcon(item)
|
|
||||||
}
|
}
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
: []
|
: []
|
||||||
@@ -631,6 +654,14 @@ const readNotifications = computed(() => notificationItems.value.filter((item) =
|
|||||||
const activeNotifications = computed(() => (
|
const activeNotifications = computed(() => (
|
||||||
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
|
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
|
||||||
))
|
))
|
||||||
|
const notificationBulkActionLabel = computed(() => (
|
||||||
|
notificationTab.value === 'unread' ? '全部已读' : '删除已读'
|
||||||
|
))
|
||||||
|
const notificationBulkActionDisabled = computed(() => (
|
||||||
|
notificationTab.value === 'unread'
|
||||||
|
? unreadNotifications.value.length === 0
|
||||||
|
: readNotifications.value.length === 0
|
||||||
|
))
|
||||||
const topbarNotificationCount = computed(() => {
|
const topbarNotificationCount = computed(() => {
|
||||||
const count = unreadNotifications.value.length
|
const count = unreadNotifications.value.length
|
||||||
return count > 0 ? Math.min(count, 99) : 0
|
return count > 0 ? Math.min(count, 99) : 0
|
||||||
@@ -659,26 +690,6 @@ function scheduleDocumentInboxInitialRefresh() {
|
|||||||
}, props.activeView === 'workbench' ? 1200 : 6000)
|
}, props.activeView === 'workbench' ? 1200 : 6000)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveNotificationIcon(item) {
|
|
||||||
if (item?.icon) {
|
|
||||||
return item.icon
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item?.tone === 'danger') {
|
|
||||||
return 'mdi mdi-alert-circle-outline'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item?.tone === 'warning') {
|
|
||||||
return 'mdi mdi-alert-outline'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item?.tone === 'success') {
|
|
||||||
return 'mdi mdi-check-circle-outline'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'mdi mdi-bell-outline'
|
|
||||||
}
|
|
||||||
|
|
||||||
function markNotificationRead(item) {
|
function markNotificationRead(item) {
|
||||||
if (!item?.id || !item.unread) {
|
if (!item?.id || !item.unread) {
|
||||||
return
|
return
|
||||||
@@ -691,8 +702,8 @@ function markNotificationRead(item) {
|
|||||||
void markNotificationStateRead(item)
|
void markNotificationStateRead(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAllNotifications() {
|
function markUnreadNotificationsRead() {
|
||||||
const currentItems = notificationItems.value
|
const currentItems = unreadNotifications.value
|
||||||
if (!currentItems.length) {
|
if (!currentItems.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -705,8 +716,29 @@ function clearAllNotifications() {
|
|||||||
markDocumentInboxRowsRead(documentRows)
|
markDocumentInboxRowsRead(documentRows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void markNotificationStatesRead(currentItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteReadNotifications() {
|
||||||
|
const currentItems = readNotifications.value
|
||||||
|
if (!currentItems.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
void hideNotificationStates(currentItems)
|
void hideNotificationStates(currentItems)
|
||||||
notificationTab.value = 'unread'
|
}
|
||||||
|
|
||||||
|
function handleNotificationBulkAction() {
|
||||||
|
if (notificationBulkActionDisabled.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notificationTab.value === 'unread') {
|
||||||
|
markUnreadNotificationsRead()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteReadNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNotification(item) {
|
function openNotification(item) {
|
||||||
|
|||||||
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 { isApplicationDocumentNo } from '../../utils/documentClassification.js'
|
||||||
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../../utils/riskFlags.js'
|
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../../utils/riskFlags.js'
|
||||||
|
import {
|
||||||
|
DOCUMENT_TYPE_APPLICATION,
|
||||||
|
DOCUMENT_TYPE_EXPENSE_APPLICATION,
|
||||||
|
DOCUMENT_TYPE_REIMBURSEMENT,
|
||||||
|
resolveDocumentTypeLabel
|
||||||
|
} from '../../constants/documentProtocol.js'
|
||||||
|
|
||||||
const EXPENSE_TYPE_LABELS = {
|
const EXPENSE_TYPE_LABELS = {
|
||||||
travel: '差旅费',
|
travel: '差旅费',
|
||||||
@@ -49,8 +55,6 @@ const DOCUMENT_BACKED_EXPENSE_TYPES = new Set([
|
|||||||
'hotel_ticket',
|
'hotel_ticket',
|
||||||
'ride_ticket'
|
'ride_ticket'
|
||||||
])
|
])
|
||||||
const DOCUMENT_TYPE_APPLICATION = 'application'
|
|
||||||
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
|
||||||
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
|
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
|
||||||
const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态'
|
const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态'
|
||||||
const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档'
|
const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档'
|
||||||
@@ -179,14 +183,14 @@ function resolveDocumentTypeMeta(claim, typeCode) {
|
|||||||
const normalizedType = String(typeCode || '').trim()
|
const normalizedType = String(typeCode || '').trim()
|
||||||
const isApplication =
|
const isApplication =
|
||||||
explicitType === DOCUMENT_TYPE_APPLICATION
|
explicitType === DOCUMENT_TYPE_APPLICATION
|
||||||
|| explicitType === 'expense_application'
|
|| explicitType === DOCUMENT_TYPE_EXPENSE_APPLICATION
|
||||||
|| isApplicationDocumentNo(claimNo)
|
|| isApplicationDocumentNo(claimNo)
|
||||||
|| normalizedType === 'application'
|
|| normalizedType === 'application'
|
||||||
|| normalizedType.endsWith('_application')
|
|| normalizedType.endsWith('_application')
|
||||||
|
|
||||||
return isApplication
|
return isApplication
|
||||||
? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: '申请单' }
|
? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: resolveDocumentTypeLabel(DOCUMENT_TYPE_APPLICATION) }
|
||||||
: { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: '报销单' }
|
: { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: resolveDocumentTypeLabel(DOCUMENT_TYPE_REIMBURSEMENT) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeExpenseType(typeCode) {
|
function normalizeExpenseType(typeCode) {
|
||||||
|
|||||||
@@ -139,7 +139,14 @@ export function useTopBarNotificationStates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markNotificationStateRead(item) {
|
function markNotificationStateRead(item) {
|
||||||
return syncNotificationPatches([buildPatch(item, { read: true })])
|
return markNotificationStatesRead([item])
|
||||||
|
}
|
||||||
|
|
||||||
|
function markNotificationStatesRead(items) {
|
||||||
|
const patches = (Array.isArray(items) ? items : [])
|
||||||
|
.map((item) => buildPatch(item, { read: true, hidden: false }))
|
||||||
|
.filter(Boolean)
|
||||||
|
return syncNotificationPatches(patches)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideNotificationStates(items) {
|
function hideNotificationStates(items) {
|
||||||
@@ -158,6 +165,7 @@ export function useTopBarNotificationStates() {
|
|||||||
isNotificationHidden,
|
isNotificationHidden,
|
||||||
isNotificationRead,
|
isNotificationRead,
|
||||||
loadNotificationStates,
|
loadNotificationStates,
|
||||||
markNotificationStateRead
|
markNotificationStateRead,
|
||||||
|
markNotificationStatesRead
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ import {
|
|||||||
} from './workbenchAiComposerModel.js'
|
} from './workbenchAiComposerModel.js'
|
||||||
import {
|
import {
|
||||||
createWorkbenchAiMessageRuntime,
|
createWorkbenchAiMessageRuntime,
|
||||||
formatMessageTime,
|
formatMessageTime
|
||||||
normalizeInlineAttachmentOcrDetails
|
|
||||||
} from './workbenchAiMessageModel.js'
|
} from './workbenchAiMessageModel.js'
|
||||||
import { useWorkbenchAiActionRouter } from './useWorkbenchAiActionRouter.js'
|
import { useWorkbenchAiActionRouter } from './useWorkbenchAiActionRouter.js'
|
||||||
import { useWorkbenchAiAttachmentAssociationFlow } from './useWorkbenchAiAttachmentAssociationFlow.js'
|
import { useWorkbenchAiAttachmentAssociationFlow } from './useWorkbenchAiAttachmentAssociationFlow.js'
|
||||||
@@ -34,8 +33,10 @@ import { useWorkbenchAiComposerFiles } from './useWorkbenchAiComposerFiles.js'
|
|||||||
import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js'
|
import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js'
|
||||||
import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js'
|
import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js'
|
||||||
import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
|
import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
|
||||||
|
import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js'
|
||||||
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
|
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
|
||||||
import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
|
import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
|
||||||
|
import { isReimbursementCreationIntent } from './workbenchAiApplicationGateModel.js'
|
||||||
|
|
||||||
const AI_SEARCH_CONVERSATION_ID = 'ai-search'
|
const AI_SEARCH_CONVERSATION_ID = 'ai-search'
|
||||||
const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
|
const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
|
||||||
@@ -174,6 +175,23 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
...card,
|
...card,
|
||||||
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
|
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
|
||||||
})))
|
})))
|
||||||
|
const {
|
||||||
|
hasInlineAttachmentOcrDetails,
|
||||||
|
hasInlineThinking,
|
||||||
|
isInlineAttachmentOcrExpanded,
|
||||||
|
isInlineThinkingExpanded,
|
||||||
|
resolveInlineAttachmentOcrDocuments,
|
||||||
|
resolveInlineAttachmentOcrFileCount,
|
||||||
|
resolveInlineThinkingEvents,
|
||||||
|
toggleInlineAttachmentOcrDetails,
|
||||||
|
toggleInlineThinking
|
||||||
|
} = useWorkbenchAiMessageExpansion({
|
||||||
|
attachmentOcrExpandedMessageIds,
|
||||||
|
inlineConversationAutoScrollPinned,
|
||||||
|
scrollInlineConversationToBottom,
|
||||||
|
thinkingCollapsedMessageIds,
|
||||||
|
thinkingExpandedMessageIds
|
||||||
|
})
|
||||||
|
|
||||||
const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
|
const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
|
||||||
activateInlineConversation,
|
activateInlineConversation,
|
||||||
@@ -324,6 +342,10 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAssistantInputRef(element) {
|
||||||
|
assistantInputRef.value = element
|
||||||
|
}
|
||||||
|
|
||||||
function isInlineConversationNearBottom() {
|
function isInlineConversationNearBottom() {
|
||||||
const el = conversationScrollRef.value
|
const el = conversationScrollRef.value
|
||||||
if (!el) {
|
if (!el) {
|
||||||
@@ -499,78 +521,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
return renderAiConversationHtml(content)
|
return renderAiConversationHtml(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveInlineThinkingEvents(message) {
|
|
||||||
return Array.isArray(message?.stewardPlan?.thinkingEvents) ? message.stewardPlan.thinkingEvents : []
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasInlineThinking(message) {
|
|
||||||
return resolveInlineThinkingEvents(message).length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInlineThinkingExpanded(message) {
|
|
||||||
if (!message?.id) {
|
|
||||||
return Boolean(message?.pending)
|
|
||||||
}
|
|
||||||
if (thinkingCollapsedMessageIds.value.has(message.id)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return Boolean(message.pending || thinkingExpandedMessageIds.value.has(message.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleInlineThinking(message) {
|
|
||||||
if (!message?.id) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const nextExpandedIds = new Set(thinkingExpandedMessageIds.value)
|
|
||||||
const nextCollapsedIds = new Set(thinkingCollapsedMessageIds.value)
|
|
||||||
if (isInlineThinkingExpanded(message)) {
|
|
||||||
nextExpandedIds.delete(message.id)
|
|
||||||
nextCollapsedIds.add(message.id)
|
|
||||||
} else {
|
|
||||||
nextCollapsedIds.delete(message.id)
|
|
||||||
nextExpandedIds.add(message.id)
|
|
||||||
}
|
|
||||||
thinkingExpandedMessageIds.value = nextExpandedIds
|
|
||||||
thinkingCollapsedMessageIds.value = nextCollapsedIds
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasInlineAttachmentOcrDetails(message = {}) {
|
|
||||||
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
|
|
||||||
return Boolean(details?.documents?.length || details?.fileNames?.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveInlineAttachmentOcrDocuments(message = {}) {
|
|
||||||
return normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)?.documents || []
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveInlineAttachmentOcrFileCount(message = {}) {
|
|
||||||
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
|
|
||||||
return Math.max(details?.documents?.length || 0, details?.fileNames?.length || 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInlineAttachmentOcrExpanded(message = {}) {
|
|
||||||
return Boolean(message?.id && attachmentOcrExpandedMessageIds.value.has(message.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleInlineAttachmentOcrDetails(message = {}, forceExpanded = null) {
|
|
||||||
if (!message?.id || !hasInlineAttachmentOcrDetails(message)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const nextExpandedIds = new Set(attachmentOcrExpandedMessageIds.value)
|
|
||||||
const shouldExpand = forceExpanded === null
|
|
||||||
? !nextExpandedIds.has(message.id)
|
|
||||||
: Boolean(forceExpanded)
|
|
||||||
if (shouldExpand) {
|
|
||||||
nextExpandedIds.add(message.id)
|
|
||||||
} else {
|
|
||||||
nextExpandedIds.delete(message.id)
|
|
||||||
}
|
|
||||||
attachmentOcrExpandedMessageIds.value = nextExpandedIds
|
|
||||||
nextTick(() => {
|
|
||||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInlinePromptText(rawPrompt, files = []) {
|
function buildInlinePromptText(rawPrompt, files = []) {
|
||||||
const prompt = buildWorkbenchPromptText(rawPrompt)
|
const prompt = buildWorkbenchPromptText(rawPrompt)
|
||||||
if (prompt) {
|
if (prompt) {
|
||||||
@@ -579,20 +529,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
return files.length ? '请帮我处理已上传的附件。' : ''
|
return files.length ? '请帮我处理已上传的附件。' : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function isReimbursementCreationIntent(prompt = '') {
|
|
||||||
const compact = String(prompt || '').replace(/\s+/g, '')
|
|
||||||
if (!compact || !/报销|报账/.test(compact)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (/标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗|查一下|查询|状态|进度|多少钱|多少/.test(compact)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
/^(我要|我想|我需要|帮我|请帮我|需要|发起|新建|创建|提交|办理|走).{0,16}(报销|报账)/.test(compact) ||
|
|
||||||
/^(报销|报账)(一下|一笔|单|流程)?$/.test(compact)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAiAnswerMarkdownClick(event) {
|
function handleAiAnswerMarkdownClick(event) {
|
||||||
const target = event?.target
|
const target = event?.target
|
||||||
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
|
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
|
||||||
@@ -800,6 +736,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
scrollInlineConversationToTop,
|
scrollInlineConversationToTop,
|
||||||
selectedFileCards,
|
selectedFileCards,
|
||||||
sending,
|
sending,
|
||||||
|
setAssistantInputRef,
|
||||||
setWorkbenchDateMode,
|
setWorkbenchDateMode,
|
||||||
submitAiModePrompt,
|
submitAiModePrompt,
|
||||||
toggleInlineAttachmentOcrDetails,
|
toggleInlineAttachmentOcrDetails,
|
||||||
|
|||||||
@@ -24,10 +24,15 @@ import {
|
|||||||
buildInlineApplicationSubmitPrecheckPayload,
|
buildInlineApplicationSubmitPrecheckPayload,
|
||||||
buildInlineApplicationSubmitThinkingEvents,
|
buildInlineApplicationSubmitThinkingEvents,
|
||||||
completeInlineThinkingEvents,
|
completeInlineThinkingEvents,
|
||||||
extractInlineApplicationDraftPayload,
|
extractInlineApplicationDraftPayload
|
||||||
resolveInlineApplicationPreviewActionFromText
|
|
||||||
} from './workbenchAiApplicationPreviewModel.js'
|
} from './workbenchAiApplicationPreviewModel.js'
|
||||||
import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js'
|
import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js'
|
||||||
|
import {
|
||||||
|
isOrphanInlineApplicationPreviewMessage,
|
||||||
|
resolveInlineApplicationPreviewTextAction,
|
||||||
|
resolveLatestApplicationPreviewMessage,
|
||||||
|
resolveLatestOrphanApplicationPreviewMessage
|
||||||
|
} from './workbenchAiApplicationGateModel.js'
|
||||||
|
|
||||||
function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) {
|
function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) {
|
||||||
const fields = normalizeApplicationPreview(applicationPreview).fields || {}
|
const fields = normalizeApplicationPreview(applicationPreview).fields || {}
|
||||||
@@ -197,23 +202,12 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
].join('\n\n')
|
].join('\n\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLatestApplicationPreviewMessage() {
|
function resolveLatestInlineApplicationPreviewMessage() {
|
||||||
return [...conversationMessages.value]
|
return resolveLatestApplicationPreviewMessage(conversationMessages.value)
|
||||||
.reverse()
|
|
||||||
.find((message) => message.role === 'assistant' && message.applicationPreview)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOrphanInlineApplicationPreviewMessage(message = {}) {
|
function resolveLatestOrphanInlineApplicationPreviewMessage() {
|
||||||
if (message?.applicationPreview || message?.role !== 'assistant') {
|
return resolveLatestOrphanApplicationPreviewMessage(conversationMessages.value)
|
||||||
return false
|
|
||||||
}
|
|
||||||
return /下方表格|申请核对表|点击对应行即可直接编辑/.test(String(message.content || message.text || ''))
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLatestOrphanApplicationPreviewMessage() {
|
|
||||||
return [...conversationMessages.value]
|
|
||||||
.reverse()
|
|
||||||
.find((message) => isOrphanInlineApplicationPreviewMessage(message))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) {
|
function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) {
|
||||||
@@ -310,7 +304,7 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) {
|
async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) {
|
||||||
const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestApplicationPreviewMessage()
|
const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestInlineApplicationPreviewMessage()
|
||||||
if (!targetMessage?.applicationPreview) {
|
if (!targetMessage?.applicationPreview) {
|
||||||
toast('当前没有可提交的申请表。')
|
toast('当前没有可提交的申请表。')
|
||||||
return false
|
return false
|
||||||
@@ -446,12 +440,12 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
toast('请等待费用测算完成后再继续操作。')
|
toast('请等待费用测算完成后再继续操作。')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const actionType = resolveInlineApplicationPreviewActionFromText(prompt)
|
const actionType = resolveInlineApplicationPreviewTextAction(prompt)
|
||||||
if (!actionType) {
|
if (!actionType) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!resolveLatestApplicationPreviewMessage()) {
|
if (!resolveLatestInlineApplicationPreviewMessage()) {
|
||||||
const orphanPreviewMessage = resolveLatestOrphanApplicationPreviewMessage()
|
const orphanPreviewMessage = resolveLatestOrphanInlineApplicationPreviewMessage()
|
||||||
if (!orphanPreviewMessage) {
|
if (!orphanPreviewMessage) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_SAVE_DRAFT,
|
||||||
AI_APPLICATION_ACTION_SUBMIT
|
AI_APPLICATION_ACTION_SUBMIT
|
||||||
} from '../../services/aiApplicationPreviewActions.js'
|
} from '../../services/aiApplicationPreviewActions.js'
|
||||||
|
import { INLINE_APPLICATION_STATUS_LABELS } from '../../constants/documentProtocol.js'
|
||||||
const INLINE_APPLICATION_STATUS_LABELS = {
|
import { resolveInlineApplicationPreviewTextAction } from './workbenchAiApplicationGateModel.js'
|
||||||
draft: '草稿',
|
|
||||||
submitted: '审批中',
|
|
||||||
pending: '待处理',
|
|
||||||
approved: '已审批',
|
|
||||||
completed: '已完成',
|
|
||||||
archived: '已归档',
|
|
||||||
returned: '已退回',
|
|
||||||
rejected: '已驳回',
|
|
||||||
pending_payment: '待付款',
|
|
||||||
paid: '已付款'
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeInlineApplicationResultTableCell(value, fallback = '-') {
|
function normalizeInlineApplicationResultTableCell(value, fallback = '-') {
|
||||||
const text = String(value || '')
|
const text = String(value || '')
|
||||||
@@ -201,17 +190,7 @@ export function buildInlineApplicationDetailAction(draftPayload = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveInlineApplicationPreviewActionFromText(text = '') {
|
export function resolveInlineApplicationPreviewActionFromText(text = '') {
|
||||||
const normalized = String(text || '').replace(/\s+/g, '').trim()
|
return resolveInlineApplicationPreviewTextAction(text)
|
||||||
if (!normalized) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) {
|
|
||||||
return AI_APPLICATION_ACTION_SAVE_DRAFT
|
|
||||||
}
|
|
||||||
if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) {
|
|
||||||
return AI_APPLICATION_ACTION_SUBMIT
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback = '费用申请') {
|
export function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback = '费用申请') {
|
||||||
|
|||||||
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 { renderLegacyAttachmentAssociationHtml } from './aiConversationLegacyAttachmentRenderer.js'
|
||||||
import { parseTableRow, renderTable } from './aiConversationTableRenderer.js'
|
import { parseTableRow, renderTable } from './aiConversationTableRenderer.js'
|
||||||
|
import {
|
||||||
const ALLOWED_COLON_HEADING_TITLES = new Set([
|
DOCUMENT_DETAIL_HREF_PREFIX,
|
||||||
'基础信息识别结果',
|
extractTrustedHtmlBlocks,
|
||||||
'报销测算参考',
|
normalizeConversationText,
|
||||||
'补充信息'
|
restoreTrustedHtmlBlocks
|
||||||
])
|
} from './conversationTrustedHtml.js'
|
||||||
|
|
||||||
const BUSINESS_FIELD_LABELS = new Set([
|
|
||||||
'时间',
|
|
||||||
'地点',
|
|
||||||
'事由',
|
|
||||||
'金额',
|
|
||||||
'费用类型',
|
|
||||||
'报销类型',
|
|
||||||
'商户',
|
|
||||||
'商户/开票方',
|
|
||||||
'客户',
|
|
||||||
'客户/项目对象',
|
|
||||||
'附件',
|
|
||||||
'附件/凭证',
|
|
||||||
'出行方式'
|
|
||||||
])
|
|
||||||
|
|
||||||
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
||||||
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
||||||
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
|
|
||||||
const TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g
|
|
||||||
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
|
|
||||||
const TRUSTED_HTML_ALLOWED_TAGS = new Set([
|
|
||||||
'section',
|
|
||||||
'article',
|
|
||||||
'header',
|
|
||||||
'footer',
|
|
||||||
'div',
|
|
||||||
'span',
|
|
||||||
'strong',
|
|
||||||
'a'
|
|
||||||
])
|
|
||||||
const TRUSTED_HTML_ALLOWED_ATTRS = new Set([
|
|
||||||
'aria-label',
|
|
||||||
'class',
|
|
||||||
'data-ai-action',
|
|
||||||
'href'
|
|
||||||
])
|
|
||||||
|
|
||||||
function escapeHtml(value = '') {
|
function escapeHtml(value = '') {
|
||||||
return String(value)
|
return String(value)
|
||||||
@@ -146,150 +111,6 @@ function renderInlineHtml(value = '') {
|
|||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitColonHeadingLine(line) {
|
|
||||||
const rawLine = String(line || '')
|
|
||||||
const trimmed = rawLine.trim()
|
|
||||||
if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) {
|
|
||||||
return [rawLine]
|
|
||||||
}
|
|
||||||
|
|
||||||
const chineseColonIndex = trimmed.indexOf(':')
|
|
||||||
const asciiColonIndex = trimmed.indexOf(':')
|
|
||||||
const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0)
|
|
||||||
if (!colonIndexes.length) {
|
|
||||||
return [rawLine]
|
|
||||||
}
|
|
||||||
|
|
||||||
const colonIndex = Math.min(...colonIndexes)
|
|
||||||
const title = trimmed.slice(0, colonIndex)
|
|
||||||
const body = trimmed.slice(colonIndex + 1).trim()
|
|
||||||
if (!ALLOWED_COLON_HEADING_TITLES.has(title)) {
|
|
||||||
return [rawLine]
|
|
||||||
}
|
|
||||||
return body ? [`### ${title}`, '', body] : [`### ${title}`]
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBusinessFieldLine(line) {
|
|
||||||
const rawLine = String(line || '')
|
|
||||||
const trimmed = rawLine.trim()
|
|
||||||
if (
|
|
||||||
!trimmed ||
|
|
||||||
trimmed.startsWith('|') ||
|
|
||||||
/^[-*+]\s/.test(trimmed) ||
|
|
||||||
/^#{1,6}\s/.test(trimmed)
|
|
||||||
) {
|
|
||||||
return rawLine
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = trimmed.match(/^([^::\n]{1,16})[::]\s*(.+)$/u)
|
|
||||||
if (!match) {
|
|
||||||
return rawLine
|
|
||||||
}
|
|
||||||
const label = match[1].trim()
|
|
||||||
const value = match[2].trim()
|
|
||||||
if (!BUSINESS_FIELD_LABELS.has(label) || !value) {
|
|
||||||
return rawLine
|
|
||||||
}
|
|
||||||
return `- **${label}**:${value}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeConversationText(text = '') {
|
|
||||||
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n')
|
|
||||||
const normalizedLines = []
|
|
||||||
let inFence = false
|
|
||||||
|
|
||||||
lines.forEach((line) => {
|
|
||||||
if (/^\s*(```|~~~)/.test(line)) {
|
|
||||||
inFence = !inFence
|
|
||||||
normalizedLines.push(line)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (inFence) {
|
|
||||||
normalizedLines.push(line)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLines = splitColonHeadingLine(line)
|
|
||||||
if (nextLines[0]?.startsWith('### ') && normalizedLines.length) {
|
|
||||||
const previousLine = normalizedLines[normalizedLines.length - 1]
|
|
||||||
if (String(previousLine || '').trim()) {
|
|
||||||
normalizedLines.push('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine)))
|
|
||||||
})
|
|
||||||
|
|
||||||
return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasOnlyTrustedHtmlTags(html = '') {
|
|
||||||
const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi
|
|
||||||
let match = tagPattern.exec(html)
|
|
||||||
while (match) {
|
|
||||||
const tagName = String(match[1] || '').toLowerCase()
|
|
||||||
if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const attrText = String(match[2] || '')
|
|
||||||
const attrPattern = /\s([:@\w-]+)\s*=/g
|
|
||||||
let attrMatch = attrPattern.exec(attrText)
|
|
||||||
while (attrMatch) {
|
|
||||||
const attrName = String(attrMatch[1] || '').toLowerCase()
|
|
||||||
if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
attrMatch = attrPattern.exec(attrText)
|
|
||||||
}
|
|
||||||
match = tagPattern.exec(html)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeTrustedHtmlBlock(html = '') {
|
|
||||||
const value = String(html || '').trim()
|
|
||||||
if (!value || !value.includes('class="ai-document-card-list"')) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
if (!hasOnlyTrustedHtmlTags(value)) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim())
|
|
||||||
if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTrustedHtmlBlocks(text = '') {
|
|
||||||
const trustedHtmlBlocks = []
|
|
||||||
const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => {
|
|
||||||
const sanitizedHtml = sanitizeTrustedHtmlBlock(html)
|
|
||||||
if (!sanitizedHtml) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}`
|
|
||||||
trustedHtmlBlocks.push(sanitizedHtml)
|
|
||||||
return `\n\n${placeholder}\n\n`
|
|
||||||
})
|
|
||||||
return { content, trustedHtmlBlocks }
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = []) {
|
|
||||||
return trustedHtmlBlocks.reduce((nextHtml, block, index) => {
|
|
||||||
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}`
|
|
||||||
const paragraphPattern = new RegExp(`<p class="ai-html-paragraph">${placeholder}</p>`, 'g')
|
|
||||||
return nextHtml
|
|
||||||
.replace(paragraphPattern, block)
|
|
||||||
.replaceAll(placeholder, block)
|
|
||||||
}, html)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFenceLine(line = '') {
|
function isFenceLine(line = '') {
|
||||||
return /^\s*(```|~~~)/.test(String(line || ''))
|
return /^\s*(```|~~~)/.test(String(line || ''))
|
||||||
}
|
}
|
||||||
@@ -501,7 +322,7 @@ export function renderAiConversationHtml(content = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const extracted = extractTrustedHtmlBlocks(content)
|
const extracted = extractTrustedHtmlBlocks(content)
|
||||||
const normalized = normalizeConversationText(extracted.content)
|
const normalized = normalizeConversationText(extracted.content, { trim: true })
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -628,6 +449,7 @@ export function renderAiConversationHtml(content = '') {
|
|||||||
|
|
||||||
return restoreTrustedHtmlBlocks(
|
return restoreTrustedHtmlBlocks(
|
||||||
`<div class="ai-html-flow">${blocks.filter(Boolean).join('')}</div>`,
|
`<div class="ai-html-flow">${blocks.filter(Boolean).join('')}</div>`,
|
||||||
extracted.trustedHtmlBlocks
|
extracted.trustedHtmlBlocks,
|
||||||
|
{ paragraphClass: 'ai-html-paragraph' }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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 { isNewDocument } from './documentCenterNewState.js'
|
||||||
import { isArchivedDocumentRow } from './documentCenterRows.js'
|
import { isArchivedDocumentRow } from './documentCenterRows.js'
|
||||||
import { sortDocumentRowsByLatestTime } from './documentCenterSort.js'
|
import { sortDocumentRowsByLatestTime } from './documentCenterSort.js'
|
||||||
|
import {
|
||||||
|
DOCUMENT_TYPE_ALL,
|
||||||
|
DOCUMENT_TYPE_APPLICATION,
|
||||||
|
DOCUMENT_TYPE_REIMBURSEMENT,
|
||||||
|
resolveDocumentTypeLabel
|
||||||
|
} from '../constants/documentProtocol.js'
|
||||||
import {
|
import {
|
||||||
extractDateText,
|
extractDateText,
|
||||||
formatDocumentListTime,
|
formatDocumentListTime,
|
||||||
@@ -10,9 +16,11 @@ import {
|
|||||||
} from './documentCenterTime.js'
|
} from './documentCenterTime.js'
|
||||||
import { normalizeRequestForUi } from './requestViewModel.js'
|
import { normalizeRequestForUi } from './requestViewModel.js'
|
||||||
|
|
||||||
export const DOCUMENT_TYPE_ALL = 'all'
|
export {
|
||||||
export const DOCUMENT_TYPE_APPLICATION = 'application'
|
DOCUMENT_TYPE_ALL,
|
||||||
export const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
DOCUMENT_TYPE_APPLICATION,
|
||||||
|
DOCUMENT_TYPE_REIMBURSEMENT
|
||||||
|
}
|
||||||
export const SCENE_ALL = 'all'
|
export const SCENE_ALL = 'all'
|
||||||
export const DOCUMENT_SCOPE_ALL = '全部'
|
export const DOCUMENT_SCOPE_ALL = '全部'
|
||||||
export const DOCUMENT_SCOPE_APPLICATION = '申请单'
|
export const DOCUMENT_SCOPE_APPLICATION = '申请单'
|
||||||
@@ -129,7 +137,7 @@ export function buildDocumentRow(request, options = {}) {
|
|||||||
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
|
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
|
||||||
const documentTypeLabel =
|
const documentTypeLabel =
|
||||||
normalized.documentTypeLabel
|
normalized.documentTypeLabel
|
||||||
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
|
|| resolveDocumentTypeLabel(documentTypeCode)
|
||||||
const initiatorName = String(
|
const initiatorName = String(
|
||||||
normalized.person
|
normalized.person
|
||||||
|| normalized.employeeName
|
|| normalized.employeeName
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
|
import {
|
||||||
|
DOCUMENT_DETAIL_HREF_PREFIX,
|
||||||
|
extractTrustedHtmlBlocks,
|
||||||
|
normalizeConversationText,
|
||||||
|
restoreTrustedHtmlBlocks
|
||||||
|
} from './conversationTrustedHtml.js'
|
||||||
|
|
||||||
const markdown = new MarkdownIt({
|
const markdown = new MarkdownIt({
|
||||||
html: false,
|
html: false,
|
||||||
@@ -25,25 +31,6 @@ const ACTION_LINK_CLASS_BY_HREF = {
|
|||||||
'#review-quick-edit': 'markdown-action-link-edit',
|
'#review-quick-edit': 'markdown-action-link-edit',
|
||||||
'#review-risk-panel': 'markdown-action-link-risk'
|
'#review-risk-panel': 'markdown-action-link-risk'
|
||||||
}
|
}
|
||||||
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
|
|
||||||
const TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g
|
|
||||||
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
|
|
||||||
const TRUSTED_HTML_ALLOWED_TAGS = new Set([
|
|
||||||
'section',
|
|
||||||
'article',
|
|
||||||
'header',
|
|
||||||
'footer',
|
|
||||||
'div',
|
|
||||||
'span',
|
|
||||||
'strong',
|
|
||||||
'a'
|
|
||||||
])
|
|
||||||
const TRUSTED_HTML_ALLOWED_ATTRS = new Set([
|
|
||||||
'aria-label',
|
|
||||||
'class',
|
|
||||||
'data-ai-action',
|
|
||||||
'href'
|
|
||||||
])
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
return String(text || '')
|
return String(text || '')
|
||||||
@@ -136,176 +123,8 @@ markdown.renderer.rules.table_close = (tokens, idx, options, env, self) => (
|
|||||||
`${defaultTableClose ? defaultTableClose(tokens, idx, options, env, self) : '</table>'}</div>`
|
`${defaultTableClose ? defaultTableClose(tokens, idx, options, env, self) : '</table>'}</div>`
|
||||||
)
|
)
|
||||||
|
|
||||||
const ALLOWED_COLON_HEADING_TITLES = new Set([
|
|
||||||
'基础信息识别结果',
|
|
||||||
'报销测算参考',
|
|
||||||
'补充信息'
|
|
||||||
])
|
|
||||||
|
|
||||||
const BUSINESS_FIELD_LABELS = new Set([
|
|
||||||
'时间',
|
|
||||||
'地点',
|
|
||||||
'事由',
|
|
||||||
'金额',
|
|
||||||
'费用类型',
|
|
||||||
'报销类型',
|
|
||||||
'商户',
|
|
||||||
'商户/开票方',
|
|
||||||
'客户',
|
|
||||||
'客户/项目对象',
|
|
||||||
'附件',
|
|
||||||
'附件/凭证',
|
|
||||||
'出行方式'
|
|
||||||
])
|
|
||||||
|
|
||||||
function splitColonHeadingLine(line) {
|
|
||||||
const rawLine = String(line || '')
|
|
||||||
const trimmed = rawLine.trim()
|
|
||||||
if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) {
|
|
||||||
return [rawLine]
|
|
||||||
}
|
|
||||||
|
|
||||||
const chineseColonIndex = trimmed.indexOf(':')
|
|
||||||
const asciiColonIndex = trimmed.indexOf(':')
|
|
||||||
const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0)
|
|
||||||
if (!colonIndexes.length) {
|
|
||||||
return [rawLine]
|
|
||||||
}
|
|
||||||
|
|
||||||
const colonIndex = Math.min(...colonIndexes)
|
|
||||||
const title = trimmed.slice(0, colonIndex + 1)
|
|
||||||
const titleText = title.slice(0, -1)
|
|
||||||
const body = trimmed.slice(colonIndex + 1).trim()
|
|
||||||
if (!ALLOWED_COLON_HEADING_TITLES.has(titleText)) {
|
|
||||||
return [rawLine]
|
|
||||||
}
|
|
||||||
|
|
||||||
return body ? [`### ${titleText}`, '', body] : [`### ${titleText}`]
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBusinessFieldLine(line) {
|
|
||||||
const rawLine = String(line || '')
|
|
||||||
const trimmed = rawLine.trim()
|
|
||||||
if (
|
|
||||||
!trimmed ||
|
|
||||||
trimmed.startsWith('|') ||
|
|
||||||
/^[-*+]\s/.test(trimmed) ||
|
|
||||||
/^#{1,6}\s/.test(trimmed)
|
|
||||||
) {
|
|
||||||
return rawLine
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = trimmed.match(/^([^::\n]{1,16})[::]\s*(.+)$/u)
|
|
||||||
if (!match) {
|
|
||||||
return rawLine
|
|
||||||
}
|
|
||||||
const label = match[1].trim()
|
|
||||||
const value = match[2].trim()
|
|
||||||
if (!BUSINESS_FIELD_LABELS.has(label) || !value) {
|
|
||||||
return rawLine
|
|
||||||
}
|
|
||||||
return `- **${label}**:${value}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeColonHeadings(text) {
|
|
||||||
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n')
|
|
||||||
const normalizedLines = []
|
|
||||||
let inFence = false
|
|
||||||
|
|
||||||
lines.forEach((line) => {
|
|
||||||
if (/^\s*(```|~~~)/.test(line)) {
|
|
||||||
inFence = !inFence
|
|
||||||
normalizedLines.push(line)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (inFence) {
|
|
||||||
normalizedLines.push(line)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLines = splitColonHeadingLine(line)
|
|
||||||
if (nextLines[0]?.startsWith('### ') && normalizedLines.length) {
|
|
||||||
const previousLine = normalizedLines[normalizedLines.length - 1]
|
|
||||||
if (String(previousLine || '').trim()) {
|
|
||||||
normalizedLines.push('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine)))
|
|
||||||
})
|
|
||||||
|
|
||||||
return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasOnlyTrustedHtmlTags(html = '') {
|
|
||||||
const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi
|
|
||||||
let match = tagPattern.exec(html)
|
|
||||||
while (match) {
|
|
||||||
const tagName = String(match[1] || '').toLowerCase()
|
|
||||||
if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const attrText = String(match[2] || '')
|
|
||||||
const attrPattern = /\s([:@\w-]+)\s*=/g
|
|
||||||
let attrMatch = attrPattern.exec(attrText)
|
|
||||||
while (attrMatch) {
|
|
||||||
const attrName = String(attrMatch[1] || '').toLowerCase()
|
|
||||||
if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
attrMatch = attrPattern.exec(attrText)
|
|
||||||
}
|
|
||||||
match = tagPattern.exec(html)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeTrustedHtmlBlock(html = '') {
|
|
||||||
const value = String(html || '').trim()
|
|
||||||
if (!value || !value.includes('class="ai-document-card-list"')) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
if (!hasOnlyTrustedHtmlTags(value)) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim())
|
|
||||||
if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTrustedHtmlBlocks(text = '') {
|
|
||||||
const trustedHtmlBlocks = []
|
|
||||||
const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => {
|
|
||||||
const sanitizedHtml = sanitizeTrustedHtmlBlock(html)
|
|
||||||
if (!sanitizedHtml) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}`
|
|
||||||
trustedHtmlBlocks.push(sanitizedHtml)
|
|
||||||
return `\n\n${placeholder}\n\n`
|
|
||||||
})
|
|
||||||
return { content, trustedHtmlBlocks }
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = []) {
|
|
||||||
return trustedHtmlBlocks.reduce((nextHtml, block, index) => {
|
|
||||||
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}`
|
|
||||||
const paragraphPattern = new RegExp(`<p>${placeholder}</p>\\n?`, 'g')
|
|
||||||
return nextHtml
|
|
||||||
.replace(paragraphPattern, block)
|
|
||||||
.replaceAll(placeholder, block)
|
|
||||||
}, html)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderMarkdown(text = '') {
|
export function renderMarkdown(text = '') {
|
||||||
const { content, trustedHtmlBlocks } = extractTrustedHtmlBlocks(text)
|
const { content, trustedHtmlBlocks } = extractTrustedHtmlBlocks(text)
|
||||||
const normalized = normalizeColonHeadings(content).trim()
|
const normalized = normalizeConversationText(content).trim()
|
||||||
return normalized ? restoreTrustedHtmlBlocks(markdown.render(normalized), trustedHtmlBlocks) : ''
|
return normalized ? restoreTrustedHtmlBlocks(markdown.render(normalized), trustedHtmlBlocks) : ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
@new-chat="openAiSidebarNewChat"
|
@new-chat="openAiSidebarNewChat"
|
||||||
@open-recent="openAiSidebarRecent"
|
@open-recent="openAiSidebarRecent"
|
||||||
@rename-conversation="handleAiConversationRename"
|
@rename-conversation="handleAiConversationRename"
|
||||||
|
@prefetch-view="prefetchAppView"
|
||||||
@logout="handleLogout"
|
@logout="handleLogout"
|
||||||
/>
|
/>
|
||||||
<SidebarRail
|
<SidebarRail
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
@logout="handleLogout"
|
@logout="handleLogout"
|
||||||
@toggle-collapse="toggleSidebarCollapsed"
|
@toggle-collapse="toggleSidebarCollapsed"
|
||||||
@navigate="handleNavigateWithMobileClose"
|
@navigate="handleNavigateWithMobileClose"
|
||||||
|
@prefetch-view="prefetchAppView"
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,24 +245,18 @@ import AiSidebarRail from '../components/layout/AiSidebarRail.vue'
|
|||||||
import SidebarRail from '../components/layout/SidebarRail.vue'
|
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||||
import TopBar from '../components/layout/TopBar.vue'
|
import TopBar from '../components/layout/TopBar.vue'
|
||||||
import FilterBar from '../components/layout/FilterBar.vue'
|
import FilterBar from '../components/layout/FilterBar.vue'
|
||||||
import AuditView from './AuditView.vue'
|
|
||||||
import BudgetCenterView from './BudgetCenterView.vue'
|
|
||||||
import DigitalEmployeesView from './DigitalEmployeesView.vue'
|
|
||||||
import DocumentsCenterView from './DocumentsCenterView.vue'
|
|
||||||
import EmployeeManagementView from './EmployeeManagementView.vue'
|
|
||||||
import OverviewView from './OverviewView.vue'
|
|
||||||
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
|
|
||||||
import PoliciesView from './PoliciesView.vue'
|
|
||||||
import ReceiptFolderView from './ReceiptFolderView.vue'
|
|
||||||
import SettingsView from './SettingsView.vue'
|
|
||||||
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
|
||||||
import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
|
||||||
|
|
||||||
import { useAppShell } from '../composables/useAppShell.js'
|
import { useAppShell } from '../composables/useAppShell.js'
|
||||||
import { useSystemState } from '../composables/useSystemState.js'
|
import { useSystemState } from '../composables/useSystemState.js'
|
||||||
import { filterNavItemsByAccess, isPlatformAdminUser } from '../utils/accessControl.js'
|
import { filterNavItemsByAccess, isPlatformAdminUser } from '../utils/accessControl.js'
|
||||||
import { isBusinessDocumentReference } from '../utils/aiDocumentDetailReference.js'
|
import { isBusinessDocumentReference } from '../utils/aiDocumentDetailReference.js'
|
||||||
import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js'
|
import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js'
|
||||||
|
import {
|
||||||
|
defineAsyncModalView,
|
||||||
|
defineAsyncRouteView,
|
||||||
|
preloadAppView,
|
||||||
|
scheduleRelatedAppViewPreload
|
||||||
|
} from './scripts/appShellAsyncViews.js'
|
||||||
|
|
||||||
const employeeSummary = ref(null)
|
const employeeSummary = ref(null)
|
||||||
const knowledgeSummary = ref(null)
|
const knowledgeSummary = ref(null)
|
||||||
@@ -300,6 +296,18 @@ const aiSidebarCommandSeq = ref(0)
|
|||||||
const aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
|
const aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
|
||||||
const aiActiveConversationId = ref('')
|
const aiActiveConversationId = ref('')
|
||||||
const aiConversationHistory = ref([])
|
const aiConversationHistory = ref([])
|
||||||
|
const AuditView = defineAsyncRouteView('audit')
|
||||||
|
const BudgetCenterView = defineAsyncRouteView('budget')
|
||||||
|
const DigitalEmployeesView = defineAsyncRouteView('digitalEmployees')
|
||||||
|
const DocumentsCenterView = defineAsyncRouteView('documents')
|
||||||
|
const EmployeeManagementView = defineAsyncRouteView('employees')
|
||||||
|
const OverviewView = defineAsyncRouteView('overview')
|
||||||
|
const PersonalWorkbenchView = defineAsyncRouteView('workbench')
|
||||||
|
const PoliciesView = defineAsyncRouteView('policies')
|
||||||
|
const ReceiptFolderView = defineAsyncRouteView('receiptFolder')
|
||||||
|
const SettingsView = defineAsyncRouteView('settings')
|
||||||
|
const TravelReimbursementCreateView = defineAsyncModalView('travelCreate')
|
||||||
|
const TravelRequestDetailView = defineAsyncRouteView('travelDetail')
|
||||||
|
|
||||||
function toggleSidebarCollapsed() {
|
function toggleSidebarCollapsed() {
|
||||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||||
@@ -310,6 +318,10 @@ function handleNavigateWithMobileClose(viewId) {
|
|||||||
mobileSidebarOpen.value = false
|
mobileSidebarOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prefetchAppView(viewId) {
|
||||||
|
void preloadAppView(viewId).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
function toggleWorkbenchMode() {
|
function toggleWorkbenchMode() {
|
||||||
const nextMode = workbenchMode.value === 'ai' ? 'traditional' : 'ai'
|
const nextMode = workbenchMode.value === 'ai' ? 'traditional' : 'ai'
|
||||||
if (nextMode === 'ai') {
|
if (nextMode === 'ai') {
|
||||||
@@ -580,4 +592,12 @@ watch(
|
|||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => activeView.value,
|
||||||
|
(view) => {
|
||||||
|
scheduleRelatedAppViewPreload(view)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
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 {
|
export {
|
||||||
DATE_INPUT_FORMAT,
|
buildBusinessTimeContextFromReviewValues,
|
||||||
buildReviewAttachmentStatus,
|
buildReviewCorrectionMessage,
|
||||||
cloneReviewEditFields,
|
buildReviewFactCards,
|
||||||
createEmptyInlineReviewState,
|
buildReviewFormContextFromPayload,
|
||||||
formatAmountDisplay,
|
buildReviewMainMessageText,
|
||||||
formatReviewSceneDisplayValue,
|
buildReviewRiskConversationText,
|
||||||
normalizeReviewRiskLevel,
|
buildReviewRiskItems,
|
||||||
shouldShowReviewFactCard,
|
canExposeReviewPanelScope,
|
||||||
buildBusinessTimeContextFromReviewValues as buildBusinessTimeContextFromReviewValuesModel,
|
isTravelReviewPayload,
|
||||||
buildReviewFormContextFromPayload as buildReviewFormContextFromPayloadModel,
|
normalizeReviewPanelScope,
|
||||||
isTravelReviewPayload as isTravelReviewPayloadModel,
|
resolveReviewRiskBriefs,
|
||||||
resolveReviewTravelTransportType as resolveReviewTravelTransportTypeModel
|
resolveReviewTravelTransportType
|
||||||
} from './travelReimbursementReviewModel.js'
|
} from './travelReimbursementReviewPanelModel.js'
|
||||||
|
|
||||||
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
|
|
||||||
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
|
|
||||||
const REVIEW_PANEL_SCOPE_RISK = 'risk'
|
|
||||||
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
|
|
||||||
const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要你确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g
|
|
||||||
|
|
||||||
const REVIEW_RISK_LEVEL_META = {
|
|
||||||
high: {
|
|
||||||
label: '高风险',
|
|
||||||
icon: 'mdi mdi-alert-octagon-outline',
|
|
||||||
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
|
|
||||||
},
|
|
||||||
medium: {
|
|
||||||
label: '中风险',
|
|
||||||
icon: 'mdi mdi-alert-circle-outline',
|
|
||||||
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
label: '提示',
|
|
||||||
icon: 'mdi mdi-information-outline',
|
|
||||||
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
|
|
||||||
},
|
|
||||||
low: {
|
|
||||||
label: '低风险',
|
|
||||||
icon: 'mdi mdi-information-outline',
|
|
||||||
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeReviewPanelScope(scope) {
|
|
||||||
const normalized = String(scope || '').trim()
|
|
||||||
return [REVIEW_PANEL_SCOPE_OVERVIEW, REVIEW_PANEL_SCOPE_DOCUMENTS, REVIEW_PANEL_SCOPE_RISK].includes(normalized)
|
|
||||||
? normalized
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canExposeReviewPanelScope(scope) {
|
|
||||||
return Boolean(normalizeReviewPanelScope(scope))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildBusinessTimeContextFromReviewValues(values = {}) {
|
|
||||||
return buildBusinessTimeContextFromReviewValuesModel(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) {
|
|
||||||
return buildReviewFormContextFromPayloadModel(reviewPayload, inlineState)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildReviewCorrectionMessage(fields) {
|
|
||||||
const lines = ['请按以下核对后的报销信息更新当前识别结果:']
|
|
||||||
for (const item of cloneReviewEditFields(fields)) {
|
|
||||||
if (!item.label || (!item.value && !item.required)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines.push(`${item.label}:${String(item.value || '').trim() || '待补充'}`)
|
|
||||||
}
|
|
||||||
return lines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
|
||||||
return isTravelReviewPayloadModel(reviewPayload, inlineState)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
|
|
||||||
return resolveReviewTravelTransportTypeModel(reviewPayload, fallbackText)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveReviewRiskBriefs(reviewPayload) {
|
|
||||||
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
|
|
||||||
return reviewPayload.risk_briefs.filter((item) => {
|
|
||||||
const title = String(item?.title || '').trim()
|
|
||||||
return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
|
||||||
const pendingAttachmentCount = Math.max(0, Number(inlineState.pending_attachment_count || 0))
|
|
||||||
const totalAttachmentCount = Math.max(0, Number(inlineState.attachment_count || 0))
|
|
||||||
const existingAttachmentCount = Math.max(0, totalAttachmentCount - pendingAttachmentCount)
|
|
||||||
const attachmentStatus =
|
|
||||||
pendingAttachmentCount > 0
|
|
||||||
? existingAttachmentCount > 0
|
|
||||||
? `已上传 ${existingAttachmentCount} 份,待新增 ${pendingAttachmentCount} 份`
|
|
||||||
: `待保存 ${pendingAttachmentCount} 份`
|
|
||||||
: totalAttachmentCount > 0
|
|
||||||
? `已上传 ${totalAttachmentCount} 份`
|
|
||||||
: buildReviewAttachmentStatus(reviewPayload)
|
|
||||||
if (isTravelReviewPayload(reviewPayload, inlineState)) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: 'occurred_date',
|
|
||||||
label: '发生时间',
|
|
||||||
value: String(inlineState.occurred_date || '').trim() || '待补充',
|
|
||||||
icon: 'mdi mdi-calendar-month-outline',
|
|
||||||
editor: 'date',
|
|
||||||
modelKey: 'occurred_date',
|
|
||||||
placeholder: `例如 ${DATE_INPUT_FORMAT}`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'amount',
|
|
||||||
label: '金额',
|
|
||||||
value: formatAmountDisplay(inlineState.amount) || '待补充',
|
|
||||||
icon: 'mdi mdi-cash',
|
|
||||||
editor: 'amount',
|
|
||||||
modelKey: 'amount',
|
|
||||||
placeholder: '例如 200.00'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'transport_type',
|
|
||||||
label: '交通类型',
|
|
||||||
value: String(inlineState.transport_type || '').trim() || '待确认',
|
|
||||||
icon: 'mdi mdi-train-car',
|
|
||||||
editor: 'text',
|
|
||||||
modelKey: 'transport_type',
|
|
||||||
placeholder: '例如 火车/高铁、飞机'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'hotel_name',
|
|
||||||
label: '酒店名称',
|
|
||||||
value: String(inlineState.merchant_name || '').trim() || '待补充',
|
|
||||||
icon: 'mdi mdi-bed-outline',
|
|
||||||
editor: 'text',
|
|
||||||
modelKey: 'merchant_name',
|
|
||||||
placeholder: '请输入酒店名称'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'travel_purpose',
|
|
||||||
label: '出差事宜',
|
|
||||||
value: String(inlineState.reason_value || '').trim() || '待补充',
|
|
||||||
icon: 'mdi mdi-briefcase-edit-outline',
|
|
||||||
editor: 'textarea',
|
|
||||||
modelKey: 'reason_value',
|
|
||||||
placeholder: '请填写本次出差的具体工作内容或业务意图',
|
|
||||||
wide: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
const cards = [
|
|
||||||
{
|
|
||||||
key: 'occurred_date',
|
|
||||||
label: '发生时间',
|
|
||||||
value: String(inlineState.occurred_date || '').trim() || '待补充',
|
|
||||||
icon: 'mdi mdi-calendar-month-outline',
|
|
||||||
editor: 'date',
|
|
||||||
modelKey: 'occurred_date',
|
|
||||||
placeholder: `例如 ${DATE_INPUT_FORMAT}`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'amount',
|
|
||||||
label: '金额',
|
|
||||||
value: formatAmountDisplay(inlineState.amount) || '待补充',
|
|
||||||
icon: 'mdi mdi-cash',
|
|
||||||
editor: 'amount',
|
|
||||||
modelKey: 'amount',
|
|
||||||
placeholder: '例如 200.00'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'scene',
|
|
||||||
label: '场景 / 事由',
|
|
||||||
value: formatReviewSceneDisplayValue(inlineState),
|
|
||||||
icon: 'mdi mdi-silverware-fork-knife',
|
|
||||||
editor: 'select',
|
|
||||||
modelKey: 'scene_label',
|
|
||||||
placeholder: '请选择场景'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'attachments',
|
|
||||||
label: '票据状态',
|
|
||||||
value: attachmentStatus,
|
|
||||||
icon: 'mdi mdi-file-document-outline',
|
|
||||||
editor: 'upload',
|
|
||||||
modelKey: 'attachment_names',
|
|
||||||
placeholder: ''
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
if (shouldShowReviewFactCard(reviewPayload, 'customer_name', inlineState.customer_name)) {
|
|
||||||
cards.splice(cards.length - 1, 0, {
|
|
||||||
key: 'customer_name',
|
|
||||||
label: '关联客户',
|
|
||||||
value: String(inlineState.customer_name || '').trim() || '待补充',
|
|
||||||
icon: 'mdi mdi-domain',
|
|
||||||
editor: 'text',
|
|
||||||
modelKey: 'customer_name',
|
|
||||||
placeholder: '请输入客户名称'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) {
|
|
||||||
cards.splice(cards.length - 1, 0, {
|
|
||||||
key: 'location',
|
|
||||||
label: '业务地点',
|
|
||||||
value: String(inlineState.location || '').trim() || '待补充',
|
|
||||||
icon: 'mdi mdi-map-marker-outline',
|
|
||||||
editor: 'text',
|
|
||||||
modelKey: 'location',
|
|
||||||
placeholder: '请输入业务地点'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldShowReviewFactCard(reviewPayload, 'merchant_name', inlineState.merchant_name)) {
|
|
||||||
cards.splice(cards.length - 1, 0, {
|
|
||||||
key: 'merchant_name',
|
|
||||||
label: '酒店/商户',
|
|
||||||
value: String(inlineState.merchant_name || '').trim() || '待补充',
|
|
||||||
icon: 'mdi mdi-storefront-outline',
|
|
||||||
editor: 'text',
|
|
||||||
modelKey: 'merchant_name',
|
|
||||||
placeholder: '请输入酒店或商户名称'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldShowReviewFactCard(reviewPayload, 'participants', inlineState.participants)) {
|
|
||||||
cards.splice(cards.length - 1, 0, {
|
|
||||||
key: 'participants',
|
|
||||||
label: '同行人员',
|
|
||||||
value: String(inlineState.participants || '').trim() || '待补充',
|
|
||||||
icon: 'mdi mdi-account-group-outline',
|
|
||||||
editor: 'text',
|
|
||||||
modelKey: 'participants',
|
|
||||||
placeholder: '例如 客户 2 人,我方 1 人'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return cards
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeReviewRiskTitle(title, fallbackTitle) {
|
|
||||||
const normalized = String(title || '').trim()
|
|
||||||
const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示'
|
|
||||||
if (!normalized) return fallback
|
|
||||||
const cleaned = normalized
|
|
||||||
.replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示')
|
|
||||||
.replace(/(高风险|中风险|低风险)/g, '')
|
|
||||||
.replace(/^[::\-—\s]+|[::\-—\s]+$/g, '')
|
|
||||||
.trim()
|
|
||||||
return cleaned || fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildReviewRiskItems(reviewPayload) {
|
|
||||||
return resolveReviewRiskBriefs(reviewPayload)
|
|
||||||
.map((brief, index) => {
|
|
||||||
const title = String(brief?.title || '').trim()
|
|
||||||
const content = String(brief?.content || '').trim()
|
|
||||||
const detail = String(brief?.detail || '').trim()
|
|
||||||
const suggestion = String(brief?.suggestion || '').trim()
|
|
||||||
const level = normalizeReviewRiskLevel(brief?.level)
|
|
||||||
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low
|
|
||||||
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
|
|
||||||
const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle)
|
|
||||||
const summary = content || normalizedTitle
|
|
||||||
|
|
||||||
if (!normalizedTitle && !summary) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: `${level}-${normalizedTitle}-${index}`,
|
|
||||||
title: normalizedTitle,
|
|
||||||
summary,
|
|
||||||
detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。',
|
|
||||||
level,
|
|
||||||
levelLabel: meta.label,
|
|
||||||
icon: meta.icon,
|
|
||||||
sourceLabel: meta.label,
|
|
||||||
suggestion: suggestion || meta.suggestion
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildReviewRiskConversationText(item, detailTarget = {}) {
|
|
||||||
const title = String(item?.title || '风险提示').trim()
|
|
||||||
const summary = String(item?.summary || '').trim()
|
|
||||||
const detail = String(item?.detail || '').trim()
|
|
||||||
const suggestion = String(item?.suggestion || '').trim()
|
|
||||||
const isInfo = String(item?.level || '').trim() === 'info'
|
|
||||||
const detailHref = String(detailTarget?.href || '').trim()
|
|
||||||
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
|
|
||||||
const lines = [`${title}`]
|
|
||||||
|
|
||||||
if (summary) {
|
|
||||||
lines.push('', `${isInfo ? '提示内容' : '风险点'}:${summary}`)
|
|
||||||
}
|
|
||||||
if (detail && detail !== summary) {
|
|
||||||
lines.push('', `规则依据:${detail}`)
|
|
||||||
}
|
|
||||||
if (suggestion) {
|
|
||||||
lines.push('', `${isInfo ? '处理建议' : '修改建议'}:${suggestion}`)
|
|
||||||
}
|
|
||||||
if (detailHref) {
|
|
||||||
lines.push('', `[${detailLabel}](${detailHref})`)
|
|
||||||
}
|
|
||||||
return lines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildReviewMainMessageText(message) {
|
|
||||||
const text = String(message?.text || '')
|
|
||||||
if (!message?.reviewPayload) {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
.replace(REVIEW_PENDING_SUMMARY_PATTERN, '\n')
|
|
||||||
.replace(/\n{3,}/g, '\n\n')
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ test('AI sidebar has quick actions, business navigation and conversation history
|
|||||||
assert.match(aiSidebar, /class="ai-nav-list"/)
|
assert.match(aiSidebar, /class="ai-nav-list"/)
|
||||||
assert.match(aiSidebar, /v-for="item in businessNavItems"/)
|
assert.match(aiSidebar, /v-for="item in businessNavItems"/)
|
||||||
assert.match(aiSidebar, /ai-nav-copy/)
|
assert.match(aiSidebar, /ai-nav-copy/)
|
||||||
assert.match(aiSidebar, /item\.aiIcon/)
|
assert.match(aiSidebar, /item\.aiIconPaths/)
|
||||||
assert.match(aiSidebar, /aria-current/)
|
assert.match(aiSidebar, /aria-current/)
|
||||||
assert.doesNotMatch(aiSidebar, /displayHint/)
|
assert.doesNotMatch(aiSidebar, /displayHint/)
|
||||||
assert.doesNotMatch(aiSidebar, /个人工作台/)
|
assert.doesNotMatch(aiSidebar, /个人工作台/)
|
||||||
@@ -136,7 +136,7 @@ test('AI sidebar has quick actions, business navigation and conversation history
|
|||||||
assert.match(aiSidebar, /displayUser\.subtitle/)
|
assert.match(aiSidebar, /displayUser\.subtitle/)
|
||||||
assert.match(aiSidebar, /aria-label="用户操作"/)
|
assert.match(aiSidebar, /aria-label="用户操作"/)
|
||||||
assert.match(aiSidebar, /emit\('logout'\)/)
|
assert.match(aiSidebar, /emit\('logout'\)/)
|
||||||
assert.match(aiSidebar, /defineEmits\(\['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout'\]\)/)
|
assert.match(aiSidebar, /defineEmits\(\['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout', 'prefetch-view'\]\)/)
|
||||||
assert.doesNotMatch(aiSidebar, /search-chat/)
|
assert.doesNotMatch(aiSidebar, /search-chat/)
|
||||||
assert.doesNotMatch(aiSidebar, /打开系统设置/)
|
assert.doesNotMatch(aiSidebar, /打开系统设置/)
|
||||||
assert.doesNotMatch(aiSidebar, /mdi-chevron-up/)
|
assert.doesNotMatch(aiSidebar, /mdi-chevron-up/)
|
||||||
@@ -154,10 +154,21 @@ test('AI sidebar visual treatment keeps a restrained three-layer workspace', ()
|
|||||||
assert.match(aiSidebarStyles, /\.ai-rail-brand\s*\{[\s\S]*min-height:\s*74px;[\s\S]*grid-template-columns:\s*42px minmax\(0,\s*1fr\);/)
|
assert.match(aiSidebarStyles, /\.ai-rail-brand\s*\{[\s\S]*min-height:\s*74px;[\s\S]*grid-template-columns:\s*42px minmax\(0,\s*1fr\);/)
|
||||||
assert.match(aiSidebarStyles, /\.ai-brand-logo\s*\{[\s\S]*width:\s*42px;[\s\S]*height:\s*42px;/)
|
assert.match(aiSidebarStyles, /\.ai-brand-logo\s*\{[\s\S]*width:\s*42px;[\s\S]*height:\s*42px;/)
|
||||||
assert.match(aiSidebarStyles, /\.ai-brand-logo svg\s*\{[\s\S]*width:\s*26px;[\s\S]*height:\s*26px;/)
|
assert.match(aiSidebarStyles, /\.ai-brand-logo svg\s*\{[\s\S]*width:\s*26px;[\s\S]*height:\s*26px;/)
|
||||||
assert.match(aiSidebar, /icon:\s*'mdi mdi-plus'/)
|
assert.match(aiSidebar, /const tablerIconPaths = \{/)
|
||||||
|
assert.match(aiSidebar, /plus:\s*\[/)
|
||||||
|
assert.match(aiSidebar, /search:\s*\[/)
|
||||||
|
assert.match(aiSidebar, /fileText:\s*\[/)
|
||||||
|
assert.match(aiSidebar, /book2:\s*\[/)
|
||||||
|
assert.match(aiSidebar, /iconPaths:\s*tablerIconPaths\.plus/)
|
||||||
|
assert.match(aiSidebar, /aiIconPaths:\s*sidebarMeta\[item\.id\]\?\.iconPaths/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /icon:\s*'mdi mdi-plus'/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /mdi mdi-file-document-outline/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-sidebar-tabler-icon\s*\{[\s\S]*stroke-width:\s*1\.85;/)
|
||||||
assert.match(aiSidebarStyles, /\.ai-rail-quick\s*\{[\s\S]*gap:\s*6px;[\s\S]*padding:\s*8px 18px 12px;/)
|
assert.match(aiSidebarStyles, /\.ai-rail-quick\s*\{[\s\S]*gap:\s*6px;[\s\S]*padding:\s*8px 18px 12px;/)
|
||||||
assert.match(quickButtonBlock, /min-height:\s*48px;/)
|
assert.match(quickButtonBlock, /min-height:\s*48px;/)
|
||||||
assert.match(quickButtonBlock, /grid-template-columns:\s*28px minmax\(0,\s*1fr\);/)
|
assert.match(quickButtonBlock, /grid-template-columns:\s*32px minmax\(0,\s*1fr\);/)
|
||||||
|
assert.match(quickButtonBlock, /gap:\s*12px;/)
|
||||||
|
assert.match(quickButtonBlock, /padding:\s*7px 10px;/)
|
||||||
assert.match(quickButtonBlock, /background:\s*transparent;/)
|
assert.match(quickButtonBlock, /background:\s*transparent;/)
|
||||||
assert.match(quickButtonBlock, /border-color:\s*transparent;/)
|
assert.match(quickButtonBlock, /border-color:\s*transparent;/)
|
||||||
assert.match(quickButtonBlock, /box-shadow:\s*none;/)
|
assert.match(quickButtonBlock, /box-shadow:\s*none;/)
|
||||||
@@ -170,7 +181,12 @@ test('AI sidebar visual treatment keeps a restrained three-layer workspace', ()
|
|||||||
assert.doesNotMatch(navListBlock, /box-shadow:/)
|
assert.doesNotMatch(navListBlock, /box-shadow:/)
|
||||||
assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*min-height:\s*48px;/)
|
assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*min-height:\s*48px;/)
|
||||||
assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*grid-template-columns:\s*32px minmax\(0,\s*1fr\);/)
|
assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*grid-template-columns:\s*32px minmax\(0,\s*1fr\);/)
|
||||||
assert.match(aiSidebarStyles, /\.ai-nav-btn\.active\s*\{[\s\S]*background:[\s\S]*linear-gradient\(90deg,\s*rgba\(45,\s*114,\s*217,\s*0\.095\)/)
|
assert.match(aiSidebarStyles, /\.ai-quick-btn:hover,\s*\.ai-quick-btn\.active,\s*\.ai-nav-btn:hover,\s*\.ai-nav-btn\.active\s*\{[\s\S]*background:\s*rgba\(15,\s*23,\s*42,\s*0\.035\);/)
|
||||||
|
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn::before/)
|
||||||
|
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn\.active::before/)
|
||||||
|
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn\.active \.ai-nav-copy/)
|
||||||
|
assert.doesNotMatch(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-nav-btn\.active/)
|
||||||
|
assert.doesNotMatch(aiSidebarStyles, /linear-gradient\(90deg,\s*rgba\(45,\s*114,\s*217,\s*0\.095\)/)
|
||||||
assert.match(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-nav-list\s*\{[\s\S]*grid-template-columns:\s*1fr;/)
|
assert.match(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-nav-list\s*\{[\s\S]*grid-template-columns:\s*1fr;/)
|
||||||
assert.match(aiSidebarStyles, /\.ai-rail-recents\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);/)
|
assert.match(aiSidebarStyles, /\.ai-rail-recents\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);/)
|
||||||
assert.match(aiSidebarStyles, /\.ai-recents-list\s*\{[\s\S]*overflow-y:\s*auto;[\s\S]*scrollbar-width:\s*none;/)
|
assert.match(aiSidebarStyles, /\.ai-recents-list\s*\{[\s\S]*overflow-y:\s*auto;[\s\S]*scrollbar-width:\s*none;/)
|
||||||
|
|||||||
@@ -8,23 +8,65 @@ const shell = readFileSync(
|
|||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
const router = readFileSync(fileURLToPath(new URL('../src/router/index.js', import.meta.url)), 'utf8')
|
const router = readFileSync(fileURLToPath(new URL('../src/router/index.js', import.meta.url)), 'utf8')
|
||||||
|
const sidebarRail = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/components/layout/SidebarRail.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const aiSidebarRail = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/components/layout/AiSidebarRail.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
test('app shell main route views are eagerly imported', () => {
|
test('app shell lazily loads heavy business views with an in-workarea loading state', () => {
|
||||||
assert.doesNotMatch(shell, /defineAsyncRouteView/)
|
assert.match(shell, /defineAsyncRouteView\('audit'\)/)
|
||||||
assert.doesNotMatch(shell, /defineAsyncComponent/)
|
assert.match(shell, /defineAsyncRouteView\('documents'\)/)
|
||||||
assert.doesNotMatch(shell, /loadingComponent:/)
|
assert.match(shell, /defineAsyncRouteView\('workbench'\)/)
|
||||||
assert.doesNotMatch(shell, /\u9875\u9762\u5207\u6362\u4e2d/)
|
assert.match(shell, /defineAsyncModalView\('travelCreate'\)/)
|
||||||
assert.doesNotMatch(shell, /floating:\s*true/)
|
assert.match(shell, /function prefetchAppView\(viewId\)/)
|
||||||
assert.doesNotMatch(shell, /blocking:\s*true/)
|
assert.match(shell, /@prefetch-view="prefetchAppView"/)
|
||||||
assert.match(shell, /import AuditView from '\.\/AuditView\.vue'/)
|
assert.doesNotMatch(shell, /import AuditView from '\.\/AuditView\.vue'/)
|
||||||
assert.match(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/)
|
assert.doesNotMatch(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/)
|
||||||
assert.match(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/)
|
assert.doesNotMatch(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/)
|
||||||
assert.match(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/)
|
assert.doesNotMatch(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/)
|
||||||
assert.match(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/)
|
assert.doesNotMatch(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('top-level app routes are eagerly imported', () => {
|
test('app view preloading is triggered from both standard and AI sidebars', () => {
|
||||||
assert.doesNotMatch(router, /\(\)\s*=>\s*import\(/)
|
assert.match(sidebarRail, /'prefetch-view'/)
|
||||||
|
assert.match(sidebarRail, /@mouseenter="emit\('prefetch-view', item\.id\)"/)
|
||||||
|
assert.match(sidebarRail, /@focus="emit\('prefetch-view', item\.id\)"/)
|
||||||
|
assert.match(aiSidebarRail, /'prefetch-view'/)
|
||||||
|
assert.match(aiSidebarRail, /@mouseenter="emit\('prefetch-view', item\.id\)"/)
|
||||||
|
assert.match(aiSidebarRail, /@focus="emit\('prefetch-view', item\.id\)"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('async app view loader keeps transitions nonblocking and visible', () => {
|
||||||
|
const asyncViews = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/scripts/appShellAsyncViews.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const loadingState = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/components/shared/AppViewLoadingState.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const modalLoadingState = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/components/shared/AppModalLoadingState.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.match(asyncViews, /defineAsyncComponent/)
|
||||||
|
assert.match(asyncViews, /loadingComponent:\s*options\.loadingComponent \|\| AppViewLoadingState/)
|
||||||
|
assert.match(asyncViews, /loadingComponent:\s*AppModalLoadingState/)
|
||||||
|
assert.match(asyncViews, /suspensible:\s*false/)
|
||||||
|
assert.match(asyncViews, /requestIdleCallback/)
|
||||||
|
assert.match(loadingState, /正在加载页面内容/)
|
||||||
|
assert.match(loadingState, /app-view-loading-skeleton/)
|
||||||
|
assert.match(modalLoadingState, /Teleport to="body"/)
|
||||||
|
assert.match(modalLoadingState, /正在打开智能工作台/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('top-level shell routes stay eager so the layout does not blank during navigation', () => {
|
||||||
|
assert.doesNotMatch(router, /component:\s*\(\)\s*=>\s*import\(\s*'\.\.\/views\/AppShellRouteView\.vue'/)
|
||||||
assert.match(router, /import AppShellRouteView from '\.\.\/views\/AppShellRouteView\.vue'/)
|
assert.match(router, /import AppShellRouteView from '\.\.\/views\/AppShellRouteView\.vue'/)
|
||||||
assert.match(router, /import LoginRouteView from '\.\.\/views\/LoginRouteView\.vue'/)
|
assert.match(router, /import LoginRouteView from '\.\.\/views\/LoginRouteView\.vue'/)
|
||||||
assert.match(router, /import SetupRouteView from '\.\.\/views\/SetupRouteView\.vue'/)
|
assert.match(router, /import SetupRouteView from '\.\.\/views\/SetupRouteView\.vue'/)
|
||||||
|
|||||||
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 assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
const documentsCenterView = readFileSync(
|
import { readSourceFile, readSourceSurface } from './helpers/sourceSurface.mjs'
|
||||||
fileURLToPath(new URL('../src/views/DocumentsCenterView.vue', import.meta.url)),
|
|
||||||
'utf8'
|
|
||||||
)
|
|
||||||
const documentsCenterViewModel = readFileSync(
|
|
||||||
fileURLToPath(new URL('../src/utils/documentCenterViewModel.js', import.meta.url)),
|
|
||||||
'utf8'
|
|
||||||
)
|
|
||||||
const documentsCenterLogic = `${documentsCenterView}\n${documentsCenterViewModel}`
|
|
||||||
|
|
||||||
const documentsCenterStyles = readFileSync(
|
const documentsCenterView = readSourceFile('views/DocumentsCenterView.vue')
|
||||||
fileURLToPath(new URL('../src/assets/styles/views/documents-center-view.css', import.meta.url)),
|
const documentsCenterViewModel = readSourceFile('utils/documentCenterViewModel.js')
|
||||||
'utf8'
|
const documentsCenterLogic = readSourceSurface([
|
||||||
)
|
'views/DocumentsCenterView.vue',
|
||||||
const documentListSharedStyles = readFileSync(
|
'utils/documentCenterViewModel.js'
|
||||||
fileURLToPath(new URL('../src/assets/styles/components/document-list-shared.css', import.meta.url)),
|
])
|
||||||
'utf8'
|
const documentsCenterStyles = readSourceFile('assets/styles/views/documents-center-view.css')
|
||||||
)
|
const documentListSharedStyles = readSourceFile('assets/styles/components/document-list-shared.css')
|
||||||
const tableLoadingState = readFileSync(
|
const tableLoadingState = readSourceFile('components/shared/TableLoadingState.vue')
|
||||||
fileURLToPath(new URL('../src/components/shared/TableLoadingState.vue', import.meta.url)),
|
const reimbursementService = readSourceFile('services/reimbursements.js')
|
||||||
'utf8'
|
const requestsComposable = readSourceFile('composables/useRequests.js')
|
||||||
)
|
|
||||||
const reimbursementService = readFileSync(
|
|
||||||
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
|
||||||
'utf8'
|
|
||||||
)
|
|
||||||
const requestsComposable = readFileSync(
|
|
||||||
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
|
|
||||||
'utf8'
|
|
||||||
)
|
|
||||||
|
|
||||||
test('documents center keeps only the top scope tabs and renders risk level as a dropdown filter', () => {
|
test('documents center keeps only the top scope tabs and renders risk level as a dropdown filter', () => {
|
||||||
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
|
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
|
||||||
@@ -145,7 +126,7 @@ test('documents center preserves application document type from mapped requests'
|
|||||||
)
|
)
|
||||||
assert.match(
|
assert.match(
|
||||||
documentsCenterLogic,
|
documentsCenterLogic,
|
||||||
/documentTypeCode === DOCUMENT_TYPE_APPLICATION \? '申请单' : '报销单'/
|
/resolveDocumentTypeLabel\(documentTypeCode\)/
|
||||||
)
|
)
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
documentsCenterLogic,
|
documentsCenterLogic,
|
||||||
|
|||||||
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, /startDocumentInboxPolling\(\)/)
|
||||||
assert.match(topbar, /stopDocumentInboxPolling\(\)/)
|
assert.match(topbar, /stopDocumentInboxPolling\(\)/)
|
||||||
assert.match(topbar, /class="notification-clear-btn"/)
|
assert.match(topbar, /class="notification-clear-btn"/)
|
||||||
assert.match(topbar, /function clearAllNotifications\(\)/)
|
assert.match(topbar, /notificationBulkActionLabel/)
|
||||||
|
assert.match(topbar, /notificationBulkActionDisabled/)
|
||||||
|
assert.match(topbar, /function handleNotificationBulkAction\(\)/)
|
||||||
|
assert.match(topbar, /function markUnreadNotificationsRead\(\)/)
|
||||||
|
assert.match(topbar, /function deleteReadNotifications\(\)/)
|
||||||
assert.match(topbar, /function markNotificationRead\(item\)/)
|
assert.match(topbar, /function markNotificationRead\(item\)/)
|
||||||
assert.match(topbar, /class="notification-type-icon" :class="item\.tone"/)
|
assert.match(topbar, /class="notification-avatar" :class="item\.tone"/)
|
||||||
assert.match(topbarStyles, /\.notification-head-copy\s*\{[\s\S]*display:\s*grid;/)
|
assert.match(topbarStyles, /\.notification-head-copy\s*\{[\s\S]*display:\s*grid;/)
|
||||||
assert.match(topbarStyles, /\.notification-head-actions\s*\{[\s\S]*display:\s*inline-flex;/)
|
assert.match(topbarStyles, /\.notification-head-actions\s*\{[\s\S]*display:\s*inline-flex;/)
|
||||||
assert.match(topbarStyles, /\.notification-popover\s*\{[\s\S]*max-height:\s*min\(520px,\s*calc\(100vh - 96px\)\);/)
|
assert.match(topbarStyles, /\.notification-popover\s*\{[\s\S]*max-height:\s*min\(560px,\s*calc\(100vh - 68px\)\);/)
|
||||||
assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*min\(336px,\s*calc\(100vh - 226px\)\);[\s\S]*overflow-y:\s*auto;/)
|
assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*min\(420px,\s*calc\(100vh - 166px\)\);[\s\S]*overflow-y:\s*auto;/)
|
||||||
assert.match(topbarStyles, /\.notification-copy small\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
|
assert.match(topbarStyles, /\.notification-preview\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
|
||||||
assert.match(topbarStyles, /@media \(max-width: 640px\)[\s\S]*\.notification-popover\s*\{[\s\S]*position:\s*fixed;/)
|
assert.match(topbarStyles, /@media \(max-width: 640px\)[\s\S]*\.notification-popover\s*\{[\s\S]*position:\s*fixed;/)
|
||||||
assert.match(topbarStyles, /\.notification-tabs button em\s*\{[\s\S]*border-radius:\s*4px;/)
|
assert.match(topbarStyles, /\.notification-tabs button em\s*\{[\s\S]*border-radius:\s*4px;/)
|
||||||
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 16px;/)
|
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*52px minmax\(0,\s*1fr\);/)
|
||||||
assert.doesNotMatch(topbarStyles, /\.notification-dot/)
|
assert.doesNotMatch(topbarStyles, /\.notification-dot/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('topbar notification popover uses inbox-style rows with formatted time labels', () => {
|
test('topbar notification bulk action label follows active tab semantics', () => {
|
||||||
assert.match(topbar, /class="notification-row-main"/)
|
assert.match(topbar, />\s*\{\{ notificationBulkActionLabel \}\}\s*<\/button>/)
|
||||||
assert.match(topbar, /class="notification-row-head"/)
|
assert.match(topbar, /:disabled="notificationBulkActionDisabled"/)
|
||||||
|
assert.match(topbar, /@click="handleNotificationBulkAction"/)
|
||||||
|
assert.match(topbar, /const notificationBulkActionLabel = computed\(\(\) => \(\s*notificationTab\.value === 'unread' \? '全部已读' : '删除已读'\s*\)\)/)
|
||||||
|
assert.match(topbar, /const notificationBulkActionDisabled = computed\(\(\) => \(\s*notificationTab\.value === 'unread'\s*\? unreadNotifications\.value\.length === 0\s*: readNotifications\.value\.length === 0\s*\)\)/)
|
||||||
|
assert.match(topbar, /function handleNotificationBulkAction\(\) \{[\s\S]*if \(notificationTab\.value === 'unread'\) \{[\s\S]*markUnreadNotificationsRead\(\)[\s\S]*return[\s\S]*deleteReadNotifications\(\)[\s\S]*\}/)
|
||||||
|
assert.match(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*const currentItems = unreadNotifications\.value[\s\S]*markDocumentInboxRowsRead\(documentRows\)[\s\S]*markNotificationStatesRead\(currentItems\)/)
|
||||||
|
assert.match(topbar, /function deleteReadNotifications\(\) \{[\s\S]*const currentItems = readNotifications\.value[\s\S]*hideNotificationStates\(currentItems\)/)
|
||||||
|
assert.doesNotMatch(topbar, />\s*清空通知\s*<\/button>/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('topbar notification popover uses reference-style avatar message rows', () => {
|
||||||
|
assert.match(topbar, /class="notification-avatar" :class="item\.tone"/)
|
||||||
|
assert.match(topbar, /class="notification-avatar-label"/)
|
||||||
|
assert.match(topbar, /class="notification-avatar-badge"/)
|
||||||
|
assert.match(topbar, /class="notification-row-content"/)
|
||||||
|
assert.match(topbar, /class="notification-row-top"/)
|
||||||
assert.match(topbar, /class="notification-row-title"/)
|
assert.match(topbar, /class="notification-row-title"/)
|
||||||
assert.match(topbar, /class="notification-context"/)
|
assert.match(topbar, /class="notification-preview"/)
|
||||||
assert.match(topbar, /class="notification-row-foot"/)
|
|
||||||
assert.match(topbar, /class="notification-category-pill"/)
|
|
||||||
assert.match(topbar, /class="notification-time"/)
|
assert.match(topbar, /class="notification-time"/)
|
||||||
assert.match(topbar, /class="notification-row-action"/)
|
assert.match(topbar, /avatarLabel: resolveDocumentNotificationAvatarLabel\(row\)/)
|
||||||
|
assert.match(topbar, /avatarLabel: resolveNotificationAvatarLabel\(item\)/)
|
||||||
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(row\.updatedAt \|\| row\.createdAt\)/)
|
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(row\.updatedAt \|\| row\.createdAt\)/)
|
||||||
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(item\.time \|\| item\.updatedAt \|\| item\.due\)/)
|
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(item\.time \|\| item\.updatedAt \|\| item\.due\)/)
|
||||||
|
assert.doesNotMatch(topbar, /class="notification-category-pill"/)
|
||||||
|
assert.doesNotMatch(topbar, /class="notification-row-action"/)
|
||||||
assert.doesNotMatch(topbar, /<time>\{\{ item\.time \}\}<\/time>/)
|
assert.doesNotMatch(topbar, /<time>\{\{ item\.time \}\}<\/time>/)
|
||||||
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*36px minmax\(0,\s*1fr\);/)
|
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*52px minmax\(0,\s*1fr\);/)
|
||||||
assert.match(topbarStyles, /\.notification-context\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
|
assert.match(topbarStyles, /\.notification-avatar\s*\{[\s\S]*border-radius:\s*999px;/)
|
||||||
assert.match(topbarStyles, /\.notification-row-foot\s*\{[\s\S]*justify-content:\s*space-between;/)
|
assert.match(topbarStyles, /\.notification-preview\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
|
||||||
|
assert.match(topbarStyles, /\.notification-row-top\s*\{[\s\S]*justify-content:\s*space-between;/)
|
||||||
assert.match(topbarStyles, /\.notification-time\s*\{[\s\S]*font-variant-numeric:\s*tabular-nums;/)
|
assert.match(topbarStyles, /\.notification-time\s*\{[\s\S]*font-variant-numeric:\s*tabular-nums;/)
|
||||||
assert.match(topbarStyles, /\.notification-row-action\s*\{[\s\S]*width:\s*28px;[\s\S]*height:\s*28px;/)
|
assert.doesNotMatch(topbarStyles, /\.notification-category-pill/)
|
||||||
|
assert.doesNotMatch(topbarStyles, /\.notification-row-action/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('topbar notification popover does not render a top accent line', () => {
|
||||||
|
assert.doesNotMatch(topbarStyles, /\.notification-popover::before/)
|
||||||
|
assert.doesNotMatch(topbarStyles, /height:\s*2px;[\s\S]*background:\s*var\(--theme-primary-active\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('topbar notification state is persisted through backend API with local fallback', () => {
|
test('topbar notification state is persisted through backend API with local fallback', () => {
|
||||||
@@ -124,9 +151,20 @@ test('topbar notification state is persisted through backend API with local fall
|
|||||||
assert.match(topbarNotificationStates, /NOTIFICATION_HIDDEN_STORAGE_KEY/)
|
assert.match(topbarNotificationStates, /NOTIFICATION_HIDDEN_STORAGE_KEY/)
|
||||||
assert.match(topbarNotificationStates, /applyRemoteStates/)
|
assert.match(topbarNotificationStates, /applyRemoteStates/)
|
||||||
assert.match(topbarNotificationStates, /markNotificationStateRead/)
|
assert.match(topbarNotificationStates, /markNotificationStateRead/)
|
||||||
|
assert.match(topbarNotificationStates, /markNotificationStatesRead/)
|
||||||
assert.match(topbarNotificationStates, /hideNotificationStates/)
|
assert.match(topbarNotificationStates, /hideNotificationStates/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('topbar notification bulk actions are wired to backend state API', () => {
|
||||||
|
assert.match(topbar, /markNotificationStatesRead/)
|
||||||
|
assert.match(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*const currentItems = unreadNotifications\.value[\s\S]*markDocumentInboxRowsRead\(documentRows\)[\s\S]*markNotificationStatesRead\(currentItems\)/)
|
||||||
|
assert.doesNotMatch(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*currentItems\.forEach\(\(item\) => \{[\s\S]*markNotificationStateRead\(item\)/)
|
||||||
|
assert.match(topbar, /function deleteReadNotifications\(\) \{[\s\S]*const currentItems = readNotifications\.value[\s\S]*hideNotificationStates\(currentItems\)/)
|
||||||
|
assert.match(topbarNotificationStates, /function markNotificationStatesRead\(items\) \{[\s\S]*buildPatch\(item, \{ read: true, hidden: false \}\)[\s\S]*return syncNotificationPatches\(patches\)/)
|
||||||
|
assert.match(topbarNotificationStates, /function hideNotificationStates\(items\) \{[\s\S]*buildPatch\(item, \{ read: true, hidden: true \}\)[\s\S]*return syncNotificationPatches\(patches\)/)
|
||||||
|
assert.match(notificationStatesService, /apiRequest\('\/notification-states',\s*\{[\s\S]*method:\s*'POST'[\s\S]*body:\s*JSON\.stringify\(\{ states: batch \}\)/)
|
||||||
|
})
|
||||||
|
|
||||||
test('document inbox reuses document center viewed-key state', () => {
|
test('document inbox reuses document center viewed-key state', () => {
|
||||||
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
||||||
assert.match(documentInbox, /fetchNotificationStates/)
|
assert.match(documentInbox, /fetchNotificationStates/)
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ const reviewPanelModelScript = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementReviewPanelModel.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/travelReimbursementReviewPanelModel.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const createReviewModelScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/scripts/travelReimbursementCreateReviewModel.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
const messageItemTemplate = readFileSync(
|
const messageItemTemplate = readFileSync(
|
||||||
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
|
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
@@ -130,6 +134,13 @@ test('review drawer tools expose the default review tab before conditional docum
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('create review model remains a thin compatibility layer over review panel model', () => {
|
||||||
|
assert.match(createReviewModelScript, /export \{[\s\S]*buildReviewFactCards[\s\S]*buildReviewRiskItems[\s\S]*\} from '\.\/travelReimbursementReviewPanelModel\.js'/)
|
||||||
|
assert.doesNotMatch(createReviewModelScript, /function buildReviewFactCards/)
|
||||||
|
assert.doesNotMatch(createReviewModelScript, /function buildReviewRiskItems/)
|
||||||
|
assert.doesNotMatch(createReviewModelScript, /const REVIEW_RISK_LEVEL_META/)
|
||||||
|
})
|
||||||
|
|
||||||
test('review drawer tool buttons switch modes instead of toggling the active mode closed', () => {
|
test('review drawer tool buttons switch modes instead of toggling the active mode closed', () => {
|
||||||
assert.match(createViewScriptSurface, /const isReviewOverviewDrawer = computed\(\(\) => reviewDrawerMode\.value === REVIEW_DRAWER_MODE_REVIEW\)/)
|
assert.match(createViewScriptSurface, /const isReviewOverviewDrawer = computed\(\(\) => reviewDrawerMode\.value === REVIEW_DRAWER_MODE_REVIEW\)/)
|
||||||
assert.match(createViewScriptSurface, /function switchReviewDrawerMode\(mode\) \{[\s\S]*if \(reviewDrawerMode\.value === mode\) \{[\s\S]*return[\s\S]*\}/)
|
assert.match(createViewScriptSurface, /function switchReviewDrawerMode\(mode\) \{[\s\S]*if \(reviewDrawerMode\.value === mode\) \{[\s\S]*return[\s\S]*\}/)
|
||||||
|
|||||||
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 workbenchView = readSource('../src/views/PersonalWorkbenchView.vue')
|
||||||
const aiMode = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
|
const aiMode = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
|
||||||
const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html')
|
const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html')
|
||||||
|
const aiModeComposer = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue')
|
||||||
|
const aiModeFileStrip = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
|
||||||
const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url))
|
const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url))
|
||||||
const aiModeRuntime = readdirSync(aiModeRuntimeDir)
|
const aiModeRuntime = readdirSync(aiModeRuntimeDir)
|
||||||
.filter((file) => file.endsWith('.js'))
|
.filter((file) => file.endsWith('.js'))
|
||||||
.sort()
|
.sort()
|
||||||
.map((file) => readFileSync(new URL(`../src/composables/workbenchAiMode/${file}`, import.meta.url), 'utf8'))
|
.map((file) => readFileSync(new URL(`../src/composables/workbenchAiMode/${file}`, import.meta.url), 'utf8'))
|
||||||
.join('\n')
|
.join('\n')
|
||||||
const aiModeSurface = `${aiMode}\n${aiModeTemplate}\n${aiModeRuntime}`
|
const aiModeSurface = `${aiMode}\n${aiModeTemplate}\n${aiModeComposer}\n${aiModeFileStrip}\n${aiModeRuntime}`
|
||||||
const aiModeStyles = readSource('../src/assets/styles/components/personal-workbench-ai-mode.css')
|
const aiModeStyles = readSource('../src/assets/styles/components/personal-workbench-ai-mode.css')
|
||||||
const workbenchViewStyles = readSource('../src/assets/styles/views/personal-workbench-view.css')
|
const workbenchViewStyles = readSource('../src/assets/styles/views/personal-workbench-view.css')
|
||||||
const appStyles = readSource('../src/assets/styles/app.css')
|
const appStyles = readSource('../src/assets/styles/app.css')
|
||||||
@@ -228,7 +230,7 @@ test('AI mode screen follows the approved reference structure', () => {
|
|||||||
assert.match(aiModeSurface, /费用测算中,请稍等/)
|
assert.match(aiModeSurface, /费用测算中,请稍等/)
|
||||||
assert.match(aiModeSurface, /rows="3"/)
|
assert.match(aiModeSurface, /rows="3"/)
|
||||||
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
|
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
|
||||||
assert.match(aiModeSurface, /<article v-for="file in selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
|
assert.match(aiModeSurface, /<article v-for="file in runtime\.selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
|
||||||
assert.match(aiModeSurface, /class="workbench-ai-file-card__ocr"/)
|
assert.match(aiModeSurface, /class="workbench-ai-file-card__ocr"/)
|
||||||
assert.match(aiModeSurface, /file\.ocrState\?\.label/)
|
assert.match(aiModeSurface, /file\.ocrState\?\.label/)
|
||||||
assert.match(aiModeSurface, /mdi mdi-text-recognition/)
|
assert.match(aiModeSurface, /mdi mdi-text-recognition/)
|
||||||
@@ -301,8 +303,9 @@ test('AI mode screen follows the approved reference structure', () => {
|
|||||||
assert.match(aiModeSurface, /解释制度/)
|
assert.match(aiModeSurface, /解释制度/)
|
||||||
assert.match(aiModeSurface, /催办审批/)
|
assert.match(aiModeSurface, /催办审批/)
|
||||||
assert.match(aiModeSurface, /<Transition name="workbench-ai-panel-swap" mode="out-in" appear>/)
|
assert.match(aiModeSurface, /<Transition name="workbench-ai-panel-swap" mode="out-in" appear>/)
|
||||||
assert.match(aiModeSurface, /@submit\.prevent="submitAiModePrompt"/)
|
assert.match(aiModeSurface, /@submit\.prevent="runtime\.submitAiModePrompt"/)
|
||||||
assert.equal((aiModeSurface.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 2)
|
assert.equal((aiModeSurface.match(/<WorkbenchAiComposer\b/g) || []).length, 2)
|
||||||
|
assert.equal((aiModeSurface.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 1)
|
||||||
assert.match(aiModeSurface, /class="workbench-ai-conversation"/)
|
assert.match(aiModeSurface, /class="workbench-ai-conversation"/)
|
||||||
assert.match(aiModeSurface, /class="workbench-ai-thread"[\s\S]*@scroll\.passive="handleInlineConversationScroll"/)
|
assert.match(aiModeSurface, /class="workbench-ai-thread"[\s\S]*@scroll\.passive="handleInlineConversationScroll"/)
|
||||||
assert.match(aiModeSurface, /workbench-ai-answer-card/)
|
assert.match(aiModeSurface, /workbench-ai-answer-card/)
|
||||||
@@ -393,6 +396,14 @@ test('AI mode screen follows the approved reference structure', () => {
|
|||||||
aiModeSurface,
|
aiModeSurface,
|
||||||
/suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\([\s\S]*targetMessage\.applicationPreview/
|
/suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\([\s\S]*targetMessage\.applicationPreview/
|
||||||
)
|
)
|
||||||
|
assert.match(aiModeSurface, /function isOrphanInlineApplicationPreviewMessage\(message = \{\}\)/)
|
||||||
|
assert.match(aiModeSurface, /function resolveLatestOrphanApplicationPreviewMessage\(messages = \[\]\)/)
|
||||||
|
assert.match(aiModeSurface, /function resolveLatestOrphanInlineApplicationPreviewMessage\(\)/)
|
||||||
|
assert.match(aiModeSurface, /当前申请核对表状态不完整,我先重新生成可编辑表格。/)
|
||||||
|
assert.match(
|
||||||
|
aiModeSurface,
|
||||||
|
/const previewSourceText = resolveLatestInlineUserPrompt\(\)[\s\S]*pushInlineApplicationActionUserMessage\(prompt\)[\s\S]*startAiApplicationPreview\('travel', '差旅费', previewSourceText/
|
||||||
|
)
|
||||||
assert.doesNotMatch(aiModeSurface, /\*\*申请单号:\*\*/)
|
assert.doesNotMatch(aiModeSurface, /\*\*申请单号:\*\*/)
|
||||||
assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
|
assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
|
||||||
assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/)
|
assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/)
|
||||||
|
|||||||
@@ -314,7 +314,10 @@ test('linked application selection can create reimbursement draft from associati
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
|
test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
|
||||||
assert.match(personalWorkbenchAiMode, /function isReimbursementCreationIntent\(prompt = ''\)/)
|
assert.match(
|
||||||
|
personalWorkbenchAiMode,
|
||||||
|
/import \{ isReimbursementCreationIntent \} from '\.\/workbenchAiApplicationGateModel\.js'/
|
||||||
|
)
|
||||||
const startConversationIndex = personalWorkbenchAiMode.indexOf('function startInlineConversation')
|
const startConversationIndex = personalWorkbenchAiMode.indexOf('function startInlineConversation')
|
||||||
const gateIndex = personalWorkbenchAiMode.indexOf('expenseFlow.startAiReimbursementAssociationGate(cleanPrompt', startConversationIndex)
|
const gateIndex = personalWorkbenchAiMode.indexOf('expenseFlow.startAiReimbursementAssociationGate(cleanPrompt', startConversationIndex)
|
||||||
const stewardIndex = personalWorkbenchAiMode.indexOf('stewardFlow.requestInlineAssistantReply(cleanPrompt', startConversationIndex)
|
const stewardIndex = personalWorkbenchAiMode.indexOf('stewardFlow.requestInlineAssistantReply(cleanPrompt', startConversationIndex)
|
||||||
|
|||||||
Reference in New Issue
Block a user