1 line
223 KiB
JSON
1 line
223 KiB
JSON
{"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"}}}} |