diff --git a/.gitignore b/.gitignore index 5c15477..3c3629e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1 @@ node_modules -*.log -dist -.output -.nuxt -.vercel -app/apps/* diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 8d1c8b6..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/apps/.gitkeep b/app/apps/.gitkeep deleted file mode 100644 index 8d1c8b6..0000000 --- a/app/apps/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/apps/article/.gitkeep b/app/apps/article/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/produce/.gitkeep b/app/apps/produce/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/produce/api/data-entry.ts b/app/apps/produce/api/data-entry.ts new file mode 100644 index 0000000..7796e94 --- /dev/null +++ b/app/apps/produce/api/data-entry.ts @@ -0,0 +1,167 @@ +import request from '~/utils/request' + +export interface ProduceOption { + label: string + value: string | number +} + +export interface ProduceRecordItem { + id?: string | number + order_no?: string + process_order_no?: string + product_code?: string + quantity?: number + user_code?: string + operator?: string + scan_result?: number + scan_date?: string + created_date?: string + process?: { + process_name?: string + } + step?: { + step_name?: string + } + status?: { + status_name?: string + } +} + +export interface ProduceSubmitScanDataResult { + code: number + msg: string + data?: { + id?: string | number + } +} + +export interface ProduceStatistics { + today_total: number + success_total: number + failed_total: number +} + +export interface ProduceUserStatsItem { + user_code: string + operator?: string + count: number +} + +export interface ProduceMemberUserCheckResult { + id: number + username: string + nickname: string +} + +export interface ProduceDataEntryStepRule { + id?: string | number + process_id?: string | number + process_name?: string + step_code?: string + step_name?: string + prerequisite_steps?: Array + prerequisite_mode?: string + prerequisite_require_final?: number + allow_repeat_scan?: number + repeat_scan_statuses?: Array +} + +export interface ProduceDataEntrySpecificRule { + id?: string | number + rule_name?: string + rule_pattern?: string + rule_type?: number + apply_mode?: 'order' | 'machine' | 'both' + match_field?: 'order_no' | 'process_order_no' | 'product_code' | 'user_code' + step_ids?: Array + sort?: number + remark?: string +} + +export interface ProduceDataEntryConfig { + basic_setting: Record + step_rule: ProduceDataEntryStepRule | null + specific_rules: ProduceDataEntrySpecificRule[] + current?: { + process_id?: string | number + step_id?: string | number + order_no?: string + apply_mode?: 'order' | 'machine' | 'both' + } +} + +export function getProcessOptions() { + return request.get('/produce/process', { format: 'select' }) +} + +export function getStepOptions(processId?: string | number) { + return request.get('/produce/process-step', { + format: 'select', + ...(processId ? { process_id: processId } : {}), + }) +} + +export function getStatusOptions(stepId?: string | number) { + return request.get('/produce/step-status', { + format: 'select', + ...(stepId ? { step_id: stepId } : {}), + }) +} + +export function getProduceConfig(groupCode: string) { + return request.get>('/produce/config/info', { group_code: groupCode }) +} + +export function getDataEntryConfig(params: Record) { + return request.get('/produce/config/data-entry', params) +} + +export function getRecentRecords(params: Record) { + return request.get<{ items: ProduceRecordItem[]; total: number }>('/produce/scan-data', { + limit: 10, + page: 1, + ...params, + }) +} + +export function getProductRecentRecords(params: Record) { + return request.get<{ items: ProduceRecordItem[]; total: number }>('/produce/product-record/recent', { + limit: 10, + page: 1, + ...params, + }) +} + +export function getStatistics(params: Record) { + return request.get('/produce/scan-data/statistics', params, { showErrorMessage: false }) +} + +export function getProductStatistics(params: Record) { + return request.get('/produce/product-record/statistics', params, { showErrorMessage: false }) +} + +export function getUserCodeStats(params: Record) { + return request.get('/produce/scan-data/user-code-stats', params, { showErrorMessage: false }) +} + +export function getProductUserCodeStats(params: Record) { + return request.get('/produce/product-record/user-code-stats', params, { showErrorMessage: false }) +} + +export function checkMemberUser(userCode: string) { + return request.get('/produce/member-user/check', { user_code: userCode }, { showErrorMessage: false }) +} + +export function submitScanData(data: Record) { + return request.post('/produce/scan-data/record', data, { + showErrorMessage: false, + returnFullResponse: true, + }) +} + +export function submitProductRecord(data: Record) { + return request.post('/produce/product-record/record', data, { + showErrorMessage: false, + returnFullResponse: true, + }) +} diff --git a/app/apps/produce/api/exception.ts b/app/apps/produce/api/exception.ts new file mode 100644 index 0000000..292cb05 --- /dev/null +++ b/app/apps/produce/api/exception.ts @@ -0,0 +1,143 @@ +import request from '~/utils/request' + +export interface ProduceExceptionSetting { + id: string | number + parent_id?: string | number | null + setting_type: 'type' | 'reason' | 'issue' | string + code?: string + name: string + sort?: number + frequency?: number + remark?: string +} + +export interface ProduceExceptionRecord { + id: string | number + order_no?: string + user_code?: string + reporter?: string + type_id?: string | number + type_name?: string + reason_id?: string | number + reason_name?: string + issue_id?: string | number | null + issue_name?: string + images?: string[] + remark?: string + status?: number + status_name?: string + reported_date?: string + handled_date?: string + handler?: string +} + +export interface ProduceRepairTask { + id: string | number + device_no: string + type_name?: string + reason_name?: string + issue_name?: string + reporter?: string + user_code?: string + reported_date?: string + status?: number + status_name?: string +} + +export interface ProduceRepairPart { + product_code: string + product_name?: string + category_id?: string | number + category_name?: string + source?: 'new' | 'after_sales' | 'refurbished' | string + quantity?: number + fault_description_ids?: Array +} + +export interface ProduceScanRecord { + id: string | number + process_name?: string + step_name?: string + operator?: string + user_code?: string +} + +export function getExceptionSettings() { + return request.get('/produce/exception/settings', {}, { showErrorMessage: false }) +} + +export function getExceptionStatistics(params: Record = {}) { + return request.get<{ pending: number; review: number; done: number }>('/produce/exception/statistics', params, { showErrorMessage: false }) +} + +export function getRecentExceptions(params: Record = {}) { + return request.get('/produce/exception/recent', params, { showErrorMessage: false }) +} + +export function checkExceptionOrder(orderNo: string) { + return request.get>('/produce/exception/order-check', { order_no: orderNo.trim() }, { showErrorMessage: false }) +} + +export function submitExceptionRecord(data: Record) { + return request.post<{ code: number; msg: string; data?: { id: string | number } }>('/produce/exception/record', data, { + showErrorMessage: false, + returnFullResponse: true, + }) +} + +export function getRepairTasks(params: Record = {}) { + return request.get('/produce/repair-record/tasks', params, { showErrorMessage: false }) +} + +export function getRepairStatistics(params: Record = {}) { + return request.get<{ pending: number; processing: number; completed_today: number }>('/produce/repair-record/statistics', params, { showErrorMessage: false }) +} + +export function getRepairTask(deviceNo: string) { + return request.get('/produce/exception/repair-task', { device_no: deviceNo.trim() }, { showErrorMessage: false }) +} + +export function getScanRecords(params: Record = {}) { + return request.get<{ items: ProduceScanRecord[]; total: number }>('/produce/scan-data', { + limit: 50, + page: 1, + ...params, + }, { showErrorMessage: false }) +} + +export function getMaterialCategories() { + return request.get>('/produce/scanapp/material-category', {}, { showErrorMessage: false }) +} + +export function getFaultDescriptionOptions(categoryIds: Array) { + return request.get>( + '/produce/hardware-fault-description/options', + { category_ids: categoryIds.join(',') }, + { showErrorMessage: false }, + ) +} + +export function lookupMaterial(productCode: string) { + return request.get<{ + id: string | number + category_id: string | number + category_name: string + product_code: string + product_name: string + }>('/produce/material/lookup', { product_code: productCode.trim() }, { showErrorMessage: false }) +} + +export function submitRepairRecord(data: Record) { + return request.post<{ code: number; msg: string; data?: { id: string | number; repair_no: string } }>('/produce/repair-record/record', data, { + showErrorMessage: false, + returnFullResponse: true, + }) +} + +export async function uploadProduceImage(file: File, subDir: 'exception' | 'repair') { + const formData = new FormData() + formData.append('file', file) + formData.append('sub_dir', subDir) + const result = await request.upload>('/file/image', formData) + return result?.url || result?.full_url || result?.path || '' +} diff --git a/app/apps/produce/api/query.ts b/app/apps/produce/api/query.ts new file mode 100644 index 0000000..0c57c6f --- /dev/null +++ b/app/apps/produce/api/query.ts @@ -0,0 +1,60 @@ +import request from '~/utils/request' + +export type ProduceQueryRecordType = 'scan' | 'product' | 'exception' | 'repair' + +export interface ProduceQueryRecord { + id: string + record_type: ProduceQueryRecordType + record_type_name: string + order_no?: string + product_code?: string + process_name?: string + step_name?: string + status_name?: string + result_name?: string + operator?: string + user_code?: string + quantity?: number | null + event_at?: number + event_date?: string | null + remark?: string + // 异常提报和维修记录详情 + exception_reason?: string + exception_content?: string + material_category?: string + fault_description?: string +} + +export interface ProduceQueryPage { + items: ProduceQueryRecord[] + total: number + page: number + limit: number +} + +export interface ProduceQueryParams { + record_type: ProduceQueryRecordType + order_no?: string + keyword?: string + user_code?: string + status?: string | number + scan_result?: string | number + start_date?: string + end_date?: string + page?: number + limit?: number +} + +export interface ProduceQueryTimeline { + order_no: string + total: number + items: ProduceQueryRecord[] +} + +export function queryProduceRecords(params: ProduceQueryParams) { + return request.get('/produce/query/records', params, { showErrorMessage: false }) +} + +export function queryProduceTimeline(orderNo: string) { + return request.get('/produce/query/timeline', { order_no: orderNo.trim() }, { showErrorMessage: false }) +} diff --git a/app/apps/produce/pages/data-entry/components/MachineEntryPanel.vue b/app/apps/produce/pages/data-entry/components/MachineEntryPanel.vue new file mode 100644 index 0000000..a436d8d --- /dev/null +++ b/app/apps/produce/pages/data-entry/components/MachineEntryPanel.vue @@ -0,0 +1,549 @@ + + + + + diff --git a/app/apps/produce/pages/data-entry/components/OrderEntryPanel.vue b/app/apps/produce/pages/data-entry/components/OrderEntryPanel.vue new file mode 100644 index 0000000..3c29066 --- /dev/null +++ b/app/apps/produce/pages/data-entry/components/OrderEntryPanel.vue @@ -0,0 +1,610 @@ + + + + + diff --git a/app/apps/produce/pages/data-entry/components/entry-panel.css b/app/apps/produce/pages/data-entry/components/entry-panel.css new file mode 100644 index 0000000..ecdb41f --- /dev/null +++ b/app/apps/produce/pages/data-entry/components/entry-panel.css @@ -0,0 +1,340 @@ +.main-top-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(340px, 1fr); + gap: 20px; + align-items: stretch; +} + +.workbench-panel, +.records-panel { + background: #ffffff; + border-radius: 20px; + border: 1px solid #e5e7eb; + padding: 28px; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 24px; +} + +.column-header { + padding-bottom: 18px; + border-bottom: 1px solid #eef2f7; + margin-bottom: 22px; +} + +.panel-metric-card { + min-width: 240px; + min-height: 56px; + padding: 12px 16px; + border-radius: 16px; + background: #f8fbff; + border: 1px solid #e3ebf7; + display: flex; + align-items: center; + gap: 12px; +} + +.panel-metric-label { + font-size: 13px; + color: #64748b; +} + +.panel-metric-value { + font-size: 28px; + line-height: 1; + font-weight: 700; + color: #2563eb; + margin-left: auto; +} + +.metric-eye-button { + width: 32px; + height: 32px; + border: 0; + border-radius: 999px; + background: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + color: #94a3b8; + cursor: pointer; + transition: all 0.2s ease; +} + +.metric-eye-button svg { + width: 18px; + height: 18px; +} + +.metric-eye-button.is-hidden { + color: #c0cad8; +} + +.metric-eye-button:hover { + background: rgba(37, 99, 235, 0.08); + color: #2563eb; +} + +.panel-title { + font-size: 18px; + font-weight: 600; + color: #2563eb; +} + +.panel-subtitle { + margin-top: 8px; + font-size: 14px; + color: #6b7280; +} + +.workbench-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.field-row { + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} + +.field-row-split { + grid-template-columns: minmax(0, 1fr) 180px; +} + +.field-group { + display: flex; + flex-direction: column; + gap: 10px; +} + +.field-group-quantity { + min-width: 0; +} + +.field-group label { + font-size: 14px; + font-weight: 600; + color: #334155; +} + +.field-error-tip { + font-size: 12px; + color: #ef4444; + margin-top: 6px; +} + +.field-primary :deep(.el-input__wrapper), +.field-row :deep(.el-input__wrapper), +.field-row :deep(.el-input-number .el-input__wrapper) { + min-height: 58px; + border-radius: 14px; + box-shadow: 0 0 0 1px #dbe4f0 inset; +} + +.field-primary :deep(.el-input__inner), +.field-row :deep(.el-input__inner), +.field-row :deep(.el-input-number .el-input__inner) { + font-size: 18px; +} + +.field-primary :deep(.el-input__wrapper.is-focus), +.field-row :deep(.el-input__wrapper.is-focus), +.field-row :deep(.el-input-number .el-input__wrapper.is-focus) { + box-shadow: 0 0 0 1px var(--el-color-primary) inset; +} + +.quantity-input { + width: 100%; +} + +.quantity-input :deep(.el-input__wrapper) { + padding-right: 40px; + overflow: hidden; +} + +.quantity-input :deep(.el-input-number__increase), +.quantity-input :deep(.el-input-number__decrease) { + right: 1px; + width: 36px; + margin: 0; + background: #f8fafc; + color: #94a3b8; + border-left: 1px solid #dbe4f0; + box-shadow: none; +} + +.quantity-input :deep(.el-input-number__increase) { + top: 1px; + height: calc(50% - 0.5px); + border-top-right-radius: 13px; + border-bottom: 1px solid #e5edf6; +} + +.quantity-input :deep(.el-input-number__decrease) { + bottom: 1px; + height: calc(50% - 0.5px); + border-bottom-right-radius: 13px; +} + +.quantity-input :deep(.el-input-number__increase:hover), +.quantity-input :deep(.el-input-number__decrease:hover) { + color: var(--el-color-primary); + background: #f1f5f9; +} + +.field-label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.lock-switch { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: #64748b; +} + +.action-row { + display: flex; + gap: 12px; +} + +.action-row :deep(.el-button) { + min-width: 160px; + border-radius: 12px; +} + +.dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.dashboard-title { + font-size: 18px; + font-weight: 600; + color: #111827; +} + +.dashboard-caption { + font-size: 13px; + color: #94a3b8; +} + +.station-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.station-card { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + border-radius: 14px; + background: #ffffff; + border: 1px solid #e6edf6; +} + +.station-icon { + width: 34px; + height: 34px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: #e8f1ff; + color: #3b82f6; + font-size: 13px; + font-weight: 700; + flex-shrink: 0; +} + +.station-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.station-info strong { + font-size: 16px; + color: #1f2937; +} + +.station-card b { + margin-left: auto; + font-size: 32px; + line-height: 1; + color: #2563eb; +} + +.records-header { + margin-bottom: 20px; +} + +.records-panel :deep(.el-table) { + --el-table-border-color: #eef2f7; + --el-table-header-bg-color: #f8fafc; +} + +.records-panel :deep(.el-table th.el-table__cell) { + font-weight: 600; + color: #475569; +} + +.records-panel :deep(.el-table td.el-table__cell) { + padding-top: 14px; + padding-bottom: 14px; +} + +@media (max-width: 1024px) { + .panel-header, + .field-label-row, + .dashboard-header { + flex-direction: column; + align-items: flex-start; + } + + .panel-metric-card { + width: 100%; + min-width: 0; + } + + .main-top-grid, + .station-grid { + grid-template-columns: 1fr; + } + + .field-row-split { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .workbench-panel, + .records-panel { + padding: 18px; + border-radius: 16px; + } + + .action-row { + flex-wrap: wrap; + } + + .action-row :deep(.el-button) { + width: 100%; + } +} diff --git a/app/apps/produce/pages/data-entry/index.vue b/app/apps/produce/pages/data-entry/index.vue new file mode 100644 index 0000000..fd4c107 --- /dev/null +++ b/app/apps/produce/pages/data-entry/index.vue @@ -0,0 +1,1026 @@ + + + + + diff --git a/app/apps/produce/pages/exception/index.vue b/app/apps/produce/pages/exception/index.vue new file mode 100644 index 0000000..4aa024a --- /dev/null +++ b/app/apps/produce/pages/exception/index.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/app/apps/produce/pages/query/index.vue b/app/apps/produce/pages/query/index.vue new file mode 100644 index 0000000..cbf483c --- /dev/null +++ b/app/apps/produce/pages/query/index.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/app/apps/produce/pages/repair/index.vue b/app/apps/produce/pages/repair/index.vue new file mode 100644 index 0000000..27192a0 --- /dev/null +++ b/app/apps/produce/pages/repair/index.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/app/apps/produce/pages/repair/tasks.vue b/app/apps/produce/pages/repair/tasks.vue new file mode 100644 index 0000000..a6a201a --- /dev/null +++ b/app/apps/produce/pages/repair/tasks.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/app/apps/produce/pages/routes.ts b/app/apps/produce/pages/routes.ts new file mode 100644 index 0000000..db00be5 --- /dev/null +++ b/app/apps/produce/pages/routes.ts @@ -0,0 +1,42 @@ +export default [ + { + path: '/produce/data-entry', + component: () => import('~/apps/produce/pages/data-entry/index.vue'), + meta: { + title: '数据录入', + layout: 'default', + }, + }, + { + path: '/produce/exception', + component: () => import('~/apps/produce/pages/exception/index.vue'), + meta: { + title: '异常处理', + layout: 'default', + }, + }, + { + path: '/produce/repair', + component: () => import('~/apps/produce/pages/repair/index.vue'), + meta: { + title: '维修处理', + layout: 'default', + }, + }, + { + path: '/produce/repair/tasks', + component: () => import('~/apps/produce/pages/repair/tasks.vue'), + meta: { + title: '待维修订单', + layout: 'default', + }, + }, + { + path: '/produce/query', + component: () => import('~/apps/produce/pages/query/index.vue'), + meta: { + title: '综合查询', + layout: 'default', + }, + }, +] diff --git a/app/utils/request.ts b/app/utils/request.ts index a85919b..3939229 100644 --- a/app/utils/request.ts +++ b/app/utils/request.ts @@ -3,6 +3,7 @@ import qs from 'qs' import { getToken, getRefreshToken } from './common' import { useRuntimeConfig, navigateTo, useCookie } from 'nuxt/app' import { useMemberStore } from '~/stores/member' +import { decryptEncryptedResponse, prepareEncryptedRequest } from '~/utils/security/api-encrypt' // 响应数据类型定义 interface ResponseData { @@ -15,7 +16,7 @@ interface ResponseData { interface ConfigOption { showErrorMessage?: boolean showSuccessMessage?: boolean, - headers?: Headers + headers?: HeadersInit // 控制是否返回完整响应对象(默认 false 返回 data) returnFullResponse?: boolean // 响应类型 @@ -215,19 +216,28 @@ class Http { } // 构建请求选项 + const requestHeaders: Record = { + 'Content-Type': 'application/json', + ...(this.options.headers as Record), + ...((config.headers || {}) as Record), + ...((param.headers || {}) as Record) + } + const requestOptions: RequestInit = { method, - headers: { - 'Content-Type': 'application/json', - ...this.options.headers, - ...(config.headers || {}), - ...(param.headers || {}) - } + headers: requestHeaders } + + let encryptSession: any = null // 添加请求体 - if (param.body) { - requestOptions.body = JSON.stringify(param.body) + const encryptedRequest = await prepareEncryptedRequest(baseURL, `/${normalizedUrl}`, method, param.body) + encryptSession = encryptedRequest.session + + Object.assign(requestHeaders, encryptedRequest.headers) + + if (encryptedRequest.body) { + requestOptions.body = encryptedRequest.body } // 发送请求 @@ -248,11 +258,12 @@ class Http { if (responseType === 'json') { data = await response.json() + data = await decryptEncryptedResponse(data, encryptSession) } else if (responseType === 'blob') { // 对于blob类型,直接读取blob并返回 try { const blob = await response.blob() - resolve(blob) + resolve(blob as T) return } catch (error) { console.error('[request] Error reading blob:', error) @@ -304,10 +315,11 @@ class Http { console.log('[request] New token:', newToken?.substring(0, 20) + '...') // 重新发送请求 this.options.headers['Authorization'] = `Bearer ${newToken}` - requestOptions.headers['Authorization'] = `Bearer ${newToken}` + requestHeaders['Authorization'] = `Bearer ${newToken}` return fetch(fullURL, requestOptions) }).then(async (newResponse) => { - const newData = await newResponse.json() + let newData = await newResponse.json() + newData = await decryptEncryptedResponse(newData, encryptSession) if (newData.code == 0) { if (config.returnFullResponse) { resolve(newData as T) diff --git a/app/utils/security/api-encrypt.ts b/app/utils/security/api-encrypt.ts new file mode 100644 index 0000000..d4765ad --- /dev/null +++ b/app/utils/security/api-encrypt.ts @@ -0,0 +1,276 @@ +interface PublicKeyResponse { + enabled: boolean + scope: string + algorithm: string + key_version: string + public_key: string + encrypted_paths?: string[] +} + +interface EncryptSession { + aesKey: Uint8Array + iv: Uint8Array + keyVersion: string + encryptedKey: string + encryptedIv: string +} + +interface EncryptRequestResult { + body: string + headers: Record + session: EncryptSession | null +} + +const PUBLIC_KEY_PATH = '/system/security/api-encrypt/public-key' +let publicKeyCache: PublicKeyResponse | null = null +let publicKeyFailureAt = 0 +const PUBLIC_KEY_RETRY_INTERVAL = 30000 + +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder() + +function normalizeBaseUrl(baseURL: string): string { + return baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL +} + +function normalizePath(path: string): string { + return path.startsWith('/') ? path : `/${path}` +} + +function resolveRequestPath(baseURL: string, path: string): string { + const normalizedBaseUrl = normalizeBaseUrl(baseURL) + const normalizedPath = normalizePath(path) + + if (/^https?:\/\//i.test(normalizedPath)) { + const url = new URL(normalizedPath) + return url.pathname + } + + if (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/adminapi/')) { + return normalizedPath + } + + try { + const url = new URL(`${normalizedBaseUrl}${normalizedPath}`, window.location.origin) + return url.pathname + } catch { + return normalizedPath + } +} + +function shouldSkipEncrypt(path: string): boolean { + return path === '/api/auth/refresh' + || path === '/adminapi/auth/refresh' + || path === '/api/system/security/api-encrypt/public-key' + || path === '/adminapi/system/security/api-encrypt/public-key' +} + +function matchesEncryptedPath(path: string, encryptedPaths: string[] = []): boolean { + const normalizedPath = normalizePath(path) + return encryptedPaths.some((encryptedPath) => { + const normalizedEncryptedPath = normalizePath(encryptedPath) + return normalizedEncryptedPath === normalizedPath || normalizedPath.startsWith(`${normalizedEncryptedPath}/`) + }) +} + +function toBase64(buffer: ArrayBuffer | Uint8Array): string { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer) + let binary = '' + bytes.forEach((byte) => { + binary += String.fromCharCode(byte) + }) + return btoa(binary) +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer +} + +function fromPem(pem: string): ArrayBuffer { + const clean = pem.replace(/-----BEGIN PUBLIC KEY-----/g, '') + .replace(/-----END PUBLIC KEY-----/g, '') + .replace(/\s+/g, '') + const binary = atob(clean) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i) + } + return bytes.buffer +} + +async function importPublicKey(publicKeyPem: string): Promise { + return crypto.subtle.importKey( + 'spki', + fromPem(publicKeyPem), + { + name: 'RSA-OAEP', + hash: 'SHA-256' + }, + true, + ['encrypt'] + ) +} + +async function rsaEncrypt(publicKeyPem: string, data: Uint8Array): Promise { + const publicKey = await importPublicKey(publicKeyPem) + const encrypted = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, data) + return toBase64(encrypted) +} + +async function aesEncrypt(plainText: string, aesKey: Uint8Array, iv: Uint8Array): Promise { + const cryptoKey = await crypto.subtle.importKey( + 'raw', + toArrayBuffer(aesKey), + { name: 'AES-CBC' }, + false, + ['encrypt'] + ) + const encrypted = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toArrayBuffer(iv) }, cryptoKey, textEncoder.encode(plainText)) + return toBase64(encrypted) +} + +async function aesDecrypt(cipherText: string, aesKey: Uint8Array, iv: Uint8Array): Promise { + const cryptoKey = await crypto.subtle.importKey( + 'raw', + toArrayBuffer(aesKey), + { name: 'AES-CBC' }, + false, + ['decrypt'] + ) + const binary = atob(cipherText) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i) + } + const decrypted = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toArrayBuffer(iv) }, cryptoKey, toArrayBuffer(bytes)) + return textDecoder.decode(decrypted) +} + +async function fetchPublicKey(baseURL: string): Promise { + const requestUrl = `${normalizeBaseUrl(baseURL)}${PUBLIC_KEY_PATH}` + const response = await fetch(requestUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }) + + const result = await response.json() + if (!result || result.code !== 0 || !result.data) { + return null + } + + return result.data as PublicKeyResponse +} + +async function ensurePublicKey(baseURL: string): Promise { + if (publicKeyCache) { + return publicKeyCache + } + + if (publicKeyFailureAt > 0 && Date.now() - publicKeyFailureAt < PUBLIC_KEY_RETRY_INTERVAL) { + return null + } + + let data: PublicKeyResponse | null = null + try { + data = await fetchPublicKey(baseURL) + } catch { + publicKeyFailureAt = Date.now() + return null + } + + if (!data) { + publicKeyFailureAt = Date.now() + return null + } + + publicKeyFailureAt = 0 + publicKeyCache = data + return publicKeyCache +} + +export function clearApiEncryptPublicKeyCache() { + publicKeyCache = null + publicKeyFailureAt = 0 +} + +export async function prepareEncryptedRequest(baseURL: string, path: string, method: string, body?: unknown): Promise { + const resolvedPath = resolveRequestPath(baseURL, path) + + if (!resolvedPath.startsWith('/api/') && !resolvedPath.startsWith('/adminapi/')) { + return { + body: body ? JSON.stringify(body) : '', + headers: {}, + session: null + } + } + + if (shouldSkipEncrypt(resolvedPath)) { + return { + body: body ? JSON.stringify(body) : '', + headers: {}, + session: null + } + } + + const publicKeyData = await ensurePublicKey(baseURL) + if (!publicKeyData || !publicKeyData.enabled || publicKeyData.scope === 'off') { + return { + body: body ? JSON.stringify(body) : '', + headers: {}, + session: null + } + } + + if (publicKeyData.scope === 'partial' && !matchesEncryptedPath(resolvedPath, publicKeyData.encrypted_paths || [])) { + return { + body: body ? JSON.stringify(body) : '', + headers: {}, + session: null + } + } + + const aesKey = crypto.getRandomValues(new Uint8Array(32)) + const iv = crypto.getRandomValues(new Uint8Array(16)) + const encryptedKey = await rsaEncrypt(publicKeyData.public_key, aesKey) + const encryptedIv = await rsaEncrypt(publicKeyData.public_key, iv) + + let encryptedBody = '' + if (body !== undefined && !['GET', 'HEAD'].includes(method.toUpperCase())) { + const payload = JSON.stringify(body) + encryptedBody = await aesEncrypt(payload, aesKey, iv) + } + + return { + body: encryptedBody === '' ? '' : JSON.stringify({ + encrypted: true, + algorithm: publicKeyData.algorithm, + key_version: publicKeyData.key_version, + data: encryptedBody, + method + }), + headers: { + 'X-Api-Encrypt': '1', + 'X-Api-Encrypt-Version': publicKeyData.key_version, + 'X-Api-Encrypt-Key': encryptedKey, + 'X-Api-Encrypt-Iv': encryptedIv + }, + session: { + aesKey, + iv, + keyVersion: publicKeyData.key_version, + encryptedKey, + encryptedIv + } + } +} + +export async function decryptEncryptedResponse(data: any, session: EncryptSession | null): Promise { + if (!data || data.encrypted !== true || !session) { + return data + } + + const plainText = await aesDecrypt(String(data.data || ''), session.aesKey, session.iv) + return JSON.parse(plainText) +} diff --git a/public/icons/index.json b/public/icons/index.json index 69242f4..b089901 100644 --- a/public/icons/index.json +++ b/public/icons/index.json @@ -3613,7 +3613,7 @@ "simple-icons:stackbit", "simple-icons:stackblitz", "simple-icons:stackedit", - "simple-icons:stackexchange", + "simple-icons:stacmaschange", "simple-icons:stackhawk", "simple-icons:stackoverflow", "simple-icons:stackshare", diff --git a/public/icons/simple-icons.json b/public/icons/simple-icons.json index becd9b3..0b0f172 100644 --- a/public/icons/simple-icons.json +++ b/public/icons/simple-icons.json @@ -2780,7 +2780,7 @@ "simple-icons:stackbit", "simple-icons:stackblitz", "simple-icons:stackedit", - "simple-icons:stackexchange", + "simple-icons:stacmaschange", "simple-icons:stackhawk", "simple-icons:stackoverflow", "simple-icons:stackshare",