Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -262,6 +262,7 @@ const directionLabels = getLabels(row.directionCodes, { separator: ',' });
|
||||
- 如果后端当前接口暂时还返回数值型 ID,前端也必须尽可能在 `typings`、API 适配层或进入业务层前转成 `string`,不要把 ID 按 `number` 扩散到页面、store 和组件里。
|
||||
- 但要注意:如果后端把超出 JS 安全整数范围的 Long 直接作为 JSON 数字返回,前端在业务层再 `String(number)` 只能得到“已经丢精度后的错误字符串”。这种情况必须明确记为接口契约风险,不要误判为“前端已安全处理”。
|
||||
- 因此,新增或改造接口时,最稳妥的契约仍然是:后端长整型 ID 直接按字符串返回;前端全链路按字符串接收和传递。若后端暂未改,前端侧也不得新增 `number` 口径 ID 用法。
|
||||
- API 适配层兜底(实操约束):所有从后端接收的数值型 ID 字段(不论后端实际返回 `string`、`number` 或两者混合),都必须在 `src/service/api/*` 的 normalize 或 map 函数中显式调用 `String(rawId)` 归一一次;前端业务层(`views`、`store`、组件、`Map` 键、路由参数)只接收 `string` 形态,永远不需要自己 `String()`。这条与后端是否做了 Long → String 全局序列化无关——后端做了是双保险,没做且字段取值始终在 JS 安全整数内(例如 `infra_file_config.id` 永远是两位数)也是合理选择,前端 normalize 已经把口径收死,业务层无感。但这条不开按字段取值范围豁免的口子:前端 normalize 是无差别的,任何 ID 都要 `String()`,不要按某个字段当前取值大小决定要不要走 normalize,避免后续逐步污染仓库的 ID 纪律。
|
||||
- 对仓库中的历史代码,原则是“不再新增 number 口径 ID,当前任务触达相关链路时优先顺手矫正”;不要继续复制历史写法。
|
||||
- 遵循仓库现有的 Vue SFC 风格:`script setup`、类型化 store、职责单一的小型 composable/helper。
|
||||
- 修改界面时优先延续 `src/layouts` 和 `src/theme` 中已有的 UI 模式,不要平行引入另一套设计体系。
|
||||
|
||||
@@ -285,6 +285,15 @@ const directionLabels = getLabels(row.directionCodes, { separator: ',' });
|
||||
- **但如果后端把超 JS 安全整数的 Long 直接作为 JSON 数字返回,前端再 `String(number)` 只能得到"已经丢精度后的错误字符串"**。这种情况必须明确记为接口契约风险,不能误判为"已安全处理"。
|
||||
- 最稳妥契约:**后端 Long ID 直接按字符串返回**;前端全链路按字符串。后端未改,前端也不得新增 `number` 口径 ID。
|
||||
|
||||
### API 适配层兜底(操作约束)
|
||||
- 所有从后端接收的数值型 ID 字段,**必须**在 `src/service/api/*` 的 normalize/map 函数里显式 `String(rawId)` 一次——**不管后端返回 string、number、还是混合**。
|
||||
- 业务层(views / store / 组件 / `Map` key / 路由参数)**只接收 string**,从不需要自己 `String()`。
|
||||
- 与"后端是否已经全局 Long → String"**无关**:
|
||||
- 后端做了 → 双保险
|
||||
- 后端没做但取值在 JS 安全整数内 → 单层防御也对(实际值不丢精度)
|
||||
- 后端没做且取值超安全整数 → 不安全,必须推后端改
|
||||
- **不开"按取值范围豁免"的口子**:哪怕后端说"这个字段永远是两位数"(如 `infra_file_config.id`),前端照样 `String()`。否则后续会冒出"projectStatus 是 Long 但只有 0-99,也可以保留 number"等连锁例外,铁律字面被掏空。
|
||||
|
||||
### 历史代码原则
|
||||
不再新增 `number` 口径;当前任务触达相关链路时**顺手矫正**;不要继续复制历史写法。
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ProxyOptions } from 'vite';
|
||||
import { bgRed, bgYellow, green, lightBlue } from 'kolorist';
|
||||
import { consola } from 'consola';
|
||||
import { WEB_SERVICE_PREFIX } from '../../src/constants/service';
|
||||
import { createServiceConfig } from '../../src/utils/service';
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,14 @@ export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
|
||||
Object.assign(proxy, createProxyItem(item, isEnableProxyLog));
|
||||
});
|
||||
|
||||
// 富文本图片 <img src="/admin-api/system/file/{configId}/get/{path}"> 由浏览器直接发起,
|
||||
// 不经过 axios,没有 baseURL 前缀。这里加一条原样透传,避免被 Vite SPA fallback 兜底成 index.html。
|
||||
// 不带 rewrite —— 原样把 /admin-api/* 转发到后端;不影响现有 /proxy-default 链路。
|
||||
proxy[WEB_SERVICE_PREFIX] = {
|
||||
target: baseURL,
|
||||
changeOrigin: true
|
||||
};
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@
|
||||
"@sa/uno-preset": "workspace:*",
|
||||
"@soybeanjs/eslint-config": "1.7.1",
|
||||
"@types/bmapgl": "0.0.7",
|
||||
"@types/dompurify": "3.2.0",
|
||||
"@types/node": "24.3.0",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@unocss/eslint-config": "66.5.0",
|
||||
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -162,9 +162,6 @@ importers:
|
||||
'@types/bmapgl':
|
||||
specifier: 0.0.7
|
||||
version: 0.0.7
|
||||
'@types/dompurify':
|
||||
specifier: 3.2.0
|
||||
version: 3.2.0
|
||||
'@types/node':
|
||||
specifier: 24.3.0
|
||||
version: 24.3.0
|
||||
@@ -1491,10 +1488,6 @@ packages:
|
||||
'@types/d3-timer@3.0.2':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/dompurify@3.2.0':
|
||||
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
|
||||
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
||||
|
||||
@@ -6695,10 +6688,6 @@ snapshots:
|
||||
|
||||
'@types/d3-timer@3.0.2': {}
|
||||
|
||||
'@types/dompurify@3.2.0':
|
||||
dependencies:
|
||||
dompurify: 3.2.6
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
dependencies:
|
||||
'@types/eslint': 9.6.1
|
||||
|
||||
@@ -4,7 +4,7 @@ import '@wangeditor/editor/dist/css/style.css';
|
||||
import { ElImageViewer } from 'element-plus';
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
|
||||
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
||||
import { deleteFile, uploadFile } from '@/service/api/file';
|
||||
import { buildFileProxyUrl, deleteFile, uploadFile } from '@/service/api/file';
|
||||
|
||||
defineOptions({ name: 'BusinessRichTextEditor' });
|
||||
|
||||
@@ -198,10 +198,12 @@ const editorConfig: Partial<IEditorConfig> = {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, url } = result.data;
|
||||
// 用永久代理路径塞 <img src>,不要用 result.data.url(24h 签名会过期)
|
||||
const { id, configId, path } = result.data;
|
||||
const proxyUrl = buildFileProxyUrl(configId, path);
|
||||
// 记录 url -> fileId,后续 commit/rollback 才知道删哪个
|
||||
session.uploadedMap.set(url, id);
|
||||
insertFn(url, file.name, url);
|
||||
session.uploadedMap.set(proxyUrl, id);
|
||||
insertFn(proxyUrl, file.name, proxyUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,9 +216,6 @@ const local: App.I18n.Schema = {
|
||||
plugin_charts_echarts: 'ECharts',
|
||||
plugin_charts_antv: 'AntV',
|
||||
plugin_charts_vchart: 'VChart',
|
||||
plugin_editor: 'Editor',
|
||||
plugin_editor_quill: 'Quill',
|
||||
plugin_editor_markdown: 'Markdown',
|
||||
plugin_icon: 'Icon',
|
||||
plugin_map: 'Map',
|
||||
plugin_print: 'Print',
|
||||
|
||||
@@ -216,9 +216,6 @@ const local: App.I18n.Schema = {
|
||||
plugin_charts_echarts: 'ECharts',
|
||||
plugin_charts_antv: 'AntV',
|
||||
plugin_charts_vchart: 'VChart',
|
||||
plugin_editor: '编辑器',
|
||||
plugin_editor_quill: '富文本编辑器',
|
||||
plugin_editor_markdown: 'MD 编辑器',
|
||||
plugin_icon: '图标',
|
||||
plugin_map: '地图',
|
||||
plugin_print: '打印',
|
||||
|
||||
@@ -44,8 +44,6 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
plugin_charts_echarts: () => import("@/views/plugin/charts/echarts/index.vue"),
|
||||
plugin_charts_vchart: () => import("@/views/plugin/charts/vchart/index.vue"),
|
||||
plugin_copy: () => import("@/views/plugin/copy/index.vue"),
|
||||
plugin_editor_markdown: () => import("@/views/plugin/editor/markdown/index.vue"),
|
||||
plugin_editor_quill: () => import("@/views/plugin/editor/quill/index.vue"),
|
||||
plugin_excel: () => import("@/views/plugin/excel/index.vue"),
|
||||
plugin_gantt_dhtmlx: () => import("@/views/plugin/gantt/dhtmlx/index.vue"),
|
||||
plugin_gantt_vtable: () => import("@/views/plugin/gantt/vtable/index.vue"),
|
||||
|
||||
@@ -425,37 +425,6 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
icon: 'mdi:clipboard-outline'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'plugin_editor',
|
||||
path: '/plugin/editor',
|
||||
meta: {
|
||||
title: 'plugin_editor',
|
||||
i18nKey: 'route.plugin_editor',
|
||||
icon: 'icon-park-outline:editor'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'plugin_editor_markdown',
|
||||
path: '/plugin/editor/markdown',
|
||||
component: 'view.plugin_editor_markdown',
|
||||
meta: {
|
||||
title: 'plugin_editor_markdown',
|
||||
i18nKey: 'route.plugin_editor_markdown',
|
||||
icon: 'ri:markdown-line'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'plugin_editor_quill',
|
||||
path: '/plugin/editor/quill',
|
||||
component: 'view.plugin_editor_quill',
|
||||
meta: {
|
||||
title: 'plugin_editor_quill',
|
||||
i18nKey: 'route.plugin_editor_quill',
|
||||
icon: 'mdi:file-document-edit-outline'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'plugin_excel',
|
||||
path: '/plugin/excel',
|
||||
|
||||
@@ -203,9 +203,6 @@ const routeMap: RouteMap = {
|
||||
"plugin_charts_echarts": "/plugin/charts/echarts",
|
||||
"plugin_charts_vchart": "/plugin/charts/vchart",
|
||||
"plugin_copy": "/plugin/copy",
|
||||
"plugin_editor": "/plugin/editor",
|
||||
"plugin_editor_markdown": "/plugin/editor/markdown",
|
||||
"plugin_editor_quill": "/plugin/editor/quill",
|
||||
"plugin_excel": "/plugin/excel",
|
||||
"plugin_gantt": "/plugin/gantt",
|
||||
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx",
|
||||
|
||||
@@ -1,28 +1,61 @@
|
||||
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||
import { request } from '../request';
|
||||
import { type ServiceRequestResult, mapServiceResult } from './shared';
|
||||
|
||||
const FILE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/file`;
|
||||
|
||||
/**
|
||||
* 拼接文件永久代理路径,用于富文本 <img src>。
|
||||
*
|
||||
* 后端 GET 接口匿名访问、Content-Disposition: inline,私有桶下也不会过期。
|
||||
* 调用方拿到上传响应里的 configId + path 后直接调用本函数得到可写入 HTML 的 url。
|
||||
*/
|
||||
export function buildFileProxyUrl(configId: string, path: string) {
|
||||
return `${FILE_PREFIX}/${configId}/get/${encodeURI(path)}`;
|
||||
}
|
||||
|
||||
export interface UploadFileResult {
|
||||
/** infra_file.id 的字符串形式(避免 Long 精度丢失) */
|
||||
id: string;
|
||||
/** 文件访问 URL:私有桶带签名、公开桶裸 URL */
|
||||
/** 对象存储配置编号(字符串形式),与 path 一起拼接永久代理路径 */
|
||||
configId: string;
|
||||
/** 文件相对路径(含日期目录、文件名),与 configId 一起拼接永久代理路径 */
|
||||
path: string;
|
||||
/**
|
||||
* 文件访问 URL:私有桶带签名(24h 过期)、公开桶裸 URL。
|
||||
* ⚠️ 仅供后端调试 / 历史兼容,禁止写进富文本 <img src> —— 会随签名过期导致回显失效。
|
||||
* 富文本图片请用 buildFileProxyUrl(configId, path) 的返回值。
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
type UploadFileResponse = {
|
||||
id: string | number;
|
||||
configId: string | number;
|
||||
path: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
/** 上传文件(模式一:后端中转) */
|
||||
export function uploadFile(file: File, directory?: string) {
|
||||
export async function uploadFile(file: File, directory?: string) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (directory) {
|
||||
formData.append('directory', directory);
|
||||
}
|
||||
|
||||
return request<UploadFileResult>({
|
||||
const result = await request<UploadFileResponse>({
|
||||
url: `${FILE_PREFIX}/upload`,
|
||||
method: 'post',
|
||||
data: formData
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<UploadFileResponse>, data => ({
|
||||
id: String(data.id),
|
||||
configId: String(data.configId),
|
||||
path: data.path,
|
||||
url: data.url
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
5
src/typings/elegant-router.d.ts
vendored
5
src/typings/elegant-router.d.ts
vendored
@@ -57,9 +57,6 @@ declare module "@elegant-router/types" {
|
||||
"plugin_charts_echarts": "/plugin/charts/echarts";
|
||||
"plugin_charts_vchart": "/plugin/charts/vchart";
|
||||
"plugin_copy": "/plugin/copy";
|
||||
"plugin_editor": "/plugin/editor";
|
||||
"plugin_editor_markdown": "/plugin/editor/markdown";
|
||||
"plugin_editor_quill": "/plugin/editor/quill";
|
||||
"plugin_excel": "/plugin/excel";
|
||||
"plugin_gantt": "/plugin/gantt";
|
||||
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx";
|
||||
@@ -194,8 +191,6 @@ declare module "@elegant-router/types" {
|
||||
| "plugin_charts_echarts"
|
||||
| "plugin_charts_vchart"
|
||||
| "plugin_copy"
|
||||
| "plugin_editor_markdown"
|
||||
| "plugin_editor_quill"
|
||||
| "plugin_excel"
|
||||
| "plugin_gantt_dhtmlx"
|
||||
| "plugin_gantt_vtable"
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import Vditor from 'vditor';
|
||||
import 'vditor/dist/index.css';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
|
||||
defineOptions({ name: 'MarkdownPage' });
|
||||
|
||||
const theme = useThemeStore();
|
||||
|
||||
const vditor = ref<Vditor>();
|
||||
const domRef = ref<HTMLElement>();
|
||||
|
||||
function renderVditor() {
|
||||
if (!domRef.value) return;
|
||||
vditor.value = new Vditor(domRef.value, {
|
||||
minHeight: 400,
|
||||
theme: theme.darkMode ? 'dark' : 'classic',
|
||||
icon: 'material',
|
||||
cache: { enable: false }
|
||||
});
|
||||
}
|
||||
|
||||
const stopHandle = watch(
|
||||
() => theme.darkMode,
|
||||
newValue => {
|
||||
const themeMode = newValue ? 'dark' : 'classic';
|
||||
vditor.value?.setTheme(themeMode);
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
renderVditor();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopHandle();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<ElCard header="markdown插件" class="card-wrapper">
|
||||
<div ref="domRef"></div>
|
||||
<template #footer>
|
||||
<GithubLink link="https://github.com/Vanessa219/vditor" />
|
||||
</template>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,19 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
|
||||
defineOptions({ name: 'QuillPage' });
|
||||
|
||||
const value = ref('<p>hello <strong>wangEditor v5</strong></p>');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<ElCard header="富文本插件" class="card-wrapper">
|
||||
<BusinessRichTextEditor v-model="value" :height="360" upload-directory="demo" />
|
||||
<template #footer>
|
||||
<GithubLink link="https://github.com/wangeditor-next/wangEditor-next" />
|
||||
</template>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user