diff --git a/apps/admin/build/config/proxy.ts b/apps/admin/build/config/proxy.ts index 4e83860..6867d07 100644 --- a/apps/admin/build/config/proxy.ts +++ b/apps/admin/build/config/proxy.ts @@ -4,10 +4,10 @@ import { bgRed, bgYellow, green, lightBlue } from 'kolorist' import { createServiceConfig } from '../../src/utils/service' /** - * Set http proxy + * 设置 HTTP 代理 * - * @param env - The current env - * @param enable - If enable http proxy + * @param env - 当前环境变量 + * @param enable - 是否启用 HTTP 代理 */ export function createViteProxy(env: Env.ImportMeta, enable: boolean) { const isEnableHttpProxy = enable && env.VITE_HTTP_PROXY === 'Y' @@ -18,6 +18,7 @@ export function createViteProxy(env: Env.ImportMeta, enable: boolean) { const isEnableProxyLog = env.VITE_PROXY_LOG === 'Y' const { baseURL, proxyPattern, other } = createServiceConfig(env) + console.log('baseURL:', baseURL, 'proxyPattern:', proxyPattern, 'other:', other) const proxy: Record = createProxyItem({ baseURL, proxyPattern }, isEnableProxyLog) @@ -28,6 +29,12 @@ export function createViteProxy(env: Env.ImportMeta, enable: boolean) { return proxy } +/** + * 创建 HTTP 代理项 + * + * @param item - 服务配置项 + * @param enableLog - 是否启用日志记录 + */ function createProxyItem(item: App.Service.ServiceConfigItem, enableLog: boolean) { const proxy: Record = {} @@ -39,16 +46,16 @@ function createProxyItem(item: App.Service.ServiceConfigItem, enableLog: boolean if (!enableLog) return - const requestUrl = `${lightBlue('[proxy url]')}: ${bgYellow(` ${req.method} `)} ${green(`${item.proxyPattern}${req.url}`)}` + const requestUrl = `${lightBlue('[代理地址]')}: ${bgYellow(` ${req.method} `)} ${green(`${item.proxyPattern}${req.url}`)}` - const proxyUrl = `${lightBlue('[real request url]')}: ${green(`${options.target}${req.url}`)}` + const proxyUrl = `${lightBlue('[真实请求地址]')}: ${green(`${options.target}${req.url}`)}` consola.log(`${requestUrl}\n${proxyUrl}`) }) _proxy.on('error', (_err, req, _res) => { if (!enableLog) return - consola.log(bgRed(`Error: ${req.method} `), green(`${options.target}${req.url}`)) + consola.log(bgRed(`错误: ${req.method} `), green(`${options.target}${req.url}`)) }) }, rewrite: path => path.replace(new RegExp(`^${item.proxyPattern}`), ''), diff --git a/apps/admin/package.json b/apps/admin/package.json index 3870bfd..ab69e3b 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -26,6 +26,7 @@ "dependencies": { "@better-scroll/core": "2.5.1", "@iconify/vue": "5.0.0", + "@opentiny/fluent-editor": "^4.0.1", "@sa/axios": "workspace:*", "@sa/color": "workspace:*", "@sa/hooks": "workspace:*", @@ -37,9 +38,12 @@ "defu": "6.1.4", "echarts": "6.0.0", "json5": "2.2.3", + "katex": "^0.16.27", + "mathlive": "^0.108.2", "naive-ui": "2.43.2", "nprogress": "0.2.0", "pinia": "3.0.4", + "quill-toolbar-tip": "^0.1.0", "tailwind-merge": "3.4.0", "vue": "3.5.26", "vue-draggable-plus": "0.6.0", diff --git a/apps/admin/src/components/common/rest-basic-editor/components/math-iframe-dialog.vue b/apps/admin/src/components/common/rest-basic-editor/components/math-iframe-dialog.vue new file mode 100644 index 0000000..154deaa --- /dev/null +++ b/apps/admin/src/components/common/rest-basic-editor/components/math-iframe-dialog.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/admin/src/components/common/rest-basic-editor/components/up-file-dialog.vue b/apps/admin/src/components/common/rest-basic-editor/components/up-file-dialog.vue new file mode 100644 index 0000000..7c1c7ef --- /dev/null +++ b/apps/admin/src/components/common/rest-basic-editor/components/up-file-dialog.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/apps/admin/src/components/common/rest-basic-editor/editor-util.ts b/apps/admin/src/components/common/rest-basic-editor/editor-util.ts new file mode 100644 index 0000000..53946ce --- /dev/null +++ b/apps/admin/src/components/common/rest-basic-editor/editor-util.ts @@ -0,0 +1,263 @@ +import { getAliOssTokenAxios } from '@/service/api/upload'; +import { browserPathJoin } from '@/utils/date'; +import { initOSSClient, uploadFileToOSS } from '@/utils/oss'; +import type { IEditorConfig, ToolbarOptions } from '@opentiny/fluent-editor/types/config/types'; +import type OSS from 'ali-oss'; +import katex from 'katex'; +import { default as FluentEditor, type MathliveModule, type Range, generateToolbarTip } from '@opentiny/fluent-editor'; +import QuillToolbarTip, { type QuillToolbarTipOptions } from 'quill-toolbar-tip'; +import { base64ToFile } from '@/utils/file'; +import { getSnowflake } from '@/utils/rest'; +import type { GetEditorConfigParams, MyToolbarOption, UpFile } from './types'; +import { ref } from 'vue'; + +/** 自定义打开iframe的按钮的key */ +export const MY_OPEN_PROCESSON_IFRAME = 'my-open-processon-iframe' as const; +export const upFileList = ref([]); + +/** + * 初始化编辑器 + */ +export function initEditor(container: HTMLElement, params: IEditorConfig): FluentEditor { + // 需要依赖 katex + window.katex = katex; + + // 处理图片给每个图片添加类名和最大宽度 + const ImageBlot: any = FluentEditor.import('formats/image'); + class CustomImageBlot extends ImageBlot { + static create(value: any) { + const node: HTMLImageElement = super.create(value); + editImageAttribute(node, false); + return node; + } + } + FluentEditor.register(CustomImageBlot, true); + + // 处理视频给每个视频添加类名和最大宽度 + const VideoBlot: any = FluentEditor.import('formats/video'); + class CustomVideoBlot extends VideoBlot { + static create(value: any) { + const node: HTMLVideoElement = super.create(value); + editVideoAttribute(node, false); + return node; + } + } + FluentEditor.register(CustomVideoBlot, true); + + // 注册工具栏提示模块 + FluentEditor.register({ 'modules/toolbar-tip': generateToolbarTip(QuillToolbarTip) }, true); + const Parchment = FluentEditor.import('parchment'); + addToolbarIcon(); + + // 居中问题 + const config = { scope: Parchment.Scope.BLOCK, whitelist: ['right', 'center', 'justify'] }; + new Parchment.StyleAttributor('align', 'text-align', config); + const Align = FluentEditor.import('attributors/style/align'); + FluentEditor.register(Align, true); + + // 工具栏配置 + return new FluentEditor(container, params); +} + +/** + * 得到编辑器的配置 + */ +export async function getEditorConfig(params: GetEditorConfigParams): Promise { + const editorOptions: IEditorConfig = { + theme: 'snow', + modules: { + // 是否开启数学公式模块 + mathlive: true, + // 开启字数统计 + // counter: { + // count: 2000, + // }, + toolbar: { + // 工具栏显示那些按钮 + container: await getToolbarOption(params.toolbarOption || {}), + handlers: { + formula() { + // 点击数学公式按钮时打开弹出框 + const mathlive = this.quill.getModule('mathlive') as MathliveModule; + mathlive.createDialog(''); + }, + // 自定义打开iframe的按钮的点击事件的回调 + [`${MY_OPEN_PROCESSON_IFRAME}`](_value: boolean) { + params[MY_OPEN_PROCESSON_IFRAME](); // 执行回调 + }, + }, + }, + uploader: { + // only allow image + mimetypes: ['*', 'video/*', 'audio/*'], + async handler(_range: Range, files: File[]): Promise<(string | false)[]> { + try { + const paths = await Promise.all(files.map((file, index) => upFileAxios(file, `${Date.now()}_${index}`))); + return paths; + } catch (error) { + console.log('error====', error); + return files.map(() => false); + } + }, + }, + clipboard: { + matchers: [ + [ + 'img', + (node: HTMLImageElement, delta: any) => { + editImageAttribute(node, false); + return delta; + }, + ], + [ + 'video', + (node: HTMLVideoElement, delta: any) => { + editVideoAttribute(node, false); + return delta; + }, + ], + ], + }, + // 工具栏提示配置 + 'toolbar-tip': { + defaultTooltipOptions: { + tipHoverable: false, + }, + tipTextMap: { + [MY_OPEN_PROCESSON_IFRAME]: '跳转第三方公式编辑器\n可在第三方编辑器编辑好公式后复制过来\n[注意]:需要复制到公式输入框内才能生效', + }, + } satisfies Partial, + // 国际化配置 + i18n: { lang: 'zh-CN' }, + }, + }; + return editorOptions; +} + +/** + * 得到工具栏配置(显示那些按钮) + */ +function getToolbarOption(params: MyToolbarOption): Promise { + const { show, showUpFile, showUpVideo, customToolbar } = params; + + if (show === false) { + return Promise.resolve([] satisfies ToolbarOptions['container']); + } + + if (customToolbar !== undefined) { + return Promise.resolve(customToolbar); + } + const va = ['image']; + showUpVideo !== false && va.push('video'); // 只要不等于false就添加 (undefined也添加) + showUpFile !== false && va.push('file'); // 只要不等于false就添加 (undefined也添加) + // 工具栏配置 + const TOOLBAR_CONFIG: ToolbarOptions['container'] = [ + ['undo', 'redo', 'clean', 'format-painter'], + [ + { header: [1, 2, 3, 4, 5, 6, false] }, + { size: [false, '12px', '14px', '16px', '18px', '20px', '24px', '32px', '36px', '48px', '72px'] }, + { 'line-height': [false, '1.2', '1.5', '1.75', '2', '3', '4', '5'] }, + { 'font': [false, 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', '宋体', '黑体', '微软雅黑', '楷体', '仿宋', '等线'] }, + ], + ['bold', 'italic', 'strike', 'underline', 'divider'], + [{ color: [] }, { background: [] }], + [{ align: '' }, { align: 'center' }, { align: 'right' }, { align: 'justify' }], // 这里可以为字符串 + [{ list: 'ordered' }, { list: 'bullet' }, { list: 'check' }], + [{ script: 'sub' }, { script: 'super' }], + [{ indent: '-1' }, { indent: '+1' }], + ['link', 'blockquote'], + [...va], + ['fullscreen', 'formula', MY_OPEN_PROCESSON_IFRAME], + ]; + return Promise.resolve(TOOLBAR_CONFIG); +} + +/** + * 添加打开iframe的弹出框的自定义按钮 + */ +function addToolbarIcon() { + // 这里时增加按钮 + const myOpenProcessonIframeIcon = ``; + const icons = FluentEditor.import('ui/icons') as Record; + icons[MY_OPEN_PROCESSON_IFRAME] = myOpenProcessonIframeIcon; +} + +/** + * 上传文件 + */ +async function upFileAxios(file: File, id: string): Promise { + const item: UpFile = { id, name: file.name, progress: 0 }; + try { + upFileList.value.push(item); + const tokenRes = await getAliOssTokenAxios(); + // 初始化OSS客户端 + const client = initOSSClient(tokenRes); + const path = browserPathJoin(`temp/${Date.now()}`, file!.name); + // 上传文件 + const res: OSS.MultipartUploadResult = await uploadFileToOSS(client, file, path, (progress) => { + item.progress = Math.floor(progress * 10000) / 100; + }); + const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, res.name); + upFileList.value.splice(upFileList.value.indexOf(item), 1); + return Promise.resolve(url); + // oxlint-disable-next-line no-unused-vars + } catch (error) { + upFileList.value.splice(upFileList.value.indexOf(item), 1); + window.$message?.error('上传失败'); + return Promise.resolve(false); + } +} + +/** 编辑视频属性 */ +export function editVideoAttribute(node: HTMLVideoElement, isReplace = true) { + node.setAttribute('style', 'max-width: 100%;'); // 强制内联样式 + node.classList.add('my-ql-video'); + isReplace && replaceFileUrl(node); +} + +/** 编辑图片属性 */ +export function editImageAttribute(node: HTMLImageElement, isReplace = true) { + node.setAttribute('style', 'max-width: 100%;'); // 强制内联样式 + node.classList.add('my-ql-image'); + isReplace && replaceFileUrl(node); +} + +/** + * base64替换为url地址 + * @param node + */ +export function replaceFileUrl(node: HTMLImageElement | HTMLVideoElement) { + const ex = getExtensionFromDataUrl(node.src); + if (ex) { + const file = base64ToFile(node.src, `${Date.now()}_${getSnowflake()}.${ex}`); + upFileAxios(file, file.name); + } +} + +const mimeToExtension: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg': 'webp', + 'video/mp4': 'mp4', + 'audio/mpeg': 'mp3', + 'application/pdf': 'pdf', + 'text/plain': 'txt', +}; + +/** + * 从 base64 获取文件扩展名 + * @param dataUrl + * @param mimeMap + */ +function getExtensionFromDataUrl(dataUrl: string, mimeMap = mimeToExtension): string | null { + const base64Pattern = /^data:(image|video)\/([a-zA-Z]+);base64,([A-Za-z0-9+/]+={0,2})$/; + if (base64Pattern.test(dataUrl)) { + const mimeType = dataUrl.split(',')?.[0]?.split(':')?.[1]?.split(';')[0]; + return mimeType && mimeType in mimeMap ? mimeMap[mimeType]! : null; + } else { + return null; + } +} diff --git a/apps/admin/src/components/common/rest-basic-editor/rest-basic-editor.vue b/apps/admin/src/components/common/rest-basic-editor/rest-basic-editor.vue new file mode 100644 index 0000000..c25865c --- /dev/null +++ b/apps/admin/src/components/common/rest-basic-editor/rest-basic-editor.vue @@ -0,0 +1,236 @@ + + + + + + + diff --git a/apps/admin/src/components/common/rest-basic-editor/types.ts b/apps/admin/src/components/common/rest-basic-editor/types.ts new file mode 100644 index 0000000..7d678bc --- /dev/null +++ b/apps/admin/src/components/common/rest-basic-editor/types.ts @@ -0,0 +1,23 @@ +import type { ToolbarOptions } from '@opentiny/fluent-editor'; +import type { MY_OPEN_PROCESSON_IFRAME } from './editor-util'; + +export type MyToolbarOption = { + /** 是否显示工具栏 */ + show?: boolean; + /** 是否显示上传文件按钮 */ + showUpFile?: boolean; + /** 是否显示上传视频按钮 */ + showUpVideo?: boolean; + /** 自定义工具栏 */ + customToolbar?: ToolbarOptions['container']; +}; + +export type GetEditorConfigParams = { + toolbarOption?: MyToolbarOption; + [MY_OPEN_PROCESSON_IFRAME]: () => void; +}; +export type UpFile = { + id: string; + name: string; + progress: number; +}; diff --git a/apps/admin/src/components/custom/README.md b/apps/admin/src/components/custom/README.md new file mode 100644 index 0000000..8aa0345 --- /dev/null +++ b/apps/admin/src/components/custom/README.md @@ -0,0 +1,113 @@ +# SvgIcon 图标组件使用指南 + +`SvgIcon` 是本项目统一使用的图标组件,底层基于 `@iconify/vue`,支持渲染 **Iconify 开源图标** 和 **本地 SVG 图标**。 + +## 1. 快速开始 + +### 1.1 使用 Iconify 图标 (推荐) +本项目集成了 [Iconify](https://iconify.design/),可直接使用海量开源图标库(如 Material Design, Carbon, Phosphor 等)。 + +**语法**: +```vue + +``` + +**示例**: +```vue + + + + + +``` + +### 1.2 使用本地 SVG 图标 +当需要使用设计师提供的自定义图标或彩色图标时。 + +1. **存放**:将 `.svg` 文件放入 `src/assets/svg-icon/` 目录。 +2. **引用**:使用 `local-icon` 属性引用文件名(不含 `.svg` 后缀)。 + +**语法**: +```vue + +``` + +**示例**: +假设文件位于 `src/assets/svg-icon/custom-logo.svg`: +```vue + +``` + +--- + +## 2. 属性说明 (Props) + +该组件定义在 `src/components/custom/svg-icon.vue`。 + +| 属性名 | 类型 | 必填 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | :--- | +| `icon` | `string` | 否 | - | Iconify 图标名称,格式为 `collection:name`。 | +| `localIcon` | `string` | 否 | - | 本地 SVG 文件名。**优先级高于 `icon`**。 | + +> **提示**: +> - 组件设置了 `inheritAttrs: false`,但会手动绑定 `class` 和 `style` 到根元素。 +> - 你可以通过 Tailwind CSS 类名(如 `text-xl`, `text-red-500`)直接控制图标的大小和颜色。 + +--- + +## 3. 如何查找与使用图标 + +推荐使用 [Icones.js.org](https://icones.js.org/) 图标搜索引擎。 + +### 3.1 查找步骤 +1. 打开 [Icones.js.org](https://icones.js.org/)。 +2. 输入关键词搜索(如 `user`, `setting`)。 +3. 点击选中的图标,复制底部的 **ID**(例如 `mdi:shield-airplane-outline`)。 + +### 3.2 使用示例对照 + +| Icones 图标 ID | 在本项目中的写法 (推荐) | +| :--- | :--- | +| `mdi:shield-airplane-outline` | `` | +| `material-symbols:android-wifi-4-bar-question` | `` | +| `solar:minimize-square-minimalistic-outline` | `` | +| `tabler:align-box-top-right` | `` | + +--- + +## 4. 常见问题与进阶 + +### 4.1 什么是 `icon-ic-baseline-refresh`? +在项目中(如 `competition-add/index.vue`)你可能会看到这种写法: +```vue + +``` +这是 `unplugin-icons` 插件提供的**自动组件导入**功能。 +- **命名规则**:`{Prefix}-{Collection}-{Name}` +- **配置来源**:`.env` 文件中的 `VITE_ICON_PREFIX=icon`。 +- **解析**:`icon-ic-baseline-refresh` 对应图集 `ic` 下的 `baseline-refresh` 图标。 + +**建议**:虽然支持这种写法,但为了统一性和灵活性(支持动态变量),**推荐统一使用 `` 组件**。 + +### 4.2 在 Render 函数中使用 (TSX) +在 Naive UI 的 `NTree`, `NDataTable` 或 `NMenu` 等需要渲染函数的场景中: + +```ts +import { h } from 'vue' +import SvgIcon from '@/components/custom/svg-icon.vue' + +// 渲染 Iconify 图标 +function renderIcon() { + return h(SvgIcon, { + icon: 'carbon:folder', + class: 'text-gray-500' + }) +} + +// 渲染本地图标 +function renderLocalIcon() { + return h(SvgIcon, { + localIcon: 'custom-logo' + }) +} +``` diff --git a/apps/admin/src/constants/business.ts b/apps/admin/src/constants/business.ts new file mode 100644 index 0000000..9fa06b5 --- /dev/null +++ b/apps/admin/src/constants/business.ts @@ -0,0 +1,29 @@ +import { transformRecordToOption } from '@/utils/common'; + +export const enableStatusRecord: Record = { + '1': '启用', + '2': '禁用' +}; + +export const enableStatusOptions = transformRecordToOption(enableStatusRecord); + +export const userGenderRecord: Record = { + '1': '男', + '2': '女' +}; + +export const userGenderOptions = transformRecordToOption(userGenderRecord); + +export const menuTypeRecord: Record = { + '1': '目录', + '2': '菜单' +}; + +export const menuTypeOptions = transformRecordToOption(menuTypeRecord); + +export const menuIconTypeRecord: Record = { + '1': 'iconify', + '2': '本地图标' +}; + +export const menuIconTypeOptions = transformRecordToOption(menuIconTypeRecord); diff --git a/apps/admin/src/hooks/common/table.ts b/apps/admin/src/hooks/common/table.ts index d670e70..7983373 100644 --- a/apps/admin/src/hooks/common/table.ts +++ b/apps/admin/src/hooks/common/table.ts @@ -13,13 +13,13 @@ export type UseNaiveTableOptions & { /** - * get column visible + * 获取列的可见性 * * @param column * * @default true * - * @returns true if the column is visible, false otherwise + * @returns 如果列可见返回 true,否则返回 false */ getColumnVisible?: (column: NaiveUI.TableColumn) => boolean } @@ -38,7 +38,7 @@ export function useNaiveTable(options: UseNaiveTableOptio getColumns, }) - // calculate the total width of the table this is used for horizontal scrolling + // 计算表格的总宽度,用于水平滚动 const scrollX = computed(() => { return result.columns.value.reduce((acc, column) => { return acc + Number(column.width ?? column.minWidth ?? 120) @@ -69,7 +69,7 @@ type PaginationParams = Pick type UseNaivePaginatedTableOptions = UseNaiveTableOptions & { paginationProps?: Omit /** - * whether to show the total count of the table + * 是否显示表格的总条数 * * @default true */ @@ -104,7 +104,7 @@ export function useNaivePaginatedTable( ...options.paginationProps, }) as PaginationProps - // this is for mobile, if the system does not support mobile, you can use `pagination` directly + // 针对移动端,如果系统不支持移动端,可以直接使用 `pagination` const mobilePagination = computed(() => { const p: PaginationProps = { ...pagination, @@ -186,7 +186,7 @@ export function useTableOperate( openDrawer() } - /** the editing row data */ + /** 编辑行数据 */ const editingData = shallowRef(null) function handleEdit(id: TableData[keyof TableData]) { @@ -197,10 +197,10 @@ export function useTableOperate( openDrawer() } - /** the checked row keys of table */ + /** 表格的选中行 keys */ const checkedRowKeys = shallowRef([]) - /** the hook after the batch delete operation is completed */ + /** 批量删除操作完成后的钩子 */ async function onBatchDeleted() { window.$message?.success($t('common.deleteSuccess')) @@ -209,7 +209,7 @@ export function useTableOperate( await getData() } - /** the hook after the delete operation is completed */ + /** 删除操作完成后的钩子 */ async function onDeleted() { window.$message?.success($t('common.deleteSuccess')) diff --git a/apps/admin/src/layouts/modules/global-logo/index.vue b/apps/admin/src/layouts/modules/global-logo/index.vue index be617d4..3dd27c2 100644 --- a/apps/admin/src/layouts/modules/global-logo/index.vue +++ b/apps/admin/src/layouts/modules/global-logo/index.vue @@ -17,7 +17,7 @@ interface Props {