Compare commits

..
11 Commits
Author SHA1 Message Date
Timi ccbb924c9a support sms login 2026-08-21 18:14:07 +08:00
Timi 98ab992728 refactor API export 2026-08-21 16:34:39 +08:00
Timi 175838f7c5 add notify user 2026-08-21 01:39:43 +08:00
Timi 68d9946b70 add tiptap 2026-08-21 01:11:23 +08:00
Timi 763783b984 add Notify 2026-08-20 22:58:53 +08:00
Timi 5302ad9798 add contact-number 2026-08-20 22:27:27 +08:00
Timi 7c937a090f add pass-time-label 2026-08-18 11:56:03 +08:00
Timi bb1fdb5547 update permission 2026-08-11 12:02:38 +08:00
Timi e5473eb499 update api 2026-08-07 19:44:42 +08:00
Timi f0ba55fe25 remove deprecated api 2026-08-07 10:46:14 +08:00
Timi efea624376 v0.0.24 2026-08-01 17:35:48 +08:00
72 changed files with 6779 additions and 650 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ module.exports = {
"extends": [ "extends": [
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"plugin:vue/vue3-essential", "plugin:vue/essential",
"./.eslintrc-auto-import.json" "./.eslintrc-auto-import.json"
], ],
"overrides": [ "overrides": [
+38
View File
@@ -0,0 +1,38 @@
# 2026-08-06 工作日志
## TypeScript enum 重构(全量消除 enum
用户决定改用纯联合类型,自行修改依赖方。全项目 18 处 enum 分两类处理:
### 改为纯联合类型(字符串枚举,仅作类型用)
- `Attachment.ts` AttachmentBizType、`Template.ts` TemplateBizType
- `Model.ts` RunEnv / OrderType / ImageTypeImageType 值为 ir-auto 等,与 key 不同)
- `Comment.ts` CommentReplyBizType / CommentBizType
- `User.ts` UserAttachType(在 UserAPI.ts 有值引用,已同步改为字符串字面量,import 改 type-only
### 改为 const 对象 + 派生类型(数字枚举或运行时值引用,纯联合不可行)
- `User.ts` LoginType、`Article.ts` ArticleType / ArticleAttachType / ArticleSoftwareDownloadType / ArticleSoftwareRuntime(数字枚举,保留数值避免破坏数据契约)
- `deviceStore.ts` Breakpoints(数字,用于比较 width < Breakpoints.SM
- `IOSize.ts` Unit(用 Object.keys/values + Unit.KB 值引用)
- `Prismjs.ts` PrismjsType / PrismjsViewerMap 键值引用)
- `directives/Popup.ts` PopupTypeswitch case 值引用)
### 结论
- `vue-tsc --noEmit` 通过(exit 0
- ESLint 因项目缺 eslint.config.js(仍用旧 .eslintrcESLint9 不支持)无法运行,属项目既有配置问题
- 判断依据:纯联合无运行时存在,凡 `Object.keys(enum)``map.set(Enum.X)``case Enum.X``Enum.X` 作值的都无法纯联合,必须 const 对象
## Storage.ts 设计修正
- `getString` 返回 `string | null`(对齐 `localStorage.getItem` 契约),不再 throw
- `getJSON` 返回 `unknown`,键不存在时返回 `null`,值无法解析时才抛错
- `getObject` 返回 `T | null`,不再 throw(修正原 JSDoc 已写 `T | undefined` 但代码 throw 的矛盾)
- `is` / `not` 键不存在时自然返回 `false` / `true`,不再崩
- `has` 去掉死代码 `!== undefined`localStorage.getItem 只返回 string|null),单次查询
- `vue-tsc --noEmit` 通过
## Toolkit.ts debounce 修复
- **Bug 修复**`cancel()` 原先只清定时器不重置 `leading` 状态,导致立即模式下 cancel 后首次调用丢失 leading 触发
- **类型修复**:返回类型从 `T & { cancel(): void }` 改为 `((...args: Parameters<T>) => void) & { cancel(): void }`,防抖函数实际返回 void 而非 T 的返回值
- 补充完整 JSDoc(含 `@example`),说明立即模式(leading + trailing)和延迟模式(trailing only)的行为差异
- 状态变量 `immediate``leading`,参数 `defaultImmediate``immediate`,语义更清晰
- 去掉 `eslint-disable-next-line` 和多余注释
- `vue-tsc --noEmit` 通过
+6 -9
View File
@@ -1,5 +1,10 @@
<template> <template>
<div class="root"> <div class="root">
<pass-time-label :timestamp="1786694772700" underline />
<br>
<pass-time-label :timestamp="1786694772700" only-date />
<br>
<pass-time-label :timestamp="1786694772700" only-time />
<copyright /> <copyright />
</div> </div>
</template> </template>
@@ -7,17 +12,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import {axios, Copyright, SettingMapper} from "timi-web"; import {axios, Copyright, SettingMapper} from "timi-web";
import CommonAPI from "../src/api/CommonAPI"; import CommonAPI from "../src/api/CommonAPI";
import PassTimeLabel from "../src/components/passtime-label/index.vue";
onMounted(async () => { onMounted(async () => {
const result = await CommonAPI.settingMap({
"SYSTEM": [
"SETTING_TTL"
]
});
SettingMapper.appendSettingMap(result);
console.log(SettingMapper.getValue("SYSTEM", "SETTING_TTL").value);
}) })
</script> </script>
+22 -2
View File
@@ -5,7 +5,7 @@
"module": "./dist/timi-web.mjs", "module": "./dist/timi-web.mjs",
"style": "./dist/timi-web.css", "style": "./dist/timi-web.css",
"private": false, "private": false,
"version": "0.0.23", "version": "0.0.24",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -39,6 +39,24 @@
"node": ">=16.0.0" "node": ">=16.0.0"
}, },
"dependencies": { "dependencies": {
"@tiptap/extension-code-block": "^3.22.5",
"@tiptap/extension-color": "^3.22.4",
"@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-placeholder": "^3.22.4",
"@tiptap/extension-subscript": "^3.22.4",
"@tiptap/extension-superscript": "^3.22.4",
"@tiptap/extension-table": "^3.22.5",
"@tiptap/extension-table-cell": "^3.22.5",
"@tiptap/extension-table-header": "^3.22.5",
"@tiptap/extension-table-row": "^3.22.5",
"@tiptap/extension-text-align": "^3.22.4",
"@tiptap/extension-text-style": "^3.22.4",
"@tiptap/extension-underline": "^3.22.4",
"@tiptap/pm": "^3.30.2",
"@tiptap/starter-kit": "^3.22.4",
"@tiptap/vue-3": "^3.22.4",
"@marsio/vue-split-pane": "^1.0.0",
"axios": "1.18.0", "axios": "1.18.0",
"less": "4.5.1", "less": "4.5.1",
"pinia": "^3.0.2", "pinia": "^3.0.2",
@@ -50,6 +68,7 @@
"terser": "^5.44.1" "terser": "^5.44.1"
}, },
"devDependencies": { "devDependencies": {
"@types/marked": "^6.0.0",
"@types/node": "^25.0.8", "@types/node": "^25.0.8",
"@types/prismjs": "1.26.5", "@types/prismjs": "1.26.5",
"@typescript-eslint/eslint-plugin": "^8.53.0", "@typescript-eslint/eslint-plugin": "^8.53.0",
@@ -74,6 +93,7 @@
"vitest": "^4.1.4", "vitest": "^4.1.4",
"vue": "^3.5.26", "vue": "^3.5.26",
"vue-tsc": "^3.2.2", "vue-tsc": "^3.2.2",
"vue-eslint-parser": "^10.0.0" "vue-eslint-parser": "^10.0.0",
"tdesign-vue-next": "^1.20.6"
} }
} }
+960
View File
File diff suppressed because it is too large Load Diff
+22 -24
View File
@@ -1,27 +1,25 @@
import { ArticleView } from "../types"; import type { Article } from "../types";
import { axios } from "./BaseAPI"; import { axios } from "./BaseAPI";
/** export const ArticleAPI = {
* 获取文章 /**
* * 获取文章
* @param id 文章 ID *
* @returns 文章数据 * @param id 文章 ID
*/ * @returns 文章数据
async function view(id: string): Promise<ArticleView<any>> { */
return axios.get(`/article/${id}`); async view(id: string): Promise<Article> {
} return axios.get(`/article/${id}`);
},
/** /**
* 喜欢文章,后端有限调用,1240ms 一次 * 喜欢文章,后端有限调用,1240ms 一次
* *
* @param id 文章 ID * @param id 文章 ID
* @returns 最新喜欢数量 * @returns 最新喜欢数量
*/ */
async function like(id: string): Promise<number> { async like(id: string): Promise<number> {
return axios.get(`/article/like/${id}`); return axios.get(`/article/like/${id}`);
} }
export default {
view,
like,
}; };
export default ArticleAPI;
+53 -51
View File
@@ -1,58 +1,60 @@
import {Attachment, BizUpdateReq, TempFileResp} from "../types"; import { Attachment, BizIdUpdate, TempFile } from "../types";
import {axios} from "./BaseAPI"; import { axios } from "./BaseAPI";
import CommonAPI from "./CommonAPI"; import CommonAPI from "./CommonAPI";
const BASE_URI = "/attach"; const BASE_URI = "/attach";
/** export const AttachmentAPI = {
* 上传临时附件 /**
* * 上传临时附件
* @param file 文件或文件列表 *
* @param ttl 有效期 * @param file 文件或文件列表
* @returns 临时附件列表 * @param ttl 有效期
*/ * @returns 临时附件列表
async function uploadTemp(file: File | File[], ttl?: string): Promise<TempFileResp[]> { */
const formData = new FormData(); async upload(file: File | File[], ttl?: string): Promise<TempFile[]> {
const fileList = Array.isArray(file) ? file : [file]; const formData = new FormData();
for (const item of fileList) { const fileList = Array.isArray(file) ? file : [file];
formData.append("file", item); for (const item of fileList) {
} formData.append("file", item);
if (ttl) {
formData.append("ttl", ttl);
}
return await axios.post(`${BASE_URI}/temp/upload`, formData);
}
/**
* 按业务差分更新附件
*
* @param req 更新请求
*/
async function updateByBiz(req: BizUpdateReq): Promise<void> {
return await axios.post(`${BASE_URI}/update/biz`, req);
}
/**
* 按业务查询附件列表
*
* @param bizType 业务类型
* @param bizId 业务 ID
* @param attachTypeList 附件类型列表
*/
async function listByBiz(bizType: string, bizId: string, attachTypeList?: string[]): Promise<Attachment[]> {
return await axios.get(`${BASE_URI}/list/biz`, {
params: {
bizType,
bizId,
attachTypeList
} }
}); if (ttl) {
} formData.append("ttl", ttl);
}
return await axios.post(`${BASE_URI}/temp/upload`, formData);
},
/**
* 按业务差分更新附件
*
* @param req 更新请求
*/
async updateByBizId(req: BizIdUpdate): Promise<void> {
return await axios.post(`${BASE_URI}/update/biz/id`, req);
},
/**
* 按业务查询附件列表
*
* @param bizType 业务类型
* @param bizId 业务 ID
* @param attachTypeList 附件类型列表
*/
async listByBizId(bizType: string, bizId: string, attachTypeList?: string[]): Promise<Attachment[]> {
return await axios.get(`${BASE_URI}/list/biz/id`, {
params: {
bizType,
bizId,
attachTypeList
}
});
},
export default { getReadURL(id: string): string {
uploadTemp, return CommonAPI.getAttachmentReadAPI(id);
updateByBiz, },
listByBiz,
getReadURL: CommonAPI.getAttachmentReadAPI, getTempReadURL(id: string): string {
getTempReadURL: CommonAPI.getAttachmentTempReadAPI return CommonAPI.getAttachmentTempReadAPI(id);
}
}; };
export default AttachmentAPI;
+10 -22
View File
@@ -1,27 +1,15 @@
import { CaptchaData, Comment, CommentReply, Page, PageResult } from "../types"; import { CaptchaData, Comment, Page, PageResult } from "../types";
import { axios } from "./BaseAPI"; import { axios } from "./BaseAPI";
const BASE_URI = "/comment"; const BASE_URI = "/comment";
async function page(page: Page<Comment>): Promise<PageResult<Comment>> { export const CommentAPI = {
return axios.post(`${BASE_URI}/list`, page); async page(page: Page<Comment>): Promise<PageResult<Comment>> {
} return axios.post(`${BASE_URI}/list`, page);
},
async function create(captchaData: CaptchaData<Comment>): Promise<void> { async create(captchaData: CaptchaData<Comment>): Promise<void> {
return axios.post(`${BASE_URI}/create`, captchaData); return axios.post(`${BASE_URI}/create`, captchaData);
} }
async function pageReply(page: Page<CommentReply>): Promise<PageResult<CommentReply>> {
return axios.post(`${BASE_URI}/reply/list`, page);
}
async function createReply(captchaData: CaptchaData<CommentReply>): Promise<void> {
return axios.post(`${BASE_URI}/reply/create`, captchaData);
}
export default {
page,
create,
createReply,
pageReply
}; };
export default CommentAPI;
+33 -40
View File
@@ -1,5 +1,5 @@
import {CaptchaResult, Setting, TemplateBizType} from "../types"; import { CaptchaResult, Setting } from "../types";
import {axios} from "./BaseAPI"; import { axios } from "./BaseAPI";
function getBaseURI(): string { function getBaseURI(): string {
const baseURL = axios.defaults.baseURL; const baseURL = axios.defaults.baseURL;
@@ -9,44 +9,37 @@ function getBaseURI(): string {
return baseURL.replace(/\/$/, ""); return baseURL.replace(/\/$/, "");
} }
const getCaptchaAPI = () => `${getBaseURI()}/captcha`; export const CommonAPI = {
getCaptchaAPI(): string {
async function captcha(width: number, height: number): Promise<CaptchaResult> { return `${getBaseURI()}/captcha`;
return await axios.get(`/captcha?width=${width}&height=${height}`); },
} async captcha(width: number, height: number): Promise<CaptchaResult> {
return await axios.get(`/captcha?width=${width}&height=${height}`);
const getAttachmentReadAPI = (id: string) => `${getBaseURI()}/attach/read/${id}`; },
getAttachmentReadAPI(id: string): string {
const getAttachmentTempReadAPI = (id: string) => `${getBaseURI()}/attach/temp/read?id=${id}`; return `${getBaseURI()}/attach/read/${id}`;
},
async function getTemplate(bizType: TemplateBizType, code: string): Promise<string> { getAttachmentTempReadAPI(id: string): string {
return axios.get(`/template?bizType=${bizType}&bizCode=${code}`); return `${getBaseURI()}/attach/temp/read?id=${id}`;
} },
async settingMap(map: Record<string, string[]>): Promise<Map<string, Map<string, Setting>>> {
async function settingMap(map: Record<string, string[]>): Promise<Map<string, Map<string, Setting>>> { const raw = await axios.post("/setting/map", map);
const raw = await axios.post("/setting/map", map); const moduleMap = new Map<string, Map<string, Setting>>();
const moduleMap = new Map<string, Map<string, Setting>>(); if (!raw || typeof raw !== "object") {
if (!raw || typeof raw !== "object") { return moduleMap;
}
for (const [module, settingRawMap] of Object.entries(raw as Record<string, unknown>)) {
if (!settingRawMap || typeof settingRawMap !== "object") {
continue;
}
const settingMap = new Map<string, Setting>();
for (const [key, setting] of Object.entries(settingRawMap as Record<string, Setting>)) {
settingMap.set(key, setting);
}
moduleMap.set(module, settingMap);
}
return moduleMap; return moduleMap;
} }
for (const [module, settingRawMap] of Object.entries(raw as Record<string, unknown>)) {
if (!settingRawMap || typeof settingRawMap !== "object") {
continue;
}
const settingMap = new Map<string, Setting>();
for (const [key, setting] of Object.entries(settingRawMap as Record<string, Setting>)) {
settingMap.set(key, setting);
}
moduleMap.set(module, settingMap);
}
return moduleMap;
}
export default {
getCaptchaAPI,
captcha,
getAttachmentReadAPI,
getAttachmentTempReadAPI,
getTemplate,
settingMap
}; };
export default CommonAPI;
+6 -6
View File
@@ -3,10 +3,10 @@ import { axios } from "./BaseAPI";
const BASE_URI = "/git/developer"; const BASE_URI = "/git/developer";
async function get(): Promise<Developer> { export const DeveloperAPI = {
return axios.post(`${BASE_URI}`); async get(): Promise<Developer> {
} return axios.post(`${BASE_URI}`);
}
export default {
get
}; };
export default DeveloperAPI;
+51
View File
@@ -0,0 +1,51 @@
import { axios } from "./BaseAPI";
import type { CaptchaData, Notify, NotifyDetail, Page, PageResult } from "../types";
const BASE_URI = "/notify/internal";
export const NotifyAPI = {
async smsSend(captchaData: CaptchaData<Notify>) {
return await axios.post("/notify/sms/captcha/create", captchaData);
},
/** 查询当前用户的站内通知详情。 */
async internalDetail(id: string): Promise<NotifyDetail> {
return await axios.post(`${BASE_URI}/detail`, undefined, {
params: { id }
});
},
/** 查询当前用户的站内通知。 */
async listInternal(page?: Page<NotifyDetail>, unreadOnly = false): Promise<PageResult<NotifyDetail>> {
return await axios.post(`${BASE_URI}/internal`, page, {
params: { unreadOnly }
});
},
/** 查询当前用户未读站内通知数量。 */
async countUnreadInternal(): Promise<number> {
return await axios.post(`${BASE_URI}/unread/count`);
},
/** 标记当前用户的一条站内通知已读。 */
async markInternalRead(id: string): Promise<void> {
await axios.post(`${BASE_URI}/read`, undefined, {
params: { id }
});
},
/** 删除当前用户的一条站内通知。 */
async deleteInternal(id: string): Promise<void> {
await axios.post(`${BASE_URI}/delete`, undefined, {
params: { id }
});
},
/** 标记当前用户全部站内通知已读。 */
async markAllInternalRead(): Promise<void> {
await axios.post(`${BASE_URI}/read/all`);
}
};
export default NotifyAPI;
+24 -24
View File
@@ -1,29 +1,29 @@
import { axios } from "./BaseAPI"; import { axios } from "./BaseAPI";
import type { Page, PageResult, Permission, PermissionPayload } from "../types"; import type { ModuleCode, Page, PageResult, Permission, PermissionPayload } from "../types";
const BASE_URI = "/user/permission"; const BASE_URI = "/user/permission";
async function list(req: Page<Permission>): Promise<PageResult<Permission>> { export const PermissionAPI = {
return axios.post(`${BASE_URI}/list`, req); async list(req: Page<Permission>): Promise<PageResult<Permission>> {
} return axios.post(`${BASE_URI}/list`, req);
},
async function create(req: PermissionPayload): Promise<void> { async create(req: PermissionPayload): Promise<void> {
return axios.post(`${BASE_URI}/create`, req); return axios.post(`${BASE_URI}/create`, req);
} },
async update(req: PermissionPayload): Promise<void> {
async function update(req: PermissionPayload): Promise<void> { return axios.post(`${BASE_URI}/update`, req);
return axios.post(`${BASE_URI}/update`, req); },
} async remove(id: string): Promise<void> {
return axios.post(`${BASE_URI}/delete`, undefined, {
async function remove(id: string): Promise<void> { params: { id }
return axios.post(`${BASE_URI}/delete`, undefined, { });
params: { id } },
}); /** 查询当前登录用户在指定模块内可转授的权限。 */
} async grantableList(moduleCode: ModuleCode): Promise<Permission[]> {
return await axios.post(`${BASE_URI}/grantable/list`, undefined, {
export default { params: { moduleCode }
list, });
create, }
update,
remove
}; };
export default PermissionAPI;
+89 -50
View File
@@ -1,55 +1,94 @@
import { axios } from "./BaseAPI"; import { axios } from "./BaseAPI";
import type { Page, PageResult, Permission, Role, RolePayload, UserRoleAuthorizeReq } from "../types"; import type { ModuleCode, Page, PageResult, Permission, Role, RolePayload, AuthorizeUserRole } from "../types";
const BASE_URI = "/user/role"; const BASE_URI = "/user/role";
async function list(req: Page<Role>): Promise<PageResult<Role>> { export const RoleAPI = {
return axios.post(`${BASE_URI}/list`, req); async list(req: Page<Role>): Promise<PageResult<Role>> {
} return axios.post(`${BASE_URI}/list`, req);
},
async function detail(id: string): Promise<Role> { async detail(id: string): Promise<Role> {
return axios.post(`${BASE_URI}/detail`, undefined, { return axios.post(`${BASE_URI}/detail`, undefined, {
params: { id } params: { id }
}); });
} },
async create(req: RolePayload): Promise<void> {
async function create(req: RolePayload): Promise<void> { return axios.post(`${BASE_URI}/create`, req);
return axios.post(`${BASE_URI}/create`, req); },
} async update(req: RolePayload): Promise<void> {
return axios.post(`${BASE_URI}/update`, req);
async function update(req: RolePayload): Promise<void> { },
return axios.post(`${BASE_URI}/update`, req); async remove(id: string): Promise<void> {
} return axios.post(`${BASE_URI}/delete`, undefined, {
params: { id }
async function remove(id: string): Promise<void> { });
return axios.post(`${BASE_URI}/delete`, undefined, { },
params: { id } /** 查询当前登录用户在模块内可管理的角色。 */
}); async manageableList(moduleCode: ModuleCode): Promise<Role[]> {
} return await axios.post(`${BASE_URI}/manageable/list`, undefined, {
params: { moduleCode }
async function authorizedList(userId: string): Promise<Role[]> { });
return axios.post(`${BASE_URI}/authorized/list`, undefined, { },
params: { userId } /** 查询当前登录用户在模块内可授权给账号的角色。 */
}); async grantableList(moduleCode: ModuleCode): Promise<Role[]> {
} return await axios.post(`${BASE_URI}/grantable/list`, undefined, {
params: { moduleCode }
async function authorize(req: UserRoleAuthorizeReq): Promise<void> { });
return axios.post(`${BASE_URI}/authorized/create`, req); },
} /** 查询当前登录用户直属角色详情,不按可管理范围过滤。 */
async currentDetailList(moduleCode?: ModuleCode): Promise<Role[]> {
async function authorizedPermission(userId: string): Promise<Permission[]> { return await axios.post(`${BASE_URI}/current/detail/list`, undefined, {
return axios.post(`${BASE_URI}/authorized/permission`, undefined, { params: { moduleCode }
params: { userId } });
}); },
} /** 查询当前登录用户可以给目标角色配置的直接权限 P 候选项。 */
async grantablePermissionList(roleId: string): Promise<Permission[]> {
export default { return await axios.post(`${BASE_URI}/permission/grantable/list`, undefined, {
list, params: { roleId }
detail, });
create, },
update, /** 查询目标角色当前可继续转授的权限 D。 */
remove, async delegationList(roleId: string): Promise<Permission[]> {
authorizedList, return await axios.post(`${BASE_URI}/delegation/list`, undefined, {
authorize, params: { roleId }
authorizedPermission });
},
/** 查询当前登录用户可以给目标角色配置的 D 候选项。 */
async grantableDelegationList(roleId: string): Promise<Permission[]> {
return await axios.post(`${BASE_URI}/delegation/grantable/list`, undefined, {
params: { roleId }
});
},
/** 覆盖保存目标角色可继续转授的权限 D。 */
async updateDelegation(req: Pick<RolePayload, "id" | "delegationPermissionIdList">): Promise<void> {
await axios.post(`${BASE_URI}/delegation/update`, req);
},
/** 查询目标角色可选管理父级。 */
async parentGrantableList(roleId: string): Promise<Role[]> {
return await axios.post(`${BASE_URI}/parent/grantable/list`, undefined, {
params: { roleId }
});
},
/** 保存目标角色管理父级。 */
async updateParent(req: Pick<RolePayload, "id" | "parentRoleId">): Promise<void> {
await axios.post(`${BASE_URI}/parent/update`, req);
},
async authorizedList(userId: string, moduleCode?: ModuleCode): Promise<Role[]> {
return axios.post(`${BASE_URI}/authorized/list`, undefined, {
params: {
userId,
moduleCode
}
});
},
async authorize(req: AuthorizeUserRole): Promise<void> {
return axios.post(`${BASE_URI}/authorized/create`, req);
},
async authorizedPermission(userId: string): Promise<Permission[]> {
return axios.post(`${BASE_URI}/authorized/permission`, undefined, {
params: { userId }
});
}
}; };
export default RoleAPI;
+65 -71
View File
@@ -1,5 +1,4 @@
import type {Attachment, CaptchaData, LoginRequest, LoginResponse, RegisterRequest, UpdatePasswordRequest, User} from "../types"; import type {Attachment, CaptchaData, Gender, LoginRequest, LoginResponse, RegisterRequest, SmsLoginRequest, UpdatePasswordRequest, User, UserAttachType} from "../types";
import {UserAttachType} from "../types";
import {axios} from "./BaseAPI"; import {axios} from "./BaseAPI";
import CommonAPI from "./CommonAPI"; import CommonAPI from "./CommonAPI";
@@ -7,65 +6,11 @@ const BASE_URI = "/user";
type UserAttachmentOwner = { type UserAttachmentOwner = {
attachmentList?: readonly Attachment[]; attachmentList?: readonly Attachment[];
}; };
type UpdateCurrentRequest = Omit<Partial<User>, "gender"> & {
gender?: Gender | "";
};
async function register(req: CaptchaData<RegisterRequest>): Promise<LoginResponse> { function findAttachmentByType(attachmentList: readonly Attachment[], ...types: UserAttachType[]) {
return await axios.post(`${BASE_URI}/register`, req);
}
/**
* 登录
*
* @param req 验证码登录对象
* @returns LoginResponse
*/
async function login(req: CaptchaData<LoginRequest>): Promise<LoginResponse> {
return await axios.post(`${BASE_URI}/login`, req);
}
/**
* 验证是否已登录
*
* @returns true 为已登录
*/
async function loginByToken(): Promise<LoginResponse> {
return await axios.post(`${BASE_URI}/login/token`);
}
async function logout(): Promise<void> {
return await axios.post(`${BASE_URI}/logout`);
}
async function updateCurrent(req: Partial<User>): Promise<LoginResponse> {
return await axios.post(`${BASE_URI}/current/update`, req);
}
async function updatePassword(req: UpdatePasswordRequest): Promise<void> {
return await axios.post(`${BASE_URI}/update/password`, req);
}
/**
* 获取用户数据
*
* @param id 用户 ID
* @returns 用户数据
*/
async function view(id: string): Promise<User> {
return await axios.post(`${BASE_URI}/view/${id}`);
}
function getAvatarURL(user?: UserAttachmentOwner) {
if (user?.attachmentList) {
return findAttachmentByType(user.attachmentList, [UserAttachType.AVATAR, UserAttachType.DEFAULT_AVATAR]);
}
}
function getWrapperURL(user?: UserAttachmentOwner) {
if (user?.attachmentList) {
return findAttachmentByType(user.attachmentList, [UserAttachType.WRAPPER, UserAttachType.DEFAULT_WRAPPER]);
}
}
function findAttachmentByType(attachmentList: readonly Attachment[], types: UserAttachType[]) {
for (let i = 0; i < attachmentList.length; i++) { for (let i = 0; i < attachmentList.length; i++) {
const attachType = attachmentList[i].attachType as UserAttachType | undefined; const attachType = attachmentList[i].attachType as UserAttachType | undefined;
const id = attachmentList[i].id; const id = attachmentList[i].id;
@@ -75,15 +20,64 @@ function findAttachmentByType(attachmentList: readonly Attachment[], types: User
} }
} }
export default { export const UserAPI = {
register, async register(req: CaptchaData<RegisterRequest>): Promise<LoginResponse> {
login, return await axios.post(`${BASE_URI}/register`, req);
loginByToken, },
logout, /**
updateCurrent, * 登录
updatePassword, *
* @param req 验证码登录对象
view, * @returns LoginResponse
getAvatarURL, */
getWrapperURL async login(req: CaptchaData<LoginRequest>): Promise<LoginResponse> {
return await axios.post(`${BASE_URI}/login`, req);
},
/**
* 短信验证码登录
*
* @param req 短信验证码登录请求
* @returns LoginResponse
*/
async loginBySms(req: SmsLoginRequest): Promise<LoginResponse> {
return await axios.post(`${BASE_URI}/login/sms`, req);
},
/**
* 验证是否已登录
*
* @returns true 为已登录
*/
async loginByToken(): Promise<LoginResponse> {
return await axios.post(`${BASE_URI}/login/token`);
},
async logout(): Promise<void> {
return await axios.post(`${BASE_URI}/logout`);
},
async updateCurrent(req: UpdateCurrentRequest): Promise<LoginResponse> {
return await axios.post(`${BASE_URI}/current/update`, req);
},
async updatePassword(req: UpdatePasswordRequest): Promise<void> {
return await axios.post(`${BASE_URI}/update/password`, req);
},
/**
* 获取用户数据
*
* @param id 用户 ID
* @returns 用户数据
*/
async view(id: string): Promise<User> {
return await axios.post(`${BASE_URI}/view/${id}`);
},
getAvatarURL(user?: UserAttachmentOwner) {
if (user?.attachmentList) {
return findAttachmentByType(user.attachmentList, "AVATAR");
}
},
getWrapperURL(user?: UserAttachmentOwner) {
if (user?.attachmentList) {
return findAttachmentByType(user.attachmentList, "WRAPPER");
}
}
}; };
export default UserAPI;
+9 -8
View File
@@ -1,8 +1,9 @@
export { default as ArticleAPI } from "./ArticleAPI"; export { ArticleAPI } from "./ArticleAPI";
export { default as AttachmentAPI } from "./AttachmentAPI"; export { AttachmentAPI } from "./AttachmentAPI";
export { default as CommentAPI } from "./CommentAPI"; export { CommentAPI } from "./CommentAPI";
export { default as CommonAPI } from "./CommonAPI"; export { CommonAPI } from "./CommonAPI";
export { default as DeveloperAPI } from "./DeveloperAPI"; export { DeveloperAPI } from "./DeveloperAPI";
export { default as PermissionAPI } from "./PermissionAPI"; export { NotifyAPI } from "./NotifyAPI";
export { default as RoleAPI } from "./RoleAPI"; export { PermissionAPI } from "./PermissionAPI";
export { default as UserAPI } from "./UserAPI"; export { RoleAPI } from "./RoleAPI";
export { UserAPI } from "./UserAPI";
+8 -7
View File
@@ -24,6 +24,7 @@
html { html {
cursor: var(--tui-cur-default); cursor: var(--tui-cur-default);
text-underline-offset: .16rem;
} }
body { body {
@@ -103,23 +104,23 @@ textarea {
-webkit-filter: grayscale(1); -webkit-filter: grayscale(1);
} }
.bold { .text-bold {
font-weight: bold !important; font-weight: bold !important;
} }
.italic { .text-italic {
font-style: italic !important; font-style: italic !important;
} }
.delete { .text-delete {
text-decoration: line-through !important; text-decoration: line-through !important;
} }
.underline { .text-underline {
text-decoration: underline !important; text-decoration: underline !important;
} }
.not-underline { .text-not-underline {
text-decoration: none !important; text-decoration: none !important;
} }
@@ -130,7 +131,7 @@ textarea {
// 模糊玻璃效果:白色 // 模糊玻璃效果:白色
.glass-white { .glass-white {
color: var(--eui-black, #000); color: var(--eui-black, #000);
background: rgba(255, 255, 255, .8); background: rgba(255, 255, 255, .6);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
} }
@@ -138,7 +139,7 @@ textarea {
// 模糊玻璃效果:黑色 // 模糊玻璃效果:黑色
.glass-black { .glass-black {
color: var(--eui-white, #FFF); color: var(--eui-white, #FFF);
background: rgba(0, 0, 0, .8); background: rgba(0, 0, 0, .6);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
} }
+13
View File
@@ -0,0 +1,13 @@
import view from "./index.vue";
import Toolkit from "../../utils/Toolkit";
import type { Article } from "../../types";
export * from "./shared";
export type ArticleEditorExpose = {
submit: () => Promise<Article>;
getPendingMediaCount: () => number;
};
export const ArticleEditor = Toolkit.withInstall(view);
export default ArticleEditor;
+289
View File
@@ -0,0 +1,289 @@
<template>
<t-textarea
v-if="isTextContent"
:model-value="editorContent"
:autosize="textareaAutosize"
:placeholder="placeholder"
@update:model-value="onSimpleEditorCommit"
/>
<markdown-editor
v-else-if="isMarkdownContent"
:data="editorContent"
@update:data="onSimpleEditorCommit"
/>
<tiptap-editor
v-else
ref="tiptapEditorRef"
:model-value="editorContent"
:placeholder="placeholder"
:upload-image="handleUploadImage"
:upload-video="handleUploadVideo"
@change="onEditorChange"
@update:model-value="onEditorCommit"
/>
</template>
<script lang="ts" setup>
import type { Article, ArticleContentType, Attachment } from "../../types";
import AttachmentAPI from "../../api/AttachmentAPI";
import { MarkdownEditor } from "../markdown-editor";
import type { TiptapEditorExpose, UploadResult } from "../tiptap-editor";
import { TiptapEditor } from "../tiptap-editor";
import {
ATTACHMENT_ID_ATTR,
TEMP_FILE_ID_ATTR,
buildAttachmentListFromContent,
getAttachmentIdFromSrc,
getTempFileIdFromSrc,
normalizeArticleContentType,
normalizeContent,
normalizeContentForStore,
normalizeRawArticleContent,
sameAttachmentList
} from "./shared";
defineOptions({
name: "ArticleEditor"
});
const props = withDefaults(defineProps<{
modelValue?: Article;
placeholder?: string;
uploadImage?: (file: File) => Promise<UploadResult>;
uploadVideo?: (file: File) => Promise<UploadResult>;
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
resolveTempUrl?: (tempFileId: string) => string | Promise<string>;
}>(), {
modelValue: () => ({}),
placeholder: "请输入文章正文内容"
});
const emit = defineEmits<{
"change": [value: Article];
"update:modelValue": [value: Article];
}>();
const textareaAutosize = {
minRows: 8,
maxRows: 32
};
const editorContent = ref("");
const tiptapEditorRef = ref<TiptapEditorExpose>();
const lastOutgoingContent = ref("");
const lastOutgoingAttachmentList = ref<Attachment[]>([]);
const contentType = computed(() => {
return normalizeArticleContentType(props.modelValue?.contentType);
});
const isTextContent = computed(() => contentType.value === "TEXT");
const isMarkdownContent = computed(() => contentType.value === "MARKDOWN");
const attachmentMap = computed(() => {
return new Map((props.modelValue?.attachmentList || []).map(item => [item.id, item]));
});
async function defaultUploadImage(file: File) {
return defaultUploadMedia(file);
}
async function defaultUploadVideo(file: File) {
return defaultUploadMedia(file);
}
async function defaultUploadMedia(file: File) {
const tempFileList = await AttachmentAPI.upload([file]);
const tempFileId = tempFileList[0]?.id || "";
if (!tempFileId) {
throw new Error("上传临时文件失败");
}
return {
id: tempFileId,
url: AttachmentAPI.getTempReadURL(tempFileId),
name: file.name
};
}
async function handleUploadImage(file: File) {
if (props.uploadImage) {
return props.uploadImage(file);
}
return defaultUploadImage(file);
}
async function handleUploadVideo(file: File) {
if (props.uploadVideo) {
return props.uploadVideo(file);
}
return defaultUploadVideo(file);
}
async function resolveAttachmentPreviewUrl(attachmentId: string) {
const attachment = attachmentMap.value.get(attachmentId);
if (props.resolveAttachmentUrl) {
const customUrl = await props.resolveAttachmentUrl(attachmentId, attachment);
if (customUrl) {
return customUrl;
}
}
const previewUrl = `${attachment?.metadata?.previewUrl || ""}`;
if (previewUrl) {
return previewUrl;
}
return attachmentId ? AttachmentAPI.getReadURL(attachmentId) : "";
}
async function resolveTempPreviewUrl(tempFileId: string) {
if (props.resolveTempUrl) {
return props.resolveTempUrl(tempFileId);
}
return AttachmentAPI.getTempReadURL(tempFileId);
}
async function convertContentForEditor(value?: string) {
if (contentType.value !== "TIP_TAP") {
return normalizeRawArticleContent(value);
}
const rawContent = normalizeContent(value);
if (!rawContent) {
return "";
}
const parser = new DOMParser();
const doc = parser.parseFromString(rawContent, "text/html");
const mediaList = Array.from(doc.querySelectorAll("img, video"));
for (const media of mediaList) {
const src = media.getAttribute("src") || "";
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src);
if (tempFileId) {
media.setAttribute(TEMP_FILE_ID_ATTR, tempFileId);
media.setAttribute(ATTACHMENT_ID_ATTR, attachmentId || tempFileId);
media.setAttribute("src", await resolveTempPreviewUrl(tempFileId));
continue;
}
if (!attachmentId) {
continue;
}
media.setAttribute(ATTACHMENT_ID_ATTR, attachmentId);
const previewUrl = await resolveAttachmentPreviewUrl(attachmentId);
if (previewUrl) {
media.setAttribute("src", previewUrl);
}
}
return normalizeContent(doc.body.innerHTML);
}
function normalizeIncomingArticleContent(content: string | undefined, currentContentType: ArticleContentType) {
if (currentContentType === "TIP_TAP") {
return normalizeContent(content);
}
return normalizeRawArticleContent(content);
}
function buildStoredContent(content: string, currentContentType: ArticleContentType) {
if (currentContentType === "TIP_TAP") {
return normalizeContentForStore(content);
}
return normalizeRawArticleContent(content);
}
function buildArticleAttachmentList(content: string, currentContentType: ArticleContentType) {
if (currentContentType === "TIP_TAP") {
return buildAttachmentListFromContent(content);
}
return props.modelValue?.attachmentList || [];
}
function buildArticleValue(content: string) {
const nextContentType = contentType.value;
return {
...(props.modelValue || {}),
contentType: nextContentType,
content: buildStoredContent(content, nextContentType),
attachmentList: buildArticleAttachmentList(content, nextContentType)
};
}
function syncLastOutgoingArticle(value: Article) {
const nextContentType = normalizeArticleContentType(value.contentType);
lastOutgoingContent.value = normalizeIncomingArticleContent(value.content, nextContentType);
lastOutgoingAttachmentList.value = value.attachmentList || [];
}
// 实时变更只给预览态使用,不能反向驱动编辑器提交态
function onEditorChange(value: string) {
const nextValue = buildArticleValue(value);
syncLastOutgoingArticle(nextValue);
emit("change", {
...nextValue
});
}
// 提交态只在离开代码块后的同步时机更新
function onEditorCommit(value: string) {
editorContent.value = value;
const nextValue = buildArticleValue(value);
syncLastOutgoingArticle(nextValue);
emit("update:modelValue", {
...nextValue
});
}
function onSimpleEditorCommit(value: string) {
editorContent.value = value;
const nextValue = buildArticleValue(value);
syncLastOutgoingArticle(nextValue);
emit("change", {
...nextValue
});
emit("update:modelValue", {
...nextValue
});
}
watch(() => [props.modelValue?.content, contentType.value] as const, async ([value, nextContentType]) => {
const nextValue = normalizeIncomingArticleContent(value, nextContentType);
if (nextValue === lastOutgoingContent.value) {
return;
}
if (buildStoredContent(editorContent.value, nextContentType) === nextValue) {
return;
}
const nextContent = await convertContentForEditor(nextValue);
if (nextContent === editorContent.value) {
return;
}
editorContent.value = nextContent;
}, { immediate: true });
watch(() => props.modelValue?.attachmentList, async (value) => {
if (contentType.value !== "TIP_TAP") {
return;
}
if (sameAttachmentList(lastOutgoingAttachmentList.value, value)) {
return;
}
if (sameAttachmentList(buildAttachmentListFromContent(editorContent.value), value)) {
return;
}
const nextContent = await convertContentForEditor(props.modelValue?.content);
if (nextContent === editorContent.value) {
return;
}
editorContent.value = nextContent;
}, { deep: true });
async function submit() {
const nextEditorContent = contentType.value === "TIP_TAP" && tiptapEditorRef.value ? await tiptapEditorRef.value.submit() : editorContent.value;
const nextValue = buildArticleValue(nextEditorContent);
editorContent.value = nextEditorContent;
syncLastOutgoingArticle(nextValue);
emit("update:modelValue", nextValue);
return nextValue;
}
defineExpose({
submit,
getPendingMediaCount: () => tiptapEditorRef.value?.getPendingMediaCount() || 0
});
</script>
+138
View File
@@ -0,0 +1,138 @@
import type { ArticleContentType, Attachment } from "../../types";
import { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR, isLocalMediaId, normalizeContent } from "../tiptap-editor/shared";
export const ATTACHMENT_ID_PREFIX = "attachment-id:";
export const TEMP_FILE_ID_PREFIX = "temp-file-id:";
export const ARTICLE_IMAGE_ATTACH_TYPE = "CONTENT_IMAGE";
export const ARTICLE_VIDEO_ATTACH_TYPE = "CONTENT_VIDEO";
export const DEFAULT_ARTICLE_CONTENT_TYPE: ArticleContentType = "TIP_TAP";
export const ARTICLE_CONTENT_TYPE_LIST: ArticleContentType[] = ["TEXT", "MARKDOWN", "TIP_TAP"];
export { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR, normalizeContent };
export function normalizeArticleContentType(contentType?: string): ArticleContentType {
if (contentType === "TIPTAP") {
return "TIP_TAP";
}
return ARTICLE_CONTENT_TYPE_LIST.includes(contentType as ArticleContentType) ? contentType as ArticleContentType : DEFAULT_ARTICLE_CONTENT_TYPE;
}
export function normalizeRawArticleContent(content?: string) {
return content || "";
}
export function getAttachmentIdFromSrc(src?: string) {
if (!src?.startsWith(ATTACHMENT_ID_PREFIX)) {
return "";
}
return src.slice(ATTACHMENT_ID_PREFIX.length);
}
export function getTempFileIdFromSrc(src?: string) {
if (!src?.startsWith(TEMP_FILE_ID_PREFIX)) {
return "";
}
return src.slice(TEMP_FILE_ID_PREFIX.length);
}
export function buildAttachmentIdSrc(attachmentId: string) {
return `${ATTACHMENT_ID_PREFIX}${attachmentId}`;
}
function getMediaAttachType(tagName: string) {
return tagName.toLowerCase() === "video" ? ARTICLE_VIDEO_ATTACH_TYPE : ARTICLE_IMAGE_ATTACH_TYPE;
}
function buildAttachmentMetadata(src: string) {
if (!src.startsWith("blob:")) {
return undefined;
}
return {
previewUrl: src
};
}
function normalizeAttachmentTempFileId(tempFileId?: string) {
if (!tempFileId || isLocalMediaId(tempFileId)) {
return undefined;
}
return tempFileId;
}
export function buildAttachmentListFromContent(content?: string) {
const rawContent = normalizeContent(content);
if (!rawContent) {
return [] as Attachment[];
}
const parser = new DOMParser();
const doc = parser.parseFromString(rawContent, "text/html");
const mediaList = Array.from(doc.querySelectorAll("img, video"));
const attachmentMap = new Map<string, Attachment>();
for (const media of mediaList) {
const src = media.getAttribute("src") || "";
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src);
const key = attachmentId || tempFileId;
if (!key || attachmentMap.has(key)) {
continue;
}
attachmentMap.set(key, {
id: attachmentId || key,
tempFileId: normalizeAttachmentTempFileId(tempFileId),
name: media.getAttribute("title") || media.getAttribute("alt") || "",
attachType: getMediaAttachType(media.tagName),
metadata: buildAttachmentMetadata(src)
});
}
return Array.from(attachmentMap.values());
}
export function normalizeContentForStore(content?: string) {
const rawContent = normalizeContent(content);
if (!rawContent) {
return "";
}
const parser = new DOMParser();
const doc = parser.parseFromString(rawContent, "text/html");
const mediaList = Array.from(doc.querySelectorAll("img, video"));
for (const media of mediaList) {
const src = media.getAttribute("src") || "";
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src);
const finalAttachmentId = attachmentId || tempFileId;
if (!finalAttachmentId) {
continue;
}
media.setAttribute(ATTACHMENT_ID_ATTR, finalAttachmentId);
media.removeAttribute(TEMP_FILE_ID_ATTR);
media.setAttribute("src", buildAttachmentIdSrc(finalAttachmentId));
}
return normalizeContent(doc.body.innerHTML);
}
export function sameAttachmentList(left?: Attachment[], right?: Attachment[]) {
const leftList = left || [];
const rightList = right || [];
if (leftList.length !== rightList.length) {
return false;
}
for (let i = 0; i < leftList.length; i += 1) {
const leftItem = leftList[i];
const rightItem = rightList[i];
if (!rightItem) {
return false;
}
if (leftItem.id !== rightItem.id) {
return false;
}
if ((leftItem.tempFileId || "") !== (rightItem.tempFileId || "")) {
return false;
}
if ((leftItem.accessKey?.value || "") !== (rightItem.accessKey?.value || "")) {
return false;
}
}
return true;
}
+6
View File
@@ -0,0 +1,6 @@
import view from "./index.vue";
import Toolkit from "../../utils/Toolkit";
export const ArticleView = Toolkit.withInstall(view);
export default ArticleView;
+118
View File
@@ -0,0 +1,118 @@
<template>
<t-textarea
v-if="isTextContent && hasRawContent"
class="text-view"
:model-value="rawContent"
:autosize="textareaAutosize"
readonly
/>
<t-empty v-else-if="isTextContent" :description="emptyText" />
<markdown-view v-else-if="isMarkdownContent && hasRawContent" :content="rawContent" />
<t-empty v-else-if="isMarkdownContent" :description="emptyText" />
<tiptap-view v-else :content="renderContent" :empty-text="emptyText" />
</template>
<script lang="ts" setup>
import type { Article, Attachment } from "../../types";
import AttachmentAPI from "../../api/AttachmentAPI";
import { MarkdownView } from "../markdown-view";
import { TiptapView } from "../tiptap-view";
import {
ATTACHMENT_ID_ATTR,
getAttachmentIdFromSrc,
normalizeArticleContentType,
normalizeContent,
normalizeRawArticleContent
} from "../article-editor/shared";
defineOptions({
name: "ArticleView"
});
const props = withDefaults(defineProps<{
article?: Article;
emptyText?: string;
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
}>(), {
article: () => ({}),
emptyText: "暂无正文内容"
});
const textareaAutosize = {
minRows: 8,
maxRows: 32
};
const renderContent = ref("");
const contentType = computed(() => {
return normalizeArticleContentType(props.article?.contentType);
});
const isTextContent = computed(() => contentType.value === "TEXT");
const isMarkdownContent = computed(() => contentType.value === "MARKDOWN");
const rawContent = computed(() => {
return normalizeRawArticleContent(props.article?.content);
});
const hasRawContent = computed(() => {
return 0 < rawContent.value.trim().length;
});
const attachmentMap = computed(() => {
return new Map((props.article?.attachmentList || []).map(item => [item.id, item]));
});
async function buildAttachmentPreviewUrl(attachmentId: string) {
const attachment = attachmentMap.value.get(attachmentId);
if (props.resolveAttachmentUrl) {
const customUrl = await props.resolveAttachmentUrl(attachmentId, attachment);
if (customUrl) {
return customUrl;
}
}
const previewUrl = `${attachment?.metadata?.previewUrl || ""}`;
if (previewUrl) {
return previewUrl;
}
return attachmentId ? AttachmentAPI.getReadURL(attachmentId) : "";
}
async function buildRenderContent(content?: string) {
const rawContent = normalizeContent(content);
if (!rawContent) {
return "";
}
const parser = new DOMParser();
const doc = parser.parseFromString(rawContent, "text/html");
const nodes = Array.from(doc.querySelectorAll("img, video, source"));
for (const node of nodes) {
const src = node.getAttribute("src") || "";
const attachmentId = node.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
if (!attachmentId) {
continue;
}
const previewUrl = await buildAttachmentPreviewUrl(attachmentId);
if (previewUrl) {
node.setAttribute("src", previewUrl);
}
}
return normalizeContent(doc.body.innerHTML);
}
watch(
() => [props.article?.content, props.article?.attachmentList, contentType.value] as const,
async ([, , nextContentType]) => {
if (nextContentType !== "TIP_TAP") {
renderContent.value = "";
return;
}
renderContent.value = await buildRenderContent(props.article?.content);
},
{ immediate: true, deep: true }
);
</script>
<style lang="less" scoped>
.text-view {
width: 100%;
}
</style>
+26
View File
@@ -0,0 +1,26 @@
import view from "./index.vue";
import Toolkit from "../../utils/Toolkit";
import "./style.less";
export type CodeEditorScrollState = {
top: number;
left: number;
};
export type CodeEditorExpose = {
focus: () => void;
blur: () => void;
getScrollState: () => CodeEditorScrollState;
setScrollState: (state: Partial<CodeEditorScrollState>) => void;
findNext: () => boolean;
findPrevious: () => boolean;
replaceCurrent: () => boolean;
replaceAll: () => number;
openSearch: (showReplace: boolean) => void;
closeSearch: () => void;
textareaRef?: HTMLTextAreaElement;
};
export const CodeEditor = Toolkit.withInstall(view);
export default CodeEditor;
+450
View File
@@ -0,0 +1,450 @@
<template>
<div
ref="rootRef"
class="tui-code-editor"
:class="{
readonly,
disabled,
'with-line-numbers': lineNumbers
}"
>
<div v-if="lineNumbers" ref="gutterRef" class="gutter diselect" aria-hidden="true">
<span v-for="line in lineCount" :key="line" v-text="line" />
</div>
<div class="body">
<editor-search-bar
ref="searchBarRef"
:visible="searchVisible"
:expanded="replaceExpanded"
:keyword="searchKeyword"
:replace-value="replaceKeyword"
:status-error="searchError"
:match-index="searchMatchIndex"
:match-count="searchMatchList.length"
:case-sensitive="searchOptions.caseSensitive"
:use-regex="searchOptions.useRegex"
@update:expanded="replaceExpanded = $event"
@update:keyword="searchKeyword = $event"
@update:replace-value="replaceKeyword = $event"
@update:case-sensitive="searchOptions.caseSensitive = $event"
@update:use-regex="searchOptions.useRegex = $event"
@previous="findPrevious"
@next="onSearchNext"
@replace="replaceCurrent"
@replace-all="replaceAll"
@close="closeSearch"
/>
<pre
ref="preRef"
class="preview cur-text"
:class="languageClass"
@scroll="onPreviewScroll"
><code :class="languageClass" v-html="highlightedHTML"></code></pre>
<textarea
v-if="!readonly"
ref="textareaRef"
v-model="currentValue"
class="input"
:placeholder="placeholder"
:disabled="disabled"
wrap="off"
:spellcheck="false"
@input="onInput"
@scroll="onScroll"
@keydown="onKeydown"
></textarea>
</div>
</div>
</template>
<script lang="ts" setup>
import Prism from "prismjs";
import EditorSearchBar from "../editor-search/index.vue";
import {
findTextMatches,
replaceAllText,
replaceMatchText,
type TextSearchMatch,
type TextSearchOptions
} from "../editor-search/search";
import { resolveCodeEditorKeydown } from "./input";
import { resolvePrismLanguage } from "../../utils/Prism";
defineOptions({
name: "CodeEditor"
});
type ScrollState = {
top: number;
left: number;
};
type EditorSearchBarExpose = {
focusKeyword: (selectAll?: boolean) => void;
focusReplace: (selectAll?: boolean) => void;
};
const props = withDefaults(defineProps<{
modelValue?: string;
value?: string;
language?: string;
placeholder?: string;
readonly?: boolean;
disabled?: boolean;
lineNumbers?: boolean;
}>(), {
modelValue: "",
language: "typescript",
placeholder: "",
readonly: false,
disabled: false,
lineNumbers: true
});
const emit = defineEmits<{
(event: "update:modelValue", value: string): void;
(event: "change", value: string): void;
}>();
const bindValue = computed(() => props.value ?? props.modelValue);
const currentValue = ref(bindValue.value);
const rootRef = ref<HTMLDivElement>();
const gutterRef = ref<HTMLDivElement>();
const preRef = ref<HTMLPreElement>();
const textareaRef = ref<HTMLTextAreaElement>();
const searchBarRef = ref<EditorSearchBarExpose>();
const searchVisible = ref(false);
const replaceExpanded = ref(false);
const searchKeyword = ref("");
const replaceKeyword = ref("");
const searchError = ref("");
const searchMatchIndex = ref(-1);
const searchMatchList = ref<TextSearchMatch[]>([]);
const scrollState = reactive<ScrollState>({
top: 0,
left: 0
});
const searchOptions = reactive<TextSearchOptions>({
caseSensitive: false,
useRegex: false
});
const resolvedLanguage = computed(() => resolvePrismLanguage(props.language));
const languageClass = computed(() => resolvedLanguage.value ? `language-${resolvedLanguage.value}` : "");
const lineCount = computed(() => Math.max(currentValue.value.split("\n").length, 1));
const highlightedHTML = computed(() => {
const value = currentValue.value || " ";
if (!resolvedLanguage.value) {
return escapeHTML(value);
}
return Prism.highlight(value, Prism.languages[resolvedLanguage.value], resolvedLanguage.value);
});
watch(bindValue, (value) => {
if (value === currentValue.value) {
return;
}
currentValue.value = value;
refreshSearchMatchState();
nextTick(syncScrollPosition);
});
watch([searchKeyword, () => searchOptions.caseSensitive, () => searchOptions.useRegex], () => {
refreshSearchMatchState();
});
const onInput = () => {
emit("update:modelValue", currentValue.value);
emit("change", currentValue.value);
refreshSearchMatchState();
};
const onScroll = () => {
syncScrollPosition();
};
const onPreviewScroll = () => {
if (!props.readonly || !preRef.value || !gutterRef.value) {
return;
}
scrollState.top = preRef.value.scrollTop;
scrollState.left = preRef.value.scrollLeft;
gutterRef.value.scrollTop = preRef.value.scrollTop;
};
const onKeydown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && !event.altKey) {
const key = event.key.toLowerCase();
if ("f" === key) {
event.preventDefault();
openSearch(false);
return;
}
if ("h" === key && !props.readonly) {
event.preventDefault();
openSearch(true);
return;
}
}
if (props.readonly || props.disabled || !textareaRef.value) {
return;
}
const textarea = textareaRef.value;
const nextState = resolveCodeEditorKeydown(currentValue.value, {
start: textarea.selectionStart,
end: textarea.selectionEnd
}, event);
if (!nextState) {
return;
}
event.preventDefault();
currentValue.value = nextState.value;
emit("update:modelValue", currentValue.value);
emit("change", currentValue.value);
refreshSearchMatchState();
nextTick(() => {
textarea.selectionStart = nextState.start;
textarea.selectionEnd = nextState.end;
syncScrollPosition();
});
};
const getScrollState = (): ScrollState => ({
top: scrollState.top,
left: scrollState.left
});
const setScrollState = (nextState: Partial<ScrollState>) => {
scrollState.top = Math.max(0, nextState.top || 0);
scrollState.left = Math.max(0, nextState.left || 0);
applyScrollState();
};
const syncScrollPosition = () => {
if (textareaRef.value) {
scrollState.top = textareaRef.value.scrollTop;
scrollState.left = textareaRef.value.scrollLeft;
applyScrollState();
return;
}
if (props.readonly && preRef.value) {
scrollState.top = preRef.value.scrollTop;
scrollState.left = preRef.value.scrollLeft;
applyScrollState();
}
};
const applyScrollState = () => {
if (textareaRef.value) {
textareaRef.value.scrollTop = scrollState.top;
textareaRef.value.scrollLeft = scrollState.left;
}
if (preRef.value) {
preRef.value.scrollTop = scrollState.top;
preRef.value.scrollLeft = scrollState.left;
}
if (gutterRef.value) {
gutterRef.value.scrollTop = scrollState.top;
}
};
const getSelectionKeyword = () => {
if (!textareaRef.value) {
return "";
}
const { selectionStart, selectionEnd } = textareaRef.value;
if (selectionStart === selectionEnd) {
return "";
}
return currentValue.value.slice(selectionStart, selectionEnd);
};
const refreshSearchMatchState = () => {
const result = findTextMatches(currentValue.value, searchKeyword.value, searchOptions);
searchError.value = result.error;
searchMatchList.value = result.matches;
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
searchMatchIndex.value = -1;
return;
}
const { selectionStart, selectionEnd } = textareaRef.value;
searchMatchIndex.value = searchMatchList.value.findIndex((match) => (
match.start === selectionStart && match.end === selectionEnd
));
if (0 > searchMatchIndex.value) {
searchMatchIndex.value = 0;
}
};
const openSearch = (showReplace: boolean) => {
searchVisible.value = true;
replaceExpanded.value = showReplace;
const selectedText = getSelectionKeyword();
if (selectedText) {
searchKeyword.value = selectedText;
}
refreshSearchMatchState();
nextTick(() => {
searchBarRef.value?.focusKeyword(true);
});
};
const closeSearch = () => {
searchVisible.value = false;
replaceExpanded.value = false;
focus();
};
const selectMatch = (match: TextSearchMatch, matchIndex: number) => {
if (!textareaRef.value) {
return false;
}
textareaRef.value.focus();
textareaRef.value.selectionStart = match.start;
textareaRef.value.selectionEnd = match.end;
textareaRef.value.setSelectionRange(match.start, match.end);
textareaRef.value.scrollIntoView({ block: "nearest", inline: "nearest" });
scrollState.top = textareaRef.value.scrollTop;
scrollState.left = textareaRef.value.scrollLeft;
applyScrollState();
searchMatchIndex.value = matchIndex;
return true;
};
const findNext = () => {
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
return false;
}
const { selectionEnd } = textareaRef.value;
const nextIndex = searchMatchList.value.findIndex((match) => match.start >= selectionEnd);
const targetIndex = 0 <= nextIndex ? nextIndex : 0;
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
};
const findPrevious = () => {
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
return false;
}
const { selectionStart } = textareaRef.value;
let targetIndex = searchMatchList.value.length - 1;
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
if (searchMatchList.value[index].start < selectionStart) {
targetIndex = index;
break;
}
}
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
};
const onSearchNext = (shiftKey: boolean) => {
if (shiftKey) {
findPrevious();
return;
}
findNext();
};
const replaceSelection = (match: TextSearchMatch) => {
const nextText = replaceMatchText(match.text, searchKeyword.value, replaceKeyword.value, searchOptions);
currentValue.value = currentValue.value.slice(0, match.start) + nextText + currentValue.value.slice(match.end);
emit("update:modelValue", currentValue.value);
emit("change", currentValue.value);
};
const replaceCurrent = () => {
if (props.readonly || !textareaRef.value || !searchKeyword.value) {
return false;
}
const { selectionStart, selectionEnd } = textareaRef.value;
let currentMatchIndex = searchMatchList.value.findIndex((match) => (
match.start === selectionStart && match.end === selectionEnd
));
if (0 > currentMatchIndex) {
if (!findNext()) {
return false;
}
currentMatchIndex = searchMatchIndex.value;
}
const currentMatch = searchMatchList.value[currentMatchIndex];
if (!currentMatch) {
return false;
}
replaceSelection(currentMatch);
refreshSearchMatchState();
nextTick(() => {
const nextMatch = searchMatchList.value[currentMatchIndex] || searchMatchList.value[currentMatchIndex - 1];
if (!nextMatch) {
focus();
return;
}
selectMatch(nextMatch, Math.min(currentMatchIndex, searchMatchList.value.length - 1));
});
return true;
};
const replaceAll = () => {
if (props.readonly || !searchKeyword.value) {
return 0;
}
const result = replaceAllText(currentValue.value, searchKeyword.value, replaceKeyword.value, searchOptions);
searchError.value = result.error;
if (result.error || 1 > result.count) {
refreshSearchMatchState();
return 0;
}
currentValue.value = result.value;
emit("update:modelValue", currentValue.value);
emit("change", currentValue.value);
refreshSearchMatchState();
nextTick(() => {
if (searchMatchList.value[0]) {
selectMatch(searchMatchList.value[0], 0);
return;
}
focus();
});
return result.count;
};
const focus = () => {
textareaRef.value?.focus();
};
const blur = () => {
textareaRef.value?.blur();
};
onActivated(() => {
nextTick(() => {
applyScrollState();
});
});
defineExpose({
rootRef,
textareaRef,
focus,
blur,
getScrollState,
setScrollState,
findNext,
findPrevious,
replaceCurrent,
replaceAll,
openSearch,
closeSearch
});
function escapeHTML(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
</script>
+208
View File
@@ -0,0 +1,208 @@
type EditorSelection = {
start: number;
end: number;
};
type EditorEditResult = EditorSelection & {
value: string;
};
const TAB = "\t";
const OPEN_CHAR_LIST = new Set(["(", "[", "{"]);
const WRAP_CHAR_MAP: Record<string, string> = {
"(": ")",
"[": "]",
"{": "}",
"\"": "\"",
"'": "'",
"`": "`",
"<": ">"
};
const CLOSE_CHAR_LIST = new Set(Object.values(WRAP_CHAR_MAP));
export const resolveCodeEditorKeydown = (
value: string,
selection: EditorSelection,
event: KeyboardEvent
): EditorEditResult | undefined => {
if (event.isComposing || event.ctrlKey || event.altKey || event.metaKey) {
return undefined;
}
if (event.key === "Tab") {
return resolveTabInput(value, selection, event.shiftKey);
}
if (event.key === "Enter") {
return resolveEnterInput(value, selection);
}
if (event.key === "Backspace") {
return resolvePairBackspace(value, selection);
}
return resolvePairInput(value, selection, event.key);
};
// 处理 Tab / Shift+Tab 缩进
const resolveTabInput = (
value: string,
selection: EditorSelection,
shiftKey: boolean
): EditorEditResult => {
const { start, end } = selection;
if (!shiftKey && start === end) {
return {
value: value.slice(0, start) + TAB + value.slice(end),
start: start + 1,
end: start + 1
};
}
const lineStart = value.lastIndexOf("\n", Math.max(start - 1, 0)) + 1;
const lineEndIndex = value.indexOf("\n", end);
const blockEnd = lineEndIndex < 0 ? value.length : lineEndIndex;
const selectedBlock = value.slice(lineStart, blockEnd);
const lineList = selectedBlock.split("\n");
let nextStart = start;
let nextEnd = end;
const nextBlock = lineList.map((line, index) => {
if (shiftKey) {
if (line.startsWith(TAB)) {
if (index === 0) {
nextStart = Math.max(lineStart, nextStart - 1);
}
nextEnd = Math.max(nextStart, nextEnd - 1);
return line.slice(1);
}
return line;
}
if (index === 0) {
nextStart += 1;
}
nextEnd += 1;
return TAB + line;
}).join("\n");
return {
value: value.slice(0, lineStart) + nextBlock + value.slice(blockEnd),
start: nextStart,
end: nextEnd
};
};
// 回车时继承当前行缩进,位于块级起始括号后时额外增加一层缩进
const resolveEnterInput = (
value: string,
selection: EditorSelection
): EditorEditResult => {
const { start, end } = selection;
const lineStart = value.lastIndexOf("\n", Math.max(start - 1, 0)) + 1;
const lineIndent = getLineIndent(value.slice(lineStart, start));
const prevChar = value[start - 1] ?? "";
const nextChar = value[end] ?? "";
const isWrappedBlock = start === end
&& OPEN_CHAR_LIST.has(prevChar)
&& WRAP_CHAR_MAP[prevChar] === nextChar;
if (isWrappedBlock) {
const nextValue = value.slice(0, start)
+ `\n${lineIndent}${TAB}\n${lineIndent}`
+ value.slice(end);
const nextCursor = start + 1 + lineIndent.length + TAB.length;
return {
value: nextValue,
start: nextCursor,
end: nextCursor
};
}
const extraIndent = OPEN_CHAR_LIST.has(prevChar) ? TAB : "";
const nextValue = value.slice(0, start)
+ `\n${lineIndent}${extraIndent}`
+ value.slice(end);
const nextCursor = start + 1 + lineIndent.length + extraIndent.length;
return {
value: nextValue,
start: nextCursor,
end: nextCursor
};
};
// 处理成对符号输入,选中文本时直接包裹
const resolvePairInput = (
value: string,
selection: EditorSelection,
key: string
): EditorEditResult | undefined => {
const wrapChar = WRAP_CHAR_MAP[key];
if (wrapChar) {
return wrapSelection(value, selection, key, wrapChar);
}
if (CLOSE_CHAR_LIST.has(key) && selection.start === selection.end && value[selection.start] === key) {
return {
value,
start: selection.start + 1,
end: selection.start + 1
};
}
return undefined;
};
// 光标位于成对符号中间时,退格一次同时删除左右字符
const resolvePairBackspace = (
value: string,
selection: EditorSelection
): EditorEditResult | undefined => {
const { start, end } = selection;
if (start !== end || start < 1) {
return undefined;
}
const prevChar = value[start - 1];
const nextChar = value[start];
if (WRAP_CHAR_MAP[prevChar] !== nextChar) {
return undefined;
}
return {
value: value.slice(0, start - 1) + value.slice(start + 1),
start: start - 1,
end: start - 1
};
};
// 将当前选区替换为一对包裹字符,并把光标移动到合理位置
const wrapSelection = (
value: string,
selection: EditorSelection,
openChar: string,
closeChar: string
): EditorEditResult => {
const { start, end } = selection;
const selectedText = value.slice(start, end);
const nextValue = value.slice(0, start) + openChar + selectedText + closeChar + value.slice(end);
if (start === end) {
return {
value: nextValue,
start: start + 1,
end: start + 1
};
}
return {
value: nextValue,
start: start + 1,
end: end + 1
};
};
const getLineIndent = (value: string) => {
const match = value.match(/^[\t ]*/);
return match?.[0] ?? "";
};
+202
View File
@@ -0,0 +1,202 @@
.tui-code-editor {
width: calc(100% - 2px);
min-width: 0;
border: 1px solid var(--td-component-stroke, #B8BBC9);
display: flex;
overflow: hidden;
background: transparent;
border-radius: var(--td-radius-medium, 0);
.gutter {
flex: none;
color: var(--td-text-color-placeholder, #999);
overflow: hidden;
text-align: right;
background: #F2F2F2E6;
border-right: 1px solid var(--td-component-stroke, #B8BBC9);
> span {
width: 3.25rem;
height: 1.5rem;
display: block;
font-size: 1rem;
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
line-height: 1.5rem;
padding-right: .25rem;
}
}
.body {
flex: 1;
min-width: 0;
min-height: 0;
position: relative;
overflow: hidden;
}
.search-bar {
top: .5rem;
right: .5rem;
gap: .5rem;
z-index: 3;
display: flex;
padding: .5rem;
position: absolute;
align-items: center;
border-radius: var(--td-radius-default, .375rem);
background: var(--td-bg-color-container, #fff);
border: 1px solid var(--td-component-stroke, #B8BBC9);
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .12);
}
.search-input {
width: 10rem;
height: 2rem;
padding: 0 .5rem;
color: var(--td-text-color-primary, #111);
border: 1px solid var(--td-component-stroke, #B8BBC9);
background: var(--td-bg-color-container, #fff);
border-radius: var(--td-radius-default, .375rem);
font: inherit;
}
.search-actions {
gap: .25rem;
display: flex;
align-items: center;
}
.search-button {
height: 2rem;
padding: 0 .625rem;
color: var(--td-text-color-primary, #111);
border: 1px solid var(--td-component-stroke, #B8BBC9);
background: var(--td-bg-color-container, #fff);
border-radius: var(--td-radius-default, .375rem);
cursor: pointer;
&:hover {
background: var(--td-bg-color-container-hover, #f6f6f6);
}
}
.search-status {
color: var(--td-text-color-secondary, #666);
white-space: nowrap;
font-size: .875rem;
}
.preview,
.input {
inset: 0;
width: 100%;
height: 100%;
margin: 0;
tab-size: 4;
overflow: auto;
max-width: 100%;
min-width: 0;
line-height: 1.5rem;
font-size: 1rem;
box-sizing: border-box;
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
}
.preview {
top: 0;
left: 0;
border: none;
position: absolute;
word-break: normal;
background: transparent;
transition: none;
white-space: pre;
border-radius: 0;
pointer-events: none;
code {
padding: 0;
display: block;
width: max-content;
min-width: 100%;
min-height: 100%;
color: inherit;
background: transparent;
text-shadow: none;
font-size: inherit;
line-height: inherit;
font-family: inherit;
}
}
.input {
top: 0;
left: 0;
color: transparent;
resize: none;
border: none;
outline: none;
position: absolute;
background: transparent;
white-space: pre;
word-break: normal;
overflow-wrap: normal;
caret-color: var(--td-text-color-primary, #111);
-webkit-text-fill-color: transparent;
}
.input::placeholder {
color: var(--td-text-color-placeholder, #999);
-webkit-text-fill-color: var(--td-text-color-placeholder, #999);
}
.input::selection {
background: rgb(0 122 255 / 18%);
}
&.disabled {
opacity: .65;
}
&.readonly {
.preview {
pointer-events: auto;
}
}
}
@media (prefers-color-scheme: dark) {
.tui-code-editor {
border-color: var(--td-component-stroke, #434343);
.gutter {
border-right-color: var(--td-component-stroke, #434343);
}
.search-bar {
background: var(--td-bg-color-container, #1f1f1f);
border-color: var(--td-component-stroke, #434343);
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .28);
}
.search-input,
.search-button {
color: var(--td-text-color-primary, #F5F5F5);
border-color: var(--td-component-stroke, #434343);
background: var(--td-bg-color-container, #1f1f1f);
}
.search-button:hover {
background: var(--td-bg-color-container-hover, #2b2b2b);
}
.input {
caret-color: var(--td-text-color-primary, #F5F5F5);
}
.input::selection {
background: rgb(64 153 255 / 28%);
}
}
}
+5
View File
@@ -0,0 +1,5 @@
import view from "./index.vue";
import Toolkit from "../../utils/Toolkit";
export const ContactNumber = Toolkit.withInstall(view);
export default ContactNumber;
+101
View File
@@ -0,0 +1,101 @@
<template>
<span class="tui-contact-number">
<a v-if="value" :href="`tel:${dialValue}`">
<span v-if="$slots.icon" class="icon">
<slot name="icon"></slot>
</span>
<span :class="{ underline }" v-text="formattedValue"></span>
</a>
<span v-else>
<span v-if="$slots.icon" class="icon">
<slot name="icon"></slot>
</span>
<span :class="{ underline }" v-text="formattedValue"></span>
</span>
</span>
</template>
<script setup lang="ts">
defineOptions({
name: "ContactNumber"
});
const props = withDefaults(defineProps<{
value?: string | number;
underline?: boolean;
separator?: " " | "-";
}>(), {
underline: false,
separator: " "
});
const { underline, separator } = toRefs(props);
/** 将号码按指定长度分组。 */
function splitNumber(number: string, lengths: number[], separator: " " | "-"): string {
let start = 0;
const groups: string[] = [];
for (const length of lengths) {
groups.push(number.slice(start, start + length));
start += length;
}
if (start < number.length) {
groups.push(number.slice(start));
}
return groups.filter(Boolean).join(separator);
}
/** 格式化号码展示文本,保留开头的国际区号。 */
function formatNumber(number: string): string {
const normalized = number.replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");
const digits = normalized.replace(/\D/g, "");
if (digits.length < 8) {
return normalized;
}
if (normalized.startsWith("+")) {
const countryCode = digits.slice(0, 2);
const localNumber = digits.slice(2);
if (localNumber.length >= 11) {
return `+${countryCode}${separator.value}${splitNumber(localNumber, [3, 4], separator.value)}`;
}
return `+${countryCode}${separator.value}${splitNumber(localNumber, [4], separator.value)}`;
}
if (digits.length === 11) {
return splitNumber(digits, [3, 4], separator.value);
}
return splitNumber(digits, [4], separator.value);
}
const value = computed(() => props.value !== undefined && props.value !== null && String(props.value).trim() !== "");
const dialValue = computed(() => String(props.value ?? "").replace(/[^\d+]/g, "").replace(/(?!^)\+/g, ""));
const formattedValue = computed(() => formatNumber(String(props.value ?? "").trim()));
</script>
<style lang="less" scoped>
.tui-contact-number {
.icon {
display: inline-flex;
margin-right: .25rem;
vertical-align: middle;
}
a {
color: inherit;
text-decoration: none;
span {
color: inherit;
}
}
.underline {
text-decoration: underline;
}
}
</style>
+271
View File
@@ -0,0 +1,271 @@
<template>
<div v-if="visible" class="tui-editor-search" @mousedown.stop>
<div class="row">
<t-button
class="toggle-btn"
shape="square"
size="small"
type="button"
variant="outline"
:title="expanded ? '收起替换' : '展开替换'"
@click="emit('update:expanded', !expanded)"
>
<t-icon :name="expanded ? 'chevron-down-s' : 'chevron-right-s'" />
</t-button>
<t-input
ref="keywordInputRef"
:value="keyword"
class="text-input"
placeholder="查找"
size="small"
@change="onKeywordChange"
@enter="onKeywordEnter"
@keydown="onKeywordKeydown"
>
<template #suffix-icon>
<t-button
shape="square"
size="small"
type="button"
variant="text"
:theme="caseSensitive ? 'primary' : 'default'"
title="大小写匹配"
@click="emit('update:case-sensitive', !caseSensitive)"
>
Aa
</t-button>
<t-button
shape="square"
size="small"
type="button"
variant="text"
:theme="useRegex ? 'primary' : 'default'"
title="正则搜索"
@click="emit('update:use-regex', !useRegex)"
>
.*
</t-button>
</template>
</t-input>
<span class="status" v-text="resolvedStatusText" />
<t-button shape="square" size="small" type="button" variant="text" title="上一个" @click="emit('previous')">
<t-icon name="arrow-up" />
</t-button>
<t-button shape="square" size="small" type="button" variant="text" title="下一个" @click="emit('next', false)">
<t-icon name="arrow-down" />
</t-button>
<t-button shape="square" size="small" type="button" variant="text" title="关闭" @click="emit('close')">
<t-icon name="close" />
</t-button>
</div>
<div v-if="expanded" class="row replace-row">
<div class="toggle-holder" />
<t-input
ref="replaceInputRef"
:value="replaceValue"
class="text-input"
placeholder="替换"
size="small"
@change="onReplaceChange"
@enter="emit('replace')"
@keydown="onReplaceKeydown"
/>
<div class="replace-actions">
<t-button size="small" type="button" theme="primary" @click="emit('replace')">替换</t-button>
<t-button size="small" type="button" variant="outline" @click="emit('replace-all')">全部替换</t-button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { ComponentPublicInstance } from "vue";
defineOptions({
name: "EditorSearchBar"
});
const props = withDefaults(defineProps<{
visible?: boolean;
expanded?: boolean;
keyword?: string;
replaceValue?: string;
statusText?: string;
statusError?: string;
matchIndex?: number;
matchCount?: number;
caseSensitive?: boolean;
useRegex?: boolean;
}>(), {
visible: false,
expanded: false,
keyword: "",
replaceValue: "",
statusText: undefined,
statusError: "",
matchIndex: -1,
matchCount: 0,
caseSensitive: false,
useRegex: false
});
const emit = defineEmits<{
(event: "update:expanded", value: boolean): void;
(event: "update:keyword", value: string): void;
(event: "update:replace-value", value: string): void;
(event: "update:case-sensitive", value: boolean): void;
(event: "update:use-regex", value: boolean): void;
(event: "previous"): void;
(event: "next", shiftKey: boolean): void;
(event: "replace"): void;
(event: "replace-all"): void;
(event: "close"): void;
}>();
type InputInstance = ComponentPublicInstance & {
$el: HTMLElement;
};
type InputKeyboardContext = {
e: KeyboardEvent;
};
const keywordInputRef = ref<InputInstance>();
const replaceInputRef = ref<InputInstance>();
const resolvedStatusText = computed(() => {
if (undefined !== props.statusText) {
return props.statusText;
}
if (props.statusError) {
return props.statusError;
}
if (!props.keyword) {
return "无结果";
}
if (1 > props.matchCount) {
return "未找到";
}
return `${props.matchIndex + 1} / ${props.matchCount}`;
});
const onKeywordChange = (value: string | number) => {
emit("update:keyword", `${value}`);
};
const onReplaceChange = (value: string | number) => {
emit("update:replace-value", `${value}`);
};
const onKeywordEnter = (_value: string | number, context: InputKeyboardContext) => {
emit("next", context.e.shiftKey);
};
const onKeywordKeydown = (_value: string | number, context: InputKeyboardContext) => {
if ("Escape" !== context.e.key) {
return;
}
context.e.preventDefault();
emit("close");
};
const onReplaceKeydown = (_value: string | number, context: InputKeyboardContext) => {
if ("Escape" !== context.e.key) {
return;
}
context.e.preventDefault();
emit("close");
};
const getNativeInput = (instance?: InputInstance) => instance?.$el.querySelector("input") || undefined;
const focusKeyword = (selectAll = false) => {
const input = getNativeInput(keywordInputRef.value);
input?.focus();
if (selectAll && input) {
input.selectionStart = 0;
input.selectionEnd = input.value.length;
}
};
const focusReplace = (selectAll = false) => {
const input = getNativeInput(replaceInputRef.value);
input?.focus();
if (selectAll && input) {
input.selectionStart = 0;
input.selectionEnd = input.value.length;
}
};
defineExpose({
focusKeyword,
focusReplace,
keywordInputRef,
replaceInputRef
});
</script>
<style lang="less" scoped>
.tui-editor-search {
top: .5rem;
right: .5rem;
gap: .375rem;
z-index: 3;
display: flex;
padding: .5rem;
position: absolute;
min-width: 20rem;
max-width: calc(100% - 1rem);
box-sizing: border-box;
border-radius: var(--td-radius-default, .375rem);
background: var(--td-bg-color-container, #fff);
border: 1px solid var(--td-component-stroke, #B8BBC9);
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .12);
flex-direction: column;
.row {
gap: .375rem;
display: flex;
min-width: 0;
align-items: center;
.toggle-holder {
flex: none;
width: 24px;
}
.toggle-btn {
flex: none;
}
.text-input {
width: 12rem;
:deep(.t-input) {
padding-right: 0;
}
}
.status {
width: 3rem;
color: var(--td-text-color-secondary, #666);
font-size: .875rem;
white-space: nowrap;
}
}
.replace-actions {
gap: .375rem;
display: flex;
align-items: center;
}
}
@media (prefers-color-scheme: dark) {
.tui-editor-search {
background: var(--td-bg-color-container, #1f1f1f);
border-color: var(--td-component-stroke, #434343);
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .28);
}
}
</style>
+134
View File
@@ -0,0 +1,134 @@
export type TextSearchOptions = {
caseSensitive: boolean;
useRegex: boolean;
};
export type TextSearchMatch = {
start: number;
end: number;
text: string;
};
export type TextSearchResult = {
matches: TextSearchMatch[];
error: string;
};
const buildFlags = (options: TextSearchOptions, global = true) => {
let flags = global ? "g" : "";
if (!options.caseSensitive) {
flags += "i";
}
return flags;
};
export const escapeSearchKeyword = (value: string) => (
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
);
export const buildSearchRegex = (
keyword: string,
options: TextSearchOptions,
global = true
) => {
if (!keyword) {
return { regex: undefined, error: "" };
}
try {
const source = options.useRegex ? keyword : escapeSearchKeyword(keyword);
const regex = new RegExp(source, buildFlags(options, global));
const emptyMatch = "".match(regex);
if (emptyMatch && emptyMatch[0] === "") {
return {
regex: undefined,
error: "正则不能匹配空文本"
};
}
return { regex, error: "" };
} catch {
return {
regex: undefined,
error: "正则表达式无效"
};
}
};
export const findTextMatches = (
value: string,
keyword: string,
options: TextSearchOptions
): TextSearchResult => {
const { regex, error } = buildSearchRegex(keyword, options, true);
if (!regex || error) {
return {
matches: [],
error
};
}
const matches: TextSearchMatch[] = [];
let match = regex.exec(value);
while (match) {
if (!match[0]) {
return {
matches: [],
error: "正则不能匹配空文本"
};
}
matches.push({
start: match.index,
end: match.index + match[0].length,
text: match[0]
});
match = regex.exec(value);
}
return {
matches,
error: ""
};
};
export const replaceMatchText = (
matchText: string,
keyword: string,
replaceText: string,
options: TextSearchOptions
) => {
if (!options.useRegex) {
return replaceText;
}
const { regex, error } = buildSearchRegex(keyword, options, false);
if (!regex || error) {
return matchText;
}
return matchText.replace(regex, replaceText);
};
export const replaceAllText = (
value: string,
keyword: string,
replaceText: string,
options: TextSearchOptions
) => {
const { matches, error } = findTextMatches(value, keyword, options);
if (error || 1 > matches.length) {
return {
value,
count: 0,
error
};
}
let nextValue = "";
let lastEnd = 0;
matches.forEach((match) => {
nextValue += value.slice(lastEnd, match.start);
nextValue += replaceMatchText(match.text, keyword, replaceText, options);
lastEnd = match.end;
});
nextValue += value.slice(lastEnd);
return {
value: nextValue,
count: matches.length,
error: ""
};
};
+38 -2
View File
@@ -9,6 +9,13 @@ import EmptyTips from "./empty-tips";
import MarkdownView from "./markdown-view"; import MarkdownView from "./markdown-view";
import BEFlowerFall from "./background-effect/flower-fall"; import BEFlowerFall from "./background-effect/flower-fall";
import MarkdownEditor from "./markdown-editor"; import MarkdownEditor from "./markdown-editor";
import PassTimeLabel from "./passtime-label";
import ContactNumber from "./contact-number";
import CodeEditor from "./code-editor";
import TiptapEditor from "./tiptap-editor";
import TiptapView from "./tiptap-view";
import ArticleEditor from "./article-editor";
import ArticleView from "./article-view";
export default [ export default [
Icon, Icon,
@@ -20,7 +27,14 @@ export default [
EmptyTips, EmptyTips,
MarkdownView, MarkdownView,
BEFlowerFall, BEFlowerFall,
MarkdownEditor MarkdownEditor,
PassTimeLabel,
ContactNumber,
CodeEditor,
TiptapEditor,
TiptapView,
ArticleEditor,
ArticleView
]; ];
export { export {
@@ -33,5 +47,27 @@ export {
EmptyTips, EmptyTips,
MarkdownView, MarkdownView,
BEFlowerFall, BEFlowerFall,
MarkdownEditor MarkdownEditor,
PassTimeLabel,
ContactNumber,
CodeEditor,
TiptapEditor,
TiptapView,
ArticleEditor,
ArticleView
}; };
export type {
CodeEditorExpose
} from "./code-editor";
export type {
UploadResult,
AlignType,
TiptapEditorExpose,
TiptapEditorInstance
} from "./tiptap-editor";
export type {
ArticleEditorExpose
} from "./article-editor";
+5
View File
@@ -0,0 +1,5 @@
import view from "./index.vue";
import Toolkit from "../../utils/Toolkit";
export const PassTimeLabel = Toolkit.withInstall(view);
export default PassTimeLabel;
+62
View File
@@ -0,0 +1,62 @@
<template>
<span
v-if="timestamp"
class="tui-passtime-label"
:class="{ underline }"
role="button"
tabindex="0"
v-text="content"
@click="toggle"
@keydown.enter.space.prevent="toggle"
/>
</template>
<script lang="ts" setup>
import Time from "../../utils/Time";
defineOptions({
name: "PassTimeLabel"
});
const props = withDefaults(defineProps<{
timestamp?: number;
onlyTime?: boolean;
onlyDate?: boolean;
underline?: boolean;
}>(), {
onlyTime: false,
onlyDate: false,
underline: false
});
const { timestamp, onlyTime, onlyDate } = toRefs(props);
const showingDetail = ref(false);
const content = computed(() => {
if (!showingDetail.value) {
return Time.toPassedDateTime(timestamp.value);
}
if (onlyTime.value) {
return Time.toTime(timestamp.value);
}
if (onlyDate.value) {
return Time.toDate(timestamp.value);
}
return Time.toDateTime(timestamp.value);
});
function toggle(): void {
showingDetail.value = !showingDetail.value;
}
</script>
<style lang="less" scoped>
.tui-passtime-label {
cursor: pointer;
&.underline {
text-decoration: underline;
}
}
</style>
+181
View File
@@ -0,0 +1,181 @@
.tui-markdown-view,
.tui-code-editor,
.tp-content-body {
img,
video,
iframe.tp-iframe {
max-width: 100%;
display: block;
margin: 1rem auto;
}
img {
height: auto;
border-radius: var(--td-radius-default, .375rem);
}
video,
iframe.tp-iframe {
width: min(100%, 56rem);
overflow: hidden;
border-radius: var(--td-radius-default, .375rem);
background: var(--td-bg-color-container, #fff);
border: 1px solid var(--td-component-border, #dcdcdc);
}
iframe.tp-iframe {
height: 28rem;
}
table {
width: 100%;
margin: 1rem 0;
border-spacing: 0;
border-collapse: collapse;
}
th,
td {
padding: .5rem .75rem;
vertical-align: middle;
border: 1px solid var(--td-component-border, #dcdcdc);
}
th {
background: var(--td-bg-color-container-hover, #f6f6f6);
}
pre[class*="language-"] {
padding: 0;
border: 1px solid var(--td-component-stroke, #B8BBC9);
overflow: auto;
background: transparent;
transition: max-height .5s ease;
border-radius: var(--td-radius-medium, 0);
code {
color: #333;
margin: 0;
padding: 0;
display: block;
font-size: 1rem;
background: transparent;
text-shadow: none;
line-height: 1.5;
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
.line-numbers-rows {
left: 0;
flex: none;
width: 3.25rem;
display: block;
text-align: right;
background: #F2F2F2E6;
border-right: 1px solid var(--td-component-stroke, #B8BBC9);
padding-right: .25rem;
> span {
color: var(--td-text-color-placeholder, #999);
display: block;
}
> span::before {
content: none;
}
}
.codes {
display: block;
min-width: max-content;
padding-left: .25rem;
}
}
}
pre[class*="language-"].has-line-numbers {
code {
padding: 0;
display: inline-flex;
min-width: 100%;
align-items: stretch;
}
.line-numbers-rows {
left: 0;
z-index: 1;
position: sticky;
}
.codes {
flex: 1;
}
}
.token.namespace {
opacity: 1;
}
.token.punctuation {
color: #333;
}
.token.constant {
color: #FF7A9B;
}
.token.annotation {
color: purple;
}
.token.function {
color: #777;
}
.token.class-name {
color: #FF461F;
}
.token.generics .class-name {
color: #895532;
font-weight: bold;
}
.token.comment {
color: #999;
}
.token.string {
color: #55AA55;
}
.token.number {
color: #EB9354;
}
.token.keyword,
.token.boolean {
color: #177CB0;
}
}
.tui-markdown-view pre[class*="language-"],
.tp-content-body pre[class*="language-"] {
position: relative;
}
.tp-editor-body {
pre[class*="language-"] {
max-height: 24rem;
}
}
.tui-code-editor {
pre[class*="language-"] {
border: none;
padding: 2px;
}
}
+46
View File
@@ -0,0 +1,46 @@
.tp-content-theme() {
color: var(--td-text-color-primary);
line-height: 1.6;
p {
margin: 0 0 .75rem;
}
p:empty:before {
content: "\00a0";
}
ul,
ol {
margin: 0 0 .75rem;
padding-left: 1.25rem;
}
table {
width: 100%;
margin: .75rem 0;
table-layout: fixed;
border-collapse: collapse;
}
th,
td {
border: 1px solid var(--td-component-border);
}
th {
background: var(--td-bg-color-secondarycontainer);
}
img,
video,
iframe {
margin: 0 auto;
max-width: 100%;
}
}
.tp-content-body {
.tp-content-theme();
}
@@ -0,0 +1,174 @@
import CodeBlock from "@tiptap/extension-code-block";
import { findChildren, mergeAttributes } from "@tiptap/vue-3";
import type { Node as ProsemirrorNode } from "@tiptap/pm/model";
import type { EditorState, Transaction } from "@tiptap/pm/state";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import Prism from "prismjs";
import { resolvePrismLanguage } from "../../utils/Prism";
type HighlightNode = {
text: string;
classes: string[];
};
type PrismToken = {
type: string;
alias?: string | string[];
content: string | PrismToken | Array<string | PrismToken>;
};
type StepRange = {
from?: number;
to?: number;
};
function buildLanguageClassName(languageClassPrefix: string | null | undefined, language?: string | null) {
if (!languageClassPrefix || !language) {
return null;
}
return `${languageClassPrefix}${language}`;
}
function buildTokenClassList(type: string, alias?: string | string[]) {
const aliasList = Array.isArray(alias) ? alias : alias ? [alias] : [];
return ["token", type, ...aliasList];
}
function parsePrismTokens(tokens: Array<string | PrismToken>, className: string[] = []): HighlightNode[] {
return tokens.flatMap((token) => {
if (typeof token === "string") {
return [{
text: token,
classes: className
}];
}
const classes = [...className, ...buildTokenClassList(token.type, token.alias)];
if (typeof token.content === "string") {
return [{
text: token.content,
classes
}];
}
if (Array.isArray(token.content)) {
return parsePrismTokens(token.content, classes);
}
return parsePrismTokens([token.content], classes);
});
}
function getDecorations({
doc,
name,
defaultLanguage
}: {
doc: ProsemirrorNode;
name: string;
defaultLanguage: string | null | undefined;
}) {
const decorations: Decoration[] = [];
findChildren(doc, node => node.type.name === name).forEach((block) => {
let from = block.pos + 1;
const prismLanguage = resolvePrismLanguage(block.node.attrs.language || defaultLanguage);
const grammar = prismLanguage ? Prism.languages[prismLanguage] : null;
const tokens = grammar ? Prism.tokenize(block.node.textContent, grammar) : [block.node.textContent];
parsePrismTokens(tokens).forEach((node) => {
const to = from + node.text.length;
if (node.classes.length) {
decorations.push(Decoration.inline(from, to, {
class: node.classes.join(" ")
}));
}
from = to;
});
});
return DecorationSet.create(doc, decorations);
}
function createPrismPlugin({
name,
defaultLanguage
}: {
name: string;
defaultLanguage: string | null | undefined;
}) {
const prismPlugin: Plugin<any> = new Plugin({
key: new PluginKey("prism"),
state: {
init: (_: unknown, { doc }: EditorState) => getDecorations({
doc,
name,
defaultLanguage
}),
apply: (transaction: Transaction, decorationSet: DecorationSet, oldState: EditorState, newState: EditorState) => {
const oldNodeName = oldState.selection.$head.parent.type.name;
const newNodeName = newState.selection.$head.parent.type.name;
const oldNodes = findChildren(oldState.doc, node => node.type.name === name);
const newNodes = findChildren(newState.doc, node => node.type.name === name);
if (
transaction.docChanged
&& (
[oldNodeName, newNodeName].includes(name)
|| newNodes.length !== oldNodes.length
|| transaction.steps.some((step) => {
const range = step as StepRange;
return range.from !== undefined
&& range.to !== undefined
&& oldNodes.some(node => node.pos >= range.from! && node.pos + node.node.nodeSize <= range.to!);
})
)
) {
return getDecorations({
doc: transaction.doc,
name,
defaultLanguage
});
}
return decorationSet.map(transaction.mapping, transaction.doc);
}
},
props: {
decorations(state: EditorState) {
return prismPlugin.getState(state);
}
}
});
return prismPlugin;
}
export default CodeBlock.extend({
renderHTML({ node, HTMLAttributes }: { node: ProsemirrorNode; HTMLAttributes: Record<string, unknown> }) {
const languageClassName = buildLanguageClassName(this.options.languageClassPrefix, node.attrs.language);
return [
"pre",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: languageClassName
}),
[
"code",
{
class: languageClassName
},
0
]
];
},
addProseMirrorPlugins() {
return [
createPrismPlugin({
name: this.name,
defaultLanguage: this.options.defaultLanguage
})
];
}
});
@@ -0,0 +1,56 @@
<template>
<t-popconfirm
v-model:visible="visible"
trigger="click"
:confirm-btn="{ content: '插入', theme: 'primary' }"
:cancel-btn="{ content: '取消', theme: 'default' }"
placement="bottom"
@visible-change="onVisibleChange"
@confirm="onConfirm"
>
<template #icon><span></span></template>
<template #content>
<t-input v-model="codeLanguage" placeholder="代码语言,可留空" />
</template>
<t-button v-popup="'代码块'" variant="outline" :theme="active ? 'primary' : 'default'">
<t-icon name="code" />
</t-button>
</t-popconfirm>
</template>
<script lang="ts" setup>
import type { TiptapEditorInstance } from "./useTiptapEditor";
defineOptions({
name: "TiptapCodeBlockButton"
});
const props = defineProps<{
editor?: TiptapEditorInstance;
active: boolean;
}>();
const visible = ref(false);
const codeLanguage = ref("");
function onVisibleChange(nextVisible: boolean) {
if (!nextVisible || !props.editor) {
return;
}
codeLanguage.value = "";
}
function onConfirm() {
if (!props.editor) {
return;
}
const language = codeLanguage.value.trim();
if (!language) {
props.editor.chain().focus().setCodeBlock().run();
visible.value = false;
return;
}
props.editor.chain().focus().setCodeBlock({ language }).run();
visible.value = false;
}
</script>
@@ -0,0 +1,113 @@
<template>
<t-popconfirm
class="tiptap-iframe-button"
v-model:visible="visible"
trigger="click"
:confirm-btn="{ content: '插入', theme: 'primary' }"
:cancel-btn="{ content: '取消', theme: 'default' }"
placement="bottom"
@visible-change="onVisibleChange"
@confirm="onConfirm"
>
<template #icon><span></span></template>
<template #content>
<t-form class="popconfirm-content" label-align="left" label-width="auto">
<t-form-item label="网页地址">
<t-input v-model="iframeForm.src" placeholder="请输入网页地址" />
</t-form-item>
<t-form-item label="网页标题">
<t-input v-model="iframeForm.title" placeholder="请输入网页标题,可留空" />
</t-form-item>
<t-form-item label="宽度">
<t-input v-model="iframeForm.width" placeholder="请输入宽度,默认 100%" />
</t-form-item>
<t-form-item label="高度">
<t-input v-model="iframeForm.height" placeholder="请输入高度,默认 28rem" />
</t-form-item>
</t-form>
</template>
<t-button v-popup="'插入内嵌网页'" variant="outline" :theme="active ? 'primary' : 'default'">
<t-icon name="web" />
</t-button>
</t-popconfirm>
</template>
<script lang="ts" setup>
import { showTDesignMessage } from "../../utils/TDesignMessage";
import type { TiptapEditorInstance } from "./useTiptapEditor";
defineOptions({
name: "TiptapIframeButton"
});
const props = defineProps<{
editor?: TiptapEditorInstance;
active: boolean;
}>();
const appContext = getCurrentInstance()?.appContext;
const visible = ref(false);
const iframeForm = reactive({
src: "",
title: "",
width: "100%",
height: "28rem"
});
function onVisibleChange(nextVisible: boolean) {
if (!nextVisible || !props.editor) {
return;
}
const attributes = props.editor.getAttributes("iframe");
iframeForm.src = (attributes.src as string) || "https://";
iframeForm.title = (attributes.title as string) || "";
iframeForm.width = (attributes.width as string) || "100%";
iframeForm.height = (attributes.height as string) || "28rem";
}
function isValidHttpUrl(value: string) {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
async function onConfirm() {
if (!props.editor) {
return;
}
const src = iframeForm.src.trim();
if (!src) {
visible.value = false;
return;
}
if (!isValidHttpUrl(src)) {
await showTDesignMessage(appContext, "warning", "请输入有效的 http 或 https 地址");
return;
}
props.editor
.chain()
.focus()
.insertContent({
type: "iframe",
attrs: {
src,
title: iframeForm.title.trim() || "内嵌网页",
width: iframeForm.width.trim() || "100%",
height: iframeForm.height.trim() || "28rem"
}
})
.run();
visible.value = false;
}
</script>
<style lang="less" scoped>
.popconfirm-content {
--td-comp-margin-xxl: .5rem;
padding: 0 .5rem;
}
</style>
@@ -0,0 +1,95 @@
<template>
<t-popconfirm
v-model:visible="visible"
trigger="click"
:confirm-btn="{ content: '插入', theme: 'primary' }"
:cancel-btn="{ content: '取消', theme: 'default' }"
placement="bottom"
@visible-change="onVisibleChange"
@confirm="onConfirm"
>
<template #icon><span></span></template>
<template #content>
<div class="link-form">
<t-input v-model="linkForm.href" placeholder="请输入链接地址" />
<t-input v-model="linkForm.text" placeholder="请输入显示文本,可留空" />
<t-checkbox v-model="linkForm.syncToHref">显示文本同步为链接</t-checkbox>
</div>
</template>
<t-button v-popup="'插入链接'" variant="outline" :theme="active ? 'primary' : 'default'">
<t-icon name="link-1" />
</t-button>
</t-popconfirm>
</template>
<script lang="ts" setup>
import type { TiptapEditorInstance } from "./useTiptapEditor";
defineOptions({
name: "TiptapLinkButton"
});
const props = defineProps<{
editor?: TiptapEditorInstance;
active: boolean;
}>();
const visible = ref(false);
const linkForm = reactive({
href: "",
text: "",
syncToHref: false
});
function onVisibleChange(nextVisible: boolean) {
if (!nextVisible || !props.editor) {
return;
}
const previousUrl = (props.editor.getAttributes("link").href as string) || "";
const selectedText = props.editor.state.doc.textBetween(
props.editor.state.selection.from,
props.editor.state.selection.to,
""
);
linkForm.href = previousUrl || "https://";
linkForm.text = selectedText || "";
linkForm.syncToHref = false;
}
function onConfirm() {
if (!props.editor) {
return;
}
const href = linkForm.href.trim();
if (!href) {
props.editor.chain().focus().unsetLink().run();
visible.value = false;
return;
}
const text = linkForm.syncToHref ? href : linkForm.text.trim();
if (text) {
props.editor
.chain()
.focus()
.insertContent({
type: "text",
text,
marks: [{ type: "link", attrs: { href } }]
})
.run();
visible.value = false;
return;
}
props.editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
visible.value = false;
}
</script>
<style lang="less" scoped>
.link-form {
gap: .75rem;
width: 20rem;
display: flex;
flex-direction: column;
}
</style>
@@ -0,0 +1,184 @@
<template>
<t-popconfirm
class="tiptap-media-button"
v-model:visible="visible"
trigger="click"
:confirm-btn="{ content: '插入', theme: 'primary' }"
:cancel-btn="{ content: '取消', theme: 'default' }"
placement="bottom"
@visible-change="onVisibleChange"
@confirm="onConfirm"
>
<template #icon><span></span></template>
<template #content>
<t-form class="popconfirm-content" label-align="left" label-width="auto">
<t-form-item label="文件">
<t-button size="small" variant="outline" @click="pickFile">
<span
class="file-name keep-text"
v-text="selectedFile ? '重新选择' : `选择${typeLabel}`"
/>
</t-button>
<span class="word-space gray" v-text="selectedFile?.name || `未选择${typeLabel}文件`" />
</t-form-item>
<t-form-item label="标题">
<t-input v-model="mediaForm.title" :placeholder="`请输入${typeLabel}标题,可留空`" />
</t-form-item>
<t-form-item label="宽度">
<t-input v-model="mediaForm.width" placeholder="请输入宽度,默认 100%" />
</t-form-item>
<t-form-item label="高度">
<t-input v-model="mediaForm.height" placeholder="请输入高度,默认 auto" />
</t-form-item>
</t-form>
</template>
<t-button variant="outline" :theme="active ? 'primary' : 'default'" :loading="uploading">
<t-icon :name="iconName" />
</t-button>
</t-popconfirm>
<input ref="fileInputRef" class="file-input" type="file" :accept="accept" @change="onFileChange" />
</template>
<script lang="ts" setup>
import { showTDesignMessage } from "../../utils/TDesignMessage";
import type { TiptapEditorInstance } from "./useTiptapEditor";
import type { UploadResult } from "./shared";
defineOptions({
name: "TiptapMediaButton"
});
const props = defineProps<{
editor?: TiptapEditorInstance;
active: boolean;
type: "image" | "video";
upload?: (file: File) => Promise<UploadResult>;
}>();
const appContext = getCurrentInstance()?.appContext;
const visible = ref(false);
const uploading = ref(false);
const selectedFile = ref<File>();
const fileInputRef = ref<HTMLInputElement>();
const mediaForm = reactive({
title: "",
width: "100%",
height: ""
});
const typeLabel = computed(() => {
return props.type === "image" ? "图片" : "视频";
});
const iconName = computed(() => {
return props.type === "image" ? "image-add" : "video";
});
const accept = computed(() => {
return props.type === "image" ? "image/*" : "video/*";
});
function buildDefaultHeight() {
return "auto";
}
function pickFile(event?: MouseEvent) {
event?.stopPropagation();
fileInputRef.value?.click();
}
function onVisibleChange(nextVisible: boolean) {
if (!nextVisible || !props.editor) {
return;
}
selectedFile.value = undefined;
const attributes = props.editor.getAttributes(props.type);
mediaForm.title = (attributes.title as string) || "";
mediaForm.width = (attributes.width as string) || "100%";
mediaForm.height = (attributes.height as string) || buildDefaultHeight();
}
function onFileChange(event: Event) {
const target = event.target as HTMLInputElement;
selectedFile.value = target.files?.[0];
target.value = "";
if (!selectedFile.value) {
return;
}
if (!mediaForm.title.trim()) {
mediaForm.title = selectedFile.value.name;
}
}
async function onConfirm() {
if (!props.editor) {
return;
}
if (!props.upload) {
await showTDesignMessage(appContext, "warning", `${typeLabel.value}上传功能未启用`);
return;
}
if (!selectedFile.value) {
await showTDesignMessage(appContext, "warning", `请选择${typeLabel.value}文件`);
return;
}
uploading.value = true;
try {
const file = selectedFile.value;
const result = await props.upload(file);
const title = mediaForm.title.trim() || result.name || file.name;
const attrs = {
src: result.url,
title,
width: mediaForm.width.trim() || "100%",
height: mediaForm.height.trim() || buildDefaultHeight(),
attachmentId: result.id,
tempFileId: result.id
};
if (props.type === "image") {
props.editor
.chain()
.focus()
.setImage({
src: attrs.src,
alt: title,
title
})
.updateAttributes("image", {
attachmentId: attrs.attachmentId,
tempFileId: attrs.tempFileId,
width: attrs.width,
height: attrs.height
})
.run();
} else {
props.editor
.chain()
.focus()
.insertContent({
type: "video",
attrs
})
.run();
}
visible.value = false;
} catch (error) {
console.error(`上传${typeLabel.value}失败:`, error);
await showTDesignMessage(appContext, "error", `上传${typeLabel.value}失败`);
} finally {
uploading.value = false;
}
}
</script>
<style lang="less" scoped>
.popconfirm-content {
--td-comp-margin-xxl: .5rem;
padding: 0 .5rem;
}
.file-input {
display: none;
}
</style>
@@ -0,0 +1,99 @@
<template>
<t-popconfirm
v-model:visible="visible"
trigger="click"
placement="bottom-right"
:show-arrow="false"
:confirm-btn="null"
:cancel-btn="null"
:popup-props="{ overlayClassName: 'tp-padding-pop' }"
@visible-change="onVisibleChange"
>
<template #icon><span></span></template>
<template #content>
<div class="padding-form">
<div class="item">
<div class="head">
<span class="label">上下内边距</span>
<span class="value" v-text="formatPaddingValue(paddingTB)" />
</div>
<t-slider :min="0" :max="6" :step=".25" :value="paddingTB" @change="onPaddingTBChange" />
</div>
<div class="item">
<div class="head">
<span class="label">左右内边距</span>
<span class="value" v-text="formatPaddingValue(paddingLR)" />
</div>
<t-slider :min="0" :max="16" :step=".25" :value="paddingLR" @change="onPaddingLRChange" />
</div>
</div>
</template>
<t-button v-popup="'内边距'" variant="outline">
<t-icon name="fullscreen-exit-1" />
</t-button>
</t-popconfirm>
</template>
<script lang="ts" setup>
import { formatPaddingValue, normalizeSliderValue } from "./shared";
defineOptions({
name: "TiptapPaddingButton"
});
const props = defineProps<{
paddingTB: number;
paddingLR: number;
}>();
const emit = defineEmits<{
"update:paddingTB": [value: number];
"update:paddingLR": [value: number];
}>();
const visible = ref(false);
function onVisibleChange(nextVisible: boolean) {
if (nextVisible) {
return;
}
}
function onPaddingTBChange(value: number | number[]) {
emit("update:paddingTB", normalizeSliderValue(value, props.paddingTB));
}
function onPaddingLRChange(value: number | number[]) {
emit("update:paddingLR", normalizeSliderValue(value, props.paddingLR));
}
</script>
<style lang="less" scoped>
.padding-form {
gap: .75rem;
width: 18rem;
display: flex;
flex-direction: column;
.item {
gap: .5rem;
display: flex;
flex-direction: column;
.head {
display: flex;
align-items: center;
justify-content: space-between;
.label {
color: var(--td-text-color-primary);
}
.value {
color: var(--td-text-color-secondary);
font-size: .75rem;
}
}
}
}
</style>
@@ -0,0 +1,126 @@
<template>
<t-popconfirm
v-model:visible="visible"
trigger="click"
placement="bottom"
:show-arrow="false"
:popup-props="{ overlayClassName: 'tp-table-pop' }"
@visible-change="onVisibleChange"
>
<template #icon><span></span></template>
<template #content>
<div class="table-picker" @mouseleave="onReset">
<div class="tip" v-text="`${hoverRow} × ${hoverCol}`" />
<div class="grid">
<button
v-for="item in tablePickerItems"
:key="item.key"
class="cell"
:class="{ active: item.active }"
type="button"
@mousemove="onHover(item.row, item.col)"
@click="onSelect(item.row, item.col)"
/>
</div>
</div>
</template>
<t-button v-popup="'插入表格'" variant="outline" :theme="active ? 'primary' : 'default'">
<t-icon name="table" />
</t-button>
</t-popconfirm>
</template>
<script lang="ts" setup>
import { TABLE_PICKER_SIZE } from "./shared";
import type { TiptapEditorInstance } from "./useTiptapEditor";
defineOptions({
name: "TiptapTableButton"
});
const props = defineProps<{
editor?: TiptapEditorInstance;
active: boolean;
}>();
const visible = ref(false);
const hoverRow = ref(1);
const hoverCol = ref(1);
const tablePickerItems = computed(() => {
const items: { key: string; row: number; col: number; active: boolean }[] = [];
for (let row = 1; row < TABLE_PICKER_SIZE + 1; row += 1) {
for (let col = 1; col < TABLE_PICKER_SIZE + 1; col += 1) {
items.push({
key: `${row}-${col}`,
row,
col,
active: row < hoverRow.value + 1 && col < hoverCol.value + 1
});
}
}
return items;
});
function onVisibleChange(nextVisible: boolean) {
if (!nextVisible) {
return;
}
hoverRow.value = 1;
hoverCol.value = 1;
}
function onReset() {
hoverRow.value = 1;
hoverCol.value = 1;
}
function onHover(row: number, col: number) {
hoverRow.value = row;
hoverCol.value = col;
}
function onSelect(row: number, col: number) {
if (!props.editor) {
return;
}
hoverRow.value = row;
hoverCol.value = col;
props.editor.chain().focus().insertTable({ rows: row, cols: col, withHeaderRow: true }).run();
visible.value = false;
}
</script>
<style lang="less" scoped>
.table-picker {
width: 10.5rem;
.tip {
height: 1.5rem;
color: var(--td-text-color-secondary);
font-size: .75rem;
line-height: 1.5rem;
}
.grid {
gap: .125rem;
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.cell {
width: 1.25rem;
height: 1.25rem;
padding: 0;
border: 1px solid var(--td-component-border);
cursor: pointer;
background: var(--td-bg-color-container);
transition: background .15s ease, border-color .15s ease;
&.active {
border-color: var(--td-brand-color);
background: var(--td-brand-color-light);
}
}
}
</style>
@@ -0,0 +1,431 @@
<template>
<div class="toolbar">
<div class="left" :class="{ 'toolbar-disabled': sourceCodeMode }">
<div class="fixed">
<t-button v-popup="'撤销'" variant="outline" :disabled="!canExec('undo')" @click="run('undo')">
<t-icon name="rollback" />
</t-button>
<t-button v-popup="'重做'" variant="outline" :disabled="!canExec('redo')" @click="run('redo')">
<t-icon name="rollfront" />
</t-button>
<t-button v-popup="'清除格式'" variant="outline" @click="run('clear')">
<t-icon name="clear-formatting" />
</t-button>
</div>
<div class="scroll">
<t-dropdown trigger="click" :options="headingOptions" :min-column-width="120" @click="onHeadingSelect">
<t-button v-popup="'标题'" variant="outline" :theme="isHeadingActive ? 'primary' : 'default'">
<t-icon name="text" />
</t-button>
</t-dropdown>
<t-button v-popup="'加粗'" variant="outline" :theme="isActive('bold') ? 'primary' : 'default'" @click="run('bold')">
<t-icon name="textformat-bold" />
</t-button>
<t-button v-popup="'斜体'" variant="outline" :theme="isActive('italic') ? 'primary' : 'default'" @click="run('italic')">
<t-icon name="textformat-italic" />
</t-button>
<t-button v-popup="'下划线'" variant="outline" :theme="isActive('underline') ? 'primary' : 'default'" @click="run('underline')">
<t-icon name="textformat-underline" />
</t-button>
<t-button v-popup="'删除线'" variant="outline" :theme="isActive('strike') ? 'primary' : 'default'" @click="run('strike')">
<t-icon name="textformat-strikethrough" />
</t-button>
<t-button v-popup="'前景色'" variant="outline" @click="pickColor('text')">
<t-icon name="textformat-color" />
</t-button>
<t-button v-popup="'背景色'" variant="outline" @click="pickColor('bg')">
<t-icon name="fill-color-1" />
</t-button>
<t-button v-popup="'有序列表'" variant="outline" :theme="isActive('orderedList') ? 'primary' : 'default'" @click="run('orderedList')">
<t-icon name="order-list" />
</t-button>
<tiptap-table-button :editor="editor" :active="isActive('table')" />
<t-button v-popup="'无序列表'" variant="outline" :theme="isActive('bulletList') ? 'primary' : 'default'" @click="run('bulletList')">
<t-icon name="bulletpoint" />
</t-button>
<t-button v-popup="'行内代码'" variant="outline" :theme="isActive('code') ? 'primary' : 'default'" @click="run('code')">
<t-icon name="highlighted-block" />
</t-button>
<tiptap-code-block-button :editor="editor" :active="isActive('codeBlock')" />
<t-button v-popup="'下标'" variant="outline" :theme="isActive('subscript') ? 'primary' : 'default'" @click="run('subscript')">
<t-icon name="subscript" />
</t-button>
<t-button v-popup="'上标'" variant="outline" :theme="isActive('superscript') ? 'primary' : 'default'" @click="run('superscript')">
<t-icon name="superscript" />
</t-button>
<t-button v-popup="'左对齐'" variant="outline" :theme="isAlignActive('left') ? 'primary' : 'default'" @click="setAlign('left')">
<t-icon name="format-vertical-align-left" />
</t-button>
<t-button v-popup="'居中对齐'" variant="outline" :theme="isAlignActive('center') ? 'primary' : 'default'" @click="setAlign('center')">
<t-icon name="format-vertical-align-center" />
</t-button>
<t-button v-popup="'右对齐'" variant="outline" :theme="isAlignActive('right') ? 'primary' : 'default'" @click="setAlign('right')">
<t-icon name="format-vertical-align-right" />
</t-button>
<t-button v-popup="'两端对齐'" variant="outline" :theme="isAlignActive('justify') ? 'primary' : 'default'" @click="setAlign('justify')">
<t-icon name="expand-horizontal" />
</t-button>
<t-button v-popup="'分割线'" variant="outline" @click="run('horizontalRule')">
<t-icon name="divider-1" />
</t-button>
<tiptap-link-button :editor="editor" :active="isActive('link')" />
<tiptap-media-button :editor="editor" :active="isActive('image')" type="image" :upload="uploadImage" />
<tiptap-media-button :editor="editor" :active="isActive('video')" type="video" :upload="uploadVideo" />
<tiptap-iframe-button :editor="editor" :active="isActive('iframe')" />
</div>
</div>
<div class="right">
<div v-if="showPaddingTool" class="right__group" :class="{ 'toolbar-disabled': sourceCodeMode }">
<tiptap-padding-button
:padding-t-b="paddingTB"
:padding-l-r="paddingLR"
@update:padding-t-b="emit('update:paddingTB', $event)"
@update:padding-l-r="emit('update:paddingLR', $event)"
/>
</div>
<t-button
v-popup="sourceCodeMode ? '富文本' : '源码'"
class="source-btn"
variant="outline"
:theme="sourceCodeMode ? 'primary' : 'default'"
@click="emit('toggle-source-code')"
>
<t-icon v-if="sourceCodeMode" name="edit-1" />
<t-icon v-else name="braces" />
</t-button>
</div>
<input ref="textColorRef" class="hidden" type="color" :value="textColor" @input="onTextColorChange" />
<input ref="bgColorRef" class="hidden" type="color" :value="bgColor" @input="onBgColorChange" />
</div>
</template>
<script lang="ts" setup>
import type { AlignType, UploadResult } from "./shared";
import { buildHeadingOptions } from "./shared";
import type { TiptapEditorInstance } from "./useTiptapEditor";
import TiptapCodeBlockButton from "./TiptapCodeBlockButton.vue";
import TiptapIframeButton from "./TiptapIframeButton.vue";
import TiptapLinkButton from "./TiptapLinkButton.vue";
import TiptapMediaButton from "./TiptapMediaButton.vue";
import TiptapPaddingButton from "./TiptapPaddingButton.vue";
import TiptapTableButton from "./TiptapTableButton.vue";
defineOptions({
name: "TiptapToolbar"
});
const props = withDefaults(defineProps<{
editor?: TiptapEditorInstance;
uploadImage?: (file: File) => Promise<UploadResult>;
uploadVideo?: (file: File) => Promise<UploadResult>;
paddingTB: number;
paddingLR: number;
showPaddingTool?: boolean;
sourceCodeMode?: boolean;
}>(), {
showPaddingTool: true,
sourceCodeMode: false
});
const emit = defineEmits<{
"update:paddingTB": [value: number];
"update:paddingLR": [value: number];
"toggle-source-code": [];
}>();
type RunType =
| "undo"
| "redo"
| "bold"
| "italic"
| "underline"
| "strike"
| "orderedList"
| "bulletList"
| "code"
| "subscript"
| "superscript"
| "horizontalRule"
| "clear";
const textColorRef = ref<HTMLInputElement>();
const bgColorRef = ref<HTMLInputElement>();
const textColor = ref("#000000");
const bgColor = ref("#ffff00");
const currentHeadingValue = computed(() => {
if (!props.editor) {
return "p";
}
for (let level = 1; level < 7; level += 1) {
if (props.editor.isActive("heading", { level })) {
return `h${level}`;
}
}
return "p";
});
const headingOptions = computed(() => {
return buildHeadingOptions(currentHeadingValue.value);
});
const isHeadingActive = computed(() => {
return currentHeadingValue.value !== "p";
});
function canExec(type: "undo" | "redo") {
if (!props.editor) {
return false;
}
switch (type) {
case "undo":
return props.editor.can().undo();
case "redo":
return props.editor.can().redo();
default:
return false;
}
}
function isActive(type: string) {
if (!props.editor) {
return false;
}
return props.editor.isActive(type);
}
function isAlignActive(align: AlignType) {
if (!props.editor) {
return false;
}
return props.editor.isActive({ textAlign: align });
}
function run(type: RunType) {
if (!props.editor) {
return;
}
const chain = props.editor.chain().focus();
switch (type) {
case "undo":
chain.undo().run();
return;
case "redo":
chain.redo().run();
return;
case "bold":
chain.toggleBold().run();
return;
case "italic":
chain.toggleItalic().run();
return;
case "underline":
chain.toggleUnderline().run();
return;
case "strike":
chain.toggleStrike().run();
return;
case "orderedList":
chain.toggleOrderedList().run();
return;
case "bulletList":
chain.toggleBulletList().run();
return;
case "code":
chain.toggleCode().run();
return;
case "subscript":
chain.toggleSubscript().run();
return;
case "superscript":
chain.toggleSuperscript().run();
return;
case "horizontalRule":
chain.setHorizontalRule().run();
return;
case "clear":
chain.clearNodes().unsetAllMarks().run();
return;
default:
}
}
function setAlign(align: AlignType) {
props.editor?.chain().focus().setTextAlign(align).run();
}
function onHeadingSelect(option: { value?: string | number | Record<string, unknown> }) {
if (!props.editor) {
return;
}
const value = `${option?.value || "p"}`;
const chain = props.editor.chain().focus();
if (value === "p") {
chain.setParagraph().run();
return;
}
const level = Number(value.replace("h", ""));
if (0 < level && level < 7) {
chain.setHeading({ level: level as 1 | 2 | 3 | 4 | 5 | 6 }).run();
}
}
function pickColor(type: "text" | "bg") {
if (type === "text") {
textColorRef.value?.click();
return;
}
bgColorRef.value?.click();
}
function onTextColorChange(event: Event) {
if (!props.editor) {
return;
}
const target = event.target as HTMLInputElement;
textColor.value = target.value;
props.editor.chain().focus().setColor(target.value).run();
}
function onBgColorChange(event: Event) {
if (!props.editor) {
return;
}
const target = event.target as HTMLInputElement;
bgColor.value = target.value;
props.editor.chain().focus().setMark("textStyle", { backgroundColor: target.value }).run();
}
</script>
<style lang="less" scoped>
.toolbar {
display: flex;
position: relative;
margin-top: -1px;
justify-content: space-between;
&:after {
content: "";
width: 100%;
height: 1px;
bottom: 0;
position: absolute;
background: var(--td-component-border);
}
.hidden {
display: none;
}
.left,
.right {
gap: 0;
display: flex;
flex-wrap: nowrap;
:deep(.t-button) {
z-index: 1;
position: relative;
margin-left: -1px;
border-radius: 0;
--td-comp-paddingLR-l: 8px;
&:hover,
&:focus-visible,
&.t-is-active,
&.t-button--theme-primary {
z-index: 2;
}
}
}
.left {
flex: 1;
min-width: 0;
.fixed,
.scroll {
gap: 0;
display: flex;
flex-wrap: nowrap;
}
.fixed {
flex: none;
}
.scroll {
flex: 1;
min-width: 0;
> * {
flex: none;
}
:deep(.t-button:last-child) {
margin-right: 3rem;
}
}
}
.right {
flex: none;
margin-right: -1px;
.right__group {
display: flex;
}
}
.toolbar-disabled {
opacity: .55;
pointer-events: none;
}
.source-btn {
margin-left: -1px;
border-radius: 0;
}
}
@media screen and (max-width: 48rem) {
.toolbar {
justify-content: flex-start;
.left {
.scroll {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
}
}
:global(.tp-heading-option) {
display: block;
line-height: 1.2;
}
.heading-size(@name, @size, @weight) {
:global(.tp-heading-option.@{name}) {
font-size: @size;
font-weight: @weight;
}
}
.heading-size(p, 1rem, 400);
.heading-size(h1, 2rem, 700);
.heading-size(h2, 1.5rem, 700);
.heading-size(h3, 1.17rem, 700);
.heading-size(h4, 1rem, 700);
.heading-size(h5, .83rem, 700);
.heading-size(h6, .67rem, 700);
</style>
+259
View File
@@ -0,0 +1,259 @@
import { mergeAttributes, Node } from "@tiptap/vue-3";
import Color from "@tiptap/extension-color";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import Subscript from "@tiptap/extension-subscript";
import Superscript from "@tiptap/extension-superscript";
import { Table } from "@tiptap/extension-table";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import TableRow from "@tiptap/extension-table-row";
import TextAlign from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style";
import Underline from "@tiptap/extension-underline";
import type { Extensions } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import PrismCodeBlock from "./PrismCodeBlock";
import { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR } from "./shared";
function buildStyleString(styleList: Array<string | null | undefined>) {
return styleList.filter(Boolean).join("; ");
}
function buildNodeSizeAttributes(defaultHeight = "auto") {
return {
width: {
default: "100%",
parseHTML: (element: HTMLElement) => element.style.width || "100%"
},
height: {
default: defaultHeight,
parseHTML: (element: HTMLElement) => element.style.height || defaultHeight
}
};
}
function buildNodeSizeStyle(attributes: Record<string, string>) {
return buildStyleString([
attributes.style,
attributes.width ? `width: ${attributes.width}` : null,
attributes.height ? `height: ${attributes.height}` : null
]);
}
const ExtTextStyle = TextStyle.extend({
addGlobalAttributes() {
return [
...(this.parent?.() || []),
{
types: ["textStyle"],
attributes: {
backgroundColor: {
default: null,
parseHTML: (element: HTMLElement) => element.style.backgroundColor || null,
renderHTML: (attributes: Record<string, string>) => {
if (!attributes.backgroundColor) {
return {};
}
return {
style: `background-color: ${attributes.backgroundColor}`
};
}
}
}
}
];
}
});
const ExtImage = Image.configure({
allowBase64: true
}).extend({
addAttributes() {
return {
...this.parent?.(),
...buildNodeSizeAttributes(),
attachmentId: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute(ATTACHMENT_ID_ATTR),
renderHTML: (attributes: Record<string, string>) => {
if (!attributes.attachmentId) {
return {};
}
return {
[ATTACHMENT_ID_ATTR]: attributes.attachmentId
};
}
},
tempFileId: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute(TEMP_FILE_ID_ATTR),
renderHTML: (attributes: Record<string, string>) => {
if (!attributes.tempFileId) {
return {};
}
return {
[TEMP_FILE_ID_ATTR]: attributes.tempFileId
};
}
}
};
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, string> }) {
return [
"img",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: buildNodeSizeStyle(HTMLAttributes)
})
];
}
});
const ExtVideo = Node.create({
name: "video",
group: "block",
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
src: {
default: null
},
title: {
default: null
},
...buildNodeSizeAttributes(),
attachmentId: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute(ATTACHMENT_ID_ATTR),
renderHTML: (attributes: Record<string, string>) => {
if (!attributes.attachmentId) {
return {};
}
return {
[ATTACHMENT_ID_ATTR]: attributes.attachmentId
};
}
},
tempFileId: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute(TEMP_FILE_ID_ATTR),
renderHTML: (attributes: Record<string, string>) => {
if (!attributes.tempFileId) {
return {};
}
return {
[TEMP_FILE_ID_ATTR]: attributes.tempFileId
};
}
}
};
},
parseHTML() {
return [
{
tag: "video"
}
];
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, string> }) {
return [
"video",
mergeAttributes(
{
controls: "true",
style: buildNodeSizeStyle(HTMLAttributes)
},
HTMLAttributes
)
];
}
});
const ExtIframe = Node.create({
name: "iframe",
group: "block",
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
src: {
default: null
},
title: {
default: null
},
...buildNodeSizeAttributes("28rem")
};
},
parseHTML() {
return [
{
tag: "iframe"
}
];
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, string> }) {
const style = buildNodeSizeStyle(HTMLAttributes);
return [
"iframe",
mergeAttributes(
{
class: "tp-iframe",
frameborder: "0",
allowfullscreen: "true",
referrerpolicy: "no-referrer-when-downgrade",
style
},
HTMLAttributes
)
];
}
});
export function buildTiptapExtensions(placeholder?: string): Extensions {
return [
StarterKit.configure({
codeBlock: false
}),
PrismCodeBlock.configure({
languageClassPrefix: "language-"
}),
Placeholder.configure({
placeholder: placeholder || "请输入内容"
}),
ExtImage,
ExtVideo,
ExtIframe,
Link.configure({
openOnClick: false,
autolink: true,
defaultProtocol: "https"
}),
Underline,
ExtTextStyle,
Color,
Subscript,
Superscript,
Table.configure({
resizable: true
}),
TableRow,
TableHeader,
TableCell,
TextAlign.configure({
types: ["heading", "paragraph"]
})
];
}
+15
View File
@@ -0,0 +1,15 @@
import view from "./index.vue";
import Toolkit from "../../utils/Toolkit";
import type { TiptapEditorInstance } from "./useTiptapEditor";
export * from "./shared";
export type { TiptapEditorInstance } from "./useTiptapEditor";
export type TiptapEditorExpose = {
submit: () => Promise<string>;
getPendingMediaCount: () => number;
editor?: TiptapEditorInstance;
};
export const TiptapEditor = Toolkit.withInstall(view);
export default TiptapEditor;
+617
View File
@@ -0,0 +1,617 @@
<template>
<div class="tp-editor">
<tiptap-toolbar
:editor="editor || undefined"
:padding-t-b="articlePadding.paddingTB"
:padding-l-r="articlePadding.paddingLR"
:show-padding-tool="showPaddingTool"
:source-code-mode="sourceCodeMode"
:upload-image="handleInsertImage"
:upload-video="handleInsertVideo"
@update:padding-t-b="articlePadding.paddingTB = $event"
@update:padding-l-r="articlePadding.paddingLR = $event"
@toggle-source-code="toggleSourceCodeMode"
/>
<div class="content">
<code-editor
v-if="sourceCodeMode"
ref="sourceEditorRef"
v-model="sourceCode"
class="source-editor"
language="html"
placeholder="请输入 HTML 源码"
/>
<div v-else-if="editor" class="value-wrap">
<editor-search-bar
ref="searchBarRef"
:visible="searchVisible"
:expanded="replaceExpanded"
:keyword="searchKeyword"
:replace-value="replaceKeyword"
:status-error="searchError"
:match-index="searchMatchIndex"
:match-count="searchMatchList.length"
:case-sensitive="searchOptions.caseSensitive"
:use-regex="searchOptions.useRegex"
@update:expanded="replaceExpanded = $event"
@update:keyword="searchKeyword = $event"
@update:replace-value="replaceKeyword = $event"
@update:case-sensitive="searchOptions.caseSensitive = $event"
@update:use-regex="searchOptions.useRegex = $event"
@previous="findPrevious"
@next="onSearchNext"
@replace="replaceCurrent"
@replace-all="replaceAll"
@close="closeSearch"
/>
<editor-content
class="value"
:style="editorBodyStyle"
:editor="editor"
:spellcheck="false"
@keydown.capture="onRichEditorKeydown"
/>
</div>
<t-empty v-else description="编辑器初始化中" />
</div>
</div>
</template>
<script lang="ts" setup>
import CodeEditor from "../code-editor/index.vue";
import EditorSearchBar from "../editor-search/index.vue";
import {
buildSearchRegex,
replaceMatchText,
type TextSearchOptions
} from "../editor-search/search";
import Toolkit from "../../utils/Toolkit";
import type { PendingMediaItem, UploadMediaType, UploadResult } from "./shared";
import TiptapToolbar from "./TiptapToolbar.vue";
import { useTiptapEditor } from "./useTiptapEditor";
import {
ATTACHMENT_ID_ATTR,
LOCAL_MEDIA_ID_PREFIX,
TEMP_FILE_ID_ATTR,
isLocalMediaId,
normalizeContent
} from "./shared";
defineOptions({
name: "TiptapEditor"
});
type EditorSearchBarExpose = {
focusKeyword: (selectAll?: boolean) => void;
focusReplace: (selectAll?: boolean) => void;
};
type CodeEditorExpose = {
openSearch: (showReplace: boolean) => void;
};
type RichTextMatch = {
from: number;
to: number;
text: string;
};
const props = withDefaults(defineProps<{
modelValue: string;
placeholder?: string;
paddingTB?: number;
paddingLR?: number;
showPaddingTool?: boolean;
uploadImage?: (file: File) => Promise<UploadResult>;
uploadVideo?: (file: File) => Promise<UploadResult>;
}>(), {
modelValue: "",
placeholder: "请输入内容",
paddingTB: 1,
paddingLR: 1.25,
showPaddingTool: true
});
const emit = defineEmits<{
"update:modelValue": [value: string];
"change": [value: string];
"update:paddingTB": [value: number];
"update:paddingLR": [value: number];
}>();
const { EditorContent, articlePadding, editor, editorBodyStyle } = useTiptapEditor(props, emit);
const pendingMediaMap = reactive(new Map<string, PendingMediaItem>());
const sourceCodeMode = ref(false);
const sourceCode = ref("");
const sourceEditorRef = ref<CodeEditorExpose>();
const searchBarRef = ref<EditorSearchBarExpose>();
const searchVisible = ref(false);
const replaceExpanded = ref(false);
const searchKeyword = ref("");
const replaceKeyword = ref("");
const searchError = ref("");
const searchMatchIndex = ref(-1);
const searchMatchList = ref<RichTextMatch[]>([]);
const searchOptions = reactive<TextSearchOptions>({
caseSensitive: false,
useRegex: false
});
function buildLocalMediaId() {
return `${LOCAL_MEDIA_ID_PREFIX}${Toolkit.uuid()}`;
}
function revokeMediaUrl(url?: string) {
if (url?.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
function removePendingMedia(id: string) {
const item = pendingMediaMap.get(id);
if (!item) {
return;
}
revokeMediaUrl(item.url);
pendingMediaMap.delete(id);
}
function clearPendingMedia() {
Array.from(pendingMediaMap.keys()).forEach(removePendingMedia);
}
function collectMediaIds(content?: string) {
const rawContent = normalizeContent(content);
if (!rawContent) {
return new Set<string>();
}
const parser = new DOMParser();
const doc = parser.parseFromString(rawContent, "text/html");
const mediaList = Array.from(doc.querySelectorAll("img, video"));
return new Set(
mediaList
.map(item => item.getAttribute(ATTACHMENT_ID_ATTR) || item.getAttribute(TEMP_FILE_ID_ATTR) || "")
.filter(Boolean)
);
}
function syncPendingMedia(content?: string) {
const mediaIdSet = collectMediaIds(content);
Array.from(pendingMediaMap.keys()).forEach((id) => {
if (!mediaIdSet.has(id)) {
removePendingMedia(id);
}
});
}
async function handleInsertMedia(file: File, type: UploadMediaType): Promise<UploadResult> {
const id = buildLocalMediaId();
const url = URL.createObjectURL(file);
pendingMediaMap.set(id, {
id,
type,
name: file.name,
file,
url
});
return {
id,
url,
name: file.name
};
}
async function handleInsertImage(file: File) {
return handleInsertMedia(file, "image");
}
async function handleInsertVideo(file: File) {
return handleInsertMedia(file, "video");
}
async function submit() {
const instance = editor.value;
if (!instance) {
return normalizeContent(props.modelValue);
}
const rawContent = normalizeContent(instance.getHTML());
if (!rawContent) {
clearPendingMedia();
return "";
}
const parser = new DOMParser();
const doc = parser.parseFromString(rawContent, "text/html");
const mediaList = Array.from(doc.querySelectorAll("img, video"));
const uploadedMediaIdList: string[] = [];
for (const media of mediaList) {
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || media.getAttribute(TEMP_FILE_ID_ATTR) || "";
if (!isLocalMediaId(attachmentId)) {
continue;
}
const pendingMedia = pendingMediaMap.get(attachmentId);
if (!pendingMedia) {
continue;
}
const uploadFn = pendingMedia.type === "image" ? props.uploadImage : props.uploadVideo;
if (!uploadFn) {
throw new Error(`未配置${pendingMedia.type === "image" ? "图片" : "视频"}上传方法`);
}
const result = await uploadFn(pendingMedia.file);
media.setAttribute("src", result.url);
media.setAttribute(ATTACHMENT_ID_ATTR, result.id);
media.setAttribute(TEMP_FILE_ID_ATTR, result.id);
if (media.tagName.toLowerCase() === "img") {
media.setAttribute("alt", result.name || pendingMedia.name);
media.setAttribute("title", result.name || pendingMedia.name);
} else {
media.setAttribute("title", result.name || pendingMedia.name);
}
uploadedMediaIdList.push(attachmentId);
}
const nextContent = normalizeContent(doc.body.innerHTML);
if (nextContent !== rawContent) {
instance.commands.setContent(nextContent || "<p></p>");
}
uploadedMediaIdList.forEach(removePendingMedia);
syncPendingMedia(nextContent);
return nextContent;
}
function syncSourceCode(value?: string) {
sourceCode.value = value || "";
}
function onSourceCodeChange(value: string) {
sourceCode.value = value;
emit("change", value);
emit("update:modelValue", value);
}
function getSelectedEditorText() {
const instance = editor.value;
if (!instance) {
return "";
}
const { from, to } = instance.state.selection;
if (from === to) {
return "";
}
return instance.state.doc.textBetween(from, to, "\n", "\n");
}
function refreshSearchMatchState() {
const instance = editor.value;
if (!instance || sourceCodeMode.value) {
searchError.value = "";
searchMatchList.value = [];
searchMatchIndex.value = -1;
return;
}
const { regex, error } = buildSearchRegex(searchKeyword.value, searchOptions, true);
searchError.value = error;
if (!regex || error || !searchKeyword.value) {
searchMatchList.value = [];
searchMatchIndex.value = -1;
return;
}
const matchList: RichTextMatch[] = [];
instance.state.doc.descendants((node, pos) => {
if (!node.isText || !node.text) {
return;
}
regex.lastIndex = 0;
let match = regex.exec(node.text);
while (match) {
if (!match[0]) {
searchError.value = "正则不能匹配空文本";
searchMatchList.value = [];
searchMatchIndex.value = -1;
return false;
}
matchList.push({
from: pos + match.index,
to: pos + match.index + match[0].length,
text: match[0]
});
match = regex.exec(node.text);
}
});
searchMatchList.value = matchList;
const { from, to } = instance.state.selection;
searchMatchIndex.value = matchList.findIndex((match) => match.from === from && match.to === to);
if (0 > searchMatchIndex.value && 0 < matchList.length) {
searchMatchIndex.value = 0;
}
}
function openSearch(showReplace: boolean) {
if (sourceCodeMode.value) {
sourceEditorRef.value?.openSearch(showReplace);
return;
}
searchVisible.value = true;
replaceExpanded.value = showReplace;
const selectedText = getSelectedEditorText();
if (selectedText) {
searchKeyword.value = selectedText;
}
refreshSearchMatchState();
nextTick(() => {
searchBarRef.value?.focusKeyword(true);
});
}
function closeSearch() {
searchVisible.value = false;
replaceExpanded.value = false;
editor.value?.commands.focus();
}
function selectMatch(match: RichTextMatch, matchIndex: number) {
const instance = editor.value;
if (!instance) {
return false;
}
instance.chain().focus().setTextSelection({ from: match.from, to: match.to }).scrollIntoView().run();
searchMatchIndex.value = matchIndex;
return true;
}
function findNext() {
const instance = editor.value;
if (!instance || searchError.value || 1 > searchMatchList.value.length) {
return false;
}
const currentTo = instance.state.selection.to;
const nextIndex = searchMatchList.value.findIndex((match) => match.from >= currentTo);
const targetIndex = 0 <= nextIndex ? nextIndex : 0;
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
}
function findPrevious() {
const instance = editor.value;
if (!instance || searchError.value || 1 > searchMatchList.value.length) {
return false;
}
const currentFrom = instance.state.selection.from;
let targetIndex = searchMatchList.value.length - 1;
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
if (searchMatchList.value[index].from < currentFrom) {
targetIndex = index;
break;
}
}
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
}
function replaceCurrent() {
const instance = editor.value;
if (!instance || !searchKeyword.value) {
return false;
}
let currentMatchIndex = searchMatchList.value.findIndex((match) => (
match.from === instance.state.selection.from && match.to === instance.state.selection.to
));
if (0 > currentMatchIndex) {
if (!findNext()) {
return false;
}
currentMatchIndex = searchMatchIndex.value;
}
const currentMatch = searchMatchList.value[currentMatchIndex];
if (!currentMatch) {
return false;
}
const nextText = replaceMatchText(currentMatch.text, searchKeyword.value, replaceKeyword.value, searchOptions);
const transaction = instance.state.tr.insertText(nextText, currentMatch.from, currentMatch.to);
instance.view.dispatch(transaction);
refreshSearchMatchState();
nextTick(() => {
const nextMatch = searchMatchList.value[currentMatchIndex] || searchMatchList.value[currentMatchIndex - 1];
if (!nextMatch) {
instance.commands.focus();
return;
}
selectMatch(nextMatch, Math.min(currentMatchIndex, searchMatchList.value.length - 1));
});
return true;
}
function replaceAll() {
const instance = editor.value;
if (!instance || !searchKeyword.value) {
return 0;
}
if (searchError.value || 1 > searchMatchList.value.length) {
refreshSearchMatchState();
return 0;
}
const replaceCount = searchMatchList.value.length;
const transaction = instance.state.tr;
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
const match = searchMatchList.value[index];
const nextText = replaceMatchText(match.text, searchKeyword.value, replaceKeyword.value, searchOptions);
transaction.insertText(nextText, match.from, match.to);
}
instance.view.dispatch(transaction);
refreshSearchMatchState();
nextTick(() => {
if (searchMatchList.value[0]) {
selectMatch(searchMatchList.value[0], 0);
return;
}
instance.commands.focus();
});
return replaceCount;
}
function onSearchNext(shiftKey: boolean) {
if (shiftKey) {
findPrevious();
return;
}
findNext();
}
function onRichEditorKeydown(event: KeyboardEvent) {
if (sourceCodeMode.value || event.altKey || !(event.ctrlKey || event.metaKey)) {
return;
}
const key = event.key.toLowerCase();
if ("f" === key) {
event.preventDefault();
openSearch(false);
return;
}
if ("h" === key) {
event.preventDefault();
openSearch(true);
}
}
function toggleSourceCodeMode() {
const nextMode = !sourceCodeMode.value;
closeSearch();
if (nextMode) {
syncSourceCode(normalizeContent(editor.value?.getHTML()) || props.modelValue);
sourceCodeMode.value = true;
return;
}
sourceCodeMode.value = false;
const nextContent = normalizeContent(sourceCode.value);
if (editor.value) {
editor.value.commands.setContent(nextContent || "<p></p>");
}
}
watch(() => props.modelValue, (value) => {
syncPendingMedia(value);
syncSourceCode(value);
}, { immediate: true });
watch(sourceCode, (value) => {
if (!sourceCodeMode.value) {
return;
}
onSourceCodeChange(value);
});
watch([searchKeyword, () => searchOptions.caseSensitive, () => searchOptions.useRegex, sourceCodeMode], () => {
refreshSearchMatchState();
});
watch(() => editor.value?.state.selection.from, () => {
if (!searchVisible.value || sourceCodeMode.value) {
return;
}
refreshSearchMatchState();
});
onBeforeUnmount(() => {
clearPendingMedia();
});
defineExpose({
editor,
submit,
getPendingMediaCount: () => pendingMediaMap.size,
openSearch,
closeSearch,
findNext,
findPrevious,
replaceCurrent,
replaceAll
});
</script>
<style lang="less" scoped>
.tp-editor {
width: 100%;
height: 100%;
border: 1px solid var(--td-component-border);
display: flex;
flex-direction: column;
.content {
flex: 1;
width: 100%;
height: 100%;
cursor: text;
padding: 2rem 10rem;
display: flex;
overflow: auto;
box-sizing: border-box;
background: var(--td-bg-color-page);
.value-wrap {
flex: 1;
width: 100%;
height: 100%;
min-width: 0;
min-height: 100%;
position: relative;
}
.value {
flex: 1;
width: 100%;
height: 100%;
min-width: 0;
min-height: 100%;
overflow: auto;
background: var(--td-bg-color-container);
box-shadow: var(--td-shadow-1);
border-radius: var(--td-radius-default);
:deep(.editor-body) {
outline: none;
padding: var(--tp-editor-padding-tb) var(--tp-editor-padding-lr);
min-height: 100%;
box-sizing: border-box;
pre[class*='language-'] > code {
padding: .25rem .5rem;
}
p.is-editor-empty:first-child:before {
height: 0;
float: left;
color: var(--td-text-color-placeholder);
content: attr(data-placeholder);
pointer-events: none;
}
iframe.tp-iframe {
margin-right: auto;
margin-left: auto;
}
}
}
.source-editor {
flex: 1;
width: 100%;
height: 100%;
min-width: 0;
min-height: 100%;
background: var(--td-bg-color-container);
border-radius: var(--td-radius-default);
box-shadow: var(--td-shadow-1);
}
}
}
@media screen and (max-width: 48rem) {
.tp-editor {
.content {
padding: 0;
}
}
}
</style>
+81
View File
@@ -0,0 +1,81 @@
import { h } from "vue";
export type UploadResult = {
id: string;
url: string;
name?: string;
};
export type UploadMediaType = "image" | "video";
export type PendingMediaItem = {
id: string;
type: UploadMediaType;
name: string;
file: File;
url: string;
};
export type AlignType = "left" | "center" | "right" | "justify";
export const ATTACHMENT_ID_ATTR = "data-attachment-id";
export const TEMP_FILE_ID_ATTR = "data-temp-file-id";
export const TABLE_PICKER_SIZE = 7;
export const LOCAL_MEDIA_ID_PREFIX = "local-media:";
export const headingMeta = [
{ label: "正文", value: "p", className: "p" },
{ label: "标题 1", value: "h1", className: "h1" },
{ label: "标题 2", value: "h2", className: "h2" },
{ label: "标题 3", value: "h3", className: "h3" },
{ label: "标题 4", value: "h4", className: "h4" },
{ label: "标题 5", value: "h5", className: "h5" },
{ label: "标题 6", value: "h6", className: "h6" }
] as const;
export function buildHeadingOptions(currentHeadingValue: string) {
return headingMeta.map((item) => {
const active = currentHeadingValue === item.value;
return {
active,
value: item.value,
content: () => h(
"span",
{ class: ["tp-heading-option", item.className] },
item.label
)
};
});
}
export function normalizeContent(value?: string) {
const text = (value || "").trim();
if (!text || text === "<p></p>") {
return "";
}
const parser = new DOMParser();
const doc = parser.parseFromString(text, "text/html");
const normalized = doc.body.innerHTML.trim();
return normalized === "<p></p>" ? "" : normalized;
}
export function normalizePaddingValue(value: unknown, fallback: number) {
const nextValue = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(nextValue)) {
return fallback;
}
return Math.min(Math.max(nextValue, 0), 16);
}
export function normalizeSliderValue(value: number | number[], fallback: number) {
const nextValue = Array.isArray(value) ? value[0] : value;
return normalizePaddingValue(nextValue, fallback);
}
export function formatPaddingValue(value: number) {
return `${Number(value.toFixed(2))}rem`;
}
export function isLocalMediaId(id?: string | null) {
return !!id && id.startsWith(LOCAL_MEDIA_ID_PREFIX);
}
@@ -0,0 +1,135 @@
import type { Editor } from "@tiptap/vue-3";
import { EditorContent, useEditor } from "@tiptap/vue-3";
import { buildTiptapExtensions } from "./extensions";
import { normalizeContent, normalizePaddingValue } from "./shared";
type TiptapEditorProps = {
modelValue: string;
paddingTB?: number;
paddingLR?: number;
placeholder?: string;
};
type UpdateModelValue = {
(event: "update:modelValue", value: string): void;
(event: "change", value: string): void;
(event: "update:paddingTB", value: number): void;
(event: "update:paddingLR", value: number): void;
};
export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue) {
const articlePadding = reactive({
paddingTB: normalizePaddingValue(props.paddingTB, 1),
paddingLR: normalizePaddingValue(props.paddingLR, 1.25)
});
const lastEmittedContent = ref("");
const pendingEmitContent = ref<string | null>(null);
// 代码块输入期间只在编辑器内部累积,离开代码块或失焦后再统一同步
function flushPendingContent() {
if (pendingEmitContent.value === null) {
return;
}
lastEmittedContent.value = pendingEmitContent.value;
emit("change", pendingEmitContent.value);
emit("update:modelValue", pendingEmitContent.value);
pendingEmitContent.value = null;
}
function applyExternalContent(nextContent: string) {
if (!editor.value) {
return;
}
editor.value.commands.setContent(nextContent || "<p></p>", {
emitUpdate: false
});
lastEmittedContent.value = nextContent;
}
const editorBodyStyle = computed(() => {
return {
"--tp-editor-padding-tb": `${articlePadding.paddingTB}rem`,
"--tp-editor-padding-lr": `${articlePadding.paddingLR}rem`
};
});
const editor = useEditor({
extensions: buildTiptapExtensions(props.placeholder),
content: "",
editorProps: {
attributes: {
class: "editor-body tp-content-body tp-editor-body"
}
},
onBlur: () => {
flushPendingContent();
},
onSelectionUpdate: ({ editor: instance }) => {
if (instance.isActive("codeBlock")) {
return;
}
flushPendingContent();
},
onUpdate: ({ editor: instance }) => {
const nextContent = normalizeContent(instance.getHTML());
lastEmittedContent.value = nextContent;
if (instance.isActive("codeBlock")) {
pendingEmitContent.value = nextContent;
return;
}
pendingEmitContent.value = null;
emit("change", nextContent);
emit("update:modelValue", nextContent);
}
});
watch(() => props.modelValue, (value) => {
if (!editor.value) {
return;
}
const nextContent = normalizeContent(value);
const currentContent = normalizeContent(editor.value.getHTML());
if (nextContent === currentContent) {
lastEmittedContent.value = nextContent;
return;
}
if (nextContent === lastEmittedContent.value) {
return;
}
// 聚焦编辑时禁止外部回灌,避免重建选区后把光标拉回开头
if (editor.value.isFocused) {
return;
}
applyExternalContent(nextContent);
}, { immediate: true });
watch(() => props.paddingTB, (value) => {
articlePadding.paddingTB = normalizePaddingValue(value, articlePadding.paddingTB);
}, { immediate: true });
watch(() => props.paddingLR, (value) => {
articlePadding.paddingLR = normalizePaddingValue(value, articlePadding.paddingLR);
}, { immediate: true });
watch(() => articlePadding.paddingTB, (value) => {
emit("update:paddingTB", value);
});
watch(() => articlePadding.paddingLR, (value) => {
emit("update:paddingLR", value);
});
onBeforeUnmount(() => {
editor.value?.destroy();
});
return {
EditorContent,
articlePadding,
editor,
editorBodyStyle
};
}
export type TiptapEditorInstance = Editor;
+6
View File
@@ -0,0 +1,6 @@
import view from "./index.vue";
import Toolkit from "../../utils/Toolkit";
export const TiptapView = Toolkit.withInstall(view);
export default TiptapView;
+93
View File
@@ -0,0 +1,93 @@
<template>
<div class="tp-view selectable">
<div v-if="renderContent && editor" ref="contentRef" class="content-wrap" :data-max-height="maxHeight">
<editor-content class="content" :editor="editor" />
</div>
<div v-else class="empty">
<span v-text="emptyText" />
</div>
</div>
</template>
<script lang="ts" setup>
import {EditorContent, useEditor} from "@tiptap/vue-3";
import {buildTiptapExtensions} from "../tiptap-editor/extensions";
import {normalizeContent} from "../tiptap-editor/shared";
import {enhancePrismCodeBlock} from "../../utils/PrismCodeBlock";
defineOptions({
name: "TiptapView"
});
const props = withDefaults(defineProps<{
content?: string;
emptyText?: string;
maxHeight?: string;
}>(), {
content: "",
emptyText: "暂无内容",
maxHeight: "400px"
});
const renderContent = computed(() => {
return normalizeContent(props.content);
});
const contentRef = ref<HTMLElement>();
async function syncCodeBlock() {
await nextTick();
if (contentRef.value) {
enhancePrismCodeBlock(contentRef.value);
}
}
const editor = useEditor({
editable: false,
extensions: buildTiptapExtensions(),
content: renderContent.value || "<p></p>",
editorProps: {
attributes: {
class: "editor-body tp-content-body"
}
}
});
watch(() => [renderContent.value, props.maxHeight] as const, ([value]) => {
if (!editor.value) {
return;
}
editor.value.commands.setContent(value || "<p></p>", {
emitUpdate: false
});
void syncCodeBlock();
}, { immediate: true });
watch(editor, () => {
void syncCodeBlock();
}, { flush: "post" });
onBeforeUnmount(() => {
editor.value?.destroy();
});
</script>
<style lang="less" scoped>
.tp-view {
width: 100%;
.empty {
padding: 1rem;
border-radius: .375rem;
color: var(--td-text-color-placeholder);
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
}
.content {
border: 1px solid var(--td-component-border);
padding: 1rem;
min-height: 12rem;
background: var(--td-bg-color-container);
}
}
</style>
+5
View File
@@ -4,7 +4,11 @@ import components from "./components";
import "./assets/style/variable.less"; import "./assets/style/variable.less";
import "./assets/style/timi-web.less"; import "./assets/style/timi-web.less";
import "prismjs/themes/prism.css";
import "./components/prism-code-block.less";
import "./components/tiptap-content.less";
import { Network } from "./utils"; import { Network } from "./utils";
import VPopup from "./utils/directives/Popup";
export * from "./api"; export * from "./api";
export * from "./components"; export * from "./components";
@@ -18,6 +22,7 @@ const install = function (app: App) {
components.forEach(component => { components.forEach(component => {
app.use(component as unknown as { install: () => any }); app.use(component as unknown as { install: () => any });
}); });
app.directive("popup", VPopup);
}; };
export default { export default {
+7 -7
View File
@@ -32,23 +32,23 @@ const isShortScreen = ref(false);
const isMobileLayout = ref(false); const isMobileLayout = ref(false);
/** 断点配置,单位:px */ /** 断点配置,单位:px */
enum Breakpoints { const Breakpoints = {
/** 超小设备 */ /** 超小设备 */
XS = 480, XS: 480,
/** 手机 */ /** 手机 */
SM = 650, SM: 650,
/** 平板 */ /** 平板 */
MD = 768, MD: 768,
/** 笔记本 */ /** 笔记本 */
LG = 1024, LG: 1024,
/** 大屏幕 */ /** 大屏幕 */
XL = 1440 XL: 1440
} } as const;
Resizer.addListener("DEVICE_SIZE", (width, height) => { Resizer.addListener("DEVICE_SIZE", (width, height) => {
screenWidth.value = width; screenWidth.value = width;
+47 -83
View File
@@ -1,88 +1,52 @@
import type { Attachment } from "./Attachment"; import type { Attachment } from "./Attachment";
import type { Comment } from "./Comment";
import type { Model } from "./Model"; import type { Model } from "./Model";
import type { Setting } from "./Setting";
// 文章 /** 文章业务类型 */
export type Article<E extends ArticleMusicExtendData | ArticleSoftwareExtendData> = { export type ArticleBizType = "BLOG" | "FEEDBACK";
/** 文章正文类型 */
export type ArticleContentType = "TEXT" | "MARKDOWN" | "TIP_TAP";
/** 文章状态 */
export type ArticleStatus = "DART" | "NORMAL" | "HIDDEN";
/** 文章实体 */
export type Article = {
/** 业务类型 */
bizType?: ArticleBizType;
/** 业务 ID */
bizId?: string;
/** 标题 */
title?: string; title?: string;
type: ArticleType;
digest?: string; /** 描述 */
data?: string; description?: string;
extendData?: E;
reads: number; /** 正文类型 */
likes: number; contentType?: ArticleContentType;
showComment: boolean;
canComment: boolean; /** 正文内容 */
canRanking: boolean; content?: string;
/** 阅读数量 */
reads?: number;
/** 喜欢数量 */
likes?: number;
/** 文章状态 */
status?: ArticleStatus;
/** 设置列表 */
settingList?: Setting[];
/** 评论列表 */
commentList?: Comment[];
/** 附件列表 */
attachmentList?: Attachment[];
} & Model; } & Model;
export type ArticleView<E extends ArticleMusicExtendData | ArticleSoftwareExtendData> = {
comments?: number;
attachmentList: Attachment[];
} & Article<E>;
export enum ArticleType {
/** 公版 */
PUBLIC,
/** 音乐 */
MUSIC,
/** 软件 */
SOFTWARE
}
export enum ArticleAttachType {
COVER,
}
export type ArticleMusicExtendData = {
title: string;
list: ArticleMusicItem[];
info: {
key: string;
value: string;
}[];
}
export type ArticleSoftwareExtendData = {
url?: string;
downloads: ArticleSoftwareDownload[];
format: string;
runtime: ArticleSoftwareRuntime[];
size: number;
version: string;
password?: string;
}
export enum ArticleSoftwareDownloadType {
TIMI_MONGO,
TIMI_COS,
URL
}
export enum ArticleSoftwareRuntime {
JVM,
WINDOWS,
LINUX,
MAC_OS
}
export type ArticleSoftwareDownload = {
type: ArticleSoftwareDownloadType;
value: string;
}
export type ArticleMusicItem = {
title: string;
audio?: string;
video?: string;
}
+23 -17
View File
@@ -1,14 +1,15 @@
import { Model } from "./Model"; import { Model } from "./Model";
export type Attachment = { export type Attachment = {
bizType: AttachmentBizType; bizType?: AttachmentBizType;
bizId: string; bizId?: string;
attachType?: string; attachType?: string;
mongoId: string; mongoId?: string;
title?: string; title?: string;
name?: string; name?: string;
mimeType?: string; mimeType?: string;
metadata?: unknown; metadata?: Record<string, any>;
accessKey?: AttachmentAccessKey;
size?: number; size?: number;
md5?: string; md5?: string;
uploaderIp?: string; uploaderIp?: string;
@@ -17,26 +18,31 @@ export type Attachment = {
tempFileId?: string; tempFileId?: string;
} & Model; } & Model;
export type TempFileResp = { export type TempFile = {
id: string; id: string;
expireAt?: number; expireAt?: number;
}; };
export type BizUpdateReq = { export type BizIdUpdate = {
bizType: AttachmentBizType; bizType: AttachmentBizType;
bizId: string; bizId: string;
items: Partial<Attachment>[]; items: Partial<Attachment>[];
}; };
export enum AttachmentBizType { export type AttachmentBizType =
| "ARTICLE"
| "USER"
| "GAO_CUSTOMER"
| "GAO_EVENT_RECORD"
| "GAO_REGISTER_RECORD"
| "TEMP_FILE"
| "MIRROR";
USER = "USER", /** 附件访问密钥 */
export type AttachmentAccessKey = {
GAO_CUSTOMER = "GAO_CUSTOMER", value: string;
attachmentId: string;
GAO_REGISTER_RECORD = "GAO_REGISTER_RECORD", isDisposable: boolean;
expiredAt: number;
TEMP_FILE = "TEMP_FILE", createdAt: number;
};
MIRROR = "MIRROR"
}
+3 -32
View File
@@ -13,10 +13,10 @@ export type Comment = {
user?: User; user?: User;
/** 回复列表 */ /** 回复列表 */
replies?: CommentReply[]; replies?: Comment[];
/** 回复分页 */ /** 回复分页 */
repliesPage?: Page<CommentReply>; repliesPage?: Page<Comment>;
/** 用于绑定组件当前页下标 */ /** 用于绑定组件当前页下标 */
repliesCurrent?: number; repliesCurrent?: number;
@@ -31,33 +31,4 @@ export type Comment = {
repository?: object; repository?: object;
} & Model } & Model
export type CommentReply = { export type CommentBizType = "ARTICLE" | "GIT_ISSUE" | "GIT_MERGE";
replyId?: number;
commentId?: number;
senderId?: number;
senderNick?: string;
receiverId?: number;
receiverNick?: string;
content?: string;
comment?: Comment;
sender?: User;
receiver?: User;
} & Model;
export enum CommentReplyBizType {
COMMENT = "COMMENT",
SENDER = "SENDER",
RECEIVER = "RECEIVER"
}
export enum CommentBizType {
ARTICLE = "ARTICLE",
GIT_ISSUE = "GIT_ISSUE",
GIT_MERGE = "GIT_MERGE",
}
+11 -18
View File
@@ -1,9 +1,3 @@
export enum RunEnv {
DEV = "DEV",
DEV_SSL = "DEV_SSL",
PROD = "PROD"
}
// 基本实体模型 // 基本实体模型
export type Model = { export type Model = {
id?: string; id?: string;
@@ -15,22 +9,25 @@ export type Model = {
export type ApiResponse<T> = { export type ApiResponse<T> = {
code: number; code: number;
msg?: string; message: string;
messageCode: string;
success: boolean;
fail: boolean;
data: T; data: T;
} }
export type Page<T> = { export type Page<T> = {
index: number;
size: number;
orderMap?: { [K in keyof T]?: OrderType }; orderMap?: { [K in keyof T]?: OrderType };
equalsExample?: Partial<T>; equalsExample?: Partial<T>;
likesExample?: Partial<T>; likesExample?: Partial<T>;
} & BasePage;
export type BasePage = {
index: number;
size: number;
} }
export enum OrderType { export type OrderType = "ASC" | "DESC";
ASC = "ASC",
DESC = "DESC"
}
export type PageResult<T> = { export type PageResult<T> = {
total: number; total: number;
@@ -50,11 +47,7 @@ export type CaptchaResult = {
data: string; data: string;
} }
export enum ImageType { export type ImageType = "IR-AUTO" | "IR-SMOOTH" | "IR-PIXELATED";
AUTO = "ir-auto",
SMOOTH = "ir-smooth",
PIXELATED = "ir-pixelated"
}
export type KeyValue<V, K = string> = { export type KeyValue<V, K = string> = {
key: K; key: K;
+62
View File
@@ -0,0 +1,62 @@
import type { Model } from "./Model";
import type { User } from "./User";
/** 通知消息类型 */
export type NotifyMsgType = "INTERNAL" | "SMS" | "MAIL" | "HTTP" | "WECHAT";
/** 通知投递状态 */
export type NotifyStatus = "WAITING" | "REMINDED" | "TIMEOUT" | "CANCEL" | "FAIL";
/** 通知 */
export type Notify = {
/** 业务类型 */
bizType?: string;
/** 提醒业务类型 */
bizRemindedType?: string;
/** 提醒业务 ID */
bizRemindedId?: string;
/** 参数 */
args?: string;
/** 创建者 */
createdBy?: string;
/** 通知详情列表 */
detailList?: NotifyDetail[];
} & Model;
/** 通知详情 */
export type NotifyDetail = {
/** 所属通知 ID */
notifyId?: string;
/** 消息类型 */
msgType?: NotifyMsgType;
/** 模板 ID */
templateId?: string;
/** 标题 */
subject?: string;
/** 内容 */
data?: string;
/** 参数 */
args?: string;
/** 发送来源 */
sendFrom?: string;
/** 发送去向 */
sendTo?: string;
/** 发送时间 */
sendAt?: number;
/** 重试计数 */
retry?: number;
/** 超时时间 */
timeoutAt?: number;
/** 结果描述 */
resultDesc?: string;
/** 投递状态 */
status?: NotifyStatus;
/** 已读时间,站内通知未读时为空 */
readAt?: number;
/** 所属通知 */
notify?: Notify;
/** 发送用户 */
fromUser?: User;
/** 接收用户 */
toUser?: User;
} & Model;
+7
View File
@@ -7,11 +7,18 @@ export interface Permission {
nameLangId?: string; nameLangId?: string;
name?: string; name?: string;
description?: string; description?: string;
builtin?: boolean;
protectedPermission?: boolean;
ownerType?: AuthOwnerType;
ownerId?: string;
createdBy?: string;
createdAt?: number; createdAt?: number;
updatedAt?: number; updatedAt?: number;
deletedAt?: number; deletedAt?: number;
} }
export type AuthOwnerType = "SYSTEM" | "MODULE" | "TENANT" | "STORE" | "USER";
export type PermissionPayload = Pick<Permission, "moduleCode" | "code" | "nameLangId" | "name" | "description"> & { export type PermissionPayload = Pick<Permission, "moduleCode" | "code" | "nameLangId" | "name" | "description"> & {
id?: string; id?: string;
}; };
+20 -10
View File
@@ -1,31 +1,41 @@
import type { ModuleCode, Permission } from "./Permission"; import type { AuthOwnerType, ModuleCode, Permission } from "./Permission";
import { Model } from "./Model";
export interface Role { export type Role = {
id: string;
moduleCode: ModuleCode; moduleCode: ModuleCode;
code: string; code: string;
nameLangId?: string; nameLangId?: string;
name?: string; name?: string;
description?: string; description?: string;
builtin?: boolean;
protectedRole?: boolean;
parentRoleId?: string;
ownerType?: AuthOwnerType;
ownerId?: string;
createdBy?: string;
permissionList?: Permission[]; permissionList?: Permission[];
allPermissionList?: Permission[]; allPermissionList?: Permission[];
childRoleList?: Role[]; childRoleList?: Role[];
parentRole?: Role;
delegationPermissionList?: Permission[];
permissionIdList?: string[]; permissionIdList?: string[];
childRoleIdList?: string[]; childRoleIdList?: string[];
createdAt?: number; delegationPermissionIdList?: string[];
updatedAt?: number; } & Model;
deletedAt?: number;
}
export type RolePayload = Pick<Role, "moduleCode" | "code" | "nameLangId" | "name" | "description"> & { export type RolePayload = Partial<Pick<Role, "moduleCode" | "code" | "nameLangId" | "name" | "description">> & {
id?: string; id?: string;
parentRoleId?: string;
ownerType?: AuthOwnerType;
ownerId?: string;
permissionIdList?: string[]; permissionIdList?: string[];
childRoleIdList?: string[]; childRoleIdList?: string[];
delegationPermissionIdList?: string[];
}; };
export interface UserRoleAuthorizeReq { export interface AuthorizeUserRole {
userId: string; userId: string;
moduleCode?: ModuleCode; moduleCode?: ModuleCode;
roleIdList?: string[]; roleIdList?: string[];
roleCodeList?: string[];
} }
-6
View File
@@ -1,6 +0,0 @@
export enum TemplateBizType {
GIT = "GIT",
FOREVER_MC = "FOREVER_MC"
}
+12 -19
View File
@@ -1,6 +1,9 @@
import { ImageType, Model } from "./Model"; import { ImageType, Model } from "./Model";
import { Attachment } from "./Attachment"; import { Attachment } from "./Attachment";
import type { Setting } from "./Setting"; import type { Setting } from "./Setting";
import { Role } from "./Role";
export type Gender = "MALE" | "FEMALE";
export type User = { export type User = {
name: string; name: string;
@@ -13,7 +16,7 @@ export type User = {
wrapperType?: ImageType; wrapperType?: ImageType;
avatarType?: ImageType; avatarType?: ImageType;
exp?: number; exp?: number;
sex?: number; gender?: Gender;
birthdate?: number; birthdate?: number;
qq?: string; qq?: string;
description?: string; description?: string;
@@ -21,7 +24,9 @@ export type User = {
lastLoginAt?: number; lastLoginAt?: number;
unmuteAt?: number; unmuteAt?: number;
unbanAt?: number; unbanAt?: number;
roleList?: string[]; roleList?: string[];
roleEntityList?: Role[];
permissionList?: string[]; permissionList?: string[];
attachmentList?: Attachment[]; attachmentList?: Attachment[];
settingList?: Setting[]; settingList?: Setting[];
@@ -32,18 +37,7 @@ export type User = {
phoneNoVerified?: boolean; phoneNoVerified?: boolean;
} & Model; } & Model;
export enum UserAttachType { export type UserAttachType = "AVATAR" | "WRAPPER";
AVATAR = "AVATAR",
WRAPPER = "WRAPPER",
LICENSE = "LICENSE",
DEFAULT_AVATAR = "DEFAULT_AVATAR",
DEFAULT_WRAPPER = "DEFAULT_WRAPPER"
}
export type RegisterRequest = { export type RegisterRequest = {
name: string; name: string;
@@ -56,6 +50,11 @@ export type LoginRequest = {
password: string; password: string;
}; };
export type SmsLoginRequest = {
detailId: string;
captcha: string;
};
export type UpdatePasswordRequest = { export type UpdatePasswordRequest = {
oldValue: string; oldValue: string;
newValue: string; newValue: string;
@@ -76,12 +75,6 @@ export type LoginUser = {
user?: User; user?: User;
}; };
export enum LoginType {
ALERT,
IFRAME,
REDIRECT
}
export type UserLevelType = { export type UserLevelType = {
exp: number; // 经验数值,和 UserData.exp 一样 exp: number; // 经验数值,和 UserData.exp 一样
value: number; // 经验对应等级,[0, 8] value: number; // 经验对应等级,[0, 8]
+1 -1
View File
@@ -8,9 +8,9 @@ export * from "./Model";
export * from "./Permission"; export * from "./Permission";
export * from "./Role"; export * from "./Role";
export * from "./User"; export * from "./User";
export * from "./Template";
export * from "./Comment"; export * from "./Comment";
export * from "./Developer"; export * from "./Developer";
export * from "./Notify";
/** /**
* 安装方法 * 安装方法
+12 -35
View File
@@ -1,28 +1,7 @@
import Text from "./Text"; import Text from "./Text";
export enum Unit { export const UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] as const;
export type Unit = (typeof UNITS)[number];
/** B */
B = "B",
/** KB */
KB = "KB",
/** MB */
MB = "MB",
/** GB */
GB = "GB",
/** TB */
TB = "TB",
/** PB */
PB = "PB",
/** EB */
EB = "EB"
}
/** 储存单位 */ /** 储存单位 */
export default class IOSize { export default class IOSize {
@@ -48,8 +27,6 @@ export default class IOSize {
/** 1 EB */ /** 1 EB */
public static EB = IOSize.PB << 10; public static EB = IOSize.PB << 10;
public static Unit = Unit;
/** /**
* <p>格式化一个储存容量,保留两位小数 * <p>格式化一个储存容量,保留两位小数
* <pre> * <pre>
@@ -66,7 +43,7 @@ export default class IOSize {
if (size === undefined || size === null) { if (size === undefined || size === null) {
return "0 B"; return "0 B";
} }
const units = Object.keys(Unit); const units = Object.values(UNITS);
if (0 < size) { if (0 < size) {
for (let i = 0; i < units.length; i++, size /= 1024) { for (let i = 0; i < units.length; i++, size /= 1024) {
const unit = units[i]; const unit = units[i];
@@ -118,31 +95,31 @@ export default class IOSize {
let unit: Unit; let unit: Unit;
// 先尝试精确匹配枚举 // 先尝试精确匹配枚举
if (Object.values(Unit).includes(unitStr as Unit)) { if (Object.values(UNITS).includes(unitStr as Unit)) {
unit = unitStr as Unit; unit = unitStr as Unit;
} else { } else {
// 处理单字母单位缩写(K/M/G/T/P/E // 处理单字母单位缩写(K/M/G/T/P/E
switch (unitStr.charAt(0)) { switch (unitStr.charAt(0)) {
case "K": case "K":
unit = Unit.KB; unit = "KB";
break; break;
case "M": case "M":
unit = Unit.MB; unit = "MB";
break; break;
case "G": case "G":
unit = Unit.GB; unit = "GB";
break; break;
case "T": case "T":
unit = Unit.TB; unit = "TB";
break; break;
case "P": case "P":
unit = Unit.PB; unit = "PB";
break; break;
case "E": case "E":
unit = Unit.EB; unit = "EB";
break; break;
case "B": case "B":
unit = Unit.B; unit = "B";
break; break;
default: default:
throw new Error("Unknown unit: " + unitStr); throw new Error("Unknown unit: " + unitStr);
@@ -173,7 +150,7 @@ export default class IOSize {
if (val === undefined || val === null) { if (val === undefined || val === null) {
return 0; return 0;
} }
const units = Object.values(Unit); const units = Object.values(UNITS);
const ordinal = units.indexOf(unit); const ordinal = units.indexOf(unit);
return Math.round(val * Math.pow(1024, ordinal)); return Math.round(val * Math.pow(1024, ordinal));
} }
+2 -2
View File
@@ -62,13 +62,13 @@ const applyDefaultInterceptors = (instance: AxiosInstance) => {
status: axiosResp?.status, status: axiosResp?.status,
response: axiosResp, response: axiosResp,
serverResponse: serverResp, serverResponse: serverResp,
error: serverResp.msg error: serverResp.message
}); });
if (globalErrorCallback) { if (globalErrorCallback) {
globalErrorCallback(serverResp); globalErrorCallback(serverResp);
} }
// 由调用方处理 // 由调用方处理
return Promise.reject(serverResp.msg); return Promise.reject(serverResp.message);
} }
} }
notifyResponseObserver({ notifyResponseObserver({
+19
View File
@@ -0,0 +1,19 @@
import Prism from "prismjs";
const prismLanguageAliasMap: Record<string, string> = {
html: "markup",
xml: "markup",
js: "javascript",
ts: "typescript",
sh: "bash",
vue: "markup"
};
export function resolvePrismLanguage(language?: string | null) {
if (!language) {
return "";
}
const nextLanguage = prismLanguageAliasMap[language] || language;
return Prism.languages[nextLanguage] ? nextLanguage : "";
}
+123
View File
@@ -0,0 +1,123 @@
export function enhancePrismCodeBlock(root: ParentNode) {
const codeList = Array.from(root.querySelectorAll("pre > code[class*='language-'], pre[class*='language-'] > code"));
for (const code of codeList) {
if (!(code instanceof HTMLElement)) {
continue;
}
const pre = code.parentElement;
if (!(pre instanceof HTMLElement)) {
continue;
}
syncPreLanguageClass(pre, code);
resetCodeBlock(code);
normalizeCodeContent(code);
const maxHeight = resolveCodeBlockMaxHeight(pre);
if (maxHeight && maxHeight !== "auto") {
pre.style.maxHeight = maxHeight;
} else {
pre.style.removeProperty("max-height");
}
const lineCount = buildLineCount(code.textContent || "");
const rows = buildLineNumberRows(lineCount);
const codes = document.createElement("span");
codes.className = "codes";
codes.innerHTML = code.innerHTML;
pre.classList.add("has-line-numbers");
code.replaceChildren(rows, codes);
bindCodeBlockToggle(pre, code, lineCount, maxHeight);
}
}
function syncPreLanguageClass(pre: HTMLElement, code: HTMLElement) {
const classList = Array.from(code.classList).filter((item) => item.startsWith("language-"));
if (!classList.length) {
return;
}
for (const className of classList) {
if (!pre.classList.contains(className)) {
pre.classList.add(className);
}
}
}
function resetCodeBlock(code: HTMLElement) {
code.parentElement?.classList.remove("has-line-numbers");
const codes = code.querySelector(":scope > .codes");
if (codes instanceof HTMLElement) {
code.innerHTML = codes.innerHTML;
}
}
function normalizeCodeContent(code: HTMLElement) {
const firstNode = code.firstChild;
if (firstNode instanceof Text && firstNode.data.startsWith("\n")) {
firstNode.data = firstNode.data.slice(1);
if (!firstNode.data) {
firstNode.remove();
}
}
const lastNode = code.lastChild;
if (lastNode instanceof Text && lastNode.data.endsWith("\n")) {
lastNode.data = lastNode.data.replace(/\n+$/, "");
if (!lastNode.data) {
lastNode.remove();
}
}
}
function resolveCodeBlockMaxHeight(pre: HTMLElement) {
const container = pre.closest("[data-max-height]");
if (!(container instanceof HTMLElement)) {
return "";
}
return container.dataset.maxHeight || "";
}
function buildLineCount(text: string) {
if (!text) {
return 1;
}
return Math.max(text.split("\n").length, 1);
}
function buildLineNumberRows(lineCount: number) {
const rows = document.createElement("span");
rows.className = "line-numbers-rows diselect";
for (let i = 0; i < lineCount; i += 1) {
const row = document.createElement("span");
row.textContent = `${i + 1}`;
rows.appendChild(row);
}
return rows;
}
function bindCodeBlockToggle(pre: HTMLElement, code: HTMLElement, lineCount: number, maxHeight: string) {
pre.classList.remove("expand");
pre.dataset.lineCount = `${lineCount}`;
pre.ondblclick = null;
if (!maxHeight || maxHeight === "auto" || lineCount < 18) {
return;
}
pre.ondblclick = () => {
const isExpand = pre.classList.contains("expand");
if (isExpand) {
pre.style.maxHeight = maxHeight;
pre.classList.remove("expand");
} else {
const lineHeight = Number.parseFloat(getComputedStyle(code).lineHeight || "22") || 22;
pre.style.maxHeight = `${Math.ceil(lineCount * lineHeight + 2)}px`;
pre.classList.add("expand");
}
window.getSelection()?.removeAllRanges();
};
}
+29 -25
View File
@@ -1,23 +1,25 @@
export enum PrismjsType { export const PrismjsType = {
PlainText = "PlainText", PlainText: "PlainText",
Markdown = "Markdown", Markdown: "Markdown",
JavaScript = "JavaScript", JavaScript: "JavaScript",
TypeScript = "TypeScript", TypeScript: "TypeScript",
Initialization = "Initialization", Initialization: "Initialization",
PHP = "PHP", PHP: "PHP",
SQL = "SQL", SQL: "SQL",
XML = "XML", XML: "XML",
CSS = "CSS", CSS: "CSS",
VUE = "VUE", VUE: "VUE",
LESS = "LESS", LESS: "LESS",
Markup = "Markup", Markup: "Markup",
YAML = "YAML", YAML: "YAML",
Json = "Json", Json: "Json",
Java = "Java", Java: "Java",
Properties = "Properties", Properties: "Properties",
NginxConf = "NginxConf", NginxConf: "NginxConf",
ApacheConf = "ApacheConf" ApacheConf: "ApacheConf"
} } as const;
export type PrismjsType = (typeof PrismjsType)[keyof typeof PrismjsType];
export type PrismjsProperties = { export type PrismjsProperties = {
@@ -26,14 +28,16 @@ export type PrismjsProperties = {
viewer: PrismjsViewer; viewer: PrismjsViewer;
} }
export enum PrismjsViewer { export const PrismjsViewer = {
MARKDOWN = "MARKDOWN", MARKDOWN: "MARKDOWN",
CODE = "CODE", CODE: "CODE",
TEXT = "TEXT", TEXT: "TEXT"
} } as const;
export type PrismjsViewer = (typeof PrismjsViewer)[keyof typeof PrismjsViewer];
export default class Prismjs { export default class Prismjs {
+114 -27
View File
@@ -3,6 +3,7 @@ export type ScrollListener = {
source: Event; source: Event;
viewWidth: number; viewWidth: number;
viewHeight: number; viewHeight: number;
scrollHeight: number;
top: number; top: number;
bottom: number; bottom: number;
} }
@@ -14,35 +15,64 @@ export type ScrollBottomListenerOptions = {
/** 返回 true 时才触发 */ /** 返回 true 时才触发 */
canLoad?: () => boolean; canLoad?: () => boolean;
/** 自定义滚动容器,默认 window */
getElement?: () => HTMLElement | null | undefined;
} }
export default class Scroller { export default class Scroller {
private static instance: Scroller; private static instance: Scroller;
listeners = new Map<string, Function>(); listeners = new Map<string, {
target: HTMLElement | Window;
listener: EventListener;
}>();
private constructor() { private constructor() {}
window.addEventListener("scroll", source => { private static waitForLayout(): Promise<void> {
// 滚动距离 return new Promise(resolve => {
const top = document.body.scrollTop || document.documentElement.scrollTop; requestAnimationFrame(() => {
// 可视高度 requestAnimationFrame(() => resolve());
const viewWidth = document.documentElement.clientWidth || document.body.clientWidth; });
// 可视高度 });
const viewHeight = document.documentElement.clientHeight || document.body.clientHeight; }
// 滚动高度
const sH = document.documentElement.scrollHeight || document.body.scrollHeight; private static getScrollState(element?: HTMLElement | null, source?: Event): ScrollListener {
// 触发事件 if (element) {
const bottom = sH - top - viewHeight; return {
this.listeners.forEach(listener => listener({ source: source || new Event("scroll"),
source, viewWidth: element.clientWidth,
viewWidth, viewHeight: element.clientHeight,
viewHeight, scrollHeight: element.scrollHeight,
top, top: element.scrollTop,
bottom bottom: element.scrollHeight - element.scrollTop - element.clientHeight
} as ScrollListener)); };
}, true); }
const top = document.body.scrollTop || document.documentElement.scrollTop;
const viewWidth = document.documentElement.clientWidth || document.body.clientWidth;
const viewHeight = document.documentElement.clientHeight || document.body.clientHeight;
const scrollHeight = document.documentElement.scrollHeight || document.body.scrollHeight;
return {
source: source || new Event("scroll"),
viewWidth,
viewHeight,
scrollHeight,
top,
bottom: scrollHeight - top - viewHeight
};
}
private static bindTarget(name: string, target: HTMLElement | Window, listener: EventListener) {
target.addEventListener("scroll", listener, {
passive: true
});
Scroller.getInstance().listeners.set(name, {
target,
listener
});
} }
private static getInstance(): Scroller { private static getInstance(): Scroller {
@@ -58,8 +88,24 @@ export default class Scroller {
* @param name 事件名 * @param name 事件名
* @param listener 监听事件 * @param listener 监听事件
*/ */
public static addListener(name: string, listener: (event: ScrollListener) => void) { public static addListener(name: string, listener: (event: ScrollListener) => void, getElement?: () => HTMLElement | null | undefined) {
Scroller.getInstance().listeners.set(name, listener); Scroller.removeListener(name);
const targetElement = getElement?.();
if (getElement && !targetElement) {
return;
}
const target = targetElement || window;
const eventListener: EventListener = source => {
if (!targetElement) {
const eventTarget = source.target;
const isWindowScrollTarget = eventTarget === document || eventTarget === document.documentElement || eventTarget === document.body;
if (!isWindowScrollTarget) {
return;
}
}
listener(Scroller.getScrollState(targetElement, source));
};
Scroller.bindTarget(name, target, eventListener);
} }
/** /**
@@ -71,15 +117,51 @@ export default class Scroller {
*/ */
public static addBottomListener(name: string, listener: (event: ScrollListener) => void | Promise<void>, options: ScrollBottomListenerOptions = {}) { public static addBottomListener(name: string, listener: (event: ScrollListener) => void | Promise<void>, options: ScrollBottomListenerOptions = {}) {
const distance = options.distance ?? 120; const distance = options.distance ?? 120;
Scroller.addListener(name, event => { let loading = false;
if (distance < event.bottom) { let armed = true;
const triggerLoad = async (event: ScrollListener): Promise<void> => {
const inRange = event.bottom <= distance;
if (!inRange) {
armed = true;
return;
}
if (loading) {
return;
}
if (!armed) {
return; return;
} }
if (options.canLoad && !options.canLoad()) { if (options.canLoad && !options.canLoad()) {
return; return;
} }
void listener(event); armed = false;
}); loading = true;
try {
let currentEvent = event;
while (true) {
const previousScrollHeight = currentEvent.scrollHeight;
await listener(currentEvent);
await Scroller.waitForLayout();
if (options.canLoad && !options.canLoad()) {
break;
}
currentEvent = Scroller.getScrollState(options.getElement?.());
if (distance < currentEvent.bottom) {
armed = true;
break;
}
if (currentEvent.scrollHeight <= previousScrollHeight) {
armed = true;
break;
}
}
} finally {
loading = false;
}
};
Scroller.addListener(name, event => {
void triggerLoad(event);
}, options.getElement);
} }
/** /**
@@ -88,6 +170,11 @@ export default class Scroller {
* @param name 事件名 * @param name 事件名
*/ */
public static removeListener(name: string) { public static removeListener(name: string) {
const entry = Scroller.getInstance().listeners.get(name);
if (!entry) {
return;
}
entry.target.removeEventListener("scroll", entry.listener);
Scroller.getInstance().listeners.delete(name); Scroller.getInstance().listeners.delete(name);
} }
+15 -20
View File
@@ -1,7 +1,7 @@
export default class Storage { export default class Storage {
/** /**
* 获取为布尔值 * 获取为布尔值,键不存在时返回 false
* *
* @param key 键 * @param key 键
* @returns 布尔值 * @returns 布尔值
@@ -11,7 +11,7 @@ export default class Storage {
} }
/** /**
* 获取为布尔值并取反 * 获取为布尔值并取反,键不存在时返回 true
* *
* @param key 键 * @param key 键
* @returns 布尔值 * @returns 布尔值
@@ -25,13 +25,11 @@ export default class Storage {
* *
* @template T 对象类型 * @template T 对象类型
* @param key 键 * @param key 键
* @returns {T | undefined} 返回对象 * @returns 对象,键不存在时返回 null
*/ */
public static getObject<T>(key: string): T { public static getObject<T>(key: string): T | null {
if (this.has(key)) { const value = this.getJSON(key);
return this.getJSON(key) as T; return value === null ? null : value as T;
}
throw Error(`not found ${key}`);
} }
/** /**
@@ -54,24 +52,21 @@ export default class Storage {
* 获取为 JSON * 获取为 JSON
* *
* @param key 键 * @param key 键
* @returns JSON 对象 * @returns JSON 对象,键不存在时返回 null;值无法解析时抛错
*/ */
public static getJSON(key: string) { public static getJSON(key: string): unknown {
return JSON.parse(this.getString(key)); const value = this.getString(key);
return value === null ? null : JSON.parse(value);
} }
/** /**
* 获取为字符串(其他获取方式一般经过这个方法,找不到配置或配置值无效时会抛错) * 获取为字符串
* *
* @param key 键 * @param key 键
* @returns 字符串 * @returns 字符串,键不存在时返回 null
*/ */
public static getString(key: string): string { public static getString(key: string): string | null {
const value = localStorage.getItem(key); return localStorage.getItem(key);
if (value) {
return value;
}
throw new Error(`not found: ${key}, ${value}`);
} }
/** /**
@@ -81,7 +76,7 @@ export default class Storage {
* @returns true 为存在 * @returns true 为存在
*/ */
public static has(key: string): boolean { public static has(key: string): boolean {
return localStorage.getItem(key) !== undefined && localStorage.getItem(key) !== null; return localStorage.getItem(key) !== null;
} }
/** /**
+27
View File
@@ -0,0 +1,27 @@
import type { AppContext } from "vue";
type MessageType = "info" | "success" | "warning" | "error";
type MessageHandler = (content: string) => unknown | Promise<unknown>;
type MessageApi = {
[type in MessageType]?: MessageHandler;
};
export const showTDesignMessage = async (
appContext: AppContext | undefined,
type: MessageType,
content: string
) => {
const handler = (appContext?.config.globalProperties.$message as MessageApi | undefined)?.[type];
if (handler) {
await handler(content);
return;
}
if ("error" === type) {
console.error(content);
return;
}
console.warn(content);
};
+46 -16
View File
@@ -232,43 +232,73 @@ export default class Toolkit {
return Object.keys(e)[Object.values(e).indexOf(value)]; return Object.keys(e)[Object.values(e).indexOf(value)];
} }
// 防抖 /**
// eslint-disable-next-line * 防抖函数
public static debounce<T extends (...args: any[]) => any>(callback: T, defaultImmediate = true, delay = 600): T & { *
cancel(): void * 支持两种模式:
} { * - **立即模式**`immediate = true`,默认):首次调用立即执行,连续调用在延迟结束后再执行最后一次(leading + trailing
let timerId: ReturnType<typeof setTimeout> | null = null; // 存储定时器 * - **延迟模式**`immediate = false`):所有调用在延迟结束后执行最后一次(trailing only
let immediate = defaultImmediate; *
// 定义一个 cancel 办法,用于勾销防抖 * @param callback 回调函数
* @param immediate 是否首次调用立即执行,默认 true
* @param delay 延迟毫秒数,默认 600
* @returns 防抖后的函数,附带 `cancel()` 方法用于取消 pending 执行并重置状态
*
* @example
* ```ts
* // 立即模式(默认):首次立即触发,连续调用后在延迟结束时再触发一次
* const fn = Toolkit.debounce((val: string) => {
* console.log(val);
* });
* fn("a"); // 立即输出 "a"
* fn("b"); // 600ms 后输出 "b"trailing
* fn.cancel(); // 取消 pending 的 trailing 调用,重置为初始状态
*
* // 延迟模式:连续调用只在停止后触发最后一次
* const fn2 = Toolkit.debounce((val: string) => {
* console.log(val);
* }, false);
* fn2("a"); // 不输出
* fn2("b"); // 600ms 后输出 "b"
* ```
*/
public static debounce<T extends (...args: any[]) => any>(
callback: T,
immediate = true,
delay = 300
): ((...args: Parameters<T>) => void) & { cancel(): void } {
let timerId: ReturnType<typeof setTimeout> | null = null;
let leading = immediate;
const cancel = (): void => { const cancel = (): void => {
if (timerId) { if (timerId) {
clearTimeout(timerId); clearTimeout(timerId);
timerId = null; timerId = null;
} }
leading = immediate;
}; };
const debounced = function (this: ThisParameterType<T>, ...args: Parameters<T>): void { const debounced = function (this: ThisParameterType<T>, ...args: Parameters<T>): void {
const context = this; const context = this;
if (timerId) { if (timerId) {
cancel(); clearTimeout(timerId);
timerId = null;
} }
if (immediate) { if (leading) {
callback.apply(context, args); callback.apply(context, args);
immediate = false; leading = false;
timerId = setTimeout(() => { timerId = setTimeout(() => {
immediate = defaultImmediate; leading = immediate;
}, delay); }, delay);
} else { } else {
// 设置定时器,在延迟时间后执行指标函数
timerId = setTimeout(() => { timerId = setTimeout(() => {
callback.apply(context, args); callback.apply(context, args);
immediate = defaultImmediate; leading = immediate;
}, delay); }, delay);
} }
}; };
// 将 cancel 方法附加到 debounced 函数上
(debounced as any).cancel = cancel; (debounced as any).cancel = cancel;
return debounced as T & { cancel(): void }; return debounced as ((...args: Parameters<T>) => void) & { cancel(): void };
} }
public static toUserLevel(exp?: number): UserLevelType { public static toUserLevel(exp?: number): UserLevelType {
+6 -11
View File
@@ -1,12 +1,7 @@
import type { Directive, DirectiveBinding } from "vue"; import type { Directive, DirectiveBinding } from "vue";
import Toolkit from "../Toolkit"; import Toolkit from "../Toolkit";
export enum PopupType { export type PopupType = "TEXT" | "IMG" | "HTML" | "ELEMENT";
TEXT,
IMG,
HTML,
EL
}
/** */ /** */
export type PopupConfig = { export type PopupConfig = {
@@ -30,7 +25,7 @@ const VPopup: Directive = {
config = binding.value as PopupConfig; config = binding.value as PopupConfig;
} else { } else {
config = { config = {
type: PopupType.TEXT, type: "TEXT",
value: binding.value as any as string, value: binding.value as any as string,
canShow: () => true canShow: () => true
}; };
@@ -58,24 +53,24 @@ const VPopup: Directive = {
popup.appendChild(el); popup.appendChild(el);
} }
switch (config.type) { switch (config.type) {
case PopupType.TEXT: case "TEXT":
// 文本 // 文本
el = document.createElement("div"); el = document.createElement("div");
el.className = "text"; el.className = "text";
el.textContent = config.value as string; el.textContent = config.value as string;
popup.appendChild(el); popup.appendChild(el);
break; break;
case PopupType.IMG: case "IMG":
// 图片 // 图片
el = document.createElement("img"); el = document.createElement("img");
(el as HTMLImageElement).src = config.value as string; (el as HTMLImageElement).src = config.value as string;
popup.appendChild(el); popup.appendChild(el);
break; break;
case PopupType.HTML: case "HTML":
// HTML 字符串 // HTML 字符串
popup.appendChild(Toolkit.toDOM(config.value as string)); popup.appendChild(Toolkit.toDOM(config.value as string));
break; break;
case PopupType.EL: case "ELEMENT":
// DOM 节点 // DOM 节点
if (config.value instanceof HTMLElement) { if (config.value instanceof HTMLElement) {
const valueEl = config.value as HTMLElement; const valueEl = config.value as HTMLElement;
+14 -4
View File
@@ -35,15 +35,24 @@ export default defineConfig({
}, },
rollupOptions: { rollupOptions: {
external: [ external: [
"vue" "vue",
"tdesign-vue-next"
], ],
output: { output: {
globals: { globals: {
vue: "Vue" vue: "Vue",
"tdesign-vue-next": "TDesign"
} }
} }
}, },
minify: "terser", commonjsOptions: {
esmExternals: [
"vue",
"tdesign-vue-next"
],
requireReturnsDefault: false
},
minify: "terser"
}, },
plugins: [ plugins: [
vue({ vue({
@@ -67,7 +76,8 @@ export default defineConfig({
"javascript", "javascript",
"typescript", "typescript",
"apacheconf", "apacheconf",
"properties" "properties",
"bash"
], ],
plugins: [ plugins: [
"line-numbers" "line-numbers"