Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/2711944586_nexus-prime.json
T

1 line
223 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "2711944586/nexus-prime", "files": {"frontend/src/app/pages/settings.page.ts": {"1b31adb6c10be5ec293a33aac414815edf962427": {"t": 1785745599.4443688, "n": 32971, "h": "c4b428c5a5b385c5", "c": "import { CommonModule } from '@angular/common';\nimport { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { RouterLink } from '@angular/router';\nimport { MessageService } from 'primeng/api';\nimport { ButtonModule } from 'primeng/button';\nimport { InputTextModule } from 'primeng/inputtext';\nimport { TagModule } from 'primeng/tag';\nimport { TextareaModule } from 'primeng/textarea';\nimport { catchError, finalize, forkJoin, of } from 'rxjs';\n\nimport { ApiService } from '../core/api.service';\nimport { AiSettings, DataRecord, DeploymentReadiness, DeploymentReadinessCheck, ThemeMode, UserPreferences } from '../core/models';\nimport { ThemeService } from '../core/theme.service';\nimport { COMMAND_CENTER_PHOTOS } from '../core/visual-assets';\n\nconst EMPTY_AI_SETTINGS: AiSettings = {\n analysis_mode: 'local',\n local_analysis_enabled: true,\n external_configured: false,\n external_source: 'none',\n external_base: '',\n credential_masked: '',\n model: 'deepseek-chat',\n can_use_local: true,\n has_user_credential: false,\n preferences_updated_at: null,\n dashboard_scope: 'operations'\n};\n\nconst EMPTY_DEPLOYMENT_READINESS: DeploymentReadiness = {\n generated_at: '',\n source: 'empty',\n summary: {\n score: 0,\n level: 'attention',\n ready: 0,\n attention: 0,\n blocked: 0,\n total: 0,\n next_action: '等待后端就绪数据',\n frontend_boundary: 'NEXUS_API_BASE_URL only',\n backend_boundary: 'DATABASE_URL / SECRET_KEY / AI / storage secrets'\n },\n checks: [],\n service_snapshot: {\n services: 0,\n domains: [],\n avg_readiness: 0,\n avg_contract_coverage: 0,\n avg_split_score: 0,\n deployment_units: [],\n stores: [],\n dependencies: 0,\n api_surfaces: 0,\n split_plan: [],\n observability: {\n coverage: 0,\n policy: 'metrics + logs + traces',\n missing: [],\n signals: []\n },\n incident_queue: []\n },\n maturity: {\n summary: {\n score: 0,\n level: 'attention',\n target: '行业头部级制造开发管理 ERP',\n dimensions: 0,\n ready: 0,\n attention: 0,\n blocked: 0,\n next_action: '等待成熟度数据'\n },\n dimensions: [],\n capability_map: [],\n topology_nodes: [],\n topology_edges: [],\n evidence: []\n },\n runbook: []\n};\n\ninterface DeploymentLink {\n title: string;\n description: string;\n url: string;\n variable: string;\n secret: boolean;\n scope: 'frontend' | 'backend' | 'script';\n}\n\ninterface DeploymentCommand {\n title: string;\n description: string;\n command: string;\n}\n\n@Component({\n standalone: true,\n changeDetection: ChangeDetectionStrategy.OnPush,\n imports: [CommonModule, FormsModule, RouterLink, ButtonModule, InputTextModule, TagModule, TextareaModule],\n template: `\n <section class=\"ops-atlas-page settings-page\">\n <header class=\"settings-hero atlas-panel\">\n <div class=\"hero-narrative\">\n <span class=\"atlas-kicker\">系统设置</span>\n <h1>控制中心</h1>\n <p>统一控制主题、密度、导航、图表动画、右侧信息栏和 AI 分析接入。</p>\n <div class=\"atlas-actions-row\">\n <button pButton type=\"button\" (click)=\"saveAll()\" [loading]=\"saving()\" aria-label=\"保存全部设置\">\n <i class=\"pi pi-save\"></i>\n 保存全部\n </button>\n <button pButton type=\"button\" severity=\"secondary\" (click)=\"load()\" [loading]=\"loading()\" aria-label=\"重新读取设置\">\n <i class=\"pi pi-refresh\"></i>\n 重新读取\n </button>\n <a pButton severity=\"info\" routerLink=\"/app/ai\">\n <i class=\"pi pi-sparkles\"></i>\n AI 分析台\n </a>\n </div>\n </div>\n\n <div class=\"settings-status-board\">\n <article>\n <span>主题</span>\n <strong>{{ preferenceDraft.theme === 'dark-cockpit' ? '深色' : '亮色' }}</strong>\n <em>{{ preferenceDraft.density === 'compact' ? '紧凑密度' : '舒适密度' }}</em>\n </article>\n <article>\n <span>AI 接入</span>\n <strong>{{ aiDraft.analysis_mode === 'external' ? '外部' : aiDraft.analysis_mode === 'hybrid' ? '混合' : '本地' }}</strong>\n <em>{{ aiSettings().credential_masked || '未配置凭证' }}</em>\n </article>\n <article>\n <span>导航</span>\n <strong>{{ preferenceDraft.dock_labels === 'always' ? '常显' : '悬停' }}</strong>\n <em>{{ preferenceDraft.context_panel === 'compact' ? '右栏精简' : '右栏显示' }}</em>\n </article>\n </div>\n\n <div class=\"settings-visual-rail\" aria-label=\"系统控制台现场图片\">\n @for (photo of settingsPhotos; track photo.src) {\n <figure>\n <img [src]=\"photo.src\" [alt]=\"photo.alt\" loading=\"eager\" decoding=\"async\" />\n <figcaption>\n <span>{{ photo.label }}</span>\n <strong>{{ photo.caption }}</strong>\n </figcaption>\n </figure>\n }\n </div>\n </header>\n\n <section class=\"settings-grid\">\n <article class=\"atlas-panel settings-card\">\n <div class=\"atlas-panel-head\">\n <div>\n <span class=\"atlas-kicker\">外观</span>\n <h2>界面与动效</h2>\n </div>\n <p-tag severity=\"info\" value=\"全局\" />\n </div>\n\n <div class=\"settings-option-grid\">\n <button type=\"button\" [class.active]=\"preferenceDraft.theme === 'light-luxury'\" (click)=\"setTheme('light-luxury')\">\n <i class=\"pi pi-sun\"></i>\n <strong>亮色系统</strong>\n <span>适合日常办公、报表复核和文件查看。</span>\n </button>\n <button type=\"button\" [class.active]=\"preferenceDraft.theme === 'dark-cockpit'\" (click)=\"setTheme('dark-cockpit')\">\n <i class=\"pi pi-moon\"></i>\n <strong>深色驾驶舱</strong>\n <span>适合值守屏、夜间监控和经营风险看板。</span>\n </button>\n </div>\n\n <div class=\"settings-form-grid\">\n <label>\n <span>信息密度</span>\n <select [(ngModel)]=\"preferenceDraft.density\">\n <option value=\"compact\">紧凑</option>\n <option value=\"comfortable\">舒适</option>\n </select>\n </label>\n <label>\n <span>图表动效</span>\n <select [(ngModel)]=\"preferenceDraft.charts_motion\">\n <option value=\"standard\">标准</option>\n <option value=\"reduced\">减少动效</option>\n </select>\n </label>\n <label>\n <span>默认工作台</span>\n <select [(ngModel)]=\"preferenceDraft.default_workspace\">\n <option value=\"/app/overview\">运营总览</option>\n <option value=\"/app/ai\">AI 分析</option>\n <option value=\"/app/reports\">报表工作室</option>\n <option value=\"/app/files\">文件中心</option>\n </select>\n </label>\n </div>\n </article>\n\n <article class=\"atlas-panel settings-card\">\n <div class=\"atlas-panel-head\">\n <div>\n <span class=\"atlas-kicker\">导航</span>\n <h2>Dock 与上下文栏</h2>\n </div>\n <p-tag severity=\"success\" value=\"布局\" />\n </div>\n\n <div class=\"settings-form-grid\">\n <label>\n <span>Dock 名称</span>\n <select [(ngModel)]=\"preferenceDraft.dock_labels\">\n <option value=\"hover\">悬停显示</option>\n <option value=\"always\">始终显示</option>\n </select>\n </label>\n <label>\n <span>右侧上下文栏</span>\n <select [(ngModel)]=\"preferenceDraft.context_panel\">\n <option value=\"visible\">显示完整信息</option>\n <option value=\"compact\">精简显示</option>\n </select>\n </label>\n </div>\n\n <div class=\"settings-link-list\">\n <a routerLink=\"/app/profile\"><i class=\"pi pi-user\"></i><span>个人工作台</span></a>\n <a routerLink=\"/app/system/users\"><i class=\"pi pi-shield\"></i><span>用户权限</span></a>\n <a routerLink=\"/app/system/audit\"><i class=\"pi pi-lock\"></i><span>审计日志</span></a>\n </div>\n </article>\n\n <article class=\"atlas-panel settings-card ai-settings-card\">\n <div class=\"atlas-panel-head\">\n <div>\n <span class=\"atlas-kicker\">AI</span>\n <h2>AI 分析服务</h2>\n </div>\n <p-tag [severity]=\"aiSettings().external_configured ? 'success' : 'warn'\" [value]=\"aiSettings().external_configured ? '已配置' : '未配置'\" />\n </div>\n\n <div class=\"mode-switch settings-mode-switch\">\n <button type=\"button\" [class.active]=\"aiDraft.analysis_mode === 'local'\" (click)=\"aiDraft.analysis_mode = 'local'\">本地</button>\n <button type=\"button\" [class.active]=\"aiDraft.analysis_mode === 'hybrid'\" (click)=\"aiDraft.analysis_mode = 'hybrid'\">混合</button>\n <button type=\"button\" [class.active]=\"aiDraft.analysis_mode === 'external'\" (click)=\"aiDraft.analysis_mode = 'external'\">外部</button>\n </div>\n\n <div class=\"settings-form-grid\">\n <label>\n <span>Base URL</span>\n <input pInputText [(ngModel)]=\"aiDraft.ai_api_base\" placeholder=\"https://api.deepseek.com\" />\n </label>\n <label>\n <span>模型</span>\n <input pInputText [(ngModel)]=\"aiDraft.ai_model\" placeholder=\"deepseek-chat\" />\n </label>\n <label class=\"wide\">\n <span>API Key</span>\n <input pInputText [(ngModel)]=\"aiDraft.ai_api_key\" placeholder=\"留空则保留当前凭证\" />\n </label>\n </div>\n\n <div class=\"settings-note\">\n <strong>{{ aiSettings().credential_masked || '当前未配置外部凭证' }}</strong>\n <span>外部服务需兼容 Chat Completions 请求格式;保存后可在 AI 分析台运行诊断。</span>\n </div>\n </article>\n\n <article class=\"atlas-panel settings-card deployment-settings-card\">\n <div class=\"atlas-panel-head\">\n <div>\n <span class=\"atlas-kicker\">部署</span>\n <h2>上线准备与密钥入口</h2>\n </div>\n <p-tag severity=\"contrast\" value=\"后端密钥隔离\" />\n </div>\n\n <div class=\"deployment-boundary-strip\" aria-label=\"部署边界\">\n <article>\n <span>前端项目</span>\n <strong>NEXUS_API_BASE_URL</strong>\n <em>只保存公开 API 地址。</em>\n </article>\n <article>\n <span>后端项目</span>\n <strong>DATABASE_URL / SECRET_KEY / AI / Cloudinary</strong>\n <em>所有密钥只写入后端环境变量。</em>\n </article>\n <article>\n <span>旧版快照</span>\n <strong>legacy/monolith-flask</strong>\n <em>保留作升级报告对照,不参与部署。</em>\n </article>\n </div>\n\n <section class=\"deployment-readiness-board\" aria-label=\"动态部署就绪看板\">\n <article class=\"deployment-score-card\" [class.ready]=\"deploymentReadiness().summary.level === 'ready'\" [class.blocked]=\"deploymentReadiness().summary.level === 'blocked'\">\n <span>上线就绪分</span>\n <strong>{{ deploymentReadiness().summary.score }}%</strong>\n <em>{{ deploymentReadiness().summary.next_action }}</em>\n <div>\n <b>{{ deploymentReadiness().summary.ready }}</b>\n <small>ready</small>\n </div>\n <div>\n <b>{{ deploymentReadiness().summary.attention }}</b>\n <small>attention</small>\n </div>\n <div>\n <b>{{ deploymentReadiness().summary.blocked }}</b>\n <small>blocked</small>\n </div>\n </article>\n\n <div class=\"deployment-check-board\">\n <div class=\"deployment-section-head\">\n <span>部署前检查</span>\n <strong>{{ deploymentReadiness().summary.total }} 项动态检查</strong>\n </div>\n @for (check of readinessChecks(); track check.key) {\n <article [class.ready]=\"check.status === 'ready'\" [class.blocked]=\"check.status === 'blocked'\" [class.attention]=\"check.status === 'attention'\">\n <p-tag [severity]=\"statusSeverity(check.status)\" [value]=\"check.status\" />\n <span>{{ check.scope }}</span>\n <strong>{{ check.label }}</strong>\n <em>{{ check.evidence }}</em>\n <small>{{ check.action }}</small>\n @if (check.status !== 'ready') {\n <div class=\"deployment-check-actions\">\n <button\n pButton\n type=\"button\"\n size=\"small\"\n severity=\"secondary\"\n [loading]=\"deploymentTaskCreating() === check.key\"\n [disabled]=\"deploymentTaskCreating() !== ''\"\n (click)=\"createDeploymentTask(check)\"\n [attr.aria-label]=\"'创建部署预检任务 ' + check.label\"\n >\n <i class=\"pi pi-send\"></i>\n 创建任务\n </button>\n @if (deploymentTaskCreated() === check.key) {\n <a pButton [text]=\"true\" size=\"small\" routerLink=\"/app/notifications\">\n 通知中心\n </a>\n }\n </div>\n }\n </article>\n }\n </div>\n </section>\n\n <section class=\"deployment-service-snapshot\" aria-label=\"微服务拆分就绪快照\">\n <div class=\"deployment-section-head\">\n <span>微服务拆分快照</span>\n <strong>{{ deploymentReadiness().service_snapshot.services }} 服务 / {{ deploymentReadiness().service_snapshot.dependencies }} 依赖 / {{ deploymentReadiness().service_snapshot.api_surfaces }} API 面 / 拆分 {{ deploymentReadiness().service_snapshot.avg_split_score }}%</strong>\n </div>\n <div class=\"deployment-domain-grid\">\n @for (domain of readinessDomains(); track domain.domain) {\n <article [class.attention]=\"domain.attention > 0\">\n <span>{{ domain.domain }}</span>\n <strong>{{ domain.avg_readiness }}%</strong>\n <em>{{ domain.services }} 服务 · {{ domain.records }} 记录</em>\n <small>{{ domain.attention }} 个关注服务</small>\n </article>\n }\n </div>\n <div class=\"deployment-runtime-strip\">\n <span>部署单元:{{ deploymentReadiness().service_snapshot.deployment_units.join(' / ') || '等待数据' }}</span>\n <span>存储:{{ deploymentReadiness().service_snapshot.stores.join(' / ') || '等待数据' }}</span>\n <span>契约覆盖:{{ deploymentReadiness().service_snapshot.avg_contract_coverage }}%</span>\n </div>\n </section>\n\n <section class=\"deployment-architecture-board\" aria-label=\"架构拆分与可观测性\">\n <article class=\"architecture-score-card\">\n <span>可观测性覆盖</span>\n <strong>{{ deploymentReadiness().service_snapshot.observability.coverage }}%</strong>\n <em>{{ deploymentReadiness().service_snapshot.observability.policy }}</em>\n <div>\n @for (signal of architectureSignals(); track signal.key) {\n <span>\n <b>{{ signal.label }}</b>\n <small>{{ signal.ready }}/{{ signal.total }} · {{ signal.coverage }}%</small>\n </span>\n }\n </div>\n </article>\n\n <div class=\"architecture-split-plan\">\n <div class=\"deployment-section-head\">\n <span>拆分路线</span>\n <strong>{{ architectureSplitPlan().length }} 阶段 · 网关与事件优先</strong>\n </div>\n @for (phase of architectureSplitPlan(); track phase.phase) {\n <article>\n <span>{{ phase.phase }}</span>\n <strong>{{ phase.avg_split_score }}%</strong>\n <em>{{ phase.services.join(' / ') }}</em>\n <small>{{ phase.ready }} ready · {{ phase.attention }} attention · {{ phase.events }} events · {{ phase.gateway_routes }} gateway routes</small>\n </article>\n }\n </div>\n\n <div class=\"architecture-incident-queue\">\n <div class=\"deployment-section-head\">\n <span>架构治理队列</span>\n <strong>{{ architectureIncidents().length }} 项</strong>\n </div>\n @for (incident of architectureIncidents(); track incident.id) {\n <a [routerLink]=\"incident.path\" [class.p0]=\"incident.priority === 'P0'\" [class.p1]=\"incident.priority === 'P1'\">\n <span>{{ incident.priority }} · {{ incident.owner }}</span>\n <strong>{{ incident.title }}</strong>\n <em>{{ incident.runtime_unit }} · error budget {{ incident.error_budget_remaining }}%</em>\n <small>{{ incident.evidence }}</small>\n <b>{{ incident.signal_coverage }}% obs / {{ incident.contract_coverage }}% contract</b>\n </a>\n }\n </div>\n </section>\n\n <section class=\"erp-maturity-board\" aria-label=\"ERP 成熟度验收\">\n <article class=\"erp-maturity-score\" [class.ready]=\"deploymentReadiness().maturity.summary.level === 'ready'\" [class.blocked]=\"deploymentReadiness().maturity.summary.level === 'blocked'\">\n <span>行业成熟度</span>\n <strong>{{ deploymentReadiness().maturity.summary.score }}%</strong>\n <em>{{ deploymentReadiness().maturity.summary.target }}</em>\n <small>{{ deploymentReadiness().maturity.summary.next_action }}</small>\n </article>\n\n <div class=\"erp-maturity-dimensions\">\n <div class=\"deployment-section-head\">\n <span>验收维度</span>\n <strong>{{ deploymentReadiness().maturity.summary.ready }} ready / {{ deploymentReadiness().maturity.summary.attention }} attention / {{ deploymentReadiness().maturity.summary.blocked }} blocked</strong>\n </div>\n @for (dimension of maturityDimensions(); track dimension.key) {\n <article [class.ready]=\"dimension.level === 'ready'\" [class.blocked]=\"dimension.level === 'blocked'\" [class.attention]=\"dimension.level === 'attention'\">\n <div>\n <span>{{ dimension.label }}</span>\n <strong>{{ dimension.score }}%</strong>\n </div>\n <i aria-hidden=\"true\"><small [style.width.%]=\"dimension.score\"></small></i>\n <em>{{ dimension.evidence }}</em>\n <small>{{ dimension.action }}</small>\n </article>\n }\n </div>\n </section>\n\n <section class=\"erp-capability-map\" aria-label=\"能力域与服务拓扑\">\n <div class=\"deployment-section-head\">\n <span>能力域地图</span>\n <strong>{{ maturityCapabilities().length }} 域 / {{ maturityTopologyNodes().length }} 服务节点 / {{ deploymentReadiness().maturity.topology_edges.length }} 依赖边</strong>\n </div>\n <div class=\"erp-capability-grid\">\n @for (capability of maturityCapabilities(); track capability.domain) {\n <article [class.attention]=\"capability.attention > 0\">\n <span>{{ capability.domain }}</span>\n <strong>{{ capability.avg_readiness }}%</strong>\n <em>{{ capability.services }} 服务 · {{ capability.contracts }} 契约 · {{ capability.api_surfaces }} API</em>\n <small>{{ capability.modules.join(' / ') }}</small>\n </article>\n }\n </div>\n <div class=\"erp-topology-list\">\n @for (node of maturityTopologyNodes(); track node.id) {\n <a [routerLink]=\"node.path\" [class.attention]=\"node.status !== 'healthy'\">\n <span>{{ node.domain }}</span>\n <strong>{{ node.name }}</strong>\n <em>{{ node.runtime_unit }} · {{ node.owner }}</em>\n <small>{{ node.risk_note }}</small>\n <b>{{ node.readiness }}%</b>\n </a>\n }\n </div>\n <div class=\"erp-evidence-strip\" aria-label=\"交付证据\">\n @for (item of maturityEvidence(); track item.path) {\n <article [class.ready]=\"item.status === 'ready'\" [class.blocked]=\"item.status === 'blocked'\">\n <span>{{ item.label }}</span>\n <strong>{{ item.path }}</strong>\n <em>{{ item.description }}</em>\n </article>\n }\n </div>\n </section>\n\n <div class=\"deployment-token-grid\" aria-label=\"Token 获取入口\">\n @for (item of deploymentLinks; track item.title) {\n <a [href]=\"item.url\" target=\"_blank\" rel=\"noopener noreferrer\">\n <span>\n <strong>{{ item.title }}</strong>\n <em>{{ item.description }}</em>\n </span>\n <p-tag [severity]=\"item.secret ? 'warn' : 'info'\" [value]=\"item.variable\" />\n <small>{{ item.scope === 'frontend' ? '前端变量' : item.scope === 'backend' ? '后端变量' : '脚本变量' }}</small>\n </a>\n }\n </div>\n\n <div class=\"deployment-copy-grid\" aria-label=\"可复制部署命令\">\n @for (item of deploymentRunbookItems(); track item.title) {\n <article>\n <span>{{ item.title }}</span>\n <strong>{{ item.description }}</strong>\n <code>{{ item.command }}</code>\n <button pButton type=\"button\" severity=\"secondary\" (click)=\"copyCommand(item.command)\" [attr.aria-label]=\"'复制 ' + item.title\">\n <i class=\"pi pi-copy\"></i>\n 复制命令\n </button>\n </article>\n }\n </div>\n\n <div class=\"settings-note\">\n <strong>部署手册:docs/api-token-deployment-guide.md</strong>\n <span>页面只提供入口和命令模板;真实 Token、数据库密码、AI Key 和 Cloudinary secret 仍由 Vercel 后端项目或本地部署脚本保存。</span>\n </div>\n </article>\n </section>\n </section>\n `\n})\nexport class SettingsPage implements OnInit {\n private readonly api = inject(ApiService);\n private readonly theme = inject(ThemeService);\n private readonly messages = inject(MessageService);\n\n protected readonly loading = signal(false);\n protected readonly saving = signal(false);\n protected readonly deploymentTaskCreating = signal('');\n protected readonly deploymentTaskCreated = signal('');\n protected readonly aiSettings = signal<AiSettings>(EMPTY_AI_SETTINGS);\n protected readonly deploymentReadiness = signal<DeploymentReadiness>(EMPTY_DEPLOYMENT_READINESS);\n protected readonly settingsPhotos = COMMAND_CENTER_PHOTOS.slice(4, 8);\n protected readonly readinessChecks = computed(() => {\n const priority: Record<string, number> = { blocked: 0, attention: 1, ready: 2 };\n return [...this.deploymentReadiness().checks]\n .sort((a, b) => (priority[a.status] ?? 9) - (priority[b.status] ?? 9))\n .slice(0, 12);\n });\n protected readonly readinessRunbook = computed(() => this.deploymentReadiness().runbook.slice(0, 5));\n protected readonly readinessDomains = computed(() => this.deploymentReadiness().service_snapshot.domains.slice(0, 6));\n protected readonly architectureSignals = computed(() => this.deploymentReadiness().service_snapshot.observability.signals.slice(0, 3));\n protected readonly architectureSplitPlan = computed(() => this.deploymentReadiness().service_snapshot.split_plan.slice(0, 5));\n protected readonly architectureIncidents = computed(() => this.deploymentReadiness().service_snapshot.incident_queue.slice(0, 4));\n protected readonly maturityDimensions = computed(() => {\n const priority: Record<string, number> = { blocked: 0, attention: 1, ready: 2 };\n return [...this.deploymentReadiness().maturity.dimensions]\n .sort((a, b) => (priority[a.level] ?? 9) - (priority[b.level] ?? 9))\n .slice(0, 6);\n });\n protected readonly maturityCapabilities = computed(() => [...this.deploymentReadiness().maturity.capability_map]\n .sort((a, b) => b.attention - a.attention || b.avg_readiness - a.avg_readiness)\n .slice(0, 6));\n protected readonly maturityTopologyNodes = computed(() => [...this.deploymentReadiness().maturity.topology_nodes]\n .sort((a, b) => (a.status === 'healthy' ? 1 : 0) - (b.status === 'healthy' ? 1 : 0) || b.readiness - a.readiness)\n .slice(0, 8));\n protected readonly maturityEvidence = computed(() => {\n const priority: Record<string, number> = { blocked: 0, attention: 1, ready: 2 };\n return [...this.deploymentReadiness().maturity.evidence]\n .sort((a, b) => (priority[a.status] ?? 9) - (priority[b.status] ?? 9))\n .slice(0, 8);\n });\n protected readonly deploymentRunbookItems = computed<DeploymentCommand[]>(() => {\n const runbook = this.readinessRunbook();\n if (!runbook.length) {\n return this.deploymentCommands;\n }\n return runbook.map(item => ({\n title: item.step,\n description: '部署运行手册步骤',\n command: item.command\n }));\n });\n protected preferenceDraft: UserPreferences = {\n theme: 'light-luxury',\n density: 'compact',\n default_workspace: '/app/overview',\n charts_motion: 'standard',\n dock_labels: 'hover',\n context_panel: 'visible'\n };\n protected aiDraft: { analysis_mode: AiSettings['analysis_mode']; ai_api_base: string; ai_api_key: string; ai_model: string } = {\n analysis_mode: 'local',\n ai_api_base: '',\n ai_api_key: '',\n ai_model: 'deepseek-chat'\n };\n protected readonly deploymentLinks: DeploymentLink[] = [\n {\n title: 'Vercel Token',\n description: 'Account Settings / Tokens,供一键部署脚本使用。',\n url: 'https://vercel.com/account/settings/tokens',\n variable: 'VERCEL_TOKEN',\n secret: true,\n scope: 'script'\n },\n {\n title: 'Supabase Database',\n description: 'Project Settings / Database,复制 Pooler PostgreSQL URL。',\n url: 'https://supabase.com/dashboard/projects',\n variable: 'DATABASE_URL',\n secret: true,\n scope: 'backend'\n },\n {\n title: 'Supabase API Keys',\n description: '后续接 Storage 或 Edge API 时使用,当前前端不直连。',\n url: 'https://supabase.com/dashboard/projects',\n variable: 'SUPABASE_KEYS',\n secret: true,\n scope: 'backend'\n },\n {\n title: 'Cloudinary API Keys',\n description: '生产头像与文件持久化存储。',\n url: 'https://console.cloudinary.com/settings/api-keys',\n variable: 'CLOUDINARY_URL',\n secret: true,\n scope: 'backend'\n },\n {\n title: 'DeepSeek API Key',\n description: 'AI 经营分析外部模型,兼容 Chat Completions。',\n url: 'https://platform.deepseek.com/api_keys',\n variable: 'DEEPSEEK_API_KEY',\n secret: true,\n scope: 'backend'\n },\n {\n title: '前端 API 地址',\n description: 'Vercel 前端项目环境变量,只填写后端 /api/v1 地址。',\n url: 'https://vercel.com/docs/environment-variables',\n variable: 'NEXUS_API_BASE_URL',\n secret: false,\n scope: 'frontend'\n }\n ];\n protected readonly deploymentCommands: DeploymentCommand[] = [\n {\n title: '生成 SECRET_KEY',\n description: '本地生成后写入后端 Vercel 项目。',\n command: '[Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }))'\n },\n {\n title: '写入后端数据库',\n description: '在 backend 项目中添加 Supabase PostgreSQL 连接串。',\n command: 'vercel env add DATABASE_URL production --sensitive'\n },\n {\n title: '写入前端 API',\n description: '在 frontend 项目中添加公开 API 地址。',\n command: 'vercel env add NEXUS_API_BASE_URL production'\n },\n {\n title: '一键预检部署',\n description: '从仓库根目录执行前后端部署与数据同步。',\n command: '.\\\\scripts\\\\deploy-supabase-vercel.ps1 -DatabaseUrl \"postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\" -BackendProjectName \"nexus-prime-api\" -FrontendProjectName \"nexus-prime-web\" -SyncDatabase -SecretKey \"<32位以上随机字符串>\" -CloudinaryUrl \"cloudinary://api_key:api_secret@cloud_name\"'\n }\n ];\n\n ngOnInit(): void {\n this.load();\n }\n\n load(): void {\n this.loading.set(true);\n forkJoin({\n preferences: this.api.preferences().pipe(catchError(() => of({} as UserPreferences))),\n ai: this.api.get<AiSettings>('ai/settings').pipe(catchError(() => of(EMPTY_AI_SETTINGS))),\n deployment: this.api.get<DeploymentReadiness>('operations/deployment-readiness').pipe(catchError(() => of(EMPTY_DEPLOYMENT_READINESS)))\n }).pipe(finalize(() => this.loading.set(false))).subscribe(({ preferences, ai, deployment }) => {\n const currentPreferences = this.theme.preferences();\n this.preferenceDraft = {\n theme: currentPreferences.theme ?? this.theme.mode(),\n density: preferences.density ?? currentPreferences.density ?? 'compact',\n default_workspace: preferences.default_workspace ?? currentPreferences.default_workspace ?? '/app/overview',\n charts_motion: preferences.charts_motion ?? currentPreferences.charts_motion ?? 'standard',\n dock_labels: preferences.dock_labels ?? currentPreferences.dock_labels ?? 'hover',\n context_panel: preferences.context_panel ?? currentPreferences.context_panel ?? 'visible'\n };\n this.theme.setPreferences(this.preferenceDraft, false);\n this.aiSettings.set(ai);\n this.deploymentReadiness.set(deployment);\n this.aiDraft = {\n analysis_mode: ai.analysis_mode,\n ai_api_base: ai.external_base || '',\n ai_api_key: '',\n ai_model: ai.model || 'deepseek-chat'\n };\n });\n }\n\n setTheme(mode: ThemeMode): void {\n this.preferenceDraft.theme = mode;\n this.theme.setPreferences(this.preferenceDraft, false);\n }\n\n saveAll(): void {\n this.saving.set(true);\n forkJoin({\n preferences: this.api.savePreferences(this.preferenceDraft),\n ai: this.api.put<AiSettings>('ai/settings', this.aiDraft)\n }).pipe(\n catchError(error => {\n this.messages.add({ severity: 'warn', summary: '设置未保存', detail: error?.message || '请检查配置内容。' });\n return of(null);\n }),\n finalize(() => this.saving.set(false))\n ).subscribe(result => {\n if (!result) {\n return;\n }\n this.preferenceDraft = { ...this.preferenceDraft, ...result.preferences };\n this.aiSettings.set(result.ai);\n this.theme.setPreferences(result.preferences, false);\n this.messages.add({ severity: 'success', summary: '设置已保存', detail: '全局偏好和 AI 服务设置已更新。' });\n });\n }\n\n async copyCommand(command: string): Promise<void> {\n try {\n if (navigator.clipboard?.writeText) {\n await navigator.clipboard.writeText(command);\n } else if (!this.fallbackCopy(command)) {\n throw new Error('clipboard_unavailable');\n }\n this.messages.add({ severity: 'success', summary: '已复制', detail: '部署命令已复制到剪贴板。' });\n } catch {\n this.messages.add({ severity: 'warn', summary: '复制失败', detail: '请手动复制命令,浏览器未开放剪贴板权限。' });\n }\n }\n\n protected createDeploymentTask(check: DeploymentReadinessCheck): void {\n if (check.status === 'ready' || this.deploymentTaskCreating()) {\n return;\n }\n this.deploymentTaskCreating.set(check.key);\n this.api.post<DataRecord>('operations/deployment-readiness/task', {\n key: check.key,\n label: check.label,\n scope: check.scope,\n status: check.status,\n evidence: check.evidence,\n action: check.action\n }).pipe(\n catchError(error => {\n this.messages.add({\n severity: 'warn',\n summary: '任务未创建',\n detail: error?.message || '请稍后重试部署预检任务。'\n });\n return of(null);\n }),\n finalize(() => this.deploymentTaskCreating.set(''))\n ).subscribe(result => {\n if (!result) {\n return;\n }\n this.deploymentTaskCreated.set(check.key);\n this.messages.add({\n severity: 'success',\n summary: '部署任务已创建',\n detail: `${check.label} 已写入通知中心和审计日志。`\n });\n });\n }\n\n protected statusSeverity(status: string): 'success' | 'warn' | 'danger' | 'info' {\n if (status === 'ready') {\n return 'success';\n }\n if (status === 'blocked') {\n return 'danger';\n }\n if (status === 'attention') {\n return 'warn';\n }\n return 'info';\n }\n\n private fallbackCopy(value: string): boolean {\n const textarea = document.createElement('textarea');\n textarea.value = value;\n textarea.setAttribute('readonly', 'true');\n textarea.style.position = 'fixed';\n textarea.style.left = '-9999px';\n document.body.appendChild(textarea);\n textarea.select();\n try {\n return document.execCommand('copy');\n } finally {\n document.body.removeChild(textarea);\n }\n }\n}\n"}, "f164509df0d21e33e73ba54642a8ae2f260fc7ba": {"t": 1785754650.6035314, "n": 32729, "h": "1baa94c2ff88312f", "c": "import { CommonModule } from '@angular/common';\nimport { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { RouterLink } from '@angular/router';\nimport { MessageService } from 'primeng/api';\nimport { ButtonModule } from 'primeng/button';\nimport { InputTextModule } from 'primeng/inputtext';\nimport { TagModule } from 'primeng/tag';\nimport { TextareaModule } from 'primeng/textarea';\nimport { catchError, finalize, forkJoin, of } from 'rxjs';\n\nimport { ApiService } from '../core/api.service';\nimport { AiSettings, DataRecord, DeploymentReadiness, DeploymentReadinessCheck, ThemeMode, UserPreferences } from '../core/models';\nimport { ThemeService } from '../core/theme.service';\nimport { COMMAND_CENTER_PHOTOS } from '../core/visual-assets';\n\nconst EMPTY_AI_SETTINGS: AiSettings = {\n analysis_mode: 'local',\n local_analysis_enabled: true,\n external_configured: false,\n external_source: 'none',\n external_base: '',\n credential_masked: '',\n model: 'deepseek-chat',\n can_use_local: true,\n has_user_credential: false,\n preferences_updated_at: null,\n dashboard_scope: 'operations'\n};\n\nconst EMPTY_DEPLOYMENT_READINESS: DeploymentReadiness = {\n generated_at: '',\n source: 'empty',\n summary: {\n score: 0,\n level: 'attention',\n ready: 0,\n attention: 0,\n blocked: 0,\n total: 0,\n next_action: '等待后端就绪数据',\n frontend_boundary: 'NEXUS_API_BASE_URL only',\n backend_boundary: 'DATABASE_URL / SECRET_KEY / AI / storage secrets'\n },\n checks: [],\n service_snapshot: {\n services: 0,\n domains: [],\n avg_readiness: 0,\n avg_contract_coverage: 0,\n avg_split_score: 0,\n deployment_units: [],\n stores: [],\n dependencies: 0,\n api_surfaces: 0,\n split_plan: [],\n observability: {\n coverage: 0,\n policy: 'metrics + logs + traces',\n missing: [],\n signals: []\n },\n incident_queue: []\n },\n maturity: {\n summary: {\n score: 0,\n level: 'attention',\n target: '行业头部级制造开发管理 ERP',\n dimensions: 0,\n ready: 0,\n attention: 0,\n blocked: 0,\n next_action: '等待成熟度数据'\n },\n dimensions: [],\n capability_map: [],\n topology_nodes: [],\n topology_edges: [],\n evidence: []\n },\n runbook: []\n};\n\ninterface DeploymentLink {\n title: string;\n description: string;\n url: string;\n variable: string;\n secret: boolean;\n scope: 'frontend' | 'backend' | 'script';\n}\n\ninterface DeploymentCommand {\n title: string;\n description: string;\n command: string;\n}\n\n@Component({\n standalone: true,\n changeDetection: ChangeDetectionStrategy.OnPush,\n imports: [CommonModule, FormsModule, RouterLink, ButtonModule, InputTextModule, TagModule, TextareaModule],\n template: `\n <section class=\"ops-atlas-page settings-page\">\n <header class=\"settings-hero atlas-panel\">\n <div class=\"hero-narrative\">\n <span class=\"atlas-kicker\">系统设置</span>\n <h1>控制中心</h1>\n <p>统一控制主题、密度、导航、图表动画、右侧信息栏和 AI 分析接入。</p>\n <div class=\"atlas-actions-row\">\n <button pButton type=\"button\" (click)=\"saveAll()\" [loading]=\"saving()\" aria-label=\"保存全部设置\">\n <i class=\"pi pi-save\"></i>\n 保存全部\n </button>\n <button pButton type=\"button\" severity=\"secondary\" (click)=\"load()\" [loading]=\"loading()\" aria-label=\"重新读取设置\">\n <i class=\"pi pi-refresh\"></i>\n 重新读取\n </button>\n <a pButton severity=\"info\" routerLink=\"/app/ai\">\n <i class=\"pi pi-sparkles\"></i>\n AI 分析台\n </a>\n </div>\n </div>\n\n <div class=\"settings-status-board\">\n <article>\n <span>主题</span>\n <strong>{{ preferenceDraft.theme === 'dark-cockpit' ? '深色' : '亮色' }}</strong>\n <em>{{ preferenceDraft.density === 'compact' ? '紧凑密度' : '舒适密度' }}</em>\n </article>\n <article>\n <span>AI 接入</span>\n <strong>{{ aiDraft.analysis_mode === 'external' ? '外部' : aiDraft.analysis_mode === 'hybrid' ? '混合' : '本地' }}</strong>\n <em>{{ aiSettings().credential_masked || '未配置凭证' }}</em>\n </article>\n <article>\n <span>导航</span>\n <strong>{{ preferenceDraft.dock_labels === 'always' ? '常显' : '悬停' }}</strong>\n <em>{{ preferenceDraft.context_panel === 'compact' ? '右栏精简' : '右栏显示' }}</em>\n </article>\n </div>\n\n <div class=\"settings-visual-rail\" aria-label=\"系统控制台现场图片\">\n @for (photo of settingsPhotos; track photo.src) {\n <figure>\n <img [src]=\"photo.src\" [alt]=\"photo.alt\" loading=\"eager\" decoding=\"async\" />\n <figcaption>\n <span>{{ photo.label }}</span>\n <strong>{{ photo.caption }}</strong>\n </figcaption>\n </figure>\n }\n </div>\n </header>\n\n <section class=\"settings-grid\">\n <article class=\"atlas-panel settings-card\">\n <div class=\"atlas-panel-head\">\n <div>\n <span class=\"atlas-kicker\">外观</span>\n <h2>界面与动效</h2>\n </div>\n <p-tag severity=\"info\" value=\"全局\" />\n </div>\n\n <div class=\"settings-option-grid\">\n <button type=\"button\" [class.active]=\"preferenceDraft.theme === 'light-luxury'\" (click)=\"setTheme('light-luxury')\">\n <i class=\"pi pi-sun\"></i>\n <strong>亮色系统</strong>\n <span>适合日常办公、报表复核和文件查看。</span>\n </button>\n <button type=\"button\" [class.active]=\"preferenceDraft.theme === 'dark-cockpit'\" (click)=\"setTheme('dark-cockpit')\">\n <i class=\"pi pi-moon\"></i>\n <strong>深色驾驶舱</strong>\n <span>适合值守屏、夜间监控和经营风险看板。</span>\n </button>\n </div>\n\n <div class=\"settings-form-grid\">\n <label>\n <span>信息密度</span>\n <select [(ngModel)]=\"preferenceDraft.density\">\n <option value=\"compact\">紧凑</option>\n <option value=\"comfortable\">舒适</option>\n </select>\n </label>\n <label>\n <span>图表动效</span>\n <select [(ngModel)]=\"preferenceDraft.charts_motion\">\n <option value=\"standard\">标准</option>\n <option value=\"reduced\">减少动效</option>\n </select>\n </label>\n <label>\n <span>默认工作台</span>\n <select [(ngModel)]=\"preferenceDraft.default_workspace\">\n <option value=\"/app/overview\">运营总览</option>\n <option value=\"/app/ai\">AI 分析</option>\n <option value=\"/app/reports\">报表工作室</option>\n <option value=\"/app/files\">文件中心</option>\n </select>\n </label>\n </div>\n </article>\n\n <article class=\"atlas-panel settings-card\">\n <div class=\"atlas-panel-head\">\n <div>\n <span class=\"atlas-kicker\">导航</span>\n <h2>Dock 与上下文栏</h2>\n </div>\n <p-tag severity=\"success\" value=\"布局\" />\n </div>\n\n <div class=\"settings-form-grid\">\n <label>\n <span>Dock 名称</span>\n <select [(ngModel)]=\"preferenceDraft.dock_labels\">\n <option value=\"hover\">悬停显示</option>\n <option value=\"always\">始终显示</option>\n </select>\n </label>\n <label>\n <span>右侧上下文栏</span>\n <select [(ngModel)]=\"preferenceDraft.context_panel\">\n <option value=\"visible\">显示完整信息</option>\n <option value=\"compact\">精简显示</option>\n </select>\n </label>\n </div>\n\n <div class=\"settings-link-list\">\n <a routerLink=\"/app/profile\"><i class=\"pi pi-user\"></i><span>个人工作台</span></a>\n <a routerLink=\"/app/system/users\"><i class=\"pi pi-shield\"></i><span>用户权限</span></a>\n <a routerLink=\"/app/system/audit\"><i class=\"pi pi-lock\"></i><span>审计日志</span></a>\n </div>\n </article>\n\n <article class=\"atlas-panel settings-card ai-settings-card\">\n <div class=\"atlas-panel-head\">\n <div>\n <span class=\"atlas-kicker\">AI</span>\n <h2>AI 分析服务</h2>\n </div>\n <p-tag [severity]=\"aiSettings().external_configured ? 'success' : 'warn'\" [value]=\"aiSettings().external_configured ? '已配置' : '未配置'\" />\n </div>\n\n <div class=\"mode-switch settings-mode-switch\">\n <button type=\"button\" [class.active]=\"aiDraft.analysis_mode === 'local'\" (click)=\"aiDraft.analysis_mode = 'local'\">本地</button>\n <button type=\"button\" [class.active]=\"aiDraft.analysis_mode === 'hybrid'\" (click)=\"aiDraft.analysis_mode = 'hybrid'\">混合</button>\n <button type=\"button\" [class.active]=\"aiDraft.analysis_mode === 'external'\" (click)=\"aiDraft.analysis_mode = 'external'\">外部</button>\n </div>\n\n <div class=\"settings-form-grid\">\n <label>\n <span>Base URL</span>\n <input pInputText [(ngModel)]=\"aiDraft.ai_api_base\" placeholder=\"https://api.deepseek.com\" />\n </label>\n <label>\n <span>模型</span>\n <input pInputText [(ngModel)]=\"aiDraft.ai_model\" placeholder=\"deepseek-chat\" />\n </label>\n <label class=\"wide\">\n <span>API Key</span>\n <input pInputText [(ngModel)]=\"aiDraft.ai_api_key\" placeholder=\"留空则保留当前凭证\" />\n </label>\n </div>\n\n <div class=\"settings-note\">\n <strong>{{ aiSettings().credential_masked || '当前未配置外部凭证' }}</strong>\n <span>外部服务需兼容 Chat Completions 请求格式;保存后可在 AI 分析台运行诊断。</span>\n </div>\n </article>\n\n <article class=\"atlas-panel settings-card deployment-settings-card\">\n <div class=\"atlas-panel-head\">\n <div>\n <span class=\"atlas-kicker\">部署</span>\n <h2>上线准备与密钥入口</h2>\n </div>\n <p-tag severity=\"contrast\" value=\"后端密钥隔离\" />\n </div>\n\n <div class=\"deployment-boundary-strip\" aria-label=\"部署边界\">\n <article>\n <span>前端项目</span>\n <strong>NEXUS_API_BASE_URL</strong>\n <em>只保存公开 API 地址。</em>\n </article>\n <article>\n <span>后端项目</span>\n <strong>DATABASE_URL / SECRET_KEY / AI / Cloudinary</strong>\n <em>所有密钥只写入后端环境变量。</em>\n </article>\n <article>\n <span>旧版快照</span>\n <strong>legacy/monolith-flask</strong>\n <em>保留作升级报告对照,不参与部署。</em>\n </article>\n </div>\n\n <section class=\"deployment-readiness-board\" aria-label=\"动态部署就绪看板\">\n <article class=\"deployment-score-card\" [class.ready]=\"deploymentReadiness().summary.level === 'ready'\" [class.blocked]=\"deploymentReadiness().summary.level === 'blocked'\">\n <span>上线就绪分</span>\n <strong>{{ deploymentReadiness().summary.score }}%</strong>\n <em>{{ deploymentReadiness().summary.next_action }}</em>\n <div>\n <b>{{ deploymentReadiness().summary.ready }}</b>\n <small>ready</small>\n </div>\n <div>\n <b>{{ deploymentReadiness().summary.attention }}</b>\n <small>attention</small>\n </div>\n <div>\n <b>{{ deploymentReadiness().summary.blocked }}</b>\n <small>blocked</small>\n </div>\n </article>\n\n <div class=\"deployment-check-board\">\n <div class=\"deployment-section-head\">\n <span>部署前检查</span>\n <strong>{{ deploymentReadiness().summary.total }} 项动态检查</strong>\n </div>\n @for (check of readinessChecks(); track check.key) {\n <article [class.ready]=\"check.status === 'ready'\" [class.blocked]=\"check.status === 'blocked'\" [class.attention]=\"check.status === 'attention'\">\n <p-tag [severity]=\"statusSeverity(check.status)\" [value]=\"check.status\" />\n <span>{{ check.scope }}</span>\n <strong>{{ check.label }}</strong>\n <em>{{ check.evidence }}</em>\n <small>{{ check.action }}</small>\n @if (check.status !== 'ready') {\n <div class=\"deployment-check-actions\">\n <button\n pButton\n type=\"button\"\n size=\"small\"\n severity=\"secondary\"\n [loading]=\"deploymentTaskCreating() === check.key\"\n [disabled]=\"deploymentTaskCreating() !== ''\"\n (click)=\"createDeploymentTask(check)\"\n [attr.aria-label]=\"'创建部署预检任务 ' + check.label\"\n >\n <i class=\"pi pi-send\"></i>\n 创建任务\n </button>\n @if (deploymentTaskCreated() === check.key) {\n <a pButton [text]=\"true\" size=\"small\" routerLink=\"/app/notifications\">\n 通知中心\n </a>\n }\n </div>\n }\n </article>\n }\n </div>\n </section>\n\n <section class=\"deployment-service-snapshot\" aria-label=\"微服务拆分就绪快照\">\n <div class=\"deployment-section-head\">\n <span>微服务拆分快照</span>\n <strong>{{ deploymentReadiness().service_snapshot.services }} 服务 / {{ deploymentReadiness().service_snapshot.dependencies }} 依赖 / {{ deploymentReadiness().service_snapshot.api_surfaces }} API 面 / 拆分 {{ deploymentReadiness().service_snapshot.avg_split_score }}%</strong>\n </div>\n <div class=\"deployment-domain-grid\">\n @for (domain of readinessDomains(); track domain.domain) {\n <article [class.attention]=\"domain.attention > 0\">\n <span>{{ domain.domain }}</span>\n <strong>{{ domain.avg_readiness }}%</strong>\n <em>{{ domain.services }} 服务 · {{ domain.records }} 记录</em>\n <small>{{ domain.attention }} 个关注服务</small>\n </article>\n }\n </div>\n <div class=\"deployment-runtime-strip\">\n <span>部署单元:{{ deploymentReadiness().service_snapshot.deployment_units.join(' / ') || '等待数据' }}</span>\n <span>存储:{{ deploymentReadiness().service_snapshot.stores.join(' / ') || '等待数据' }}</span>\n <span>契约覆盖:{{ deploymentReadiness().service_snapshot.avg_contract_coverage }}%</span>\n </div>\n </section>\n\n <section class=\"deployment-architecture-board\" aria-label=\"架构拆分与可观测性\">\n <article class=\"architecture-score-card\">\n <span>可观测性覆盖</span>\n <strong>{{ deploymentReadiness().service_snapshot.observability.coverage }}%</strong>\n <em>{{ deploymentReadiness().service_snapshot.observability.policy }}</em>\n <div>\n @for (signal of architectureSignals(); track signal.key) {\n <span>\n <b>{{ signal.label }}</b>\n <small>{{ signal.ready }}/{{ signal.total }} · {{ signal.coverage }}%</small>\n </span>\n }\n </div>\n </article>\n\n <div class=\"architecture-split-plan\">\n <div class=\"deployment-section-head\">\n <span>拆分路线</span>\n <strong>{{ architectureSplitPlan().length }} 阶段 · 网关与事件优先</strong>\n </div>\n @for (phase of architectureSplitPlan(); track phase.phase) {\n <article>\n <span>{{ phase.phase }}</span>\n <strong>{{ phase.avg_split_score }}%</strong>\n <em>{{ phase.services.join(' / ') }}</em>\n <small>{{ phase.ready }} ready · {{ phase.attention }} attention · {{ phase.events }} events · {{ phase.gateway_routes }} gateway routes</small>\n </article>\n }\n </div>\n\n <div class=\"architecture-incident-queue\">\n <div class=\"deployment-section-head\">\n <span>架构治理队列</span>\n <strong>{{ architectureIncidents().length }} 项</strong>\n </div>\n @for (incident of architectureIncidents(); track incident.id) {\n <a [routerLink]=\"incident.path\" [class.p0]=\"incident.priority === 'P0'\" [class.p1]=\"incident.priority === 'P1'\">\n <span>{{ incident.priority }} · {{ incident.owner }}</span>\n <strong>{{ incident.title }}</strong>\n <em>{{ incident.runtime_unit }} · error budget {{ incident.error_budget_remaining }}%</em>\n <small>{{ incident.evidence }}</small>\n <b>{{ incident.signal_coverage }}% obs / {{ incident.contract_coverage }}% contract</b>\n </a>\n }\n </div>\n </section>\n\n <section class=\"erp-maturity-board\" aria-label=\"ERP 成熟度验收\">\n <article class=\"erp-maturity-score\" [class.ready]=\"deploymentReadiness().maturity.summary.level === 'ready'\" [class.blocked]=\"deploymentReadiness().maturity.summary.level === 'blocked'\">\n <span>行业成熟度</span>\n <strong>{{ deploymentReadiness().maturity.summary.score }}%</strong>\n <em>{{ deploymentReadiness().maturity.summary.target }}</em>\n <small>{{ deploymentReadiness().maturity.summary.next_action }}</small>\n </article>\n\n <div class=\"erp-maturity-dimensions\">\n <div class=\"deployment-section-head\">\n <span>验收维度</span>\n <strong>{{ deploymentReadiness().maturity.summary.ready }} ready / {{ deploymentReadiness().maturity.summary.attention }} attention / {{ deploymentReadiness().maturity.summary.blocked }} blocked</strong>\n </div>\n @for (dimension of maturityDimensions(); track dimension.key) {\n <article [class.ready]=\"dimension.level === 'ready'\" [class.blocked]=\"dimension.level === 'blocked'\" [class.attention]=\"dimension.level === 'attention'\">\n <div>\n <span>{{ dimension.label }}</span>\n <strong>{{ dimension.score }}%</strong>\n </div>\n <i aria-hidden=\"true\"><small [style.width.%]=\"dimension.score\"></small></i>\n <em>{{ dimension.evidence }}</em>\n <small>{{ dimension.action }}</small>\n </article>\n }\n </div>\n </section>\n\n <section class=\"erp-capability-map\" aria-label=\"能力域与服务拓扑\">\n <div class=\"deployment-section-head\">\n <span>能力域地图</span>\n <strong>{{ maturityCapabilities().length }} 域 / {{ maturityTopologyNodes().length }} 服务节点 / {{ deploymentReadiness().maturity.topology_edges.length }} 依赖边</strong>\n </div>\n <div class=\"erp-capability-grid\">\n @for (capability of maturityCapabilities(); track capability.domain) {\n <article [class.attention]=\"capability.attention > 0\">\n <span>{{ capability.domain }}</span>\n <strong>{{ capability.avg_readiness }}%</strong>\n <em>{{ capability.services }} 服务 · {{ capability.contracts }} 契约 · {{ capability.api_surfaces }} API</em>\n <small>{{ capability.modules.join(' / ') }}</small>\n </article>\n }\n </div>\n <div class=\"erp-topology-list\">\n @for (node of maturityTopologyNodes(); track node.id) {\n <a [routerLink]=\"node.path\" [class.attention]=\"node.status !== 'healthy'\">\n <span>{{ node.domain }}</span>\n <strong>{{ node.name }}</strong>\n <em>{{ node.runtime_unit }} · {{ node.owner }}</em>\n <small>{{ node.risk_note }}</small>\n <b>{{ node.readiness }}%</b>\n </a>\n }\n </div>\n <div class=\"erp-evidence-strip\" aria-label=\"交付证据\">\n @for (item of maturityEvidence(); track item.path) {\n <article [class.ready]=\"item.status === 'ready'\" [class.blocked]=\"item.status === 'blocked'\">\n <span>{{ item.label }}</span>\n <strong>{{ item.path }}</strong>\n <em>{{ item.description }}</em>\n </article>\n }\n </div>\n </section>\n\n <div class=\"deployment-token-grid\" aria-label=\"Token 获取入口\">\n @for (item of deploymentLinks; track item.title) {\n <a [href]=\"item.url\" target=\"_blank\" rel=\"noopener noreferrer\">\n <span>\n <strong>{{ item.title }}</strong>\n <em>{{ item.description }}</em>\n </span>\n <p-tag [severity]=\"item.secret ? 'warn' : 'info'\" [value]=\"item.variable\" />\n <small>{{ item.scope === 'frontend' ? '前端变量' : item.scope === 'backend' ? '后端变量' : '脚本变量' }}</small>\n </a>\n }\n </div>\n\n <div class=\"deployment-copy-grid\" aria-label=\"可复制部署命令\">\n @for (item of deploymentRunbookItems(); track item.title) {\n <article>\n <span>{{ item.title }}</span>\n <strong>{{ item.description }}</strong>\n <code>{{ item.command }}</code>\n <button pButton type=\"button\" severity=\"secondary\" (click)=\"copyCommand(item.command)\" [attr.aria-label]=\"'复制 ' + item.title\">\n <i class=\"pi pi-copy\"></i>\n 复制命令\n </button>\n </article>\n }\n </div>\n\n <div class=\"settings-note\">\n <strong>部署手册:docs/api-token-deployment-guide.md</strong>\n <span>页面只提供入口和命令模板;真实 Token、数据库密码、AI Key 和 Cloudinary secret 仍由 Vercel 后端项目或本地部署脚本保存。</span>\n </div>\n </article>\n </section>\n </section>\n `\n})\nexport class SettingsPage implements OnInit {\n private readonly api = inject(ApiService);\n private readonly theme = inject(ThemeService);\n private readonly messages = inject(MessageService);\n\n protected readonly loading = signal(false);\n protected readonly saving = signal(false);\n protected readonly deploymentTaskCreating = signal('');\n protected readonly deploymentTaskCreated = signal('');\n protected readonly aiSettings = signal<AiSettings>(EMPTY_AI_SETTINGS);\n protected readonly deploymentReadiness = signal<DeploymentReadiness>(EMPTY_DEPLOYMENT_READINESS);\n protected readonly settingsPhotos = COMMAND_CENTER_PHOTOS.slice(4, 8);\n protected readonly readinessChecks = computed(() => {\n const priority: Record<string, number> = { blocked: 0, attention: 1, ready: 2 };\n return [...this.deploymentReadiness().checks]\n .sort((a, b) => (priority[a.status] ?? 9) - (priority[b.status] ?? 9))\n .slice(0, 12);\n });\n protected readonly readinessRunbook = computed(() => this.deploymentReadiness().runbook.slice(0, 5));\n protected readonly readinessDomains = computed(() => this.deploymentReadiness().service_snapshot.domains.slice(0, 6));\n protected readonly architectureSignals = computed(() => this.deploymentReadiness().service_snapshot.observability.signals.slice(0, 3));\n protected readonly architectureSplitPlan = computed(() => this.deploymentReadiness().service_snapshot.split_plan.slice(0, 5));\n protected readonly architectureIncidents = computed(() => this.deploymentReadiness().service_snapshot.incident_queue.slice(0, 4));\n protected readonly maturityDimensions = computed(() => {\n const priority: Record<string, number> = { blocked: 0, attention: 1, ready: 2 };\n return [...this.deploymentReadiness().maturity.dimensions]\n .sort((a, b) => (priority[a.level] ?? 9) - (priority[b.level] ?? 9))\n .slice(0, 6);\n });\n protected readonly maturityCapabilities = computed(() => [...this.deploymentReadiness().maturity.capability_map]\n .sort((a, b) => b.attention - a.attention || b.avg_readiness - a.avg_readiness)\n .slice(0, 6));\n protected readonly maturityTopologyNodes = computed(() => [...this.deploymentReadiness().maturity.topology_nodes]\n .sort((a, b) => (a.status === 'healthy' ? 1 : 0) - (b.status === 'healthy' ? 1 : 0) || b.readiness - a.readiness)\n .slice(0, 8));\n protected readonly maturityEvidence = computed(() => {\n const priority: Record<string, number> = { blocked: 0, attention: 1, ready: 2 };\n return [...this.deploymentReadiness().maturity.evidence]\n .sort((a, b) => (priority[a.status] ?? 9) - (priority[b.status] ?? 9))\n .slice(0, 8);\n });\n protected readonly deploymentRunbookItems = computed<DeploymentCommand[]>(() => {\n const runbook = this.readinessRunbook();\n if (!runbook.length) {\n return this.deploymentCommands;\n }\n return runbook.map(item => ({\n title: item.step,\n description: '部署运行手册步骤',\n command: item.command\n }));\n });\n protected preferenceDraft: UserPreferences = {\n theme: 'light-luxury',\n density: 'compact',\n default_workspace: '/app/overview',\n charts_motion: 'standard',\n dock_labels: 'hover',\n context_panel: 'visible'\n };\n protected aiDraft: { analysis_mode: AiSettings['analysis_mode']; ai_api_base: string; ai_api_key: string; ai_model: string } = {\n analysis_mode: 'local',\n ai_api_base: '',\n ai_api_key: '',\n ai_model: 'deepseek-chat'\n };\n protected readonly deploymentLinks: DeploymentLink[] = [\n {\n title: 'Vercel Token',\n description: 'Account Settings / Tokens,供一键部署脚本使用。',\n url: 'https://vercel.com/account/settings/tokens',\n variable: 'VERCEL_TOKEN',\n secret: true,\n scope: 'script'\n },\n {\n title: 'Supabase Database',\n description: 'Project Settings / Database,复制 Pooler PostgreSQL URL。',\n url: 'https://supabase.com/dashboard/projects',\n variable: 'DATABASE_URL',\n secret: true,\n scope: 'backend'\n },\n {\n title: 'Supabase API Keys',\n description: '后续接 Storage 或 Edge API 时使用,当前前端不直连。',\n url: 'https://supabase.com/dashboard/projects',\n variable: 'SUPABASE_KEYS',\n secret: true,\n scope: 'backend'\n },\n {\n title: 'Cloudinary API Keys',\n description: '生产头像与文件持久化存储。',\n url: 'https://console.cloudinary.com/settings/api-keys',\n variable: 'CLOUDINARY_URL',\n secret: true,\n scope: 'backend'\n },\n {\n title: 'DeepSeek API Key',\n description: 'AI 经营分析外部模型,兼容 Chat Completions。',\n url: 'https://platform.deepseek.com/api_keys',\n variable: 'DEEPSEEK_API_KEY',\n secret: true,\n scope: 'backend'\n },\n {\n title: '前端 API 地址',\n description: 'Vercel 前端项目环境变量,只填写后端 /api/v1 地址。',\n url: 'https://vercel.com/docs/environment-variables',\n variable: 'NEXUS_API_BASE_URL',\n secret: false,\n scope: 'frontend'\n }\n ];\n protected readonly deploymentCommands: DeploymentCommand[] = [\n {\n title: '生成 SECRET_KEY',\n description: '本地生成后写入后端 Vercel 项目。',\n command: '[Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }))'\n },\n {\n title: '写入后端数据库',\n description: '在 backend 项目中添加 Supabase PostgreSQL 连接串。',\n command: 'vercel env add DATABASE_URL production --sensitive'\n },\n {\n title: '写入前端 API',\n description: '在 frontend 项目中添加公开 API 地址。',\n command: 'vercel env add NEXUS_API_BASE_URL production'\n },\n {\n title: '一键预检部署',\n description: '从仓库根目录执行前后端部署与数据同步。',\n command: '.\\\\scripts\\\\deploy-supabase-vercel.ps1 -DatabaseUrl \"postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\" -BackendProjectName \"nexus-prime-api\" -FrontendProjectName \"nexus-prime-web\" -SyncDatabase -SecretKey \"<32位以上随机字符串>\" -CloudinaryUrl \"cloudinary://api_key:api_secret@cloud_name\"'\n }\n ];\n\n ngOnInit(): void {\n this.load();\n }\n\n load(): void {\n this.loading.set(true);\n forkJoin({\n preferences: this.api.preferences().pipe(catchError(() => of({} as UserPreferences))),\n ai: this.api.get<AiSettings>('ai/settings').pipe(catchError(() => of(EMPTY_AI_SETTINGS))),\n deployment: this.api.get<DeploymentReadiness>('operations/deployment-readiness').pipe(catchError(() => of(EMPTY_DEPLOYMENT_READINESS)))\n }).pipe(finalize(() => this.loading.set(false))).subscribe(({ preferences, ai, deployment }) => {\n this.preferenceDraft = {\n theme: preferences.theme ?? this.theme.mode(),\n density: preferences.density ?? 'compact',\n default_workspace: preferences.default_workspace ?? '/app/overview',\n charts_motion: preferences.charts_motion ?? 'standard',\n dock_labels: preferences.dock_labels ?? 'hover',\n context_panel: preferences.context_panel ?? 'visible'\n };\n this.theme.setPreferences(this.preferenceDraft, false);\n this.aiSettings.set(ai);\n this.deploymentReadiness.set(deployment);\n this.aiDraft = {\n analysis_mode: ai.analysis_mode,\n ai_api_base: ai.external_base || '',\n ai_api_key: '',\n ai_model: ai.model || 'deepseek-chat'\n };\n });\n }\n\n setTheme(mode: ThemeMode): void {\n this.preferenceDraft.theme = mode;\n this.theme.setPreferences(this.preferenceDraft, false);\n }\n\n saveAll(): void {\n this.saving.set(true);\n forkJoin({\n preferences: this.api.savePreferences(this.preferenceDraft),\n ai: this.api.put<AiSettings>('ai/settings', this.aiDraft)\n }).pipe(\n catchError(error => {\n this.messages.add({ severity: 'warn', summary: '设置未保存', detail: error?.message || '请检查配置内容。' });\n return of(null);\n }),\n finalize(() => this.saving.set(false))\n ).subscribe(result => {\n if (!result) {\n return;\n }\n this.preferenceDraft = { ...this.preferenceDraft, ...result.preferences };\n this.aiSettings.set(result.ai);\n this.theme.setPreferences(result.preferences, false);\n this.messages.add({ severity: 'success', summary: '设置已保存', detail: '全局偏好和 AI 服务设置已更新。' });\n });\n }\n\n async copyCommand(command: string): Promise<void> {\n try {\n if (navigator.clipboard?.writeText) {\n await navigator.clipboard.writeText(command);\n } else if (!this.fallbackCopy(command)) {\n throw new Error('clipboard_unavailable');\n }\n this.messages.add({ severity: 'success', summary: '已复制', detail: '部署命令已复制到剪贴板。' });\n } catch {\n this.messages.add({ severity: 'warn', summary: '复制失败', detail: '请手动复制命令,浏览器未开放剪贴板权限。' });\n }\n }\n\n protected createDeploymentTask(check: DeploymentReadinessCheck): void {\n if (check.status === 'ready' || this.deploymentTaskCreating()) {\n return;\n }\n this.deploymentTaskCreating.set(check.key);\n this.api.post<DataRecord>('operations/deployment-readiness/task', {\n key: check.key,\n label: check.label,\n scope: check.scope,\n status: check.status,\n evidence: check.evidence,\n action: check.action\n }).pipe(\n catchError(error => {\n this.messages.add({\n severity: 'warn',\n summary: '任务未创建',\n detail: error?.message || '请稍后重试部署预检任务。'\n });\n return of(null);\n }),\n finalize(() => this.deploymentTaskCreating.set(''))\n ).subscribe(result => {\n if (!result) {\n return;\n }\n this.deploymentTaskCreated.set(check.key);\n this.messages.add({\n severity: 'success',\n summary: '部署任务已创建',\n detail: `${check.label} 已写入通知中心和审计日志。`\n });\n });\n }\n\n protected statusSeverity(status: string): 'success' | 'warn' | 'danger' | 'info' {\n if (status === 'ready') {\n return 'success';\n }\n if (status === 'blocked') {\n return 'danger';\n }\n if (status === 'attention') {\n return 'warn';\n }\n return 'info';\n }\n\n private fallbackCopy(value: string): boolean {\n const textarea = document.createElement('textarea');\n textarea.value = value;\n textarea.setAttribute('readonly', 'true');\n textarea.style.position = 'fixed';\n textarea.style.left = '-9999px';\n document.body.appendChild(textarea);\n textarea.select();\n try {\n return document.execCommand('copy');\n } finally {\n document.body.removeChild(textarea);\n }\n }\n}\n"}, "f72e3059e90b878ff7a860ab4e22a4c4619a88ba": {"t": 1785754652.3922913, "n": 32653, "h": "fb76cec8414e05ad", "c": "import { CommonModule } from '@angular/common';\nimport { Component, computed, inject, OnInit, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { RouterLink } from '@angular/router';\nimport { MessageService } from 'primeng/api';\nimport { ButtonModule } from 'primeng/button';\nimport { InputTextModule } from 'primeng/inputtext';\nimport { TagModule } from 'primeng/tag';\nimport { TextareaModule } from 'primeng/textarea';\nimport { catchError, finalize, forkJoin, of } from 'rxjs';\n\nimport { ApiService } from '../core/api.service';\nimport { AiSettings, DataRecord, DeploymentReadiness, DeploymentReadinessCheck, ThemeMode, UserPreferences } from '../core/models';\nimport { ThemeService } from '../core/theme.service';\nimport { COMMAND_CENTER_PHOTOS } from '../core/visual-assets';\n\nconst EMPTY_AI_SETTINGS: AiSettings = {\n analysis_mode: 'local',\n local_analysis_enabled: true,\n external_configured: false,\n external_source: 'none',\n external_base: '',\n credential_masked: '',\n model: 'deepseek-chat',\n can_use_local: true,\n has_user_credential: false,\n preferences_updated_at: null,\n dashboard_scope: 'operations'\n};\n\nconst EMPTY_DEPLOYMENT_READINESS: DeploymentReadiness = {\n generated_at: '',\n source: 'empty',\n summary: {\n score: 0,\n level: 'attention',\n ready: 0,\n attention: 0,\n blocked: 0,\n total: 0,\n next_action: '等待后端就绪数据',\n frontend_boundary: 'NEXUS_API_BASE_URL only',\n backend_boundary: 'DATABASE_URL / SECRET_KEY / AI / storage secrets'\n },\n checks: [],\n service_snapshot: {\n services: 0,\n domains: [],\n avg_readiness: 0,\n avg_contract_coverage: 0,\n avg_split_score: 0,\n deployment_units: [],\n stores: [],\n dependencies: 0,\n api_surfaces: 0,\n split_plan: [],\n observability: {\n coverage: 0,\n policy: 'metrics + logs + traces',\n missing: [],\n signals: []\n },\n incident_queue: []\n },\n maturity: {\n summary: {\n score: 0,\n level: 'attention',\n target: '行业头部级制造开发管理 ERP',\n dimensions: 0,\n ready: 0,\n attention: 0,\n blocked: 0,\n next_action: '等待成熟度数据'\n },\n dimensions: [],\n capability_map: [],\n topology_nodes: [],\n topology_edges: [],\n evidence: []\n },\n runbook: []\n};\n\ninterface DeploymentLink {\n title: string;\n description: string;\n url: string;\n variable: string;\n secret: boolean;\n scope: 'frontend' | 'backend' | 'script';\n}\n\ninterface DeploymentCommand {\n title: string;\n description: string;\n command: string;\n}\n\n@Component({\n standalone: true,\n imports: [CommonModule, FormsModule, RouterLink, ButtonModule, InputTextModule, TagModule, TextareaModule],\n template: `\n <section class=\"ops-atlas-page settings-page\">\n <header class=\"settings-hero atlas-panel\">\n <div class=\"hero-narrative\">\n <span class=\"atlas-kicker\">系统设置</span>\n <h1>控制中心</h1>\n <p>统一控制主题、密度、导航、图表动画、右侧信息栏和 AI 分析接入。</p>\n <div class=\"atlas-actions-row\">\n <button pButton type=\"button\" (click)=\"saveAll()\" [loading]=\"saving()\" aria-label=\"保存全部设置\">\n <i class=\"pi pi-save\"></i>\n 保存全部\n </button>\n <button pButton type=\"button\" severity=\"secondary\" (click)=\"load()\" [loading]=\"loading()\" aria-label=\"重新读取设置\">\n <i class=\"pi pi-refresh\"></i>\n 重新读取\n </button>\n <a pButton severity=\"info\" routerLink=\"/app/ai\">\n <i class=\"pi pi-sparkles\"></i>\n AI 分析台\n </a>\n </div>\n </div>\n\n <div class=\"settings-status-board\">\n <article>\n <span>主题</span>\n <strong>{{ preferenceDraft.theme === 'dark-cockpit' ? '深色' : '亮色' }}</strong>\n <em>{{ preferenceDraft.density === 'compact' ? '紧凑密度' : '舒适密度' }}</em>\n </article>\n <article>\n <span>AI 接入</span>\n <strong>{{ aiDraft.analysis_mode === 'external' ? '外部' : aiDraft.analysis_mode === 'hybrid' ? '混合' : '本地' }}</strong>\n <em>{{ aiSettings().credential_masked || '未配置凭证' }}</em>\n </article>\n <article>\n <span>导航</span>\n <strong>{{ preferenceDraft.dock_labels === 'always' ? '常显' : '悬停' }}</strong>\n <em>{{ preferenceDraft.context_panel === 'compact' ? '右栏精简' : '右栏显示' }}</em>\n </article>\n </div>\n\n <div class=\"settings-visual-rail\" aria-label=\"系统控制台现场图片\">\n @for (photo of settingsPhotos; track photo.src) {\n <figure>\n <img [src]=\"photo.src\" [alt]=\"photo.alt\" loading=\"eager\" decoding=\"async\" />\n <figcaption>\n <span>{{ photo.label }}</span>\n <strong>{{ photo.caption }}</strong>\n </figcaption>\n </figure>\n }\n </div>\n </header>\n\n <section class=\"settings-grid\">\n <article class=\"atlas-panel settings-card\">\n <div class=\"atlas-panel-head\">\n <div>\n <span class=\"atlas-kicker\">外观</span>\n <h2>界面与动效</h2>\n </div>\n <p-tag severity=\"info\" value=\"全局\" />\n </div>\n\n <div class=\"settings-option-grid\">\n <button type=\"button\" [class.active]=\"preferenceDraft.theme === 'light-luxury'\" (click)=\"setTheme('light-luxury')\">\n <i class=\"pi pi-sun\"></i>\n <strong>亮色系统</strong>\n <span>适合日常办公、报表复核和文件查看。</span>\n </button>\n <button type=\"button\" [class.active]=\"preferenceDraft.theme === 'dark-cockpit'\" (click)=\"setTheme('dark-cockpit')\">\n <i class=\"pi pi-moon\"></i>\n <strong>深色驾驶舱</strong>\n <span>适合值守屏、夜间监控和经营风险看板。</span>\n </button>\n </div>\n\n <div class=\"settings-form-grid\">\n <label>\n <span>信息密度</span>\n <select [(ngModel)]=\"preferenceDraft.density\">\n <option value=\"compact\">紧凑</option>\n <option value=\"comfortable\">舒适</option>\n </select>\n </label>\n <label>\n <span>图表动效</span>\n <select [(ngModel)]=\"preferenceDraft.charts_motion\">\n <option value=\"standard\">标准</option>\n <option value=\"reduced\">减少动效</option>\n </select>\n </label>\n <label>\n <span>默认工作台</span>\n <select [(ngModel)]=\"preferenceDraft.default_workspace\">\n <option value=\"/app/overview\">运营总览</option>\n <option value=\"/app/ai\">AI 分析</option>\n <option value=\"/app/reports\">报表工作室</option>\n <option value=\"/app/files\">文件中心</option>\n </select>\n </label>\n </div>\n </article>\n\n <article class=\"atlas-panel settings-card\">\n <div class=\"atlas-panel-head\">\n <div>\n <span class=\"atlas-kicker\">导航</span>\n <h2>Dock 与上下文栏</h2>\n </div>\n <p-tag severity=\"success\" value=\"布局\" />\n </div>\n\n <div class=\"settings-form-grid\">\n <label>\n <span>Dock 名称</span>\n <select [(ngModel)]=\"preferenceDraft.dock_labels\">\n <option value=\"hover\">悬停显示</option>\n <option value=\"always\">始终显示</option>\n </select>\n </label>\n <label>\n <span>右侧上下文栏</span>\n <select [(ngModel)]=\"preferenceDraft.context_panel\">\n <option value=\"visible\">显示完整信息</option>\n <option value=\"compact\">精简显示</option>\n </select>\n </label>\n </div>\n\n <div class=\"settings-link-list\">\n <a routerLink=\"/app/profile\"><i class=\"pi pi-user\"></i><span>个人工作台</span></a>\n <a routerLink=\"/app/system/users\"><i class=\"pi pi-shield\"></i><span>用户权限</span></a>\n <a routerLink=\"/app/system/audit\"><i class=\"pi pi-lock\"></i><span>审计日志</span></a>\n </div>\n </article>\n\n <article class=\"atlas-panel settings-card ai-settings-card\">\n <div class=\"atlas-panel-head\">\n <div>\n <span class=\"atlas-kicker\">AI</span>\n <h2>AI 分析服务</h2>\n </div>\n <p-tag [severity]=\"aiSettings().external_configured ? 'success' : 'warn'\" [value]=\"aiSettings().external_configured ? '已配置' : '未配置'\" />\n </div>\n\n <div class=\"mode-switch settings-mode-switch\">\n <button type=\"button\" [class.active]=\"aiDraft.analysis_mode === 'local'\" (click)=\"aiDraft.analysis_mode = 'local'\">本地</button>\n <button type=\"button\" [class.active]=\"aiDraft.analysis_mode === 'hybrid'\" (click)=\"aiDraft.analysis_mode = 'hybrid'\">混合</button>\n <button type=\"button\" [class.active]=\"aiDraft.analysis_mode === 'external'\" (click)=\"aiDraft.analysis_mode = 'external'\">外部</button>\n </div>\n\n <div class=\"settings-form-grid\">\n <label>\n <span>Base URL</span>\n <input pInputText [(ngModel)]=\"aiDraft.ai_api_base\" placeholder=\"https://api.deepseek.com\" />\n </label>\n <label>\n <span>模型</span>\n <input pInputText [(ngModel)]=\"aiDraft.ai_model\" placeholder=\"deepseek-chat\" />\n </label>\n <label class=\"wide\">\n <span>API Key</span>\n <input pInputText [(ngModel)]=\"aiDraft.ai_api_key\" placeholder=\"留空则保留当前凭证\" />\n </label>\n </div>\n\n <div class=\"settings-note\">\n <strong>{{ aiSettings().credential_masked || '当前未配置外部凭证' }}</strong>\n <span>外部服务需兼容 Chat Completions 请求格式;保存后可在 AI 分析台运行诊断。</span>\n </div>\n </article>\n\n <article class=\"atlas-panel settings-card deployment-settings-card\">\n <div class=\"atlas-panel-head\">\n <div>\n <span class=\"atlas-kicker\">部署</span>\n <h2>上线准备与密钥入口</h2>\n </div>\n <p-tag severity=\"contrast\" value=\"后端密钥隔离\" />\n </div>\n\n <div class=\"deployment-boundary-strip\" aria-label=\"部署边界\">\n <article>\n <span>前端项目</span>\n <strong>NEXUS_API_BASE_URL</strong>\n <em>只保存公开 API 地址。</em>\n </article>\n <article>\n <span>后端项目</span>\n <strong>DATABASE_URL / SECRET_KEY / AI / Cloudinary</strong>\n <em>所有密钥只写入后端环境变量。</em>\n </article>\n <article>\n <span>旧版快照</span>\n <strong>legacy/monolith-flask</strong>\n <em>保留作升级报告对照,不参与部署。</em>\n </article>\n </div>\n\n <section class=\"deployment-readiness-board\" aria-label=\"动态部署就绪看板\">\n <article class=\"deployment-score-card\" [class.ready]=\"deploymentReadiness().summary.level === 'ready'\" [class.blocked]=\"deploymentReadiness().summary.level === 'blocked'\">\n <span>上线就绪分</span>\n <strong>{{ deploymentReadiness().summary.score }}%</strong>\n <em>{{ deploymentReadiness().summary.next_action }}</em>\n <div>\n <b>{{ deploymentReadiness().summary.ready }}</b>\n <small>ready</small>\n </div>\n <div>\n <b>{{ deploymentReadiness().summary.attention }}</b>\n <small>attention</small>\n </div>\n <div>\n <b>{{ deploymentReadiness().summary.blocked }}</b>\n <small>blocked</small>\n </div>\n </article>\n\n <div class=\"deployment-check-board\">\n <div class=\"deployment-section-head\">\n <span>部署前检查</span>\n <strong>{{ deploymentReadiness().summary.total }} 项动态检查</strong>\n </div>\n @for (check of readinessChecks(); track check.key) {\n <article [class.ready]=\"check.status === 'ready'\" [class.blocked]=\"check.status === 'blocked'\" [class.attention]=\"check.status === 'attention'\">\n <p-tag [severity]=\"statusSeverity(check.status)\" [value]=\"check.status\" />\n <span>{{ check.scope }}</span>\n <strong>{{ check.label }}</strong>\n <em>{{ check.evidence }}</em>\n <small>{{ check.action }}</small>\n @if (check.status !== 'ready') {\n <div class=\"deployment-check-actions\">\n <button\n pButton\n type=\"button\"\n size=\"small\"\n severity=\"secondary\"\n [loading]=\"deploymentTaskCreating() === check.key\"\n [disabled]=\"deploymentTaskCreating() !== ''\"\n (click)=\"createDeploymentTask(check)\"\n [attr.aria-label]=\"'创建部署预检任务 ' + check.label\"\n >\n <i class=\"pi pi-send\"></i>\n 创建任务\n </button>\n @if (deploymentTaskCreated() === check.key) {\n <a pButton [text]=\"true\" size=\"small\" routerLink=\"/app/notifications\">\n 通知中心\n </a>\n }\n </div>\n }\n </article>\n }\n </div>\n </section>\n\n <section class=\"deployment-service-snapshot\" aria-label=\"微服务拆分就绪快照\">\n <div class=\"deployment-section-head\">\n <span>微服务拆分快照</span>\n <strong>{{ deploymentReadiness().service_snapshot.services }} 服务 / {{ deploymentReadiness().service_snapshot.dependencies }} 依赖 / {{ deploymentReadiness().service_snapshot.api_surfaces }} API 面 / 拆分 {{ deploymentReadiness().service_snapshot.avg_split_score }}%</strong>\n </div>\n <div class=\"deployment-domain-grid\">\n @for (domain of readinessDomains(); track domain.domain) {\n <article [class.attention]=\"domain.attention > 0\">\n <span>{{ domain.domain }}</span>\n <strong>{{ domain.avg_readiness }}%</strong>\n <em>{{ domain.services }} 服务 · {{ domain.records }} 记录</em>\n <small>{{ domain.attention }} 个关注服务</small>\n </article>\n }\n </div>\n <div class=\"deployment-runtime-strip\">\n <span>部署单元:{{ deploymentReadiness().service_snapshot.deployment_units.join(' / ') || '等待数据' }}</span>\n <span>存储:{{ deploymentReadiness().service_snapshot.stores.join(' / ') || '等待数据' }}</span>\n <span>契约覆盖:{{ deploymentReadiness().service_snapshot.avg_contract_coverage }}%</span>\n </div>\n </section>\n\n <section class=\"deployment-architecture-board\" aria-label=\"架构拆分与可观测性\">\n <article class=\"architecture-score-card\">\n <span>可观测性覆盖</span>\n <strong>{{ deploymentReadiness().service_snapshot.observability.coverage }}%</strong>\n <em>{{ deploymentReadiness().service_snapshot.observability.policy }}</em>\n <div>\n @for (signal of architectureSignals(); track signal.key) {\n <span>\n <b>{{ signal.label }}</b>\n <small>{{ signal.ready }}/{{ signal.total }} · {{ signal.coverage }}%</small>\n </span>\n }\n </div>\n </article>\n\n <div class=\"architecture-split-plan\">\n <div class=\"deployment-section-head\">\n <span>拆分路线</span>\n <strong>{{ architectureSplitPlan().length }} 阶段 · 网关与事件优先</strong>\n </div>\n @for (phase of architectureSplitPlan(); track phase.phase) {\n <article>\n <span>{{ phase.phase }}</span>\n <strong>{{ phase.avg_split_score }}%</strong>\n <em>{{ phase.services.join(' / ') }}</em>\n <small>{{ phase.ready }} ready · {{ phase.attention }} attention · {{ phase.events }} events · {{ phase.gateway_routes }} gateway routes</small>\n </article>\n }\n </div>\n\n <div class=\"architecture-incident-queue\">\n <div class=\"deployment-section-head\">\n <span>架构治理队列</span>\n <strong>{{ architectureIncidents().length }} 项</strong>\n </div>\n @for (incident of architectureIncidents(); track incident.id) {\n <a [routerLink]=\"incident.path\" [class.p0]=\"incident.priority === 'P0'\" [class.p1]=\"incident.priority === 'P1'\">\n <span>{{ incident.priority }} · {{ incident.owner }}</span>\n <strong>{{ incident.title }}</strong>\n <em>{{ incident.runtime_unit }} · error budget {{ incident.error_budget_remaining }}%</em>\n <small>{{ incident.evidence }}</small>\n <b>{{ incident.signal_coverage }}% obs / {{ incident.contract_coverage }}% contract</b>\n </a>\n }\n </div>\n </section>\n\n <section class=\"erp-maturity-board\" aria-label=\"ERP 成熟度验收\">\n <article class=\"erp-maturity-score\" [class.ready]=\"deploymentReadiness().maturity.summary.level === 'ready'\" [class.blocked]=\"deploymentReadiness().maturity.summary.level === 'blocked'\">\n <span>行业成熟度</span>\n <strong>{{ deploymentReadiness().maturity.summary.score }}%</strong>\n <em>{{ deploymentReadiness().maturity.summary.target }}</em>\n <small>{{ deploymentReadiness().maturity.summary.next_action }}</small>\n </article>\n\n <div class=\"erp-maturity-dimensions\">\n <div class=\"deployment-section-head\">\n <span>验收维度</span>\n <strong>{{ deploymentReadiness().maturity.summary.ready }} ready / {{ deploymentReadiness().maturity.summary.attention }} attention / {{ deploymentReadiness().maturity.summary.blocked }} blocked</strong>\n </div>\n @for (dimension of maturityDimensions(); track dimension.key) {\n <article [class.ready]=\"dimension.level === 'ready'\" [class.blocked]=\"dimension.level === 'blocked'\" [class.attention]=\"dimension.level === 'attention'\">\n <div>\n <span>{{ dimension.label }}</span>\n <strong>{{ dimension.score }}%</strong>\n </div>\n <i aria-hidden=\"true\"><small [style.width.%]=\"dimension.score\"></small></i>\n <em>{{ dimension.evidence }}</em>\n <small>{{ dimension.action }}</small>\n </article>\n }\n </div>\n </section>\n\n <section class=\"erp-capability-map\" aria-label=\"能力域与服务拓扑\">\n <div class=\"deployment-section-head\">\n <span>能力域地图</span>\n <strong>{{ maturityCapabilities().length }} 域 / {{ maturityTopologyNodes().length }} 服务节点 / {{ deploymentReadiness().maturity.topology_edges.length }} 依赖边</strong>\n </div>\n <div class=\"erp-capability-grid\">\n @for (capability of maturityCapabilities(); track capability.domain) {\n <article [class.attention]=\"capability.attention > 0\">\n <span>{{ capability.domain }}</span>\n <strong>{{ capability.avg_readiness }}%</strong>\n <em>{{ capability.services }} 服务 · {{ capability.contracts }} 契约 · {{ capability.api_surfaces }} API</em>\n <small>{{ capability.modules.join(' / ') }}</small>\n </article>\n }\n </div>\n <div class=\"erp-topology-list\">\n @for (node of maturityTopologyNodes(); track node.id) {\n <a [routerLink]=\"node.path\" [class.attention]=\"node.status !== 'healthy'\">\n <span>{{ node.domain }}</span>\n <strong>{{ node.name }}</strong>\n <em>{{ node.runtime_unit }} · {{ node.owner }}</em>\n <small>{{ node.risk_note }}</small>\n <b>{{ node.readiness }}%</b>\n </a>\n }\n </div>\n <div class=\"erp-evidence-strip\" aria-label=\"交付证据\">\n @for (item of maturityEvidence(); track item.path) {\n <article [class.ready]=\"item.status === 'ready'\" [class.blocked]=\"item.status === 'blocked'\">\n <span>{{ item.label }}</span>\n <strong>{{ item.path }}</strong>\n <em>{{ item.description }}</em>\n </article>\n }\n </div>\n </section>\n\n <div class=\"deployment-token-grid\" aria-label=\"Token 获取入口\">\n @for (item of deploymentLinks; track item.title) {\n <a [href]=\"item.url\" target=\"_blank\" rel=\"noopener noreferrer\">\n <span>\n <strong>{{ item.title }}</strong>\n <em>{{ item.description }}</em>\n </span>\n <p-tag [severity]=\"item.secret ? 'warn' : 'info'\" [value]=\"item.variable\" />\n <small>{{ item.scope === 'frontend' ? '前端变量' : item.scope === 'backend' ? '后端变量' : '脚本变量' }}</small>\n </a>\n }\n </div>\n\n <div class=\"deployment-copy-grid\" aria-label=\"可复制部署命令\">\n @for (item of deploymentRunbookItems(); track item.title) {\n <article>\n <span>{{ item.title }}</span>\n <strong>{{ item.description }}</strong>\n <code>{{ item.command }}</code>\n <button pButton type=\"button\" severity=\"secondary\" (click)=\"copyCommand(item.command)\" [attr.aria-label]=\"'复制 ' + item.title\">\n <i class=\"pi pi-copy\"></i>\n 复制命令\n </button>\n </article>\n }\n </div>\n\n <div class=\"settings-note\">\n <strong>部署手册:docs/api-token-deployment-guide.md</strong>\n <span>页面只提供入口和命令模板;真实 Token、数据库密码、AI Key 和 Cloudinary secret 仍由 Vercel 后端项目或本地部署脚本保存。</span>\n </div>\n </article>\n </section>\n </section>\n `\n})\nexport class SettingsPage implements OnInit {\n private readonly api = inject(ApiService);\n private readonly theme = inject(ThemeService);\n private readonly messages = inject(MessageService);\n\n protected readonly loading = signal(false);\n protected readonly saving = signal(false);\n protected readonly deploymentTaskCreating = signal('');\n protected readonly deploymentTaskCreated = signal('');\n protected readonly aiSettings = signal<AiSettings>(EMPTY_AI_SETTINGS);\n protected readonly deploymentReadiness = signal<DeploymentReadiness>(EMPTY_DEPLOYMENT_READINESS);\n protected readonly settingsPhotos = COMMAND_CENTER_PHOTOS.slice(4, 8);\n protected readonly readinessChecks = computed(() => {\n const priority: Record<string, number> = { blocked: 0, attention: 1, ready: 2 };\n return [...this.deploymentReadiness().checks]\n .sort((a, b) => (priority[a.status] ?? 9) - (priority[b.status] ?? 9))\n .slice(0, 12);\n });\n protected readonly readinessRunbook = computed(() => this.deploymentReadiness().runbook.slice(0, 5));\n protected readonly readinessDomains = computed(() => this.deploymentReadiness().service_snapshot.domains.slice(0, 6));\n protected readonly architectureSignals = computed(() => this.deploymentReadiness().service_snapshot.observability.signals.slice(0, 3));\n protected readonly architectureSplitPlan = computed(() => this.deploymentReadiness().service_snapshot.split_plan.slice(0, 5));\n protected readonly architectureIncidents = computed(() => this.deploymentReadiness().service_snapshot.incident_queue.slice(0, 4));\n protected readonly maturityDimensions = computed(() => {\n const priority: Record<string, number> = { blocked: 0, attention: 1, ready: 2 };\n return [...this.deploymentReadiness().maturity.dimensions]\n .sort((a, b) => (priority[a.level] ?? 9) - (priority[b.level] ?? 9))\n .slice(0, 6);\n });\n protected readonly maturityCapabilities = computed(() => [...this.deploymentReadiness().maturity.capability_map]\n .sort((a, b) => b.attention - a.attention || b.avg_readiness - a.avg_readiness)\n .slice(0, 6));\n protected readonly maturityTopologyNodes = computed(() => [...this.deploymentReadiness().maturity.topology_nodes]\n .sort((a, b) => (a.status === 'healthy' ? 1 : 0) - (b.status === 'healthy' ? 1 : 0) || b.readiness - a.readiness)\n .slice(0, 8));\n protected readonly maturityEvidence = computed(() => {\n const priority: Record<string, number> = { blocked: 0, attention: 1, ready: 2 };\n return [...this.deploymentReadiness().maturity.evidence]\n .sort((a, b) => (priority[a.status] ?? 9) - (priority[b.status] ?? 9))\n .slice(0, 8);\n });\n protected readonly deploymentRunbookItems = computed<DeploymentCommand[]>(() => {\n const runbook = this.readinessRunbook();\n if (!runbook.length) {\n return this.deploymentCommands;\n }\n return runbook.map(item => ({\n title: item.step,\n description: '部署运行手册步骤',\n command: item.command\n }));\n });\n protected preferenceDraft: UserPreferences = {\n theme: 'light-luxury',\n density: 'compact',\n default_workspace: '/app/overview',\n charts_motion: 'standard',\n dock_labels: 'hover',\n context_panel: 'visible'\n };\n protected aiDraft: { analysis_mode: AiSettings['analysis_mode']; ai_api_base: string; ai_api_key: string; ai_model: string } = {\n analysis_mode: 'local',\n ai_api_base: '',\n ai_api_key: '',\n ai_model: 'deepseek-chat'\n };\n protected readonly deploymentLinks: DeploymentLink[] = [\n {\n title: 'Vercel Token',\n description: 'Account Settings / Tokens,供一键部署脚本使用。',\n url: 'https://vercel.com/account/settings/tokens',\n variable: 'VERCEL_TOKEN',\n secret: true,\n scope: 'script'\n },\n {\n title: 'Supabase Database',\n description: 'Project Settings / Database,复制 Pooler PostgreSQL URL。',\n url: 'https://supabase.com/dashboard/projects',\n variable: 'DATABASE_URL',\n secret: true,\n scope: 'backend'\n },\n {\n title: 'Supabase API Keys',\n description: '后续接 Storage 或 Edge API 时使用,当前前端不直连。',\n url: 'https://supabase.com/dashboard/projects',\n variable: 'SUPABASE_KEYS',\n secret: true,\n scope: 'backend'\n },\n {\n title: 'Cloudinary API Keys',\n description: '生产头像与文件持久化存储。',\n url: 'https://console.cloudinary.com/settings/api-keys',\n variable: 'CLOUDINARY_URL',\n secret: true,\n scope: 'backend'\n },\n {\n title: 'DeepSeek API Key',\n description: 'AI 经营分析外部模型,兼容 Chat Completions。',\n url: 'https://platform.deepseek.com/api_keys',\n variable: 'DEEPSEEK_API_KEY',\n secret: true,\n scope: 'backend'\n },\n {\n title: '前端 API 地址',\n description: 'Vercel 前端项目环境变量,只填写后端 /api/v1 地址。',\n url: 'https://vercel.com/docs/environment-variables',\n variable: 'NEXUS_API_BASE_URL',\n secret: false,\n scope: 'frontend'\n }\n ];\n protected readonly deploymentCommands: DeploymentCommand[] = [\n {\n title: '生成 SECRET_KEY',\n description: '本地生成后写入后端 Vercel 项目。',\n command: '[Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }))'\n },\n {\n title: '写入后端数据库',\n description: '在 backend 项目中添加 Supabase PostgreSQL 连接串。',\n command: 'vercel env add DATABASE_URL production --sensitive'\n },\n {\n title: '写入前端 API',\n description: '在 frontend 项目中添加公开 API 地址。',\n command: 'vercel env add NEXUS_API_BASE_URL production'\n },\n {\n title: '一键预检部署',\n description: '从仓库根目录执行前后端部署与数据同步。',\n command: '.\\\\scripts\\\\deploy-supabase-vercel.ps1 -DatabaseUrl \"postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\" -BackendProjectName \"nexus-prime-api\" -FrontendProjectName \"nexus-prime-web\" -SyncDatabase -SecretKey \"<32位以上随机字符串>\" -CloudinaryUrl \"cloudinary://api_key:api_secret@cloud_name\"'\n }\n ];\n\n ngOnInit(): void {\n this.load();\n }\n\n load(): void {\n this.loading.set(true);\n forkJoin({\n preferences: this.api.preferences().pipe(catchError(() => of({} as UserPreferences))),\n ai: this.api.get<AiSettings>('ai/settings').pipe(catchError(() => of(EMPTY_AI_SETTINGS))),\n deployment: this.api.get<DeploymentReadiness>('operations/deployment-readiness').pipe(catchError(() => of(EMPTY_DEPLOYMENT_READINESS)))\n }).pipe(finalize(() => this.loading.set(false))).subscribe(({ preferences, ai, deployment }) => {\n this.preferenceDraft = {\n theme: preferences.theme ?? this.theme.mode(),\n density: preferences.density ?? 'compact',\n default_workspace: preferences.default_workspace ?? '/app/overview',\n charts_motion: preferences.charts_motion ?? 'standard',\n dock_labels: preferences.dock_labels ?? 'hover',\n context_panel: preferences.context_panel ?? 'visible'\n };\n this.theme.setPreferences(this.preferenceDraft, false);\n this.aiSettings.set(ai);\n this.deploymentReadiness.set(deployment);\n this.aiDraft = {\n analysis_mode: ai.analysis_mode,\n ai_api_base: ai.external_base || '',\n ai_api_key: '',\n ai_model: ai.model || 'deepseek-chat'\n };\n });\n }\n\n setTheme(mode: ThemeMode): void {\n this.preferenceDraft.theme = mode;\n this.theme.setPreferences(this.preferenceDraft, false);\n }\n\n saveAll(): void {\n this.saving.set(true);\n forkJoin({\n preferences: this.api.savePreferences(this.preferenceDraft),\n ai: this.api.put<AiSettings>('ai/settings', this.aiDraft)\n }).pipe(\n catchError(error => {\n this.messages.add({ severity: 'warn', summary: '设置未保存', detail: error?.message || '请检查配置内容。' });\n return of(null);\n }),\n finalize(() => this.saving.set(false))\n ).subscribe(result => {\n if (!result) {\n return;\n }\n this.preferenceDraft = { ...this.preferenceDraft, ...result.preferences };\n this.aiSettings.set(result.ai);\n this.theme.setPreferences(result.preferences, false);\n this.messages.add({ severity: 'success', summary: '设置已保存', detail: '全局偏好和 AI 服务设置已更新。' });\n });\n }\n\n async copyCommand(command: string): Promise<void> {\n try {\n if (navigator.clipboard?.writeText) {\n await navigator.clipboard.writeText(command);\n } else if (!this.fallbackCopy(command)) {\n throw new Error('clipboard_unavailable');\n }\n this.messages.add({ severity: 'success', summary: '已复制', detail: '部署命令已复制到剪贴板。' });\n } catch {\n this.messages.add({ severity: 'warn', summary: '复制失败', detail: '请手动复制命令,浏览器未开放剪贴板权限。' });\n }\n }\n\n protected createDeploymentTask(check: DeploymentReadinessCheck): void {\n if (check.status === 'ready' || this.deploymentTaskCreating()) {\n return;\n }\n this.deploymentTaskCreating.set(check.key);\n this.api.post<DataRecord>('operations/deployment-readiness/task', {\n key: check.key,\n label: check.label,\n scope: check.scope,\n status: check.status,\n evidence: check.evidence,\n action: check.action\n }).pipe(\n catchError(error => {\n this.messages.add({\n severity: 'warn',\n summary: '任务未创建',\n detail: error?.message || '请稍后重试部署预检任务。'\n });\n return of(null);\n }),\n finalize(() => this.deploymentTaskCreating.set(''))\n ).subscribe(result => {\n if (!result) {\n return;\n }\n this.deploymentTaskCreated.set(check.key);\n this.messages.add({\n severity: 'success',\n summary: '部署任务已创建',\n detail: `${check.label} 已写入通知中心和审计日志。`\n });\n });\n }\n\n protected statusSeverity(status: string): 'success' | 'warn' | 'danger' | 'info' {\n if (status === 'ready') {\n return 'success';\n }\n if (status === 'blocked') {\n return 'danger';\n }\n if (status === 'attention') {\n return 'warn';\n }\n return 'info';\n }\n\n private fallbackCopy(value: string): boolean {\n const textarea = document.createElement('textarea');\n textarea.value = value;\n textarea.setAttribute('readonly', 'true');\n textarea.style.position = 'fixed';\n textarea.style.left = '-9999px';\n document.body.appendChild(textarea);\n textarea.select();\n try {\n return document.execCommand('copy');\n } finally {\n document.body.removeChild(textarea);\n }\n }\n}\n"}}, "docs/api-token-deployment-guide.md": {"1b31adb6c10be5ec293a33aac414815edf962427": {"t": 1785746523.0346787, "n": 30209, "h": "868ba27a754a51ba", "c": "# API 接口、Token 获取与 Supabase + Vercel 部署手册\n\n本手册覆盖 NEXUS Prime 当前前后端分离架构的部署步骤、第三方 Token 获取入口、本系统 API 认证方式和完整接口清单。所有业务接口默认以前缀 `/api/v1` 开头。\n\n正式部署或最终交付前,建议先运行:\n\n```powershell\n.\\scripts\\quality-gate.ps1\n```\n\n该闸门会把最终 DOCX 报告、截图资产、企业数据、前后端测试、生产构建、布局审计和部署预检串成一条流水线,避免 Token 已配置完成后才发现报告或页面资产不同步。\n\n## 1. 基础地址\n\n| 环境 | 地址 |\n| --- | --- |\n| 本地前端 | `http://127.0.0.1:4200` |\n| 本地后端 API | `http://127.0.0.1:5000/api/v1` |\n| 生产前端 | `https://<frontend-project>.vercel.app` |\n| 生产后端 API | `https://<backend-project>.vercel.app/api/v1` |\n| 后端根健康页 | `https://<backend-project>.vercel.app/` |\n\n后端统一返回:\n\n```json\n{\n \"data\": {},\n \"message\": \"操作结果\",\n \"error\": null\n}\n```\n\n通用分页字段:\n\n```json\n{\n \"items\": [],\n \"pagination\": {\n \"page\": 1,\n \"page_size\": 10,\n \"total\": 100,\n \"pages\": 10,\n \"has_next\": true,\n \"has_prev\": false\n }\n}\n```\n\n## 2. Token 与密钥获取网址\n\n| 用途 | 获取网址 | 项目变量 | 说明 |\n| --- | --- | --- | --- |\n| Vercel CLI/API Token | https://vercel.com/account/settings/tokens | `VERCEL_TOKEN` | 用于脚本无交互部署前端和后端。手动 `vercel login` 后可不填。 |\n| Vercel 项目控制台 | https://vercel.com/dashboard | 无固定变量 | 创建 `frontend` 与 `backend` 两个独立项目。 |\n| Supabase 项目列表 | https://supabase.com/dashboard/projects | 无固定变量 | 新建 PostgreSQL 项目。 |\n| Supabase 数据库连接串 | `https://supabase.com/dashboard/project/<project-ref>/settings/database` | `DATABASE_URL` | 复制 Pooler 连接串,保留 `sslmode=require`。 |\n| Supabase API Key | `https://supabase.com/dashboard/project/<project-ref>/settings/api` | 暂不必填 | 当前后端直连 PostgreSQL,不需要前端直连 Supabase。后续接 Storage 时再使用。 |\n| Supabase API Key 新入口 | `https://supabase.com/dashboard/project/<project-ref>/settings/api-keys` | 暂不必填 | 部分 Supabase 项目控制台使用该入口。 |\n| Supabase 连接串文档 | https://supabase.com/docs/reference/postgres/connection-strings | 无 | 核对 direct、session pooler、transaction pooler。 |\n| Supabase SSL 文档 | https://supabase.com/docs/guides/platform/ssl-enforcement | 无 | 生产连接必须使用 SSL。 |\n| DeepSeek API Key | https://platform.deepseek.com/api_keys | `DEEPSEEK_API_KEY` | AI 经营分析外部模型凭证。 |\n| DeepSeek API 文档 | https://api-docs.deepseek.com/ | `DEEPSEEK_BASE_URL` | 默认 `https://api.deepseek.com`,模型默认 `deepseek-chat`。 |\n| Cloudinary 控制台 | https://console.cloudinary.com/ | `CLOUDINARY_URL` 或拆分变量 | Vercel/Serverless 生产头像和文件长期保存必填。进入 Settings / API Keys 获取。 |\n| Cloudinary API Keys 入口 | https://console.cloudinary.com/settings/api-keys | `CLOUDINARY_CLOUD_NAME`、`CLOUDINARY_API_KEY`、`CLOUDINARY_API_SECRET` | 如该直达入口跳转异常,从控制台首页进入 Settings。 |\n| Sentry Auth Token | https://sentry.io/settings/account/api/auth-tokens/ | 可选 | 仅在需要读取 Sentry API 时使用。 |\n| Sentry DSN | https://sentry.io/settings/projects/ | `SENTRY_DSN` | 可选错误监控。进入具体项目的 Client Keys/DSN。 |\n| GitHub Token | https://github.com/settings/tokens | 可选 | Vercel 绑定 GitHub 通常走 OAuth,不需要手动 PAT。 |\n| GitHub Token 文档 | https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens | 可选 | 仅在自动化访问私有仓库时使用。 |\n\n### 2.1 一键复制用入口与变量\n\n下面内容可直接复制到部署检查表;尖括号内内容需要替换,所有 secret 只进入后端 Vercel 项目或本地脚本环境变量。\n\n```text\nVercel Token:\nhttps://vercel.com/account/settings/tokens\n变量: VERCEL_TOKEN\n用途: scripts/deploy-supabase-vercel.ps1 非交互部署\n```\n\n```text\nSupabase Database URL:\nhttps://supabase.com/dashboard/project/<project-ref>/settings/database\n变量: DATABASE_URL\n示例: postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\n用途: 后端连接生产 PostgreSQL\n```\n\n```text\nSupabase API Keys:\nhttps://supabase.com/dashboard/project/<project-ref>/settings/api-keys\n变量: 后续接 Supabase Storage 或 Edge API 时再配置\n边界: 当前前端不直连 Supabase,不需要把 Supabase key 放进 frontend\n```\n\n```text\nCloudinary API Keys:\nhttps://console.cloudinary.com/settings/api-keys\n变量: CLOUDINARY_URL\n示例: cloudinary://api_key:api_secret@cloud_name\n用途: Vercel/Serverless 生产头像和附件持久化\n```\n\n```text\nDeepSeek API Key:\nhttps://platform.deepseek.com/api_keys\n变量: DEEPSEEK_API_KEY\nBase URL: https://api.deepseek.com\n模型: deepseek-chat\n用途: 后端真实调用 OpenAI-compatible Chat Completions\n```\n\n```text\n前端 API 地址:\nhttps://vercel.com/docs/environment-variables\n变量: NEXUS_API_BASE_URL\n示例: https://<backend-project>.vercel.app/api/v1\n边界: 前端只放公开 API 地址,不放 DATABASE_URL、SECRET_KEY、AI Key、Cloudinary secret\n```\n\n常用命令模板:\n\n```powershell\n[Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }))\n```\n\n```powershell\nvercel env add DATABASE_URL production --sensitive\nvercel env add SECRET_KEY production --sensitive\nvercel env add CLOUDINARY_URL production --sensitive\nvercel env add DEEPSEEK_API_KEY production --sensitive\nvercel env add NEXUS_API_BASE_URL production\n```\n\n官方依据:\n\n- Vercel 环境变量用于在构建和函数运行时改变行为;敏感环境变量在创建后不可读,适合保存 Token、数据库连接串和服务端密钥。\n- Vercel CLI 支持 `vercel env add <NAME> production --sensitive`,一键部署脚本对 `DATABASE_URL`、`SECRET_KEY`、`DEEPSEEK_API_KEY`、`CLOUDINARY_URL` 等变量按敏感变量写入。\n- Supabase API Keys 位于项目 Dashboard 的 `Settings -> API Keys`;当前项目后端直连 PostgreSQL,浏览器端不需要 Supabase key。\n- Supabase 官方连接建议中,Serverless 或短生命周期函数使用 transaction pooler,常见端口为 `6543`;生产连接必须启用 SSL,当前脚本要求 `sslmode=require`。\n\n`SECRET_KEY` 不是外部平台 Token,应在本机生成并只放入后端环境变量:\n\n```powershell\n[Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }))\n```\n\n## 3. 部署前准备\n\n### 3.0 架构与资源边界\n\n当前项目按前后端分离维护:\n\n```text\nfrontend/ Angular SPA、页面交互、主题、真实图片资源、Vercel 前端项目\nbackend/ Flask REST API、认证权限、业务流程、数据库、AI 调用、文件与头像\nscripts/ 本地开发、清理、质量闸门、预检、Supabase/Vercel 一键部署\ndocs/ 部署文档、接口清单、架构审阅、截图与交付说明\n```\n\n部署前确认:\n\n- `frontend/src/app/core/navigation.ts` 统一维护 Dock、更多菜单、桌面/移动可见入口。\n- `frontend/src/app/core/visual-assets.ts` 统一登记真实图片资源。\n- `frontend/public/images/` 保留页面使用的制造、仓配、工业现场图片。\n- `frontend/src/styles.scss` 保留业务页面布局稳定层,只处理居中、溢出、图标对齐、分页、图表最小尺寸和移动端网格。\n- `frontend/src/auth-entry.scss` 独立维护首页、登录页和注册流程,避免认证入口继续被全局业务样式覆盖。\n- `backend/.env.example` 已包含 `FLASK_CONFIG=production` 与 `NEXUS_RUNTIME_DIR=/tmp/nexus-prime`。\n- 浏览器端只配置 `NEXUS_API_BASE_URL`,不得放入数据库、AI Key、Supabase secret key 或后端密钥。\n\n架构审阅见 `docs/architecture-maintenance-review.md`,项目规范见 `docs/project-standards.md`。\n\n### 3.1 安装本地工具\n\n1. 安装 Git、Git LFS、Node.js LTS、Python 3.11+。\n2. 在仓库根目录拉取 LFS 数据:\n\n```powershell\ngit lfs install\ngit lfs pull\n```\n\n3. 安装依赖并启动本地开发环境:\n\n```powershell\n.\\scripts\\dev.ps1 -Install\n```\n\n如果只需要从纯净仓库恢复依赖,不启动服务:\n\n```powershell\n.\\scripts\\install-dependencies.ps1\n```\n\n4. 本地演示账号:\n\n```text\nadmin@nexus.com / admin123\nuser00001@nexus.com / password123\n```\n\n远程 `-SeedRemoteWhenEmpty` 必须使用 `NEXUS_DEMO_ADMIN_PASSWORD` 和 `NEXUS_DEMO_USER_PASSWORD` 自定义密码,脚本会拒绝本地默认密码。\n\n### 3.2 创建 Supabase 数据库\n\n1. 打开 https://supabase.com/dashboard/projects。\n2. 创建新项目并记录 `<project-ref>`、数据库密码和区域。\n3. 打开 `Project Settings -> Database`,复制 PostgreSQL connection string。\n4. Vercel/Serverless 场景优先使用 Pooler 连接串。\n5. 确保连接串形态类似:\n\n```text\npostgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\n```\n\n### 3.3 创建两个 Vercel 项目\n\n后端项目:\n\n```text\nProject Root: backend\nFramework: Other / Python\n入口: backend/server.py\n健康检查: /api/v1/health\n```\n\n前端项目:\n\n```text\nProject Root: frontend\nFramework Preset: Angular\nBuild Command: npm run build\nOutput Directory: dist/frontend/browser\n```\n\n两个项目必须分开,前端只存前端环境变量,后端只存数据库、Cookie、AI、上传等服务端密钥。\n\n## 4. 环境变量\n\n### 4.1 后端 Vercel 项目\n\n必填:\n\n```env\nFLASK_ENV=production\nFLASK_CONFIG=production\nDATABASE_URL=postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\nSECRET_KEY=<至少32位强随机字符串>\nCORS_ORIGINS=https://<frontend-project>.vercel.app\nFRONTEND_ORIGIN=https://<frontend-project>.vercel.app\nAUTH_COOKIE_SECURE=true\nAUTH_COOKIE_SAMESITE=None\nLOGIN_RATE_LIMIT_ATTEMPTS=8\nLOGIN_RATE_LIMIT_WINDOW_SECONDS=600\nNEXUS_RUNTIME_DIR=/tmp/nexus-prime\nAI_LOCAL_ANALYSIS=true\nAI_REQUEST_TIMEOUT_SECONDS=20\nAI_CONNECT_TIMEOUT_SECONDS=5\n```\n\n生产上传必填:\n\n```env\nUSE_CLOUD_STORAGE=auto\nREQUIRE_CLOUD_STORAGE_FOR_UPLOADS=auto\nUPLOAD_FOLDER=/tmp/nexus-prime/storage/uploads\nUPLOAD_FILES_FOLDER=/tmp/nexus-prime/storage/uploads/files\nUPLOAD_AVATARS_FOLDER=/tmp/nexus-prime/storage/uploads/avatars\nUPLOAD_LIBRARY_FOLDER=/tmp/nexus-prime/storage/uploads/library\nCLOUDINARY_URL=cloudinary://api_key:api_secret@cloud_name\n```\n\nVercel/Serverless 后端没有长期本地磁盘,头像和文件上传必须配置 `CLOUDINARY_URL`。未配置时上传接口会返回 `persistent_storage_required`,避免刷新后文件丢失。独立长驻主机可使用本地三目录:附件 `UPLOAD_FILES_FOLDER`、头像 `UPLOAD_AVATARS_FOLDER`、资料库 `UPLOAD_LIBRARY_FOLDER`。\n\n可选:\n\n```env\nDEEPSEEK_API_KEY=<DeepSeek API Key>\nDEEPSEEK_BASE_URL=https://api.deepseek.com\nDEEPSEEK_MODEL=deepseek-chat\nAI_REQUEST_TIMEOUT_SECONDS=20\nAI_CONNECT_TIMEOUT_SECONDS=5\nSENTRY_DSN=<Sentry DSN>\n```\n\n生产后端会在启动时强校验关键变量:必须设置 `SECRET_KEY`、`DATABASE_URL`、`CORS_ORIGINS`;正式环境默认拒绝 SQLite,数据库应使用 PostgreSQL/Supabase。只有本地演练生产配置时,才可显式设置 `ALLOW_PRODUCTION_SQLITE=true`。\n\n### 4.2 前端 Vercel 项目\n\n只需要:\n\n```env\nNEXUS_API_BASE_URL=https://<backend-project>.vercel.app/api/v1\n```\n\n不要把 `DATABASE_URL`、`SECRET_KEY`、`DEEPSEEK_API_KEY`、`CLOUDINARY_API_SECRET`、Supabase `service_role` 放入前端项目。\n\n## 5. 本地预检\n\n在仓库根目录执行:\n\n```powershell\n$env:NEXUS_API_BASE_URL=\"https://<backend-project>.vercel.app/api/v1\"\n$env:FRONTEND_ORIGIN=\"https://<frontend-project>.vercel.app\"\n$env:CORS_ORIGINS=$env:FRONTEND_ORIGIN\n$env:DATABASE_URL=\"postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\"\n$env:AUTH_COOKIE_SECURE=\"true\"\n$env:AUTH_COOKIE_SAMESITE=\"None\"\n$env:SECRET_KEY=\"<至少32位强随机字符串>\"\n$env:CLOUDINARY_URL=\"cloudinary://api_key:api_secret@cloud_name\"\n$env:AI_REQUEST_TIMEOUT_SECONDS=\"20\"\n$env:AI_CONNECT_TIMEOUT_SECONDS=\"5\"\n.\\scripts\\preflight.ps1 -SkipApiProbe\n```\n\n真实 API 已部署后,去掉 `-SkipApiProbe`,脚本会访问:\n\n```text\nhttps://<backend-project>.vercel.app/api/v1/health\n```\n\n## 6. 一键部署\n\n建议按“先质量闸门、后 Token 配置、再部署”的顺序推进:先确认 `docs/final-delivery-report.docx`、`docs/images/final/`、ER 图、真实图片登记、测试和部署预检一致,再把 `VERCEL_TOKEN`、`DATABASE_URL`、`SECRET_KEY`、`CLOUDINARY_URL`、`DEEPSEEK_API_KEY` 写入对应项目或终端环境。\n\n先设置可选 Token:\n\n```powershell\n$env:VERCEL_TOKEN=\"<从 https://vercel.com/account/settings/tokens 获取>\"\n$env:DEEPSEEK_API_KEY=\"<从 https://platform.deepseek.com/api_keys 获取,可选>\"\n$env:SECRET_KEY=\"<至少32位强随机字符串>\"\n$env:CLOUDINARY_URL=\"<从 Cloudinary 控制台复制的 cloudinary://api_key:api_secret@cloud_name>\"\n$env:NEXUS_DEMO_ADMIN_PASSWORD=\"<远程演示管理员密码>\"\n$env:NEXUS_DEMO_USER_PASSWORD=\"<远程演示普通用户密码>\"\n```\n\n执行完整部署:\n\n```powershell\n.\\scripts\\deploy-supabase-vercel.ps1 `\n -DatabaseUrl \"postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\" `\n -BackendProjectName \"nexus-prime-api\" `\n -FrontendProjectName \"nexus-prime-web\" `\n -ApiBaseUrl \"https://nexus-prime-api.vercel.app/api/v1\" `\n -FrontendOrigin \"https://nexus-prime-web.vercel.app\" `\n -SeedRemoteWhenEmpty `\n -SecretKey $env:SECRET_KEY `\n -CloudinaryUrl $env:CLOUDINARY_URL\n```\n\n只演练流程、不真正发布:\n\n```powershell\n.\\scripts\\deploy-supabase-vercel.ps1 `\n -DatabaseUrl \"postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\" `\n -BackendProjectName \"nexus-prime-api\" `\n -FrontendProjectName \"nexus-prime-web\" `\n -SecretKey $env:SECRET_KEY `\n -CloudinaryUrl $env:CLOUDINARY_URL `\n -SkipMigrations `\n -SkipDataVerify `\n -SkipBackendDeploy `\n -SkipFrontendDeploy `\n -SkipApiProbe\n```\n\n脚本执行顺序:\n\n1. 自动恢复缺失的 `venv/` 与 `frontend/node_modules/`。\n2. 校验 Node、npm、npx、Python venv、Vercel CLI 输入。\n3. 运行 `scripts/preflight.ps1`。\n4. 执行 `flask db upgrade` 升级 Supabase schema。\n5. 可选同步 `backend/instance/nexus_prime.db` 到 Supabase。\n6. 配置后端 Vercel 环境变量并部署后端。\n7. 配置前端 Vercel 环境变量并部署前端。\n8. 请求 `/api/v1/health` 做上线探测。\n\n## 7. 本系统 API 认证方式\n\n### 7.1 Cookie + CSRF\n\n正常前端使用 HttpOnly Cookie,不需要手工保存访问 Token。登录后后端设置:\n\n| 名称 | 类型 | 用途 |\n| --- | --- | --- |\n| `nexus_access_token` | HttpOnly Cookie | 登录态 JWT,浏览器脚本不可读取 |\n| `nexus_csrf_token` | 普通 Cookie | 写请求 CSRF 校验 |\n| `X-CSRF-Token` | Header | `POST/PUT/PATCH/DELETE` 必须携带 |\n\n公开接口不需要登录。登录后接口需要 Cookie。写接口在 Cookie 模式下必须带 `X-CSRF-Token`。\n\n### 7.2 Bearer Token\n\n`POST /auth/login` 响应体里也会返回 `data.token`。脚本或外部系统可以使用:\n\n```http\nAuthorization: Bearer <data.token>\n```\n\n使用 Bearer Token 时,后端不要求 CSRF Header。生产前端仍建议使用 Cookie + CSRF。\n\n### 7.3 PowerShell 调用示例\n\n```powershell\n$base = \"http://127.0.0.1:5000/api/v1\"\n$demoAdminPassword = $env:NEXUS_DEMO_ADMIN_PASSWORD\nif (-not $demoAdminPassword) { $demoAdminPassword = \"admin123\" } # 仅本地演示默认值\n$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession\n\n$csrf = (Invoke-RestMethod \"$base/auth/csrf\" -WebSession $session).data.csrf_token\n\n$login = Invoke-RestMethod \"$base/auth/login\" `\n -Method Post `\n -ContentType \"application/json\" `\n -WebSession $session `\n -Body (@{ email=\"admin@nexus.com\"; password=$demoAdminPassword } | ConvertTo-Json)\n\n$csrf = $login.data.csrf_token\n\nInvoke-RestMethod \"$base/products?page=1&page_size=10&q=轴承\" -WebSession $session\n\nInvoke-RestMethod \"$base/me/preferences\" `\n -Method Put `\n -ContentType \"application/json\" `\n -WebSession $session `\n -Headers @{ \"X-CSRF-Token\" = $csrf } `\n -Body (@{ theme=\"light-luxury\"; density=\"comfortable\" } | ConvertTo-Json)\n```\n\n### 7.4 注册调用流程\n\n1. `GET /auth/register-policy` 获取许可版本、必选勾选项和可展开的《服务许可》《隐私说明》《数据使用范围》。\n2. `GET /auth/captcha` 获取验证码图片、`captcha_token`、`terms_version`。\n3. `POST /auth/register` 提交注册信息。\n\n注册请求体示例:\n\n```json\n{\n \"username\": \"demo-user\",\n \"email\": \"demo@example.com\",\n \"password\": \"replace-with-demo-password\",\n \"full_name\": \"演示用户\",\n \"phone\": \"13800000000\",\n \"position\": \"业务协同成员\",\n \"accepted_terms\": true,\n \"accepted_privacy\": true,\n \"accepted_data_scope\": true,\n \"terms_version\": \"2026.06\",\n \"captcha_token\": \"<GET /auth/captcha 返回>\",\n \"captcha_answer\": \"<用户识别结果>\"\n}\n```\n\n### 7.5 AI 设置调用流程\n\n1. `GET /ai/settings` 读取当前分析模式、Base URL、模型和凭证配置状态。\n2. `PUT /ai/settings` 保存 `analysis_mode`、`ai_api_base`、`ai_model`、`ai_api_key`。\n3. `POST /ai/diagnostics` 检查本地分析引擎和外部模型连通性。\n4. `POST /ai/chat` 发起经营分析。`hybrid` 模式会优先外部模型,失败时回退本地经营分析。\n\n浏览器端只提交配置到后端,不保存数据库连接串、Supabase secret、Vercel Token 或 Cloudinary secret。\n\n## 8. 权限说明\n\n| 权限点 | 含义 |\n| --- | --- |\n| `admin` | 系统管理 |\n| `masterdata.write` | 主数据维护 |\n| `inventory.adjust` | 库存调整 |\n| `purchase.write` | 采购创建 |\n| `purchase.approve` | 采购审批 |\n| `purchase.receive` | 采购收货 |\n| `sales.write` | 销售履约 |\n| `finance.payment` | 收款处理 |\n| `finance.credit.write` | 信用管理 |\n| `stocktake.write` | 盘点管理 |\n| `reports.generate` | 报表生成 |\n| `files.manage` | 文件管理 |\n| `content.write` | 内容管理 |\n\n管理员账号具备全部权限。普通账号默认只能访问非管理员资源,写入动作由角色权限控制。\n\n## 9. 完整 API 接口清单\n\n除 `GET /` 外,以下路径均相对于 `/api/v1`。\n\n### 9.1 根与健康检查\n\n| 方法 | 路径 | 说明 | 认证 |\n| --- | --- | --- | --- |\n| GET | `/` | 后端根健康页 | 否 |\n| GET | `/health` | API 健康检查 | 否 |\n\n### 9.2 认证、注册、个人资料\n\n| 方法 | 路径 | 说明 | 认证 |\n| --- | --- | --- | --- |\n| GET | `/auth/csrf` | 获取 CSRF Token | 否 |\n| GET | `/auth/captcha` | 获取注册验证码 | 否 |\n| GET | `/auth/register-policy` | 获取注册许可策略 | 否 |\n| POST | `/auth/register` | 注册账号并建立会话 | 否 |\n| POST | `/auth/login` | 登录并设置 Cookie | 否 |\n| POST | `/auth/logout` | 退出登录并清除 Cookie | 是 |\n| GET | `/auth/me` | 当前用户信息并刷新 Cookie | 是 |\n| POST | `/auth/change-password` | 修改密码 | 是 |\n| PUT | `/me/profile` | 更新个人资料 | 是 |\n| POST | `/me/avatar` | 上传头像,multipart `file` | 是 |\n| DELETE | `/me/avatar` | 删除头像并恢复默认 | 是 |\n| GET | `/avatars/<filename>` | 读取本地头像 | 否 |\n| GET | `/avatars/initials/<key>` | 生成默认首字母头像 | 否 |\n| GET | `/me/preferences` | 获取用户偏好 | 是 |\n| PUT | `/me/preferences` | 保存主题、布局、图表、Dock 等偏好 | 是 |\n\n### 9.3 总览、图表、导航、搜索\n\n| 方法 | 路径 | 说明 | 认证 |\n| --- | --- | --- | --- |\n| GET | `/dashboard/summary` | 仪表盘汇总 | 是 |\n| GET | `/dashboard/charts` | 仪表盘图表数据 | 是 |\n| GET | `/overview/summary` | 新版总览汇总 | 是 |\n| GET | `/overview/charts` | 新版总览图表数据 | 是 |\n| GET | `/analytics/executive` | 经营分析指标 | 是 |\n| GET | `/manufacturing/command-center` | 制造仓配指挥数据 | 是 |\n| GET | `/meta/navigation` | 导航元数据与资源数量 | 是 |\n| GET | `/search?q=<keyword>` | 全局搜索 | 是 |\n\n### 9.4 通用 CRUD 资源接口\n\n通用查询参数:\n\n| 参数 | 说明 |\n| --- | --- |\n| `page` | 页码,默认 `1` |\n| `page_size` / `per_page` | 每页条数,最大 `100` |\n| `q` | 模糊搜索 |\n| `sort` | 排序字段 |\n| `order` | `asc` 或 `desc` |\n| 其他字段 | 若模型存在同名字段,会按等值过滤 |\n\n通用方法:\n\n| 方法 | 路径 | 说明 |\n| --- | --- | --- |\n| GET | `/<resource>` | 资源列表 |\n| GET | `/<resource>/<id>` | 资源详情 |\n| POST | `/<resource>` | 创建资源 |\n| PUT/PATCH | `/<resource>/<id>` | 更新资源 |\n| DELETE | `/<resource>/<id>` | 软删除资源 |\n\n可用资源:\n\n| Resource | 说明 | 写入权限 |\n| --- | --- | --- |\n| `users` | 用户 | 管理员 |\n| `roles` | 角色 | 管理员 |\n| `departments` | 部门 | 管理员 |\n| `categories` | 分类 | `masterdata.write` |\n| `partners` | 客户/供应商伙伴 | `masterdata.write` |\n| `products` | 物料与成品 | `masterdata.write` |\n| `warehouses` | 仓库 | `inventory.adjust` |\n| `stock` | 库存 | `inventory.adjust` |\n| `inventory-logs` | 库存流水 | 登录 |\n| `orders` | 销售订单 | `sales.write` |\n| `order-items` | 销售订单明细 | `sales.write` |\n| `purchase-orders` | 采购单 | `purchase.write` |\n| `purchase-order-items` | 采购明细 | `purchase.write` |\n| `supplier-performance` | 供应商绩效 | 登录 |\n| `receivables` | 应收 | `finance.payment` |\n| `payments` | 收款记录 | `finance.payment` |\n| `statements` | 对账单 | `reports.generate` |\n| `credits` | 客户信用 | `finance.credit.write` |\n| `stocktakes` | 盘点单 | `stocktake.write` |\n| `stocktake-items` | 盘点明细 | `stocktake.write` |\n| `notifications` | 通知 | 本人/管理员 |\n| `stock-alerts` | 库存预警 | `inventory.adjust` |\n| `replenishment-suggestions` | 补货建议 | `purchase.write` |\n| `report-subscriptions` | 报表订阅 | 本人/管理员 |\n| `generated-reports` | 已生成报表 | 本人/管理员 |\n| `reports` | `generated-reports` 别名 | 本人/管理员 |\n| `articles` | 公告文章 | `content.write` |\n| `article-comments` | 文章评论 | 本人/管理员 |\n| `files` | 文件记录 | 本人/管理员 |\n| `audit-logs` | 审计日志 | 管理员 |\n| `ai-sessions` | AI 分析会话 | 本人/管理员 |\n| `ai-messages` | AI 消息 | 本人/管理员 |\n\n新版路径别名:\n\n| 新路径 | 映射资源 |\n| --- | --- |\n| `/inventory/products` | `products` |\n| `/inventory/stock` | `stock` |\n| `/inventory/replenishment-suggestions` | `replenishment-suggestions` |\n| `/sales/orders` | `orders` |\n| `/procurement/orders` | `purchase-orders` |\n| `/finance/receivables` | `receivables` |\n| `/stocktakes` | `stocktakes` |\n| `/notifications` | `notifications` |\n| `/reports` | `generated-reports` |\n| `/content/articles` | `articles` |\n| `/files` | `files` |\n| `/ai/sessions` | `ai-sessions` |\n| `/system/users` | `users` |\n| `/system/audit` | `audit-logs` |\n\n这些别名同样支持:\n\n| 方法 | 路径 |\n| --- | --- |\n| GET/POST | `/<path:new_path>` |\n| GET/PUT/PATCH/DELETE | `/<path:new_path>/<id>` |\n\n### 9.5 库存、补货、仓配\n\n| 方法 | 路径 | 说明 | 权限 |\n| --- | --- | --- | --- |\n| GET | `/inventory/health` | 库存健康度 | 登录 |\n| POST | `/inventory/adjust` | 库存调整 | `inventory.adjust` |\n| POST | `/stock-alerts/check` | 生成/检查库存预警 | `inventory.adjust` |\n| POST | `/replenishment-suggestions/generate` | 生成补货建议 | `purchase.write` |\n| POST | `/replenishment-suggestions/<id>/accept` | 补货建议转采购 | `purchase.write` |\n| POST | `/inventory/replenishment-suggestions/generate` | 新路径:生成补货建议 | `purchase.write` |\n| POST | `/inventory/replenishment-suggestions/<id>/accept` | 新路径:补货建议转采购 | `purchase.write` |\n\n### 9.6 采购、销售、应收、信用\n\n| 方法 | 路径 | 说明 | 权限 |\n| --- | --- | --- | --- |\n| GET | `/procurement/summary` | 采购汇总 | 登录 |\n| POST | `/purchase-orders/<id>/submit` | 提交采购单 | `purchase.write` |\n| POST | `/purchase-orders/<id>/approve` | 审批采购单 | `purchase.approve` |\n| POST | `/purchase-orders/<id>/reject` | 驳回采购单 | `purchase.approve` |\n| POST | `/purchase-orders/<id>/receive` | 采购收货 | `purchase.receive` |\n| POST | `/procurement/orders/<id>/submit` | 新路径:提交采购单 | `purchase.write` |\n| POST | `/procurement/orders/<id>/approve` | 新路径:审批采购单 | `purchase.approve` |\n| POST | `/procurement/orders/<id>/reject` | 新路径:驳回采购单 | `purchase.approve` |\n| POST | `/procurement/orders/<id>/receive` | 新路径:采购收货 | `purchase.receive` |\n| POST | `/sales/orders` | 创建销售订单 | `sales.write` |\n| POST | `/sales/orders/<id>/transition` | 销售订单状态流转 | `sales.write` |\n| GET | `/finance/receivables/aging` | 应收账龄分析 | 登录 |\n| POST | `/receivables/<id>/payment` | 记录收款 | `finance.payment` |\n| POST | `/finance/receivables/<id>/payment` | 新路径:记录收款 | `finance.payment` |\n| POST | `/finance/receivables/<id>/reminder` | 创建催款提醒 | `finance.payment` |\n| GET | `/finance/credits` | 客户信用列表 | 登录 |\n| PUT | `/finance/credits/<id>` | 调整信用额度/预警线 | `finance.credit.write` |\n| POST | `/finance/credits/<id>/freeze` | 冻结客户信用 | `finance.credit.write` |\n| POST | `/finance/credits/<id>/unfreeze` | 解冻客户信用 | `finance.credit.write` |\n| POST | `/statements/generate` | 生成对账单 | `reports.generate` |\n\n### 9.7 盘点\n\n| 方法 | 路径 | 说明 | 权限 |\n| --- | --- | --- | --- |\n| POST | `/stocktakes/create` | 创建盘点单 | `stocktake.write` |\n| POST | `/stocktakes/<id>/start` | 开始盘点 | `stocktake.write` |\n| POST | `/stocktakes/<id>/count` | 批量录入盘点数量 | `stocktake.write` |\n| POST | `/stocktake-items/<id>/count` | 录入单个盘点明细数量 | `stocktake.write` |\n| POST | `/stocktakes/<id>/complete` | 完成盘点 | `stocktake.write` |\n| GET | `/stocktakes/<id>/variance` | 盘点差异汇总 | 登录 |\n\n### 9.8 报表、导出、文件、内容\n\n| 方法 | 路径 | 说明 | 权限 |\n| --- | --- | --- | --- |\n| GET | `/reports/types` | 报表类型 | 登录 |\n| POST | `/reports/generate/<report_type>` | 生成报表 | `reports.generate` |\n| GET | `/export/<resource>/<format_type>` | 导出资源,支持 `csv/excel/pdf` | 资源读取权限 |\n| POST | `/files/upload` | 上传文件,multipart `file` | 登录 |\n| GET | `/files/<id>/download` | 下载文件 | 本人/管理员 |\n| POST | `/files/bulk-delete` | 批量删除文件 | `files.manage` |\n| GET | `/articles/<id>/comments` | 文章评论列表 | 登录 |\n| POST | `/articles/<id>/comments` | 新增文章评论 | 登录 |\n\n### 9.9 通知、查询、批量动作、字典\n\n| 方法 | 路径 | 说明 | 权限 |\n| --- | --- | --- | --- |\n| POST | `/notifications/mark-read` | 批量标记通知已读 | 本人/管理员 |\n| POST | `/bulk-actions` | 批量业务动作 | 依动作而定 |\n| GET | `/lookups/products` | 商品下拉搜索 | 登录 |\n| GET | `/lookups/partners` | 客户/供应商下拉搜索 | 登录 |\n| GET | `/lookups/warehouses` | 仓库下拉搜索 | 登录 |\n| GET | `/lookups/stock-locations` | 库存库位下拉搜索 | 登录 |\n\n`POST /bulk-actions` 当前支持:\n\n| Action | 说明 | 权限 |\n| --- | --- | --- |\n| `products.delete` | 批量删除商品 | `masterdata.write` |\n| `orders.update_status` | 批量更新订单状态 | `sales.write` |\n| `purchase_orders.approve` | 批量审批采购 | `purchase.approve` |\n| `notifications.mark_read` | 批量已读 | 本人/管理员 |\n| `files.delete` | 批量删除文件 | `files.manage` |\n| `operations.dispatch_task` | 创建仓配调度任务 | 登录 |\n| `operations.data_quality_notice` | 创建数据质量通知 | 登录 |\n| `operations.customer_followup` | 创建客户跟进任务 | 登录 |\n| `operations.capacity_plan` | 创建产能计划任务 | 登录 |\n| `operations.maintenance_workorder` | 创建设备维护工单 | 登录 |\n\n### 9.10 AI 经营分析\n\n| 方法 | 路径 | 说明 | 权限 |\n| --- | --- | --- | --- |\n| GET | `/ai/sessions` | 分析会话列表 | 本人 |\n| POST | `/ai/sessions` | 创建分析会话 | 本人 |\n| GET | `/ai/sessions/<id>/messages` | 会话消息列表 | 本人 |\n| POST | `/ai/chat` | 发送经营分析消息 | 本人 |\n| POST | `/ai/analyze/inventory` | 库存风险分析 | 本人 |\n| POST | `/ai/analyze/structured` | 结构化经营分析 | 本人 |\n| GET | `/ai/settings` | 读取 AI 设置 | 本人 |\n| PUT | `/ai/settings` | 保存 API Key、Base URL、模型、分析模式 | 本人 |\n| POST | `/ai/diagnostics` | 外部模型连通性诊断 | 本人 |\n\nAI 设置请求体示例:\n\n```json\n{\n \"analysis_mode\": \"hybrid\",\n \"ai_api_base\": \"https://api.deepseek.com\",\n \"ai_model\": \"deepseek-chat\",\n \"ai_api_key\": \"sk-...\"\n}\n```\n\n`analysis_mode` 可选:\n\n| 值 | 说明 |\n| --- | --- |\n| `local` | 只使用本地经营分析 |\n| `hybrid` | 优先外部模型,失败时回退本地分析 |\n| `external` | 只使用外部模型 |\n\n补充说明:\n\n- `GET /ai/settings` 会返回当前模式、外部 Base URL、模型名、是否已配置用户凭证。\n- `POST /ai/diagnostics` 会检测本地引擎和外部推理服务的连通性。\n- `POST /ai/chat` 在 `hybrid` 模式下会先尝试外部推理,失败时回退本地经营引擎并保留可追踪来源;`external` 模式下外部服务不可用会返回 `502 ai_provider_unavailable`。\n- 外部模型调用默认连接超时 5 秒、请求总超时 20 秒,可用 `AI_CONNECT_TIMEOUT_SECONDS` 和 `AI_REQUEST_TIMEOUT_SECONDS` 调整,范围会被限制在 3 到 60 秒之间。\n\n### 9.11 运营流程、规则、集成、成本、设备维护、移动终端\n\n| 方法 | 路径 | 说明 | 权限 |\n| --- | --- | --- | --- |\n| GET | `/operations/todo` | 运营待办 | 登录 |\n| GET | `/operations/exceptions` | 运营异常 | 登录 |\n| GET | `/operations/task-queue` | 任务异常中心当班队列,聚合通知、部署预检、库存预警和采购审批 | 登录 |\n| GET | `/operations/deployment-readiness` | 部署就绪分、环境边界、ERP 成熟度、能力域地图、微服务拓扑、交付证据和上线 Runbook | 登录 |\n| POST | `/operations/deployment-readiness/task` | 创建部署预检通知任务并写入审计 | 登录 |\n| GET | `/operations/rules` | 规则治理工作台:DMN 决策表、风险队列、负责人、SLA、Runbook、服务边界 | 登录 |\n| POST | `/operations/rules/review` | 创建规则复核通知任务并写入审计 | 登录 |\n| GET | `/operations/integrations` | 集成监控数据 | 登录 |\n| POST | `/operations/integrations/resync` | 创建接口重同步任务 | 登录 |\n| GET | `/operations/data-quality` | 数据质量治理体检、维度评分、整改队列、Runbook 和血缘链路 | 登录 |\n| POST | `/operations/data-quality/remediation` | 创建数据质量整改通知任务并写入审计 | 登录 |\n| GET | `/operations/costs` | 预算成本数据 | 登录 |\n| POST | `/operations/costs/review` | 创建成本复核任务 | 登录 |\n| GET | `/operations/capacity` | 产能计划治理数据:需求、供给、齐套、工作中心、瓶颈队列、服务边界和 Runbook | 登录 |\n| POST | `/operations/capacity/review` | 创建产能瓶颈复核任务并写入审计 | 登录 |\n| GET | `/operations/maintenance` | 设备可靠性治理工作台:资产线、维护工单、MRO 备件、维修人员、停机窗口、服务边界和 Runbook | 登录 |\n| POST | `/operations/maintenance-workorder` | 创建设备维护工单 | 登录 |\n| GET | `/operations/mobile-terminal` | 移动扫码终端任务 | 登录 |\n| POST | `/operations/mobile-terminal/task` | 创建现场扫码任务 | 登录 |\n| GET | `/operations/quality-inspection` | 质量检验治理工作台:检验批、供应商质量、缺陷分类、使用决策、证据、服务边界和 Runbook | 登录 |\n| POST | `/operations/quality-inspection` | 创建质量检验任务并写入通知与审计 | 登录 |\n| GET | `/operations/procurement-control` | 采购协同控制台:采购泳道、审批队列、到货窗口、供应商风险、补货候选、服务边界和部署检查 | 登录 |\n| POST | `/operations/procurement-control/task` | 创建采购协同通知任务并写入审计 | 登录 |\n| POST | `/operations/dispatch-task` | 创建仓配调度任务 | 登录 |\n| POST | `/operations/data-quality-notice` | 创建数据质量复核任务 | 登录 |\n| POST | `/operations/customer-followup` | 创建客户跟进任务 | 登录 |\n| POST | `/operations/capacity-plan` | 创建产能计划任务 | 登录 |\n| POST | `/operations/contract-review` | 创建合同回款复核任务 | 登录 |\n| POST | `/operations/service-workorder` | 创建售后服务工单 | 登录 |\n\n## 10. 仓库纯净化与本地清理\n\n当前活跃项目和保留对照目录:\n\n```text\nbackend/ Flask REST API、模型、迁移、测试、后端部署配置\nfrontend/ Angular SPA、主题、页面、前端部署配置\nscripts/ 本地启动、清理、预检、部署脚本\ndocs/ 交付报告、部署说明、API 清单、ER 图、截图和讲稿\nlegacy/ 旧版 Flask/Jinja2 单体快照,仅用于升级报告对照\n```\n\n可保留:\n\n| 路径 | 说明 |\n| --- | --- |\n| `backend/instance/` | 本地演示数据库运行目录;`nexus_prime.db` 由 seed 命令生成,不提交。 |\n| `backend/storage/uploads/.gitkeep`、`backend/storage/uploads/files/.gitkeep`、`backend/storage/uploads/avatars/.gitkeep`、`backend/storage/uploads/library/.gitkeep` | 保留上传目录结构;真实上传文件继续忽略。 |\n| `backend/logs/.gitkeep` | 保留日志目录结构。 |\n| `frontend/package-lock.json` | 前端依赖锁定文件。 |\n| `backend/migrations/` | 数据库迁移。 |\n| `docs/images/` | 正式报告截图。 |\n| `legacy/monolith-flask/` | 旧版单体快照,保留用于说明如何从 Flask/Jinja2/Railway 升级到前后端分离。 |\n\n禁止提交:\n\n```text\n.env\n.vscode/\ndata_export/\noutput/\nfrontend/output/\n.playwright-cli/\nfrontend/.angular/\nfrontend/dist/\nbackend/.pytest_cache/\n__pycache__/\nbackend/storage/uploads/*\nbackend/storage/uploads/files/*\nbackend/storage/uploads/avatars/*\nbackend/storage/uploads/library/*\nbackend/instance/*.db-wal\nbackend/instance/*.db-shm\n*.log\nnode_modules/\nvenv/\n```\n\n清理命令:\n\n```powershell\n.\\scripts\\clean-workspace.ps1 -StopDevServers\n```\n\n该命令会停止本地 5000/4200 开发端口并清理缓存、截图输出、日志、SQLite WAL/SHM 与 Python 缓存;默认保留上传目录和头像文件,避免误删本地头像。需要同时删除依赖目录时使用:\n\n```powershell\n.\\scripts\\clean-workspace.ps1 -StopDevServers -RemoveDependencies\n```\n\n删除依赖后重新安装:\n\n```powershell\n.\\scripts\\dev.ps1 -Install\n```\n\n或只恢复依赖:\n\n```powershell\n.\\scripts\\install-dependencies.ps1\n```\n\n`.\\scripts\\preflight.ps1` 与 `.\\scripts\\deploy-supabase-vercel.ps1` 也会在缺少依赖时自动恢复。需要只检查、不安装时,预检追加 `-NoInstall`。\n\n纯净交付状态下,只检查部署配置、不恢复依赖:\n\n```powershell\n.\\scripts\\preflight.ps1 -SkipApiProbe -SkipBuild -SkipBackendTests -NoInstall -AllowMissingDependencies\n```\n\n## 11. 常用验收命令\n\n后端:\n\n```powershell\ncd backend\n$env:FLASK_APP=\"run.py\"\npython -m flask status\npython -m flask audit-enterprise-data --strict\npython -m pytest\n```\n\n前端:\n\n```powershell\ncd frontend\nnpm test -- --watch=false\nnpm run build\n```\n\n生产健康检查:\n\n```powershell\nInvoke-RestMethod \"https://<backend-project>.vercel.app/api/v1/health\"\nInvoke-RestMethod \"https://<backend-project>.vercel.app/\"\n```\n\n## 12. 上线后检查清单\n\n1. 前端能打开 `https://<frontend-project>.vercel.app`。\n2. 后端 `GET /api/v1/health` 返回 `status=ok`。\n3. `NEXUS_API_BASE_URL` 指向后端 `/api/v1`,不是 localhost。\n4. 后端 `CORS_ORIGINS` 精确包含前端域名。\n5. 生产 Cookie 为 `Secure=true`、`SameSite=None`。\n6. 后端生产日志没有 SQLite 启动错误,`DATABASE_URL` 指向 PostgreSQL/Supabase。\n7. 普通用户不能访问 `/system/users`、`/system/audit`。\n8. 管理员可以登录、上传头像、下载文件、生成报表。\n9. AI 设置页可保存 `api_key`、`base_url`、`model`,`/ai/diagnostics` 能返回诊断结果。\n10. 文件和头像上传在生产环境使用 Cloudinary 或后续 Supabase Storage,不依赖 Vercel 临时磁盘。\n11. `scripts/preflight.ps1` 通过,不再出现旧托管平台配置。\n\n## 13. 2026-06-06 本地验收记录\n\n以下命令已在本地通过,可作为部署前参考基线:\n\n```powershell\ncd frontend\nnpm run build\nnpm run audit:charts\nnpm run audit:layout\n```\n\n```powershell\ncd backend\n$env:FLASK_APP=\"run.py\"\n..\\venv\\Scripts\\python.exe -m pytest\n..\\venv\\Scripts\\python.exe -m flask audit-enterprise-data --strict\n```\n\n```powershell\n$env:NEXUS_API_BASE_URL=\"https://nexus-prime-api.vercel.app/api/v1\"\n$env:FRONTEND_ORIGIN=\"https://nexus-prime-web.vercel.app\"\n$env:CORS_ORIGINS=$env:FRONTEND_ORIGIN\n$env:DATABASE_URL=\"postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\"\n$env:AUTH_COOKIE_SECURE=\"true\"\n$env:AUTH_COOKIE_SAMESITE=\"None\"\n$env:SECRET_KEY=\"local-preflight-secret-key-32-characters\"\n$env:CLOUDINARY_URL=\"cloudinary://api_key:api_secret@cloud_name\"\n.\\scripts\\preflight.ps1 -SkipApiProbe -SkipBuild -SkipBackendTests\n```\n\n本次页面与布局层验收结果:\n\n| 项目 | 结果 |\n| --- | --- |\n| 布局审计 | 66 个页面检查通过,无文本溢出、无按钮出界、无图表过小、无 Dock 遮挡。 |\n| 图表审计 | 36 个页面文件通过。 |\n| 数据审计 | 企业数据覆盖与业务闭环均为 OK。 |\n| 后端测试 | 46 项测试全部通过,包含采购协同控制台合同、部署就绪成熟度合同与任务创建。 |\n| 前端测试 | 5 个测试文件、15 个用例全部通过。 |\n| 前端构建 | `npm run build` 生产构建通过,输出目录为 `frontend/dist/frontend/browser`。 |\n| 部署就绪与 ERP 成熟度审计 | `npm run audit:deployment-readiness` 通过,ERP 成熟度 91%,secret 泄露候选、横向溢出、控制台错误和 HTTP 错误均为 0。 |\n| 部署预检 | Supabase、Vercel、Cookie、环境变量和旧平台残留检查全部通过。 |\n\n2026-06-07 维护补充:\n\n- 前端 API 地址解析已集中到 `frontend/src/app/core/api-url.ts`,文件下载、详情页下载和通用 API 请求共用同一套运行时配置。\n- 后端生产配置已增加启动保护:缺少 `DATABASE_URL`、`CORS_ORIGINS`、强 `SECRET_KEY` 或跨站 Cookie 安全配置错误时直接失败;正式生产默认拒绝 SQLite。\n- 外部 AI 调用默认连接超时 5 秒、请求超时 20 秒,健康检查会返回当前超时配置。\n- 本地回归通过:后端 `pytest -q` 46 项、前端 `npm test -- --watch=false` 6 个文件 19 个用例、前端 `npm run build`。\n\n本轮额外确认:\n\n- `frontend/public/runtime-config.js` 已恢复为空 API 值,生产由 `NEXUS_API_BASE_URL` 注入。\n- `backend/instance/nexus_prime.db` 不再作为可提交资产;部署和交付检查改为依赖迁移、seed、同步脚本和 Supabase 状态检查。\n- 文件中心移动端类型栏不再横向溢出,布局审计已覆盖。\n- 首页、登录页、注册页、AI 分析、报表工作室、文件中心和个人工作台已统一居中、图标对齐和响应式兜底。\n- 注册页已重新通过浏览器审查:左侧说明面板在注册模式隐藏,注册卡片单卡居中,横向溢出为 0。\n- 上传长期保存链路已重新确认:本地按 `files`、`avatars`、`library` 三类目录隔离,生产 Vercel/Serverless 必须配置 Cloudinary 或后续 Supabase Storage。\n- 本地演示数据库可通过 `seed-enterprise --scale 3 --multiplier 300 --reset --seed 20241334` 重建;本轮 `flask status` 显示用户 15001、商品 57608、销售订单 100803、文件 7200,运行数据库不提交。\n- 更多模块抽屉、Dock、顶部 AI 入口、全局设置、AI 设置与诊断仍保持独立路由和真实 API 调用边界。\n"}, "f72e3059e90b878ff7a860ab4e22a4c4619a88ba": {"t": 1785754650.8486145, "n": 30209, "h": "868ba27a754a51ba", "c": "# API 接口、Token 获取与 Supabase + Vercel 部署手册\n\n本手册覆盖 NEXUS Prime 当前前后端分离架构的部署步骤、第三方 Token 获取入口、本系统 API 认证方式和完整接口清单。所有业务接口默认以前缀 `/api/v1` 开头。\n\n正式部署或最终交付前,建议先运行:\n\n```powershell\n.\\scripts\\quality-gate.ps1\n```\n\n该闸门会把最终 DOCX 报告、截图资产、企业数据、前后端测试、生产构建、布局审计和部署预检串成一条流水线,避免 Token 已配置完成后才发现报告或页面资产不同步。\n\n## 1. 基础地址\n\n| 环境 | 地址 |\n| --- | --- |\n| 本地前端 | `http://127.0.0.1:4200` |\n| 本地后端 API | `http://127.0.0.1:5000/api/v1` |\n| 生产前端 | `https://<frontend-project>.vercel.app` |\n| 生产后端 API | `https://<backend-project>.vercel.app/api/v1` |\n| 后端根健康页 | `https://<backend-project>.vercel.app/` |\n\n后端统一返回:\n\n```json\n{\n \"data\": {},\n \"message\": \"操作结果\",\n \"error\": null\n}\n```\n\n通用分页字段:\n\n```json\n{\n \"items\": [],\n \"pagination\": {\n \"page\": 1,\n \"page_size\": 10,\n \"total\": 100,\n \"pages\": 10,\n \"has_next\": true,\n \"has_prev\": false\n }\n}\n```\n\n## 2. Token 与密钥获取网址\n\n| 用途 | 获取网址 | 项目变量 | 说明 |\n| --- | --- | --- | --- |\n| Vercel CLI/API Token | https://vercel.com/account/settings/tokens | `VERCEL_TOKEN` | 用于脚本无交互部署前端和后端。手动 `vercel login` 后可不填。 |\n| Vercel 项目控制台 | https://vercel.com/dashboard | 无固定变量 | 创建 `frontend` 与 `backend` 两个独立项目。 |\n| Supabase 项目列表 | https://supabase.com/dashboard/projects | 无固定变量 | 新建 PostgreSQL 项目。 |\n| Supabase 数据库连接串 | `https://supabase.com/dashboard/project/<project-ref>/settings/database` | `DATABASE_URL` | 复制 Pooler 连接串,保留 `sslmode=require`。 |\n| Supabase API Key | `https://supabase.com/dashboard/project/<project-ref>/settings/api` | 暂不必填 | 当前后端直连 PostgreSQL,不需要前端直连 Supabase。后续接 Storage 时再使用。 |\n| Supabase API Key 新入口 | `https://supabase.com/dashboard/project/<project-ref>/settings/api-keys` | 暂不必填 | 部分 Supabase 项目控制台使用该入口。 |\n| Supabase 连接串文档 | https://supabase.com/docs/reference/postgres/connection-strings | 无 | 核对 direct、session pooler、transaction pooler。 |\n| Supabase SSL 文档 | https://supabase.com/docs/guides/platform/ssl-enforcement | 无 | 生产连接必须使用 SSL。 |\n| DeepSeek API Key | https://platform.deepseek.com/api_keys | `DEEPSEEK_API_KEY` | AI 经营分析外部模型凭证。 |\n| DeepSeek API 文档 | https://api-docs.deepseek.com/ | `DEEPSEEK_BASE_URL` | 默认 `https://api.deepseek.com`,模型默认 `deepseek-chat`。 |\n| Cloudinary 控制台 | https://console.cloudinary.com/ | `CLOUDINARY_URL` 或拆分变量 | Vercel/Serverless 生产头像和文件长期保存必填。进入 Settings / API Keys 获取。 |\n| Cloudinary API Keys 入口 | https://console.cloudinary.com/settings/api-keys | `CLOUDINARY_CLOUD_NAME`、`CLOUDINARY_API_KEY`、`CLOUDINARY_API_SECRET` | 如该直达入口跳转异常,从控制台首页进入 Settings。 |\n| Sentry Auth Token | https://sentry.io/settings/account/api/auth-tokens/ | 可选 | 仅在需要读取 Sentry API 时使用。 |\n| Sentry DSN | https://sentry.io/settings/projects/ | `SENTRY_DSN` | 可选错误监控。进入具体项目的 Client Keys/DSN。 |\n| GitHub Token | https://github.com/settings/tokens | 可选 | Vercel 绑定 GitHub 通常走 OAuth,不需要手动 PAT。 |\n| GitHub Token 文档 | https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens | 可选 | 仅在自动化访问私有仓库时使用。 |\n\n### 2.1 一键复制用入口与变量\n\n下面内容可直接复制到部署检查表;尖括号内内容需要替换,所有 secret 只进入后端 Vercel 项目或本地脚本环境变量。\n\n```text\nVercel Token:\nhttps://vercel.com/account/settings/tokens\n变量: VERCEL_TOKEN\n用途: scripts/deploy-supabase-vercel.ps1 非交互部署\n```\n\n```text\nSupabase Database URL:\nhttps://supabase.com/dashboard/project/<project-ref>/settings/database\n变量: DATABASE_URL\n示例: postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\n用途: 后端连接生产 PostgreSQL\n```\n\n```text\nSupabase API Keys:\nhttps://supabase.com/dashboard/project/<project-ref>/settings/api-keys\n变量: 后续接 Supabase Storage 或 Edge API 时再配置\n边界: 当前前端不直连 Supabase,不需要把 Supabase key 放进 frontend\n```\n\n```text\nCloudinary API Keys:\nhttps://console.cloudinary.com/settings/api-keys\n变量: CLOUDINARY_URL\n示例: cloudinary://api_key:api_secret@cloud_name\n用途: Vercel/Serverless 生产头像和附件持久化\n```\n\n```text\nDeepSeek API Key:\nhttps://platform.deepseek.com/api_keys\n变量: DEEPSEEK_API_KEY\nBase URL: https://api.deepseek.com\n模型: deepseek-chat\n用途: 后端真实调用 OpenAI-compatible Chat Completions\n```\n\n```text\n前端 API 地址:\nhttps://vercel.com/docs/environment-variables\n变量: NEXUS_API_BASE_URL\n示例: https://<backend-project>.vercel.app/api/v1\n边界: 前端只放公开 API 地址,不放 DATABASE_URL、SECRET_KEY、AI Key、Cloudinary secret\n```\n\n常用命令模板:\n\n```powershell\n[Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }))\n```\n\n```powershell\nvercel env add DATABASE_URL production --sensitive\nvercel env add SECRET_KEY production --sensitive\nvercel env add CLOUDINARY_URL production --sensitive\nvercel env add DEEPSEEK_API_KEY production --sensitive\nvercel env add NEXUS_API_BASE_URL production\n```\n\n官方依据:\n\n- Vercel 环境变量用于在构建和函数运行时改变行为;敏感环境变量在创建后不可读,适合保存 Token、数据库连接串和服务端密钥。\n- Vercel CLI 支持 `vercel env add <NAME> production --sensitive`,一键部署脚本对 `DATABASE_URL`、`SECRET_KEY`、`DEEPSEEK_API_KEY`、`CLOUDINARY_URL` 等变量按敏感变量写入。\n- Supabase API Keys 位于项目 Dashboard 的 `Settings -> API Keys`;当前项目后端直连 PostgreSQL,浏览器端不需要 Supabase key。\n- Supabase 官方连接建议中,Serverless 或短生命周期函数使用 transaction pooler,常见端口为 `6543`;生产连接必须启用 SSL,当前脚本要求 `sslmode=require`。\n\n`SECRET_KEY` 不是外部平台 Token,应在本机生成并只放入后端环境变量:\n\n```powershell\n[Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }))\n```\n\n## 3. 部署前准备\n\n### 3.0 架构与资源边界\n\n当前项目按前后端分离维护:\n\n```text\nfrontend/ Angular SPA、页面交互、主题、真实图片资源、Vercel 前端项目\nbackend/ Flask REST API、认证权限、业务流程、数据库、AI 调用、文件与头像\nscripts/ 本地开发、清理、质量闸门、预检、Supabase/Vercel 一键部署\ndocs/ 部署文档、接口清单、架构审阅、截图与交付说明\n```\n\n部署前确认:\n\n- `frontend/src/app/core/navigation.ts` 统一维护 Dock、更多菜单、桌面/移动可见入口。\n- `frontend/src/app/core/visual-assets.ts` 统一登记真实图片资源。\n- `frontend/public/images/` 保留页面使用的制造、仓配、工业现场图片。\n- `frontend/src/styles.scss` 保留业务页面布局稳定层,只处理居中、溢出、图标对齐、分页、图表最小尺寸和移动端网格。\n- `frontend/src/auth-entry.scss` 独立维护首页、登录页和注册流程,避免认证入口继续被全局业务样式覆盖。\n- `backend/.env.example` 已包含 `FLASK_CONFIG=production` 与 `NEXUS_RUNTIME_DIR=/tmp/nexus-prime`。\n- 浏览器端只配置 `NEXUS_API_BASE_URL`,不得放入数据库、AI Key、Supabase secret key 或后端密钥。\n\n架构审阅见 `docs/architecture-maintenance-review.md`,项目规范见 `docs/project-standards.md`。\n\n### 3.1 安装本地工具\n\n1. 安装 Git、Git LFS、Node.js LTS、Python 3.11+。\n2. 在仓库根目录拉取 LFS 数据:\n\n```powershell\ngit lfs install\ngit lfs pull\n```\n\n3. 安装依赖并启动本地开发环境:\n\n```powershell\n.\\scripts\\dev.ps1 -Install\n```\n\n如果只需要从纯净仓库恢复依赖,不启动服务:\n\n```powershell\n.\\scripts\\install-dependencies.ps1\n```\n\n4. 本地演示账号:\n\n```text\nadmin@nexus.com / admin123\nuser00001@nexus.com / password123\n```\n\n远程 `-SeedRemoteWhenEmpty` 必须使用 `NEXUS_DEMO_ADMIN_PASSWORD` 和 `NEXUS_DEMO_USER_PASSWORD` 自定义密码,脚本会拒绝本地默认密码。\n\n### 3.2 创建 Supabase 数据库\n\n1. 打开 https://supabase.com/dashboard/projects。\n2. 创建新项目并记录 `<project-ref>`、数据库密码和区域。\n3. 打开 `Project Settings -> Database`,复制 PostgreSQL connection string。\n4. Vercel/Serverless 场景优先使用 Pooler 连接串。\n5. 确保连接串形态类似:\n\n```text\npostgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\n```\n\n### 3.3 创建两个 Vercel 项目\n\n后端项目:\n\n```text\nProject Root: backend\nFramework: Other / Python\n入口: backend/server.py\n健康检查: /api/v1/health\n```\n\n前端项目:\n\n```text\nProject Root: frontend\nFramework Preset: Angular\nBuild Command: npm run build\nOutput Directory: dist/frontend/browser\n```\n\n两个项目必须分开,前端只存前端环境变量,后端只存数据库、Cookie、AI、上传等服务端密钥。\n\n## 4. 环境变量\n\n### 4.1 后端 Vercel 项目\n\n必填:\n\n```env\nFLASK_ENV=production\nFLASK_CONFIG=production\nDATABASE_URL=postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\nSECRET_KEY=<至少32位强随机字符串>\nCORS_ORIGINS=https://<frontend-project>.vercel.app\nFRONTEND_ORIGIN=https://<frontend-project>.vercel.app\nAUTH_COOKIE_SECURE=true\nAUTH_COOKIE_SAMESITE=None\nLOGIN_RATE_LIMIT_ATTEMPTS=8\nLOGIN_RATE_LIMIT_WINDOW_SECONDS=600\nNEXUS_RUNTIME_DIR=/tmp/nexus-prime\nAI_LOCAL_ANALYSIS=true\nAI_REQUEST_TIMEOUT_SECONDS=20\nAI_CONNECT_TIMEOUT_SECONDS=5\n```\n\n生产上传必填:\n\n```env\nUSE_CLOUD_STORAGE=auto\nREQUIRE_CLOUD_STORAGE_FOR_UPLOADS=auto\nUPLOAD_FOLDER=/tmp/nexus-prime/storage/uploads\nUPLOAD_FILES_FOLDER=/tmp/nexus-prime/storage/uploads/files\nUPLOAD_AVATARS_FOLDER=/tmp/nexus-prime/storage/uploads/avatars\nUPLOAD_LIBRARY_FOLDER=/tmp/nexus-prime/storage/uploads/library\nCLOUDINARY_URL=cloudinary://api_key:api_secret@cloud_name\n```\n\nVercel/Serverless 后端没有长期本地磁盘,头像和文件上传必须配置 `CLOUDINARY_URL`。未配置时上传接口会返回 `persistent_storage_required`,避免刷新后文件丢失。独立长驻主机可使用本地三目录:附件 `UPLOAD_FILES_FOLDER`、头像 `UPLOAD_AVATARS_FOLDER`、资料库 `UPLOAD_LIBRARY_FOLDER`。\n\n可选:\n\n```env\nDEEPSEEK_API_KEY=<DeepSeek API Key>\nDEEPSEEK_BASE_URL=https://api.deepseek.com\nDEEPSEEK_MODEL=deepseek-chat\nAI_REQUEST_TIMEOUT_SECONDS=20\nAI_CONNECT_TIMEOUT_SECONDS=5\nSENTRY_DSN=<Sentry DSN>\n```\n\n生产后端会在启动时强校验关键变量:必须设置 `SECRET_KEY`、`DATABASE_URL`、`CORS_ORIGINS`;正式环境默认拒绝 SQLite,数据库应使用 PostgreSQL/Supabase。只有本地演练生产配置时,才可显式设置 `ALLOW_PRODUCTION_SQLITE=true`。\n\n### 4.2 前端 Vercel 项目\n\n只需要:\n\n```env\nNEXUS_API_BASE_URL=https://<backend-project>.vercel.app/api/v1\n```\n\n不要把 `DATABASE_URL`、`SECRET_KEY`、`DEEPSEEK_API_KEY`、`CLOUDINARY_API_SECRET`、Supabase `service_role` 放入前端项目。\n\n## 5. 本地预检\n\n在仓库根目录执行:\n\n```powershell\n$env:NEXUS_API_BASE_URL=\"https://<backend-project>.vercel.app/api/v1\"\n$env:FRONTEND_ORIGIN=\"https://<frontend-project>.vercel.app\"\n$env:CORS_ORIGINS=$env:FRONTEND_ORIGIN\n$env:DATABASE_URL=\"postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\"\n$env:AUTH_COOKIE_SECURE=\"true\"\n$env:AUTH_COOKIE_SAMESITE=\"None\"\n$env:SECRET_KEY=\"<至少32位强随机字符串>\"\n$env:CLOUDINARY_URL=\"cloudinary://api_key:api_secret@cloud_name\"\n$env:AI_REQUEST_TIMEOUT_SECONDS=\"20\"\n$env:AI_CONNECT_TIMEOUT_SECONDS=\"5\"\n.\\scripts\\preflight.ps1 -SkipApiProbe\n```\n\n真实 API 已部署后,去掉 `-SkipApiProbe`,脚本会访问:\n\n```text\nhttps://<backend-project>.vercel.app/api/v1/health\n```\n\n## 6. 一键部署\n\n建议按“先质量闸门、后 Token 配置、再部署”的顺序推进:先确认 `docs/final-delivery-report.docx`、`docs/images/final/`、ER 图、真实图片登记、测试和部署预检一致,再把 `VERCEL_TOKEN`、`DATABASE_URL`、`SECRET_KEY`、`CLOUDINARY_URL`、`DEEPSEEK_API_KEY` 写入对应项目或终端环境。\n\n先设置可选 Token:\n\n```powershell\n$env:VERCEL_TOKEN=\"<从 https://vercel.com/account/settings/tokens 获取>\"\n$env:DEEPSEEK_API_KEY=\"<从 https://platform.deepseek.com/api_keys 获取,可选>\"\n$env:SECRET_KEY=\"<至少32位强随机字符串>\"\n$env:CLOUDINARY_URL=\"<从 Cloudinary 控制台复制的 cloudinary://api_key:api_secret@cloud_name>\"\n$env:NEXUS_DEMO_ADMIN_PASSWORD=\"<远程演示管理员密码>\"\n$env:NEXUS_DEMO_USER_PASSWORD=\"<远程演示普通用户密码>\"\n```\n\n执行完整部署:\n\n```powershell\n.\\scripts\\deploy-supabase-vercel.ps1 `\n -DatabaseUrl \"postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\" `\n -BackendProjectName \"nexus-prime-api\" `\n -FrontendProjectName \"nexus-prime-web\" `\n -ApiBaseUrl \"https://nexus-prime-api.vercel.app/api/v1\" `\n -FrontendOrigin \"https://nexus-prime-web.vercel.app\" `\n -SeedRemoteWhenEmpty `\n -SecretKey $env:SECRET_KEY `\n -CloudinaryUrl $env:CLOUDINARY_URL\n```\n\n只演练流程、不真正发布:\n\n```powershell\n.\\scripts\\deploy-supabase-vercel.ps1 `\n -DatabaseUrl \"postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\" `\n -BackendProjectName \"nexus-prime-api\" `\n -FrontendProjectName \"nexus-prime-web\" `\n -SecretKey $env:SECRET_KEY `\n -CloudinaryUrl $env:CLOUDINARY_URL `\n -SkipMigrations `\n -SkipDataVerify `\n -SkipBackendDeploy `\n -SkipFrontendDeploy `\n -SkipApiProbe\n```\n\n脚本执行顺序:\n\n1. 自动恢复缺失的 `venv/` 与 `frontend/node_modules/`。\n2. 校验 Node、npm、npx、Python venv、Vercel CLI 输入。\n3. 运行 `scripts/preflight.ps1`。\n4. 执行 `flask db upgrade` 升级 Supabase schema。\n5. 可选同步 `backend/instance/nexus_prime.db` 到 Supabase。\n6. 配置后端 Vercel 环境变量并部署后端。\n7. 配置前端 Vercel 环境变量并部署前端。\n8. 请求 `/api/v1/health` 做上线探测。\n\n## 7. 本系统 API 认证方式\n\n### 7.1 Cookie + CSRF\n\n正常前端使用 HttpOnly Cookie,不需要手工保存访问 Token。登录后后端设置:\n\n| 名称 | 类型 | 用途 |\n| --- | --- | --- |\n| `nexus_access_token` | HttpOnly Cookie | 登录态 JWT,浏览器脚本不可读取 |\n| `nexus_csrf_token` | 普通 Cookie | 写请求 CSRF 校验 |\n| `X-CSRF-Token` | Header | `POST/PUT/PATCH/DELETE` 必须携带 |\n\n公开接口不需要登录。登录后接口需要 Cookie。写接口在 Cookie 模式下必须带 `X-CSRF-Token`。\n\n### 7.2 Bearer Token\n\n`POST /auth/login` 响应体里也会返回 `data.token`。脚本或外部系统可以使用:\n\n```http\nAuthorization: Bearer <data.token>\n```\n\n使用 Bearer Token 时,后端不要求 CSRF Header。生产前端仍建议使用 Cookie + CSRF。\n\n### 7.3 PowerShell 调用示例\n\n```powershell\n$base = \"http://127.0.0.1:5000/api/v1\"\n$demoAdminPassword = $env:NEXUS_DEMO_ADMIN_PASSWORD\nif (-not $demoAdminPassword) { $demoAdminPassword = \"admin123\" } # 仅本地演示默认值\n$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession\n\n$csrf = (Invoke-RestMethod \"$base/auth/csrf\" -WebSession $session).data.csrf_token\n\n$login = Invoke-RestMethod \"$base/auth/login\" `\n -Method Post `\n -ContentType \"application/json\" `\n -WebSession $session `\n -Body (@{ email=\"admin@nexus.com\"; password=$demoAdminPassword } | ConvertTo-Json)\n\n$csrf = $login.data.csrf_token\n\nInvoke-RestMethod \"$base/products?page=1&page_size=10&q=轴承\" -WebSession $session\n\nInvoke-RestMethod \"$base/me/preferences\" `\n -Method Put `\n -ContentType \"application/json\" `\n -WebSession $session `\n -Headers @{ \"X-CSRF-Token\" = $csrf } `\n -Body (@{ theme=\"light-luxury\"; density=\"comfortable\" } | ConvertTo-Json)\n```\n\n### 7.4 注册调用流程\n\n1. `GET /auth/register-policy` 获取许可版本、必选勾选项和可展开的《服务许可》《隐私说明》《数据使用范围》。\n2. `GET /auth/captcha` 获取验证码图片、`captcha_token`、`terms_version`。\n3. `POST /auth/register` 提交注册信息。\n\n注册请求体示例:\n\n```json\n{\n \"username\": \"demo-user\",\n \"email\": \"demo@example.com\",\n \"password\": \"replace-with-demo-password\",\n \"full_name\": \"演示用户\",\n \"phone\": \"13800000000\",\n \"position\": \"业务协同成员\",\n \"accepted_terms\": true,\n \"accepted_privacy\": true,\n \"accepted_data_scope\": true,\n \"terms_version\": \"2026.06\",\n \"captcha_token\": \"<GET /auth/captcha 返回>\",\n \"captcha_answer\": \"<用户识别结果>\"\n}\n```\n\n### 7.5 AI 设置调用流程\n\n1. `GET /ai/settings` 读取当前分析模式、Base URL、模型和凭证配置状态。\n2. `PUT /ai/settings` 保存 `analysis_mode`、`ai_api_base`、`ai_model`、`ai_api_key`。\n3. `POST /ai/diagnostics` 检查本地分析引擎和外部模型连通性。\n4. `POST /ai/chat` 发起经营分析。`hybrid` 模式会优先外部模型,失败时回退本地经营分析。\n\n浏览器端只提交配置到后端,不保存数据库连接串、Supabase secret、Vercel Token 或 Cloudinary secret。\n\n## 8. 权限说明\n\n| 权限点 | 含义 |\n| --- | --- |\n| `admin` | 系统管理 |\n| `masterdata.write` | 主数据维护 |\n| `inventory.adjust` | 库存调整 |\n| `purchase.write` | 采购创建 |\n| `purchase.approve` | 采购审批 |\n| `purchase.receive` | 采购收货 |\n| `sales.write` | 销售履约 |\n| `finance.payment` | 收款处理 |\n| `finance.credit.write` | 信用管理 |\n| `stocktake.write` | 盘点管理 |\n| `reports.generate` | 报表生成 |\n| `files.manage` | 文件管理 |\n| `content.write` | 内容管理 |\n\n管理员账号具备全部权限。普通账号默认只能访问非管理员资源,写入动作由角色权限控制。\n\n## 9. 完整 API 接口清单\n\n除 `GET /` 外,以下路径均相对于 `/api/v1`。\n\n### 9.1 根与健康检查\n\n| 方法 | 路径 | 说明 | 认证 |\n| --- | --- | --- | --- |\n| GET | `/` | 后端根健康页 | 否 |\n| GET | `/health` | API 健康检查 | 否 |\n\n### 9.2 认证、注册、个人资料\n\n| 方法 | 路径 | 说明 | 认证 |\n| --- | --- | --- | --- |\n| GET | `/auth/csrf` | 获取 CSRF Token | 否 |\n| GET | `/auth/captcha` | 获取注册验证码 | 否 |\n| GET | `/auth/register-policy` | 获取注册许可策略 | 否 |\n| POST | `/auth/register` | 注册账号并建立会话 | 否 |\n| POST | `/auth/login` | 登录并设置 Cookie | 否 |\n| POST | `/auth/logout` | 退出登录并清除 Cookie | 是 |\n| GET | `/auth/me` | 当前用户信息并刷新 Cookie | 是 |\n| POST | `/auth/change-password` | 修改密码 | 是 |\n| PUT | `/me/profile` | 更新个人资料 | 是 |\n| POST | `/me/avatar` | 上传头像,multipart `file` | 是 |\n| DELETE | `/me/avatar` | 删除头像并恢复默认 | 是 |\n| GET | `/avatars/<filename>` | 读取本地头像 | 否 |\n| GET | `/avatars/initials/<key>` | 生成默认首字母头像 | 否 |\n| GET | `/me/preferences` | 获取用户偏好 | 是 |\n| PUT | `/me/preferences` | 保存主题、布局、图表、Dock 等偏好 | 是 |\n\n### 9.3 总览、图表、导航、搜索\n\n| 方法 | 路径 | 说明 | 认证 |\n| --- | --- | --- | --- |\n| GET | `/dashboard/summary` | 仪表盘汇总 | 是 |\n| GET | `/dashboard/charts` | 仪表盘图表数据 | 是 |\n| GET | `/overview/summary` | 新版总览汇总 | 是 |\n| GET | `/overview/charts` | 新版总览图表数据 | 是 |\n| GET | `/analytics/executive` | 经营分析指标 | 是 |\n| GET | `/manufacturing/command-center` | 制造仓配指挥数据 | 是 |\n| GET | `/meta/navigation` | 导航元数据与资源数量 | 是 |\n| GET | `/search?q=<keyword>` | 全局搜索 | 是 |\n\n### 9.4 通用 CRUD 资源接口\n\n通用查询参数:\n\n| 参数 | 说明 |\n| --- | --- |\n| `page` | 页码,默认 `1` |\n| `page_size` / `per_page` | 每页条数,最大 `100` |\n| `q` | 模糊搜索 |\n| `sort` | 排序字段 |\n| `order` | `asc` 或 `desc` |\n| 其他字段 | 若模型存在同名字段,会按等值过滤 |\n\n通用方法:\n\n| 方法 | 路径 | 说明 |\n| --- | --- | --- |\n| GET | `/<resource>` | 资源列表 |\n| GET | `/<resource>/<id>` | 资源详情 |\n| POST | `/<resource>` | 创建资源 |\n| PUT/PATCH | `/<resource>/<id>` | 更新资源 |\n| DELETE | `/<resource>/<id>` | 软删除资源 |\n\n可用资源:\n\n| Resource | 说明 | 写入权限 |\n| --- | --- | --- |\n| `users` | 用户 | 管理员 |\n| `roles` | 角色 | 管理员 |\n| `departments` | 部门 | 管理员 |\n| `categories` | 分类 | `masterdata.write` |\n| `partners` | 客户/供应商伙伴 | `masterdata.write` |\n| `products` | 物料与成品 | `masterdata.write` |\n| `warehouses` | 仓库 | `inventory.adjust` |\n| `stock` | 库存 | `inventory.adjust` |\n| `inventory-logs` | 库存流水 | 登录 |\n| `orders` | 销售订单 | `sales.write` |\n| `order-items` | 销售订单明细 | `sales.write` |\n| `purchase-orders` | 采购单 | `purchase.write` |\n| `purchase-order-items` | 采购明细 | `purchase.write` |\n| `supplier-performance` | 供应商绩效 | 登录 |\n| `receivables` | 应收 | `finance.payment` |\n| `payments` | 收款记录 | `finance.payment` |\n| `statements` | 对账单 | `reports.generate` |\n| `credits` | 客户信用 | `finance.credit.write` |\n| `stocktakes` | 盘点单 | `stocktake.write` |\n| `stocktake-items` | 盘点明细 | `stocktake.write` |\n| `notifications` | 通知 | 本人/管理员 |\n| `stock-alerts` | 库存预警 | `inventory.adjust` |\n| `replenishment-suggestions` | 补货建议 | `purchase.write` |\n| `report-subscriptions` | 报表订阅 | 本人/管理员 |\n| `generated-reports` | 已生成报表 | 本人/管理员 |\n| `reports` | `generated-reports` 别名 | 本人/管理员 |\n| `articles` | 公告文章 | `content.write` |\n| `article-comments` | 文章评论 | 本人/管理员 |\n| `files` | 文件记录 | 本人/管理员 |\n| `audit-logs` | 审计日志 | 管理员 |\n| `ai-sessions` | AI 分析会话 | 本人/管理员 |\n| `ai-messages` | AI 消息 | 本人/管理员 |\n\n新版路径别名:\n\n| 新路径 | 映射资源 |\n| --- | --- |\n| `/inventory/products` | `products` |\n| `/inventory/stock` | `stock` |\n| `/inventory/replenishment-suggestions` | `replenishment-suggestions` |\n| `/sales/orders` | `orders` |\n| `/procurement/orders` | `purchase-orders` |\n| `/finance/receivables` | `receivables` |\n| `/stocktakes` | `stocktakes` |\n| `/notifications` | `notifications` |\n| `/reports` | `generated-reports` |\n| `/content/articles` | `articles` |\n| `/files` | `files` |\n| `/ai/sessions` | `ai-sessions` |\n| `/system/users` | `users` |\n| `/system/audit` | `audit-logs` |\n\n这些别名同样支持:\n\n| 方法 | 路径 |\n| --- | --- |\n| GET/POST | `/<path:new_path>` |\n| GET/PUT/PATCH/DELETE | `/<path:new_path>/<id>` |\n\n### 9.5 库存、补货、仓配\n\n| 方法 | 路径 | 说明 | 权限 |\n| --- | --- | --- | --- |\n| GET | `/inventory/health` | 库存健康度 | 登录 |\n| POST | `/inventory/adjust` | 库存调整 | `inventory.adjust` |\n| POST | `/stock-alerts/check` | 生成/检查库存预警 | `inventory.adjust` |\n| POST | `/replenishment-suggestions/generate` | 生成补货建议 | `purchase.write` |\n| POST | `/replenishment-suggestions/<id>/accept` | 补货建议转采购 | `purchase.write` |\n| POST | `/inventory/replenishment-suggestions/generate` | 新路径:生成补货建议 | `purchase.write` |\n| POST | `/inventory/replenishment-suggestions/<id>/accept` | 新路径:补货建议转采购 | `purchase.write` |\n\n### 9.6 采购、销售、应收、信用\n\n| 方法 | 路径 | 说明 | 权限 |\n| --- | --- | --- | --- |\n| GET | `/procurement/summary` | 采购汇总 | 登录 |\n| POST | `/purchase-orders/<id>/submit` | 提交采购单 | `purchase.write` |\n| POST | `/purchase-orders/<id>/approve` | 审批采购单 | `purchase.approve` |\n| POST | `/purchase-orders/<id>/reject` | 驳回采购单 | `purchase.approve` |\n| POST | `/purchase-orders/<id>/receive` | 采购收货 | `purchase.receive` |\n| POST | `/procurement/orders/<id>/submit` | 新路径:提交采购单 | `purchase.write` |\n| POST | `/procurement/orders/<id>/approve` | 新路径:审批采购单 | `purchase.approve` |\n| POST | `/procurement/orders/<id>/reject` | 新路径:驳回采购单 | `purchase.approve` |\n| POST | `/procurement/orders/<id>/receive` | 新路径:采购收货 | `purchase.receive` |\n| POST | `/sales/orders` | 创建销售订单 | `sales.write` |\n| POST | `/sales/orders/<id>/transition` | 销售订单状态流转 | `sales.write` |\n| GET | `/finance/receivables/aging` | 应收账龄分析 | 登录 |\n| POST | `/receivables/<id>/payment` | 记录收款 | `finance.payment` |\n| POST | `/finance/receivables/<id>/payment` | 新路径:记录收款 | `finance.payment` |\n| POST | `/finance/receivables/<id>/reminder` | 创建催款提醒 | `finance.payment` |\n| GET | `/finance/credits` | 客户信用列表 | 登录 |\n| PUT | `/finance/credits/<id>` | 调整信用额度/预警线 | `finance.credit.write` |\n| POST | `/finance/credits/<id>/freeze` | 冻结客户信用 | `finance.credit.write` |\n| POST | `/finance/credits/<id>/unfreeze` | 解冻客户信用 | `finance.credit.write` |\n| POST | `/statements/generate` | 生成对账单 | `reports.generate` |\n\n### 9.7 盘点\n\n| 方法 | 路径 | 说明 | 权限 |\n| --- | --- | --- | --- |\n| POST | `/stocktakes/create` | 创建盘点单 | `stocktake.write` |\n| POST | `/stocktakes/<id>/start` | 开始盘点 | `stocktake.write` |\n| POST | `/stocktakes/<id>/count` | 批量录入盘点数量 | `stocktake.write` |\n| POST | `/stocktake-items/<id>/count` | 录入单个盘点明细数量 | `stocktake.write` |\n| POST | `/stocktakes/<id>/complete` | 完成盘点 | `stocktake.write` |\n| GET | `/stocktakes/<id>/variance` | 盘点差异汇总 | 登录 |\n\n### 9.8 报表、导出、文件、内容\n\n| 方法 | 路径 | 说明 | 权限 |\n| --- | --- | --- | --- |\n| GET | `/reports/types` | 报表类型 | 登录 |\n| POST | `/reports/generate/<report_type>` | 生成报表 | `reports.generate` |\n| GET | `/export/<resource>/<format_type>` | 导出资源,支持 `csv/excel/pdf` | 资源读取权限 |\n| POST | `/files/upload` | 上传文件,multipart `file` | 登录 |\n| GET | `/files/<id>/download` | 下载文件 | 本人/管理员 |\n| POST | `/files/bulk-delete` | 批量删除文件 | `files.manage` |\n| GET | `/articles/<id>/comments` | 文章评论列表 | 登录 |\n| POST | `/articles/<id>/comments` | 新增文章评论 | 登录 |\n\n### 9.9 通知、查询、批量动作、字典\n\n| 方法 | 路径 | 说明 | 权限 |\n| --- | --- | --- | --- |\n| POST | `/notifications/mark-read` | 批量标记通知已读 | 本人/管理员 |\n| POST | `/bulk-actions` | 批量业务动作 | 依动作而定 |\n| GET | `/lookups/products` | 商品下拉搜索 | 登录 |\n| GET | `/lookups/partners` | 客户/供应商下拉搜索 | 登录 |\n| GET | `/lookups/warehouses` | 仓库下拉搜索 | 登录 |\n| GET | `/lookups/stock-locations` | 库存库位下拉搜索 | 登录 |\n\n`POST /bulk-actions` 当前支持:\n\n| Action | 说明 | 权限 |\n| --- | --- | --- |\n| `products.delete` | 批量删除商品 | `masterdata.write` |\n| `orders.update_status` | 批量更新订单状态 | `sales.write` |\n| `purchase_orders.approve` | 批量审批采购 | `purchase.approve` |\n| `notifications.mark_read` | 批量已读 | 本人/管理员 |\n| `files.delete` | 批量删除文件 | `files.manage` |\n| `operations.dispatch_task` | 创建仓配调度任务 | 登录 |\n| `operations.data_quality_notice` | 创建数据质量通知 | 登录 |\n| `operations.customer_followup` | 创建客户跟进任务 | 登录 |\n| `operations.capacity_plan` | 创建产能计划任务 | 登录 |\n| `operations.maintenance_workorder` | 创建设备维护工单 | 登录 |\n\n### 9.10 AI 经营分析\n\n| 方法 | 路径 | 说明 | 权限 |\n| --- | --- | --- | --- |\n| GET | `/ai/sessions` | 分析会话列表 | 本人 |\n| POST | `/ai/sessions` | 创建分析会话 | 本人 |\n| GET | `/ai/sessions/<id>/messages` | 会话消息列表 | 本人 |\n| POST | `/ai/chat` | 发送经营分析消息 | 本人 |\n| POST | `/ai/analyze/inventory` | 库存风险分析 | 本人 |\n| POST | `/ai/analyze/structured` | 结构化经营分析 | 本人 |\n| GET | `/ai/settings` | 读取 AI 设置 | 本人 |\n| PUT | `/ai/settings` | 保存 API Key、Base URL、模型、分析模式 | 本人 |\n| POST | `/ai/diagnostics` | 外部模型连通性诊断 | 本人 |\n\nAI 设置请求体示例:\n\n```json\n{\n \"analysis_mode\": \"hybrid\",\n \"ai_api_base\": \"https://api.deepseek.com\",\n \"ai_model\": \"deepseek-chat\",\n \"ai_api_key\": \"sk-...\"\n}\n```\n\n`analysis_mode` 可选:\n\n| 值 | 说明 |\n| --- | --- |\n| `local` | 只使用本地经营分析 |\n| `hybrid` | 优先外部模型,失败时回退本地分析 |\n| `external` | 只使用外部模型 |\n\n补充说明:\n\n- `GET /ai/settings` 会返回当前模式、外部 Base URL、模型名、是否已配置用户凭证。\n- `POST /ai/diagnostics` 会检测本地引擎和外部推理服务的连通性。\n- `POST /ai/chat` 在 `hybrid` 模式下会先尝试外部推理,失败时回退本地经营引擎并保留可追踪来源;`external` 模式下外部服务不可用会返回 `502 ai_provider_unavailable`。\n- 外部模型调用默认连接超时 5 秒、请求总超时 20 秒,可用 `AI_CONNECT_TIMEOUT_SECONDS` 和 `AI_REQUEST_TIMEOUT_SECONDS` 调整,范围会被限制在 3 到 60 秒之间。\n\n### 9.11 运营流程、规则、集成、成本、设备维护、移动终端\n\n| 方法 | 路径 | 说明 | 权限 |\n| --- | --- | --- | --- |\n| GET | `/operations/todo` | 运营待办 | 登录 |\n| GET | `/operations/exceptions` | 运营异常 | 登录 |\n| GET | `/operations/task-queue` | 任务异常中心当班队列,聚合通知、部署预检、库存预警和采购审批 | 登录 |\n| GET | `/operations/deployment-readiness` | 部署就绪分、环境边界、ERP 成熟度、能力域地图、微服务拓扑、交付证据和上线 Runbook | 登录 |\n| POST | `/operations/deployment-readiness/task` | 创建部署预检通知任务并写入审计 | 登录 |\n| GET | `/operations/rules` | 规则治理工作台:DMN 决策表、风险队列、负责人、SLA、Runbook、服务边界 | 登录 |\n| POST | `/operations/rules/review` | 创建规则复核通知任务并写入审计 | 登录 |\n| GET | `/operations/integrations` | 集成监控数据 | 登录 |\n| POST | `/operations/integrations/resync` | 创建接口重同步任务 | 登录 |\n| GET | `/operations/data-quality` | 数据质量治理体检、维度评分、整改队列、Runbook 和血缘链路 | 登录 |\n| POST | `/operations/data-quality/remediation` | 创建数据质量整改通知任务并写入审计 | 登录 |\n| GET | `/operations/costs` | 预算成本数据 | 登录 |\n| POST | `/operations/costs/review` | 创建成本复核任务 | 登录 |\n| GET | `/operations/capacity` | 产能计划治理数据:需求、供给、齐套、工作中心、瓶颈队列、服务边界和 Runbook | 登录 |\n| POST | `/operations/capacity/review` | 创建产能瓶颈复核任务并写入审计 | 登录 |\n| GET | `/operations/maintenance` | 设备可靠性治理工作台:资产线、维护工单、MRO 备件、维修人员、停机窗口、服务边界和 Runbook | 登录 |\n| POST | `/operations/maintenance-workorder` | 创建设备维护工单 | 登录 |\n| GET | `/operations/mobile-terminal` | 移动扫码终端任务 | 登录 |\n| POST | `/operations/mobile-terminal/task` | 创建现场扫码任务 | 登录 |\n| GET | `/operations/quality-inspection` | 质量检验治理工作台:检验批、供应商质量、缺陷分类、使用决策、证据、服务边界和 Runbook | 登录 |\n| POST | `/operations/quality-inspection` | 创建质量检验任务并写入通知与审计 | 登录 |\n| GET | `/operations/procurement-control` | 采购协同控制台:采购泳道、审批队列、到货窗口、供应商风险、补货候选、服务边界和部署检查 | 登录 |\n| POST | `/operations/procurement-control/task` | 创建采购协同通知任务并写入审计 | 登录 |\n| POST | `/operations/dispatch-task` | 创建仓配调度任务 | 登录 |\n| POST | `/operations/data-quality-notice` | 创建数据质量复核任务 | 登录 |\n| POST | `/operations/customer-followup` | 创建客户跟进任务 | 登录 |\n| POST | `/operations/capacity-plan` | 创建产能计划任务 | 登录 |\n| POST | `/operations/contract-review` | 创建合同回款复核任务 | 登录 |\n| POST | `/operations/service-workorder` | 创建售后服务工单 | 登录 |\n\n## 10. 仓库纯净化与本地清理\n\n当前活跃项目和保留对照目录:\n\n```text\nbackend/ Flask REST API、模型、迁移、测试、后端部署配置\nfrontend/ Angular SPA、主题、页面、前端部署配置\nscripts/ 本地启动、清理、预检、部署脚本\ndocs/ 交付报告、部署说明、API 清单、ER 图、截图和讲稿\nlegacy/ 旧版 Flask/Jinja2 单体快照,仅用于升级报告对照\n```\n\n可保留:\n\n| 路径 | 说明 |\n| --- | --- |\n| `backend/instance/` | 本地演示数据库运行目录;`nexus_prime.db` 由 seed 命令生成,不提交。 |\n| `backend/storage/uploads/.gitkeep`、`backend/storage/uploads/files/.gitkeep`、`backend/storage/uploads/avatars/.gitkeep`、`backend/storage/uploads/library/.gitkeep` | 保留上传目录结构;真实上传文件继续忽略。 |\n| `backend/logs/.gitkeep` | 保留日志目录结构。 |\n| `frontend/package-lock.json` | 前端依赖锁定文件。 |\n| `backend/migrations/` | 数据库迁移。 |\n| `docs/images/` | 正式报告截图。 |\n| `legacy/monolith-flask/` | 旧版单体快照,保留用于说明如何从 Flask/Jinja2/Railway 升级到前后端分离。 |\n\n禁止提交:\n\n```text\n.env\n.vscode/\ndata_export/\noutput/\nfrontend/output/\n.playwright-cli/\nfrontend/.angular/\nfrontend/dist/\nbackend/.pytest_cache/\n__pycache__/\nbackend/storage/uploads/*\nbackend/storage/uploads/files/*\nbackend/storage/uploads/avatars/*\nbackend/storage/uploads/library/*\nbackend/instance/*.db-wal\nbackend/instance/*.db-shm\n*.log\nnode_modules/\nvenv/\n```\n\n清理命令:\n\n```powershell\n.\\scripts\\clean-workspace.ps1 -StopDevServers\n```\n\n该命令会停止本地 5000/4200 开发端口并清理缓存、截图输出、日志、SQLite WAL/SHM 与 Python 缓存;默认保留上传目录和头像文件,避免误删本地头像。需要同时删除依赖目录时使用:\n\n```powershell\n.\\scripts\\clean-workspace.ps1 -StopDevServers -RemoveDependencies\n```\n\n删除依赖后重新安装:\n\n```powershell\n.\\scripts\\dev.ps1 -Install\n```\n\n或只恢复依赖:\n\n```powershell\n.\\scripts\\install-dependencies.ps1\n```\n\n`.\\scripts\\preflight.ps1` 与 `.\\scripts\\deploy-supabase-vercel.ps1` 也会在缺少依赖时自动恢复。需要只检查、不安装时,预检追加 `-NoInstall`。\n\n纯净交付状态下,只检查部署配置、不恢复依赖:\n\n```powershell\n.\\scripts\\preflight.ps1 -SkipApiProbe -SkipBuild -SkipBackendTests -NoInstall -AllowMissingDependencies\n```\n\n## 11. 常用验收命令\n\n后端:\n\n```powershell\ncd backend\n$env:FLASK_APP=\"run.py\"\npython -m flask status\npython -m flask audit-enterprise-data --strict\npython -m pytest\n```\n\n前端:\n\n```powershell\ncd frontend\nnpm test -- --watch=false\nnpm run build\n```\n\n生产健康检查:\n\n```powershell\nInvoke-RestMethod \"https://<backend-project>.vercel.app/api/v1/health\"\nInvoke-RestMethod \"https://<backend-project>.vercel.app/\"\n```\n\n## 12. 上线后检查清单\n\n1. 前端能打开 `https://<frontend-project>.vercel.app`。\n2. 后端 `GET /api/v1/health` 返回 `status=ok`。\n3. `NEXUS_API_BASE_URL` 指向后端 `/api/v1`,不是 localhost。\n4. 后端 `CORS_ORIGINS` 精确包含前端域名。\n5. 生产 Cookie 为 `Secure=true`、`SameSite=None`。\n6. 后端生产日志没有 SQLite 启动错误,`DATABASE_URL` 指向 PostgreSQL/Supabase。\n7. 普通用户不能访问 `/system/users`、`/system/audit`。\n8. 管理员可以登录、上传头像、下载文件、生成报表。\n9. AI 设置页可保存 `api_key`、`base_url`、`model`,`/ai/diagnostics` 能返回诊断结果。\n10. 文件和头像上传在生产环境使用 Cloudinary 或后续 Supabase Storage,不依赖 Vercel 临时磁盘。\n11. `scripts/preflight.ps1` 通过,不再出现旧托管平台配置。\n\n## 13. 2026-06-06 本地验收记录\n\n以下命令已在本地通过,可作为部署前参考基线:\n\n```powershell\ncd frontend\nnpm run build\nnpm run audit:charts\nnpm run audit:layout\n```\n\n```powershell\ncd backend\n$env:FLASK_APP=\"run.py\"\n..\\venv\\Scripts\\python.exe -m pytest\n..\\venv\\Scripts\\python.exe -m flask audit-enterprise-data --strict\n```\n\n```powershell\n$env:NEXUS_API_BASE_URL=\"https://nexus-prime-api.vercel.app/api/v1\"\n$env:FRONTEND_ORIGIN=\"https://nexus-prime-web.vercel.app\"\n$env:CORS_ORIGINS=$env:FRONTEND_ORIGIN\n$env:DATABASE_URL=\"postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres?sslmode=require\"\n$env:AUTH_COOKIE_SECURE=\"true\"\n$env:AUTH_COOKIE_SAMESITE=\"None\"\n$env:SECRET_KEY=\"local-preflight-secret-key-32-characters\"\n$env:CLOUDINARY_URL=\"cloudinary://api_key:api_secret@cloud_name\"\n.\\scripts\\preflight.ps1 -SkipApiProbe -SkipBuild -SkipBackendTests\n```\n\n本次页面与布局层验收结果:\n\n| 项目 | 结果 |\n| --- | --- |\n| 布局审计 | 66 个页面检查通过,无文本溢出、无按钮出界、无图表过小、无 Dock 遮挡。 |\n| 图表审计 | 36 个页面文件通过。 |\n| 数据审计 | 企业数据覆盖与业务闭环均为 OK。 |\n| 后端测试 | 46 项测试全部通过,包含采购协同控制台合同、部署就绪成熟度合同与任务创建。 |\n| 前端测试 | 5 个测试文件、15 个用例全部通过。 |\n| 前端构建 | `npm run build` 生产构建通过,输出目录为 `frontend/dist/frontend/browser`。 |\n| 部署就绪与 ERP 成熟度审计 | `npm run audit:deployment-readiness` 通过,ERP 成熟度 91%,secret 泄露候选、横向溢出、控制台错误和 HTTP 错误均为 0。 |\n| 部署预检 | Supabase、Vercel、Cookie、环境变量和旧平台残留检查全部通过。 |\n\n2026-06-07 维护补充:\n\n- 前端 API 地址解析已集中到 `frontend/src/app/core/api-url.ts`,文件下载、详情页下载和通用 API 请求共用同一套运行时配置。\n- 后端生产配置已增加启动保护:缺少 `DATABASE_URL`、`CORS_ORIGINS`、强 `SECRET_KEY` 或跨站 Cookie 安全配置错误时直接失败;正式生产默认拒绝 SQLite。\n- 外部 AI 调用默认连接超时 5 秒、请求超时 20 秒,健康检查会返回当前超时配置。\n- 本地回归通过:后端 `pytest -q` 46 项、前端 `npm test -- --watch=false` 6 个文件 19 个用例、前端 `npm run build`。\n\n本轮额外确认:\n\n- `frontend/public/runtime-config.js` 已恢复为空 API 值,生产由 `NEXUS_API_BASE_URL` 注入。\n- `backend/instance/nexus_prime.db` 不再作为可提交资产;部署和交付检查改为依赖迁移、seed、同步脚本和 Supabase 状态检查。\n- 文件中心移动端类型栏不再横向溢出,布局审计已覆盖。\n- 首页、登录页、注册页、AI 分析、报表工作室、文件中心和个人工作台已统一居中、图标对齐和响应式兜底。\n- 注册页已重新通过浏览器审查:左侧说明面板在注册模式隐藏,注册卡片单卡居中,横向溢出为 0。\n- 上传长期保存链路已重新确认:本地按 `files`、`avatars`、`library` 三类目录隔离,生产 Vercel/Serverless 必须配置 Cloudinary 或后续 Supabase Storage。\n- 本地演示数据库可通过 `seed-enterprise --scale 3 --multiplier 300 --reset --seed 20241334` 重建;本轮 `flask status` 显示用户 15001、商品 57608、销售订单 100803、文件 7200,运行数据库不提交。\n- 更多模块抽屉、Dock、顶部 AI 入口、全局设置、AI 设置与诊断仍保持独立路由和真实 API 调用边界。\n"}}, "backend/tests/test_config.py": {"1b31adb6c10be5ec293a33aac414815edf962427": {"t": 1785746650.4405196, "n": 12069, "h": "fc59a5fb5337e0a9", "c": "import importlib\nimport os\nfrom io import BytesIO\nfrom zipfile import ZipFile\n\nimport pytest\nfrom flask import Flask\nfrom werkzeug.datastructures import FileStorage\n\nimport config as config_module\n\n\ndef reload_config():\n return importlib.reload(config_module)\n\n\ndef production_app_from(config_cls):\n app = Flask(__name__)\n app.config.from_object(config_cls)\n return app\n\n\ndef office_zip_bytes(kind='xlsx', include_office_folder=True):\n stream = BytesIO()\n with ZipFile(stream, 'w') as archive:\n archive.writestr('[Content_Types].xml', '<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"></Types>')\n if include_office_folder:\n if kind == 'docx':\n archive.writestr('word/document.xml', '<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"/>')\n else:\n archive.writestr('xl/workbook.xml', '<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"/>')\n else:\n archive.writestr('payload.txt', 'ordinary zip payload')\n stream.seek(0)\n return stream.getvalue()\n\n\ndef test_production_config_requires_database_url(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('CORS_ORIGINS', 'https://nexus.example.com')\n monkeypatch.delenv('DATABASE_URL', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='DATABASE_URL'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_production_config_rejects_sqlite_without_explicit_override(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('CORS_ORIGINS', 'https://nexus.example.com')\n monkeypatch.setenv('DATABASE_URL', 'sqlite:///instance/nexus_prime.db')\n monkeypatch.delenv('ALLOW_PRODUCTION_SQLITE', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='SQLite'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_production_config_requires_explicit_cors_origins(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('DATABASE_URL', 'postgresql://user:pass@db.example.com:5432/nexus?sslmode=require')\n monkeypatch.delenv('CORS_ORIGINS', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='CORS_ORIGINS'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_production_config_rejects_local_cors_origins(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('DATABASE_URL', 'postgresql://user:pass@db.example.com:5432/nexus?sslmode=require')\n monkeypatch.setenv('CORS_ORIGINS', 'https://nexus.example.com,http://127.0.0.1:4200')\n monkeypatch.delenv('ALLOW_PRODUCTION_LOCAL_CORS', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='localhost'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_production_config_requires_shared_cache_for_rate_limits(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('DATABASE_URL', 'postgresql://user:pass@db.example.com:5432/nexus?sslmode=require')\n monkeypatch.setenv('CORS_ORIGINS', 'https://nexus.example.com')\n monkeypatch.setenv('AUTH_COOKIE_SECURE', 'true')\n monkeypatch.setenv('AUTH_COOKIE_SAMESITE', 'None')\n monkeypatch.delenv('REDIS_URL', raising=False)\n monkeypatch.delenv('CACHE_REDIS_URL', raising=False)\n monkeypatch.delenv('UPSTASH_REDIS_URL', raising=False)\n monkeypatch.delenv('ALLOW_PRODUCTION_SIMPLE_CACHE', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='REDIS_URL|UPSTASH_REDIS_URL'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_cache_config_promotes_redis_url_to_shared_cache(monkeypatch):\n monkeypatch.setenv('REDIS_URL', 'redis://cache.example.com:6379/0')\n\n module = reload_config()\n\n assert module.cache_config_from_env()['CACHE_TYPE'] == 'RedisCache'\n assert module.cache_config_from_env()['CACHE_REDIS_URL'] == 'redis://cache.example.com:6379/0'\n assert module.is_shared_cache_configured() is True\n\n\ndef test_celery_config_defaults_to_redis_url(monkeypatch):\n monkeypatch.setenv('REDIS_URL', 'redis://cache.example.com:6379/0')\n monkeypatch.delenv('CELERY_BROKER_URL', raising=False)\n monkeypatch.delenv('CELERY_RESULT_BACKEND', raising=False)\n\n module = reload_config()\n\n assert module.celery_config_from_env()['CELERY_BROKER_URL'] == 'redis://cache.example.com:6379/0'\n assert module.celery_config_from_env()['CELERY_RESULT_BACKEND'] == 'redis://cache.example.com:6379/0'\n\n\ndef test_testing_config_uses_eager_in_memory_celery():\n module = reload_config()\n\n assert module.TestingConfig.CELERY_BROKER_URL == 'memory://'\n assert module.TestingConfig.CELERY_RESULT_BACKEND == 'cache+memory://'\n assert module.TestingConfig.CELERY_TASK_ALWAYS_EAGER is True\n assert module.TestingConfig.CELERY_TASK_EAGER_PROPAGATES is True\n\n\ndef test_otel_tracing_config_from_env(monkeypatch):\n monkeypatch.setenv('OTEL_TRACES_ENABLED', 'true')\n monkeypatch.setenv('OTEL_SERVICE_NAME', 'nexus-prime-test')\n monkeypatch.setenv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://collector:4318/v1/traces')\n\n module = reload_config()\n\n assert module.Config.OTEL_TRACES_ENABLED is True\n assert module.Config.OTEL_SERVICE_NAME == 'nexus-prime-test'\n assert module.Config.OTEL_EXPORTER_OTLP_ENDPOINT == 'http://collector:4318/v1/traces'\n\n\ndef test_ai_timeout_parser_bounds_values():\n module = reload_config()\n\n assert module.parse_ai_timeout('1', 20.0) == 3.0\n assert module.parse_ai_timeout('120', 20.0) == 60.0\n assert module.parse_ai_timeout('bad', 20.0) == 20.0\n assert module.parse_ai_timeout('0.25', 2.0, min_value=0.5) == 0.5\n\n\ndef test_ai_provider_config_prefers_coherent_openai_tuple():\n module = reload_config()\n\n result = module.resolve_ai_provider_config({\n 'OPENAI_API_KEY': 'sk-live-openai-credential',\n 'DEEPSEEK_API_KEY': 'sk-live-deepseek-credential',\n })\n\n assert result['api_key'] == 'sk-live-openai-credential'\n assert result['base_url'] == 'https://api.openai.com'\n assert result['model'] == 'gpt-4.1-mini'\n assert result['provider'] == 'openai'\n assert result['source'] == 'openai'\n\n\ndef test_ai_provider_config_does_not_mix_placeholder_generic_key_with_deepseek_base():\n module = reload_config()\n\n result = module.resolve_ai_provider_config({\n 'AI_API_KEY': 'your-openai-compatible-api-key',\n 'AI_BASE_URL': 'https://api.deepseek.com',\n 'OPENAI_API_KEY': 'sk-live-openai-credential',\n })\n\n assert result['api_key'] == 'sk-live-openai-credential'\n assert result['base_url'] == 'https://api.openai.com'\n assert result['provider'] == 'openai'\n\n\ndef test_ai_provider_config_keeps_generic_tuple_when_real_key_is_set():\n module = reload_config()\n\n result = module.resolve_ai_provider_config({\n 'AI_API_KEY': 'sk-live-gateway-credential',\n 'AI_BASE_URL': 'https://ai-gateway.example.com',\n 'AI_MODEL': 'company/ops-model',\n 'OPENAI_API_KEY': 'sk-live-openai-credential',\n })\n\n assert result['api_key'] == 'sk-live-gateway-credential'\n assert result['base_url'] == 'https://ai-gateway.example.com'\n assert result['model'] == 'company/ops-model'\n assert result['provider'] == 'openai-compatible'\n assert result['source'] == 'generic'\n\n\ndef test_init_app_creates_dedicated_upload_directories(tmp_path):\n module = reload_config()\n app = Flask(__name__)\n upload_root = tmp_path / 'uploads'\n app.config.update({\n 'UPLOAD_FOLDER': os.fspath(upload_root),\n 'UPLOAD_FILES_FOLDER': os.fspath(upload_root / 'files'),\n 'UPLOAD_AVATARS_FOLDER': os.fspath(upload_root / 'avatars'),\n 'UPLOAD_LIBRARY_FOLDER': os.fspath(upload_root / 'library'),\n })\n\n module.Config.init_app(app)\n\n assert upload_root.is_dir()\n assert (upload_root / 'files').is_dir()\n assert (upload_root / 'avatars').is_dir()\n assert (upload_root / 'library').is_dir()\n\n\ndef test_save_file_uses_dedicated_upload_directories(tmp_path):\n module = reload_config()\n app = Flask(__name__)\n upload_root = tmp_path / 'uploads'\n app.config.update({\n 'UPLOAD_FOLDER': os.fspath(upload_root),\n 'UPLOAD_FILES_FOLDER': os.fspath(upload_root / 'files'),\n 'UPLOAD_AVATARS_FOLDER': os.fspath(upload_root / 'avatars'),\n 'UPLOAD_LIBRARY_FOLDER': os.fspath(upload_root / 'library'),\n })\n\n module.Config.init_app(app)\n\n with app.app_context():\n from app.utils.file_helper import resolve_upload_destination, save_file\n\n assert resolve_upload_destination('attachments') == (os.fspath(upload_root / 'files'), 'files')\n assert resolve_upload_destination('avatars') == (os.fspath(upload_root / 'avatars'), 'avatars')\n assert resolve_upload_destination('library') == (os.fspath(upload_root / 'library'), 'library')\n\n stored = save_file(FileStorage(stream=BytesIO(b'NEXUS document'), filename='handover.txt', content_type='text/plain'), folder='attachments')\n assert stored is not None\n display_name, logical_path, file_size, mimetype = stored\n assert display_name == 'handover.txt'\n assert logical_path.startswith('files/')\n assert file_size == len(b'NEXUS document')\n assert mimetype == 'text/plain'\n assert os.path.exists(upload_root / logical_path)\n assert not (upload_root / logical_path.split('/', 1)[1]).exists()\n\n\ndef test_save_file_rejects_disallowed_extensions_and_mime_signatures(tmp_path):\n module = reload_config()\n app = Flask(__name__)\n upload_root = tmp_path / 'uploads'\n app.config.update({\n 'UPLOAD_FOLDER': os.fspath(upload_root),\n 'UPLOAD_FILES_FOLDER': os.fspath(upload_root / 'files'),\n 'UPLOAD_AVATARS_FOLDER': os.fspath(upload_root / 'avatars'),\n 'UPLOAD_LIBRARY_FOLDER': os.fspath(upload_root / 'library'),\n })\n\n module.Config.init_app(app)\n\n with app.app_context():\n from app.utils.file_helper import allowed_file, save_file\n\n assert allowed_file('handover.txt') is True\n assert allowed_file('report.xlsx') is True\n assert allowed_file('video.mp4') is False\n assert allowed_file('archive.zip') is False\n\n assert save_file(\n FileStorage(stream=BytesIO(b'<svg onload=alert(1)>'), filename='payload.svg', content_type='image/svg+xml'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(b'<script>alert(1)</script>'), filename='payload.pdf', content_type='text/html'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(b'not a real pdf'), filename='payload.pdf', content_type='application/pdf'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(b'<html><body>x</body></html>'), filename='payload.txt', content_type='text/plain'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(office_zip_bytes('xlsx', include_office_folder=False)), filename='payload.xlsx', content_type='application/octet-stream'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(office_zip_bytes('xlsx')), filename='report.xlsx', content_type='application/octet-stream'),\n folder='attachments',\n ) is not None\n assert save_file(\n FileStorage(stream=BytesIO(office_zip_bytes('docx')), filename='brief.docx', content_type='application/octet-stream'),\n folder='attachments',\n ) is not None\n"}, "f164509df0d21e33e73ba54642a8ae2f260fc7ba": {"t": 1785754649.9267945, "n": 12069, "h": "fc59a5fb5337e0a9", "c": "import importlib\nimport os\nfrom io import BytesIO\nfrom zipfile import ZipFile\n\nimport pytest\nfrom flask import Flask\nfrom werkzeug.datastructures import FileStorage\n\nimport config as config_module\n\n\ndef reload_config():\n return importlib.reload(config_module)\n\n\ndef production_app_from(config_cls):\n app = Flask(__name__)\n app.config.from_object(config_cls)\n return app\n\n\ndef office_zip_bytes(kind='xlsx', include_office_folder=True):\n stream = BytesIO()\n with ZipFile(stream, 'w') as archive:\n archive.writestr('[Content_Types].xml', '<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"></Types>')\n if include_office_folder:\n if kind == 'docx':\n archive.writestr('word/document.xml', '<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"/>')\n else:\n archive.writestr('xl/workbook.xml', '<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"/>')\n else:\n archive.writestr('payload.txt', 'ordinary zip payload')\n stream.seek(0)\n return stream.getvalue()\n\n\ndef test_production_config_requires_database_url(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('CORS_ORIGINS', 'https://nexus.example.com')\n monkeypatch.delenv('DATABASE_URL', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='DATABASE_URL'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_production_config_rejects_sqlite_without_explicit_override(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('CORS_ORIGINS', 'https://nexus.example.com')\n monkeypatch.setenv('DATABASE_URL', 'sqlite:///instance/nexus_prime.db')\n monkeypatch.delenv('ALLOW_PRODUCTION_SQLITE', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='SQLite'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_production_config_requires_explicit_cors_origins(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('DATABASE_URL', 'postgresql://user:pass@db.example.com:5432/nexus?sslmode=require')\n monkeypatch.delenv('CORS_ORIGINS', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='CORS_ORIGINS'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_production_config_rejects_local_cors_origins(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('DATABASE_URL', 'postgresql://user:pass@db.example.com:5432/nexus?sslmode=require')\n monkeypatch.setenv('CORS_ORIGINS', 'https://nexus.example.com,http://127.0.0.1:4200')\n monkeypatch.delenv('ALLOW_PRODUCTION_LOCAL_CORS', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='localhost'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_production_config_requires_shared_cache_for_rate_limits(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('DATABASE_URL', 'postgresql://user:pass@db.example.com:5432/nexus?sslmode=require')\n monkeypatch.setenv('CORS_ORIGINS', 'https://nexus.example.com')\n monkeypatch.setenv('AUTH_COOKIE_SECURE', 'true')\n monkeypatch.setenv('AUTH_COOKIE_SAMESITE', 'None')\n monkeypatch.delenv('REDIS_URL', raising=False)\n monkeypatch.delenv('CACHE_REDIS_URL', raising=False)\n monkeypatch.delenv('UPSTASH_REDIS_URL', raising=False)\n monkeypatch.delenv('ALLOW_PRODUCTION_SIMPLE_CACHE', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='REDIS_URL|UPSTASH_REDIS_URL'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_cache_config_promotes_redis_url_to_shared_cache(monkeypatch):\n monkeypatch.setenv('REDIS_URL', 'redis://cache.example.com:6379/0')\n\n module = reload_config()\n\n assert module.cache_config_from_env()['CACHE_TYPE'] == 'RedisCache'\n assert module.cache_config_from_env()['CACHE_REDIS_URL'] == 'redis://cache.example.com:6379/0'\n assert module.is_shared_cache_configured() is True\n\n\ndef test_celery_config_defaults_to_redis_url(monkeypatch):\n monkeypatch.setenv('REDIS_URL', 'redis://cache.example.com:6379/0')\n monkeypatch.delenv('CELERY_BROKER_URL', raising=False)\n monkeypatch.delenv('CELERY_RESULT_BACKEND', raising=False)\n\n module = reload_config()\n\n assert module.celery_config_from_env()['CELERY_BROKER_URL'] == 'redis://cache.example.com:6379/0'\n assert module.celery_config_from_env()['CELERY_RESULT_BACKEND'] == 'redis://cache.example.com:6379/0'\n\n\ndef test_testing_config_uses_eager_in_memory_celery():\n module = reload_config()\n\n assert module.TestingConfig.CELERY_BROKER_URL == 'memory://'\n assert module.TestingConfig.CELERY_RESULT_BACKEND == 'cache+memory://'\n assert module.TestingConfig.CELERY_TASK_ALWAYS_EAGER is True\n assert module.TestingConfig.CELERY_TASK_EAGER_PROPAGATES is True\n\n\ndef test_otel_tracing_config_from_env(monkeypatch):\n monkeypatch.setenv('OTEL_TRACES_ENABLED', 'true')\n monkeypatch.setenv('OTEL_SERVICE_NAME', 'nexus-prime-test')\n monkeypatch.setenv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://collector:4318/v1/traces')\n\n module = reload_config()\n\n assert module.Config.OTEL_TRACES_ENABLED is True\n assert module.Config.OTEL_SERVICE_NAME == 'nexus-prime-test'\n assert module.Config.OTEL_EXPORTER_OTLP_ENDPOINT == 'http://collector:4318/v1/traces'\n\n\ndef test_ai_timeout_parser_bounds_values():\n module = reload_config()\n\n assert module.parse_ai_timeout('1', 20.0) == 3.0\n assert module.parse_ai_timeout('120', 20.0) == 60.0\n assert module.parse_ai_timeout('bad', 20.0) == 20.0\n assert module.parse_ai_timeout('0.25', 2.0, min_value=0.5) == 0.5\n\n\ndef test_ai_provider_config_prefers_coherent_openai_tuple():\n module = reload_config()\n\n result = module.resolve_ai_provider_config({\n 'OPENAI_API_KEY': 'sk-live-openai-credential',\n 'DEEPSEEK_API_KEY': 'sk-live-deepseek-credential',\n })\n\n assert result['api_key'] == 'sk-live-openai-credential'\n assert result['base_url'] == 'https://api.openai.com'\n assert result['model'] == 'gpt-4.1-mini'\n assert result['provider'] == 'openai'\n assert result['source'] == 'openai'\n\n\ndef test_ai_provider_config_does_not_mix_placeholder_generic_key_with_deepseek_base():\n module = reload_config()\n\n result = module.resolve_ai_provider_config({\n 'AI_API_KEY': 'your-openai-compatible-api-key',\n 'AI_BASE_URL': 'https://api.deepseek.com',\n 'OPENAI_API_KEY': 'sk-live-openai-credential',\n })\n\n assert result['api_key'] == 'sk-live-openai-credential'\n assert result['base_url'] == 'https://api.openai.com'\n assert result['provider'] == 'openai'\n\n\ndef test_ai_provider_config_keeps_generic_tuple_when_real_key_is_set():\n module = reload_config()\n\n result = module.resolve_ai_provider_config({\n 'AI_API_KEY': 'sk-live-gateway-credential',\n 'AI_BASE_URL': 'https://ai-gateway.example.com',\n 'AI_MODEL': 'company/ops-model',\n 'OPENAI_API_KEY': 'sk-live-openai-credential',\n })\n\n assert result['api_key'] == 'sk-live-gateway-credential'\n assert result['base_url'] == 'https://ai-gateway.example.com'\n assert result['model'] == 'company/ops-model'\n assert result['provider'] == 'openai-compatible'\n assert result['source'] == 'generic'\n\n\ndef test_init_app_creates_dedicated_upload_directories(tmp_path):\n module = reload_config()\n app = Flask(__name__)\n upload_root = tmp_path / 'uploads'\n app.config.update({\n 'UPLOAD_FOLDER': os.fspath(upload_root),\n 'UPLOAD_FILES_FOLDER': os.fspath(upload_root / 'files'),\n 'UPLOAD_AVATARS_FOLDER': os.fspath(upload_root / 'avatars'),\n 'UPLOAD_LIBRARY_FOLDER': os.fspath(upload_root / 'library'),\n })\n\n module.Config.init_app(app)\n\n assert upload_root.is_dir()\n assert (upload_root / 'files').is_dir()\n assert (upload_root / 'avatars').is_dir()\n assert (upload_root / 'library').is_dir()\n\n\ndef test_save_file_uses_dedicated_upload_directories(tmp_path):\n module = reload_config()\n app = Flask(__name__)\n upload_root = tmp_path / 'uploads'\n app.config.update({\n 'UPLOAD_FOLDER': os.fspath(upload_root),\n 'UPLOAD_FILES_FOLDER': os.fspath(upload_root / 'files'),\n 'UPLOAD_AVATARS_FOLDER': os.fspath(upload_root / 'avatars'),\n 'UPLOAD_LIBRARY_FOLDER': os.fspath(upload_root / 'library'),\n })\n\n module.Config.init_app(app)\n\n with app.app_context():\n from app.utils.file_helper import resolve_upload_destination, save_file\n\n assert resolve_upload_destination('attachments') == (os.fspath(upload_root / 'files'), 'files')\n assert resolve_upload_destination('avatars') == (os.fspath(upload_root / 'avatars'), 'avatars')\n assert resolve_upload_destination('library') == (os.fspath(upload_root / 'library'), 'library')\n\n stored = save_file(FileStorage(stream=BytesIO(b'NEXUS document'), filename='handover.txt', content_type='text/plain'), folder='attachments')\n assert stored is not None\n display_name, logical_path, file_size, mimetype = stored\n assert display_name == 'handover.txt'\n assert logical_path.startswith('files/')\n assert file_size == len(b'NEXUS document')\n assert mimetype == 'text/plain'\n assert os.path.exists(upload_root / logical_path)\n assert not (upload_root / logical_path.split('/', 1)[1]).exists()\n\n\ndef test_save_file_rejects_disallowed_extensions_and_mime_signatures(tmp_path):\n module = reload_config()\n app = Flask(__name__)\n upload_root = tmp_path / 'uploads'\n app.config.update({\n 'UPLOAD_FOLDER': os.fspath(upload_root),\n 'UPLOAD_FILES_FOLDER': os.fspath(upload_root / 'files'),\n 'UPLOAD_AVATARS_FOLDER': os.fspath(upload_root / 'avatars'),\n 'UPLOAD_LIBRARY_FOLDER': os.fspath(upload_root / 'library'),\n })\n\n module.Config.init_app(app)\n\n with app.app_context():\n from app.utils.file_helper import allowed_file, save_file\n\n assert allowed_file('handover.txt') is True\n assert allowed_file('report.xlsx') is True\n assert allowed_file('video.mp4') is False\n assert allowed_file('archive.zip') is False\n\n assert save_file(\n FileStorage(stream=BytesIO(b'<svg onload=alert(1)>'), filename='payload.svg', content_type='image/svg+xml'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(b'<script>alert(1)</script>'), filename='payload.pdf', content_type='text/html'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(b'not a real pdf'), filename='payload.pdf', content_type='application/pdf'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(b'<html><body>x</body></html>'), filename='payload.txt', content_type='text/plain'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(office_zip_bytes('xlsx', include_office_folder=False)), filename='payload.xlsx', content_type='application/octet-stream'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(office_zip_bytes('xlsx')), filename='report.xlsx', content_type='application/octet-stream'),\n folder='attachments',\n ) is not None\n assert save_file(\n FileStorage(stream=BytesIO(office_zip_bytes('docx')), filename='brief.docx', content_type='application/octet-stream'),\n folder='attachments',\n ) is not None\n"}, "f72e3059e90b878ff7a860ab4e22a4c4619a88ba": {"t": 1785754651.4111958, "n": 10714, "h": "b47c12d5bb3a33c2", "c": "import importlib\nimport os\nfrom io import BytesIO\nfrom zipfile import ZipFile\n\nimport pytest\nfrom flask import Flask\nfrom werkzeug.datastructures import FileStorage\n\nimport config as config_module\n\n\ndef reload_config():\n return importlib.reload(config_module)\n\n\ndef production_app_from(config_cls):\n app = Flask(__name__)\n app.config.from_object(config_cls)\n return app\n\n\ndef office_zip_bytes(kind='xlsx', include_office_folder=True):\n stream = BytesIO()\n with ZipFile(stream, 'w') as archive:\n archive.writestr('[Content_Types].xml', '<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"></Types>')\n if include_office_folder:\n if kind == 'docx':\n archive.writestr('word/document.xml', '<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"/>')\n else:\n archive.writestr('xl/workbook.xml', '<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"/>')\n else:\n archive.writestr('payload.txt', 'ordinary zip payload')\n stream.seek(0)\n return stream.getvalue()\n\n\ndef test_production_config_requires_database_url(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('CORS_ORIGINS', 'https://nexus.example.com')\n monkeypatch.delenv('DATABASE_URL', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='DATABASE_URL'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_production_config_rejects_sqlite_without_explicit_override(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('CORS_ORIGINS', 'https://nexus.example.com')\n monkeypatch.setenv('DATABASE_URL', 'sqlite:///instance/nexus_prime.db')\n monkeypatch.delenv('ALLOW_PRODUCTION_SQLITE', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='SQLite'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_production_config_requires_explicit_cors_origins(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('DATABASE_URL', 'postgresql://user:pass@db.example.com:5432/nexus?sslmode=require')\n monkeypatch.delenv('CORS_ORIGINS', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='CORS_ORIGINS'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_production_config_rejects_local_cors_origins(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('DATABASE_URL', 'postgresql://user:pass@db.example.com:5432/nexus?sslmode=require')\n monkeypatch.setenv('CORS_ORIGINS', 'https://nexus.example.com,http://127.0.0.1:4200')\n monkeypatch.delenv('ALLOW_PRODUCTION_LOCAL_CORS', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='localhost'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_production_config_requires_shared_cache_for_rate_limits(monkeypatch):\n monkeypatch.setenv('SECRET_KEY', 'x' * 40)\n monkeypatch.setenv('DATABASE_URL', 'postgresql://user:pass@db.example.com:5432/nexus?sslmode=require')\n monkeypatch.setenv('CORS_ORIGINS', 'https://nexus.example.com')\n monkeypatch.setenv('AUTH_COOKIE_SECURE', 'true')\n monkeypatch.setenv('AUTH_COOKIE_SAMESITE', 'None')\n monkeypatch.delenv('REDIS_URL', raising=False)\n monkeypatch.delenv('CACHE_REDIS_URL', raising=False)\n monkeypatch.delenv('UPSTASH_REDIS_URL', raising=False)\n monkeypatch.delenv('ALLOW_PRODUCTION_SIMPLE_CACHE', raising=False)\n\n module = reload_config()\n app = production_app_from(module.ProductionConfig)\n\n with pytest.raises(RuntimeError, match='REDIS_URL|UPSTASH_REDIS_URL'):\n module.ProductionConfig.init_app(app)\n\n\ndef test_cache_config_promotes_redis_url_to_shared_cache(monkeypatch):\n monkeypatch.setenv('REDIS_URL', 'redis://cache.example.com:6379/0')\n\n module = reload_config()\n\n assert module.cache_config_from_env()['CACHE_TYPE'] == 'RedisCache'\n assert module.cache_config_from_env()['CACHE_REDIS_URL'] == 'redis://cache.example.com:6379/0'\n assert module.is_shared_cache_configured() is True\n\n\ndef test_ai_timeout_parser_bounds_values():\n module = reload_config()\n\n assert module.parse_ai_timeout('1', 20.0) == 3.0\n assert module.parse_ai_timeout('120', 20.0) == 60.0\n assert module.parse_ai_timeout('bad', 20.0) == 20.0\n assert module.parse_ai_timeout('0.25', 2.0, min_value=0.5) == 0.5\n\n\ndef test_ai_provider_config_prefers_coherent_openai_tuple():\n module = reload_config()\n\n result = module.resolve_ai_provider_config({\n 'OPENAI_API_KEY': 'sk-live-openai-credential',\n 'DEEPSEEK_API_KEY': 'sk-live-deepseek-credential',\n })\n\n assert result['api_key'] == 'sk-live-openai-credential'\n assert result['base_url'] == 'https://api.openai.com'\n assert result['model'] == 'gpt-4.1-mini'\n assert result['provider'] == 'openai'\n assert result['source'] == 'openai'\n\n\ndef test_ai_provider_config_does_not_mix_placeholder_generic_key_with_deepseek_base():\n module = reload_config()\n\n result = module.resolve_ai_provider_config({\n 'AI_API_KEY': 'your-openai-compatible-api-key',\n 'AI_BASE_URL': 'https://api.deepseek.com',\n 'OPENAI_API_KEY': 'sk-live-openai-credential',\n })\n\n assert result['api_key'] == 'sk-live-openai-credential'\n assert result['base_url'] == 'https://api.openai.com'\n assert result['provider'] == 'openai'\n\n\ndef test_ai_provider_config_keeps_generic_tuple_when_real_key_is_set():\n module = reload_config()\n\n result = module.resolve_ai_provider_config({\n 'AI_API_KEY': 'sk-live-gateway-credential',\n 'AI_BASE_URL': 'https://ai-gateway.example.com',\n 'AI_MODEL': 'company/ops-model',\n 'OPENAI_API_KEY': 'sk-live-openai-credential',\n })\n\n assert result['api_key'] == 'sk-live-gateway-credential'\n assert result['base_url'] == 'https://ai-gateway.example.com'\n assert result['model'] == 'company/ops-model'\n assert result['provider'] == 'openai-compatible'\n assert result['source'] == 'generic'\n\n\ndef test_init_app_creates_dedicated_upload_directories(tmp_path):\n module = reload_config()\n app = Flask(__name__)\n upload_root = tmp_path / 'uploads'\n app.config.update({\n 'UPLOAD_FOLDER': os.fspath(upload_root),\n 'UPLOAD_FILES_FOLDER': os.fspath(upload_root / 'files'),\n 'UPLOAD_AVATARS_FOLDER': os.fspath(upload_root / 'avatars'),\n 'UPLOAD_LIBRARY_FOLDER': os.fspath(upload_root / 'library'),\n })\n\n module.Config.init_app(app)\n\n assert upload_root.is_dir()\n assert (upload_root / 'files').is_dir()\n assert (upload_root / 'avatars').is_dir()\n assert (upload_root / 'library').is_dir()\n\n\ndef test_save_file_uses_dedicated_upload_directories(tmp_path):\n module = reload_config()\n app = Flask(__name__)\n upload_root = tmp_path / 'uploads'\n app.config.update({\n 'UPLOAD_FOLDER': os.fspath(upload_root),\n 'UPLOAD_FILES_FOLDER': os.fspath(upload_root / 'files'),\n 'UPLOAD_AVATARS_FOLDER': os.fspath(upload_root / 'avatars'),\n 'UPLOAD_LIBRARY_FOLDER': os.fspath(upload_root / 'library'),\n })\n\n module.Config.init_app(app)\n\n with app.app_context():\n from app.utils.file_helper import resolve_upload_destination, save_file\n\n assert resolve_upload_destination('attachments') == (os.fspath(upload_root / 'files'), 'files')\n assert resolve_upload_destination('avatars') == (os.fspath(upload_root / 'avatars'), 'avatars')\n assert resolve_upload_destination('library') == (os.fspath(upload_root / 'library'), 'library')\n\n stored = save_file(FileStorage(stream=BytesIO(b'NEXUS document'), filename='handover.txt', content_type='text/plain'), folder='attachments')\n assert stored is not None\n display_name, logical_path, file_size, mimetype = stored\n assert display_name == 'handover.txt'\n assert logical_path.startswith('files/')\n assert file_size == len(b'NEXUS document')\n assert mimetype == 'text/plain'\n assert os.path.exists(upload_root / logical_path)\n assert not (upload_root / logical_path.split('/', 1)[1]).exists()\n\n\ndef test_save_file_rejects_disallowed_extensions_and_mime_signatures(tmp_path):\n module = reload_config()\n app = Flask(__name__)\n upload_root = tmp_path / 'uploads'\n app.config.update({\n 'UPLOAD_FOLDER': os.fspath(upload_root),\n 'UPLOAD_FILES_FOLDER': os.fspath(upload_root / 'files'),\n 'UPLOAD_AVATARS_FOLDER': os.fspath(upload_root / 'avatars'),\n 'UPLOAD_LIBRARY_FOLDER': os.fspath(upload_root / 'library'),\n })\n\n module.Config.init_app(app)\n\n with app.app_context():\n from app.utils.file_helper import allowed_file, save_file\n\n assert allowed_file('handover.txt') is True\n assert allowed_file('report.xlsx') is True\n assert allowed_file('video.mp4') is False\n assert allowed_file('archive.zip') is False\n\n assert save_file(\n FileStorage(stream=BytesIO(b'<svg onload=alert(1)>'), filename='payload.svg', content_type='image/svg+xml'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(b'<script>alert(1)</script>'), filename='payload.pdf', content_type='text/html'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(b'not a real pdf'), filename='payload.pdf', content_type='application/pdf'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(b'<html><body>x</body></html>'), filename='payload.txt', content_type='text/plain'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(office_zip_bytes('xlsx', include_office_folder=False)), filename='payload.xlsx', content_type='application/octet-stream'),\n folder='attachments',\n ) is None\n assert save_file(\n FileStorage(stream=BytesIO(office_zip_bytes('xlsx')), filename='report.xlsx', content_type='application/octet-stream'),\n folder='attachments',\n ) is not None\n assert save_file(\n FileStorage(stream=BytesIO(office_zip_bytes('docx')), filename='brief.docx', content_type='application/octet-stream'),\n folder='attachments',\n ) is not None\n"}}}}