Compare commits

..
4 Commits
Author SHA1 Message Date
Timi ef71681bd5 upper to timi-ts 2026-08-28 15:19:08 +08:00
Timi f790122c01 refactor editor 2026-08-25 17:36:14 +08:00
Timi d8fbbbe616 use npm timi dependency 2026-04-08 00:51:59 +08:00
Timi b004299763 fix Comment Page 2026-01-15 15:22:18 +08:00
77 changed files with 4848 additions and 1763 deletions
+6 -4
View File
@@ -1,3 +1,9 @@
/.eslintrc-auto-import.json
/components.d.ts
/examples/auto-imports.d.ts
/AGENTS.md
/.npmrc
# Logs # Logs
logs logs
*.log *.log
@@ -22,7 +28,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/.eslintrc-auto-import.json
/components.d.ts
/examples/auto-imports.d.ts
+2
View File
@@ -0,0 +1,2 @@
/cache
/project.local.yml
+154
View File
@@ -0,0 +1,154 @@
# the name by which the project can be referenced within Serena
project_name: "timi-tdesign-pc"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# haxe java julia kotlin lua
# markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- vue
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project based on the project name or path.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
# for example by saying that the information retrieved from a memory file is no longer correct
# or no longer relevant for the project.
# * `edit_memory`: Replaces content matching a regular expression in a memory.
# * `execute_shell_command`: Executes a shell command.
# * `find_file`: Finds files in the given relative paths
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
# * `find_symbol`: Performs a global (or local) search using the language server backend.
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
# for clients that do not read the initial instructions when the MCP server is connected.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
# is relevant to the current task. You can infer whether the information
# is relevant from the memory file name.
# You should not read the same memory file multiple times in the same conversation.
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
# For JB, we use a separate tool.
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
# * `safe_delete_symbol`:
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
# The memory name should be meaningful.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as read‑only.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
+32 -3
View File
@@ -1,5 +1,34 @@
# Vue 3 + TypeScript + Vite # timi-tdesign-pc
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. Vue 3 PC 端通用组件库,依赖 `timi-web` 提供基础组件和编辑器能力。
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup). 本包不依赖 `timi-web-api`。业务项目可以只安装本包使用通用组件;需要业务接口、领域类型或用户状态时,再单独安装 `timi-web-api`。
## 文章和通知组件
`ArticleEditor`、`ArticleView`、`NotifyEditor`、`NotifyView` 只约束对象的最小字段。`Article`、`Attachment` 和 `NotifyDetail` 支持携带业务自定义字段,不要求对象来自 `timi-web-api`。
上传和附件地址由业务项目通过回调注入:
```vue
<article-editor
:model-value="article"
:upload-image="uploadImage"
:upload-video="uploadVideo"
:resolve-attachment-url="resolveAttachmentUrl"
@update:model-value="article = $event"
/>
<article-view
:article="article"
:resolve-attachment-url="resolveAttachmentUrl"
/>
```
通知组件使用同样的 `uploadImage`、`uploadVideo` 和 `resolveAttachmentUrl` 回调。没有附件上传需求时,可以不传上传回调。
## 开发
```bash
pnpm install
pnpm run build
```
+85 -17
View File
@@ -1,29 +1,97 @@
<template> <template>
<root-layout v-if="isReady" icp="test icp" author="test cr name"> <div class="demo-app">
<authorize-form class="test" /> <header class="hero">
<textarea v-text="JSON.stringify(userStore.loginUser)"></textarea> <div>
<comment :bizType="CommentBizType.ARTICLE" :bizId="1" /> <p class="eyebrow">timi-tdesign-pc</p>
</root-layout> <h1>PC 端组件演示台</h1>
<p>用固定数据覆盖编辑器、文章和通知的完整交互链路。</p>
</div>
<t-tag theme="primary" variant="light">TDesign Vue Next</t-tag>
</header>
<nav class="tabs" aria-label="演示分类">
<t-button
v-for="item in tabs"
:key="item.value"
size="small"
:theme="activeTab === item.value ? 'primary' : 'default'"
variant="outline"
@click="activeTab = item.value"
v-text="item.label"
/>
</nav>
<main>
<editor-showcase v-if="activeTab === 'editor'" />
<article-showcase v-else-if="activeTab === 'article'" />
<notify-showcase v-else />
</main>
</div>
<popup /> <popup />
<user-profile-popup />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { CommentBizType, Popup, SettingMapper, userStore } from "timi-web"; import ArticleShowcase from "./components/ArticleShowcase.vue";
import { AuthorizeForm, Comment, RootLayout, UserProfilePopup } from "~/components"; import EditorShowcase from "./components/EditorShowcase.vue";
import NotifyShowcase from "./components/NotifyShowcase.vue";
import { Popup } from "timi-web";
const isReady = ref(false); type TabValue = "editor" | "article" | "notify";
onMounted(async () => { const tabs: Array<{ label: string; value: TabValue }> = [
await SettingMapper.loadSetting(); { label: "编辑器", value: "editor" },
await userStore.login4Storage(); { label: "文章", value: "article" },
isReady.value = true; { label: "通知", value: "notify" }
}); ];
const activeTab = ref<TabValue>("editor");
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.test { .demo-app {
width: 20rem; width: min(100% - 3rem, 96rem);
margin: 2rem auto; min-height: 100vh;
margin: 0 auto;
padding: 3rem 0 5rem;
color: var(--td-text-color-primary);
}
.hero {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
margin-bottom: 1.5rem;
h1 {
margin: .25rem 0;
font-size: clamp(2rem, 4vw, 3.5rem);
}
p {
margin: 0;
color: var(--td-text-color-secondary);
}
}
.eyebrow {
font-size: .75rem;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--td-brand-color);
}
.tabs {
display: flex;
gap: .5rem;
margin-bottom: 1.5rem;
}
@media (max-width: 48rem) {
.demo-app {
width: min(100% - 1rem, 96rem);
padding-top: 1.5rem;
}
.hero {
flex-direction: column;
}
} }
</style> </style>
+177
View File
@@ -0,0 +1,177 @@
<template>
<section class="demo-section">
<div class="section-heading">
<div>
<p class="eyebrow">文章场景</p>
<h2>文章编辑与预览</h2>
<p>切换正文类型,验证统一的 Article 数据结构。</p>
</div>
<div class="actions">
<t-button
v-for="item in contentTypeOptions"
:key="item.value"
size="small"
:theme="articleValue.contentType === item.value ? 'primary' : 'default'"
variant="outline"
@click="selectContentType(item.value)"
v-text="item.label"
/>
</div>
</div>
<div class="demo-grid">
<article class="demo-card">
<div class="card-heading">
<h3>ArticleEditor</h3>
<t-button size="small" theme="primary" @click="submitArticle">提交文章</t-button>
</div>
<t-input v-model="articleValue.title" class="title-input" placeholder="文章标题" />
<article-editor
ref="articleEditorRef"
:model-value="articleValue"
:upload-image="uploadDemoMedia"
:upload-video="uploadDemoMedia"
@change="onArticleChange"
@update:model-value="onArticleChange"
/>
<p class="status" v-text="articleStatus" />
</article>
<article class="demo-card">
<div class="card-heading">
<h3>ArticleView</h3>
<span v-text="articleValue.contentType" />
</div>
<article-view :article="articleValue" />
</article>
</div>
</section>
</template>
<script lang="ts" setup>
import {
ArticleEditor,
ArticleView
} from "timi-tdesign-pc";
import type { ArticleEditorExpose } from "timi-tdesign-pc";
import type { Article, ArticleContentType } from "timi-tdesign-pc";
import { DEMO_ARTICLE_CONTENT, cloneArticle, uploadDemoMedia } from "../demo-data";
const contentTypeOptions: Array<{ label: string; value: ArticleContentType }> = [
{ label: "纯文本", value: "TEXT" },
{ label: "Markdown", value: "MARKDOWN" },
{ label: "Tiptap", value: "TIP_TAP" }
];
const articleValue = ref<Article>(cloneArticle());
const articleEditorRef = ref<ArticleEditorExpose>();
const articleStatus = ref("等待提交");
function selectContentType(contentType: ArticleContentType) {
const content = contentType === "TEXT"
? "这是一段纯文本示例。"
: contentType === "MARKDOWN"
? "## Markdown 示例\n\n这是一段 Markdown 正文。"
: DEMO_ARTICLE_CONTENT;
articleValue.value = {
...articleValue.value,
contentType,
content
};
}
function onArticleChange(value: Article) {
articleValue.value = value;
}
async function submitArticle() {
const value = await articleEditorRef.value?.submit();
if (value) {
articleValue.value = value;
articleStatus.value = `已提交,${value.attachmentList?.length || 0} 个附件`;
}
}
</script>
<style lang="less" scoped>
.demo-section {
.section-heading {
align-items: flex-end;
display: flex;
gap: 1rem;
justify-content: space-between;
margin-bottom: 1rem;
h2 {
margin: .25rem 0;
}
p {
margin: 0;
color: var(--td-text-color-secondary);
}
}
.eyebrow {
font-size: .75rem;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--td-brand-color);
}
.actions,
.card-heading {
align-items: center;
display: flex;
gap: .5rem;
justify-content: space-between;
}
.demo-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.demo-card {
padding: 1rem;
border: 1px solid var(--td-component-border);
border-radius: .75rem;
background: var(--td-bg-color-container);
h3 {
margin: 0;
}
span,
.status {
font-size: .75rem;
color: var(--td-text-color-secondary);
}
}
.title-input {
margin: 1rem 0;
}
.status {
margin: 1rem 0 0;
}
@media (max-width: 48rem) {
.section-heading,
.card-heading {
align-items: flex-start;
flex-direction: column;
}
.actions {
flex-wrap: wrap;
justify-content: flex-start;
}
.demo-grid {
grid-template-columns: 1fr;
}
}
}
</style>
+183
View File
@@ -0,0 +1,183 @@
<template>
<section class="demo-section">
<div class="section-heading">
<div>
<p class="eyebrow">编辑器基础能力</p>
<h2>文本、富文本和代码</h2>
<p>通过同一组公共数据规则验证 PC 端 TDesign 交互。</p>
</div>
<div class="actions">
<t-button size="small" theme="primary" @click="openSearch">打开搜索</t-button>
<t-button size="small" variant="outline" @click="submitEditor">提交富文本</t-button>
</div>
</div>
<div class="demo-grid">
<article class="demo-card wide">
<div class="card-heading">
<h3>Tiptap 编辑器</h3>
<span v-text="editorStatus" />
</div>
<div class="editor-box rich-editor">
<tiptap-editor
ref="tiptapEditorRef"
v-model="richContent"
:upload-image="uploadDemoMedia"
:upload-video="uploadDemoMedia"
@change="editorStatus = `内容已更新,${$event.length} 个字符`"
/>
</div>
</article>
<article class="demo-card wide">
<div class="card-heading">
<h3>Tiptap 只读视图</h3>
<t-input v-model="maxHeight" class="height-input" placeholder="最大高度,如 20rem" />
</div>
<tiptap-view :content="richContent" :max-height="maxHeight" />
</article>
<article class="demo-card wide">
<div class="card-heading">
<h3>代码编辑器</h3>
<t-button size="small" variant="text" @click="resetCode">恢复示例</t-button>
</div>
<div class="editor-box code-editor">
<code-editor
ref="codeEditorRef"
v-model="codeContent"
language="typescript"
placeholder="请输入代码"
/>
</div>
</article>
</div>
</section>
</template>
<script lang="ts" setup>
import {
CodeEditor,
TiptapEditor,
TiptapView
} from "timi-tdesign-pc";
import type {
CodeEditorExpose,
TiptapEditorExpose
} from "timi-tdesign-pc";
import { DEMO_ARTICLE_CONTENT, DEMO_CODE, uploadDemoMedia } from "../demo-data";
const richContent = ref(DEMO_ARTICLE_CONTENT);
const codeContent = ref(DEMO_CODE);
const maxHeight = ref("20rem");
const editorStatus = ref("等待编辑");
const tiptapEditorRef = ref<TiptapEditorExpose>();
const codeEditorRef = ref<CodeEditorExpose>();
function openSearch() {
tiptapEditorRef.value?.openSearch(true);
codeEditorRef.value?.openSearch(true);
}
async function submitEditor() {
const value = await tiptapEditorRef.value?.submit();
if (value !== undefined) {
editorStatus.value = `已提交,${value.length} 个字符`;
}
}
function resetCode() {
codeContent.value = DEMO_CODE;
}
</script>
<style lang="less" scoped>
.demo-section {
.section-heading {
align-items: flex-end;
display: flex;
gap: 1rem;
justify-content: space-between;
margin-bottom: 1rem;
h2 {
margin: .25rem 0;
}
p {
margin: 0;
color: var(--td-text-color-secondary);
}
}
.eyebrow {
font-size: .75rem;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--td-brand-color);
}
.actions,
.card-heading {
align-items: center;
display: flex;
gap: .5rem;
justify-content: space-between;
}
.demo-grid {
display: grid;
gap: 1rem;
}
.demo-card {
padding: 1rem;
border: 1px solid var(--td-component-border);
border-radius: .75rem;
background: var(--td-bg-color-container);
box-shadow: 0 .25rem 1rem rgba(0, 0, 0, .04);
h3 {
margin: 0;
}
span {
font-size: .75rem;
color: var(--td-text-color-secondary);
}
}
.editor-box {
height: 24rem;
margin-top: 1rem;
}
.rich-editor {
height: 28rem;
}
.height-input {
width: 12rem;
}
@media (max-width: 48rem) {
.section-heading {
align-items: stretch;
flex-direction: column;
}
.actions {
justify-content: flex-start;
}
.card-heading {
align-items: flex-start;
flex-direction: column;
}
.height-input {
width: 100%;
}
}
}
</style>
+137
View File
@@ -0,0 +1,137 @@
<template>
<section class="demo-section">
<div class="section-heading">
<div>
<p class="eyebrow">通知场景</p>
<h2>通知编辑与预览</h2>
<p>Notify 组件复用文章编辑器,同时保留通知自己的元数据。</p>
</div>
<t-button size="small" theme="primary" @click="submitNotify">提交通知</t-button>
</div>
<div class="demo-grid">
<article class="demo-card">
<div class="card-heading">
<h3>NotifyEditor</h3>
<span v-text="notifyStatus" />
</div>
<t-input v-model="notifyValue.subject" class="subject-input" placeholder="通知标题" />
<notify-editor
ref="notifyEditorRef"
:model-value="notifyValue"
:upload-image="uploadDemoMedia"
:upload-video="uploadDemoMedia"
@change="onNotifyChange"
@update:model-value="onNotifyChange"
/>
</article>
<article class="demo-card">
<div class="card-heading">
<h3>NotifyView</h3>
<span v-text="notifyValue.status" />
</div>
<notify-view :detail="notifyValue" />
</article>
</div>
</section>
</template>
<script lang="ts" setup>
import {
NotifyEditor,
NotifyView
} from "timi-tdesign-pc";
import type { NotifyEditorExpose } from "timi-tdesign-pc";
import type { NotifyDetail } from "timi-tdesign-pc";
import { cloneNotify, uploadDemoMedia } from "../demo-data";
const notifyValue = ref<NotifyDetail>(cloneNotify());
const notifyEditorRef = ref<NotifyEditorExpose>();
const notifyStatus = ref("等待提交");
function onNotifyChange(value: NotifyDetail) {
notifyValue.value = value;
}
async function submitNotify() {
const value = await notifyEditorRef.value?.submit();
if (value) {
notifyValue.value = value;
notifyStatus.value = `已提交,${value.content?.length || 0} 个字符`;
}
}
</script>
<style lang="less" scoped>
.demo-section {
.section-heading {
align-items: flex-end;
display: flex;
gap: 1rem;
justify-content: space-between;
margin-bottom: 1rem;
h2 {
margin: .25rem 0;
}
p {
margin: 0;
color: var(--td-text-color-secondary);
}
}
.eyebrow {
font-size: .75rem;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--td-brand-color);
}
.card-heading {
align-items: center;
display: flex;
gap: .5rem;
justify-content: space-between;
h3 {
margin: 0;
}
span {
font-size: .75rem;
color: var(--td-text-color-secondary);
}
}
.demo-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.demo-card {
padding: 1rem;
border: 1px solid var(--td-component-border);
border-radius: .75rem;
background: var(--td-bg-color-container);
}
.subject-input {
margin: 1rem 0;
}
@media (max-width: 48rem) {
.section-heading,
.card-heading {
align-items: flex-start;
flex-direction: column;
}
.demo-grid {
grid-template-columns: 1fr;
}
}
}
</style>
+59
View File
@@ -0,0 +1,59 @@
import type { Article, NotifyDetail, UploadResult } from "timi-tdesign-pc";
/** 编辑器演示使用的固定内容,避免每个页面各自维护一份 HTML。 */
export const DEMO_ARTICLE_CONTENT = `<h2>跨平台编辑器</h2>
<p>这段内容同时验证标题、段落、链接、表格和代码块的渲染。</p>
<blockquote><p>移动端和 PC 端只替换交互层,共享文档模型。</p></blockquote>
<pre><code class="language-typescript">const message: string = "timi editor";</code></pre>`;
export const DEMO_CODE = `type DemoUser = {
\tid: string;
\tname: string;
};
const user: DemoUser = {
\tid: "demo-001",
\tname: "Timi"
};
console.log(user.name);`;
export const DEMO_ARTICLE: Article = {
title: "编辑器组件演示文章",
description: "用于验证文章编辑、预览和附件处理。",
contentType: "TIP_TAP",
content: DEMO_ARTICLE_CONTENT,
attachmentList: []
};
export const DEMO_NOTIFY: NotifyDetail = {
subject: "编辑器通知演示",
msgType: "INTERNAL",
contentType: "TIP_TAP",
content: DEMO_ARTICLE_CONTENT,
status: "WAITING",
attachmentList: []
};
export function cloneArticle(): Article {
return {
...DEMO_ARTICLE,
attachmentList: [...(DEMO_ARTICLE.attachmentList || [])]
};
}
export function cloneNotify(): NotifyDetail {
return {
...DEMO_NOTIFY,
attachmentList: [...(DEMO_NOTIFY.attachmentList || [])]
};
}
/** 返回本地预览地址,让示例无需依赖后端上传接口。 */
export async function uploadDemoMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
+2 -6
View File
@@ -1,13 +1,9 @@
import { createApp } from "vue"; import { createApp } from "vue";
import Root from "./Root.vue"; import Root from "./Root.vue";
import TimiWebUI, { axios, Network, VPopup } from "timi-web"; // 本地开发 import TimiWebUI, { VPopup } from "timi-web";
import "timi-web/style.css"; import "timi-web/style.css";
import "~/assets/style/tencent-cloud.less"; import "~/assets/style/tdesign-custom.less";
import "~/assets/style/tencent-cloud-custom.less";
axios.defaults.baseURL = "http://localhost:8091";
axios.interceptors.request.use(Network.userTokenInterceptors);
const app = createApp(Root); const app = createApp(Root);
app.use(TimiWebUI); app.use(TimiWebUI);
+12 -8
View File
@@ -1,17 +1,21 @@
{ {
"name": "timi-tdesign-pc", "name": "timi-tdesign-pc",
"main": "./dist/timi-tdesign-pc.umd.js", "main": "./dist/timi-tdesign-pc.umd.js",
"types": "./dist/src/index.d.ts", "types": "./dist/timi-tdesign-pc/src/index.d.ts",
"module": "./dist/timi-tdesign-pc.mjs", "module": "./dist/timi-tdesign-pc.mjs",
"style": "./dist/style.css", "style": "./dist/timi-tdesign-pc.css",
"private": false, "private": false,
"version": "1.0.0", "version": "0.0.1",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:doc": "pnpm run -C docs dev", "dev:doc": "pnpm run -C docs dev",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"build:doc": "pnpm run -C docs build" "build:doc": "pnpm run -C docs build",
"pub": "pnpm run build && npm publish --registry=https://nexus.imyeyu.com/repository/npm-timi/"
},
"publishConfig": {
"registry": "https://nexus.imyeyu.com/repository/npm-timi/"
}, },
"files": [ "files": [
"dist/**", "dist/**",
@@ -22,7 +26,7 @@
], ],
"exports": { "exports": {
".": { ".": {
"types": "./dist/src/index.d.ts", "types": "./dist/timi-tdesign-pc/src/index.d.ts",
"import": "./dist/timi-tdesign-pc.mjs", "import": "./dist/timi-tdesign-pc.mjs",
"require": "./dist/timi-tdesign-pc.umd.js" "require": "./dist/timi-tdesign-pc.umd.js"
}, },
@@ -32,6 +36,8 @@
"node": ">=16.0.0" "node": ">=16.0.0"
}, },
"dependencies": { "dependencies": {
"tdesign-vue-next": "^1.18.6",
"timi-web": "link:E:/WebProject/timi-web",
"vue": "^3.5.26" "vue": "^3.5.26"
}, },
"devDependencies": { "devDependencies": {
@@ -58,8 +64,6 @@
"vite-plugin-vue-setup-extend": "^0.4.0", "vite-plugin-vue-setup-extend": "^0.4.0",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"vite": "7.3.1", "vite": "7.3.1",
"vue-tsc": "^3.2.2", "vue-tsc": "^3.2.2"
"tdesign-vue-next": "^1.18.0",
"timi-web": "link:..\\timi-web"
} }
} }
+69 -77
View File
@@ -8,6 +8,12 @@ importers:
.: .:
dependencies: dependencies:
tdesign-vue-next:
specifier: ^1.18.6
version: 1.18.6(vue@3.5.26(typescript@5.9.3))
timi-web:
specifier: link:E:/WebProject/timi-web
version: link:../timi-web
vue: vue:
specifier: ^3.5.26 specifier: ^3.5.26
version: 3.5.26(typescript@5.9.3) version: 3.5.26(typescript@5.9.3)
@@ -23,10 +29,10 @@ importers:
version: 8.53.0(eslint@9.39.2)(typescript@5.9.3) version: 8.53.0(eslint@9.39.2)(typescript@5.9.3)
'@vitejs/plugin-basic-ssl': '@vitejs/plugin-basic-ssl':
specifier: ^2.1.3 specifier: ^2.1.3
version: 2.1.3(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.39.0)) version: 2.1.3(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.46.1))
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^6.0.3 specifier: ^6.0.3
version: 6.0.3(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.39.0))(vue@3.5.26(typescript@5.9.3)) version: 6.0.3(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.46.1))(vue@3.5.26(typescript@5.9.3))
'@vue/cli-plugin-babel': '@vue/cli-plugin-babel':
specifier: ^5.0.9 specifier: ^5.0.9
version: 5.0.9(@vue/cli-service@5.0.8(@vue/compiler-sfc@3.5.26)(lodash@4.17.21)(prettier@3.8.0)(vue@3.5.26(typescript@5.9.3))(webpack-sources@3.2.3))(vue@3.5.26(typescript@5.9.3)) version: 5.0.9(@vue/cli-service@5.0.8(@vue/compiler-sfc@3.5.26)(lodash@4.17.21)(prettier@3.8.0)(vue@3.5.26(typescript@5.9.3))(webpack-sources@3.2.3))(vue@3.5.26(typescript@5.9.3))
@@ -63,12 +69,6 @@ importers:
prettier: prettier:
specifier: ^3.8.0 specifier: ^3.8.0
version: 3.8.0 version: 3.8.0
tdesign-vue-next:
specifier: ^1.18.0
version: 1.18.0(vue@3.5.26(typescript@5.9.3))
timi-web:
specifier: link:..\timi-web
version: link:../timi-web
typescript: typescript:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
@@ -80,13 +80,13 @@ importers:
version: 31.0.0(vue@3.5.26(typescript@5.9.3)) version: 31.0.0(vue@3.5.26(typescript@5.9.3))
vite: vite:
specifier: 7.3.1 specifier: 7.3.1
version: 7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.39.0) version: 7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.46.1)
vite-plugin-dts: vite-plugin-dts:
specifier: ^4.5.4 specifier: ^4.5.4
version: 4.5.4(@types/node@25.0.8)(rollup@4.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.39.0)) version: 4.5.4(@types/node@25.0.8)(rollup@4.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.46.1))
vite-plugin-vue-setup-extend: vite-plugin-vue-setup-extend:
specifier: ^0.4.0 specifier: ^0.4.0
version: 0.4.0(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.39.0)) version: 0.4.0(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.46.1))
vue-tsc: vue-tsc:
specifier: ^3.2.2 specifier: ^3.2.2
version: 3.2.2(typescript@5.9.3) version: 3.2.2(typescript@5.9.3)
@@ -988,56 +988,67 @@ packages:
resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==} resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.53.5': '@rollup/rollup-linux-arm-musleabihf@4.53.5':
resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==} resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.53.5': '@rollup/rollup-linux-arm64-gnu@4.53.5':
resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==} resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.53.5': '@rollup/rollup-linux-arm64-musl@4.53.5':
resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==} resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.53.5': '@rollup/rollup-linux-loong64-gnu@4.53.5':
resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==} resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.53.5': '@rollup/rollup-linux-ppc64-gnu@4.53.5':
resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==} resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.53.5': '@rollup/rollup-linux-riscv64-gnu@4.53.5':
resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==} resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.53.5': '@rollup/rollup-linux-riscv64-musl@4.53.5':
resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==} resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.53.5': '@rollup/rollup-linux-s390x-gnu@4.53.5':
resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==} resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.53.5': '@rollup/rollup-linux-x64-gnu@4.53.5':
resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==} resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.53.5': '@rollup/rollup-linux-x64-musl@4.53.5':
resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==} resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.53.5': '@rollup/rollup-openharmony-arm64@4.53.5':
resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==} resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==}
@@ -1424,9 +1435,6 @@ packages:
webpack-sources: webpack-sources:
optional: true optional: true
'@vue/cli-shared-utils@5.0.8':
resolution: {integrity: sha512-uK2YB7bBVuQhjOJF+O52P9yFMXeJVj7ozqJkwYE9PlMHL1LMHjtCYm4cSdOebuPzyP+/9p0BimM/OqxsevIopQ==}
'@vue/cli-shared-utils@5.0.9': '@vue/cli-shared-utils@5.0.9':
resolution: {integrity: sha512-lf4KykiG8j9KwvNVi7fKtASmHuLsxCcCsflVU2b2CHMRuR4weOIV3zuuCrjWKjk0APn/MHJhgCjJGzHMbTtd5w==} resolution: {integrity: sha512-lf4KykiG8j9KwvNVi7fKtASmHuLsxCcCsflVU2b2CHMRuR4weOIV3zuuCrjWKjk0APn/MHJhgCjJGzHMbTtd5w==}
@@ -2669,8 +2677,8 @@ packages:
flatted@3.3.3: flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
follow-redirects@1.15.9: follow-redirects@1.16.0:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
peerDependencies: peerDependencies:
debug: '*' debug: '*'
@@ -2748,7 +2756,7 @@ packages:
glob@7.2.3: glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
globals@11.12.0: globals@11.12.0:
resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
@@ -4248,13 +4256,13 @@ packages:
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
tdesign-icons-vue-next@0.4.1: tdesign-icons-vue-next@0.4.4:
resolution: {integrity: sha512-uDPuTLRORnGcTyVGNoentNaK4V+ZcBmhYwcY3KqDaQQ5rrPeLMxu0ZVmgOEf0JtF2QZiqAxY7vodNEiLUdoRKA==} resolution: {integrity: sha512-UPKyR5pea840aeI5p/k8VLVgfyVzndSyE4t1yRBD8uYWiuV0p29jZbDn8hMnes9Tz5JPgywCGzbk4QJzt684NA==}
peerDependencies: peerDependencies:
vue: ^3.0.0 vue: ^3.0.0
tdesign-vue-next@1.18.0: tdesign-vue-next@1.18.6:
resolution: {integrity: sha512-JSLb82fXAsO0FXFuMBCiLDSIvUlqG2bZ1CkWOLYfE4IFyPQ2hwjTxTsJRugVoLRYBuhiIKprhmoucZ4tlgWrGg==} resolution: {integrity: sha512-oc7wOE5awfWd0/mqCVOESv3rg1Nh6HJGr9vAvgjUkRTp6/KS9gexiKU2qR1hBKqh6cmBQkbFMWmX8MLnJJ6zLQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
peerDependencies: peerDependencies:
vue: '>=3.1.0' vue: '>=3.1.0'
@@ -4275,8 +4283,8 @@ packages:
uglify-js: uglify-js:
optional: true optional: true
terser@5.39.0: terser@5.46.1:
resolution: {integrity: sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==} resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
@@ -4448,6 +4456,7 @@ packages:
uuid@8.3.2: uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true hasBin: true
validate-npm-package-license@3.0.4: validate-npm-package-license@3.0.4:
@@ -4828,7 +4837,7 @@ snapshots:
'@babel/core': 7.26.9 '@babel/core': 7.26.9
'@babel/helper-compilation-targets': 7.26.5 '@babel/helper-compilation-targets': 7.26.5
'@babel/helper-plugin-utils': 7.26.5 '@babel/helper-plugin-utils': 7.26.5
debug: 4.4.0 debug: 4.4.3
lodash.debounce: 4.0.8 lodash.debounce: 4.0.8
resolve: 1.22.10 resolve: 1.22.10
transitivePeerDependencies: transitivePeerDependencies:
@@ -6032,14 +6041,14 @@ snapshots:
'@typescript-eslint/types': 8.53.0 '@typescript-eslint/types': 8.53.0
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
'@vitejs/plugin-basic-ssl@2.1.3(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.39.0))': '@vitejs/plugin-basic-ssl@2.1.3(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.46.1))':
dependencies: dependencies:
vite: 7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.39.0) vite: 7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.46.1)
'@vitejs/plugin-vue@6.0.3(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.39.0))(vue@3.5.26(typescript@5.9.3))': '@vitejs/plugin-vue@6.0.3(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.46.1))(vue@3.5.26(typescript@5.9.3))':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53 '@rolldown/pluginutils': 1.0.0-beta.53
vite: 7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.39.0) vite: 7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.46.1)
vue: 3.5.26(typescript@5.9.3) vue: 3.5.26(typescript@5.9.3)
'@volar/language-core@2.4.11': '@volar/language-core@2.4.11':
@@ -6212,7 +6221,7 @@ snapshots:
'@vue/cli-plugin-router@5.0.8(@vue/cli-service@5.0.8(@vue/compiler-sfc@3.5.26)(lodash@4.17.21)(prettier@3.8.0)(vue@3.5.26(typescript@5.9.3))(webpack-sources@3.2.3))': '@vue/cli-plugin-router@5.0.8(@vue/cli-service@5.0.8(@vue/compiler-sfc@3.5.26)(lodash@4.17.21)(prettier@3.8.0)(vue@3.5.26(typescript@5.9.3))(webpack-sources@3.2.3))':
dependencies: dependencies:
'@vue/cli-service': 5.0.8(@vue/compiler-sfc@3.5.26)(lodash@4.17.21)(prettier@3.8.0)(vue@3.5.26(typescript@5.9.3))(webpack-sources@3.2.3) '@vue/cli-service': 5.0.8(@vue/compiler-sfc@3.5.26)(lodash@4.17.21)(prettier@3.8.0)(vue@3.5.26(typescript@5.9.3))(webpack-sources@3.2.3)
'@vue/cli-shared-utils': 5.0.8 '@vue/cli-shared-utils': 5.0.9
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
@@ -6229,11 +6238,11 @@ snapshots:
'@vue/cli-overlay': 5.0.8 '@vue/cli-overlay': 5.0.8
'@vue/cli-plugin-router': 5.0.8(@vue/cli-service@5.0.8(@vue/compiler-sfc@3.5.26)(lodash@4.17.21)(prettier@3.8.0)(vue@3.5.26(typescript@5.9.3))(webpack-sources@3.2.3)) '@vue/cli-plugin-router': 5.0.8(@vue/cli-service@5.0.8(@vue/compiler-sfc@3.5.26)(lodash@4.17.21)(prettier@3.8.0)(vue@3.5.26(typescript@5.9.3))(webpack-sources@3.2.3))
'@vue/cli-plugin-vuex': 5.0.8(@vue/cli-service@5.0.8(@vue/compiler-sfc@3.5.26)(lodash@4.17.21)(prettier@3.8.0)(vue@3.5.26(typescript@5.9.3))(webpack-sources@3.2.3)) '@vue/cli-plugin-vuex': 5.0.8(@vue/cli-service@5.0.8(@vue/compiler-sfc@3.5.26)(lodash@4.17.21)(prettier@3.8.0)(vue@3.5.26(typescript@5.9.3))(webpack-sources@3.2.3))
'@vue/cli-shared-utils': 5.0.8 '@vue/cli-shared-utils': 5.0.9
'@vue/component-compiler-utils': 3.3.0(lodash@4.17.21) '@vue/component-compiler-utils': 3.3.0(lodash@4.17.21)
'@vue/vue-loader-v15': vue-loader@15.11.1(@vue/compiler-sfc@3.5.26)(css-loader@6.11.0(webpack@5.98.0))(lodash@4.17.21)(prettier@3.8.0)(webpack@5.98.0) '@vue/vue-loader-v15': vue-loader@15.11.1(@vue/compiler-sfc@3.5.26)(css-loader@6.11.0(webpack@5.98.0))(lodash@4.17.21)(prettier@3.8.0)(webpack@5.98.0)
'@vue/web-component-wrapper': 1.3.0 '@vue/web-component-wrapper': 1.3.0
acorn: 8.14.1 acorn: 8.15.0
acorn-walk: 8.3.4 acorn-walk: 8.3.4
address: 1.2.2 address: 1.2.2
autoprefixer: 10.4.20(postcss@8.5.6) autoprefixer: 10.4.20(postcss@8.5.6)
@@ -6246,7 +6255,7 @@ snapshots:
css-loader: 6.11.0(webpack@5.98.0) css-loader: 6.11.0(webpack@5.98.0)
css-minimizer-webpack-plugin: 3.4.1(webpack@5.98.0) css-minimizer-webpack-plugin: 3.4.1(webpack@5.98.0)
cssnano: 5.1.15(postcss@8.5.6) cssnano: 5.1.15(postcss@8.5.6)
debug: 4.4.0 debug: 4.4.3
default-gateway: 6.0.3 default-gateway: 6.0.3
dotenv: 10.0.0 dotenv: 10.0.0
dotenv-expand: 5.1.0 dotenv-expand: 5.1.0
@@ -6273,7 +6282,7 @@ snapshots:
webpack: 5.98.0 webpack: 5.98.0
webpack-bundle-analyzer: 4.10.2 webpack-bundle-analyzer: 4.10.2
webpack-chain: 6.5.1 webpack-chain: 6.5.1
webpack-dev-server: 4.15.2(debug@4.4.0)(webpack@5.98.0) webpack-dev-server: 4.15.2(debug@4.4.3)(webpack@5.98.0)
webpack-merge: 5.10.0 webpack-merge: 5.10.0
webpack-virtual-modules: 0.4.6 webpack-virtual-modules: 0.4.6
whatwg-fetch: 3.6.20 whatwg-fetch: 3.6.20
@@ -6349,23 +6358,6 @@ snapshots:
- webpack-cli - webpack-cli
- whiskers - whiskers
'@vue/cli-shared-utils@5.0.8':
dependencies:
'@achrinza/node-ipc': 9.2.9
chalk: 4.1.2
execa: 1.0.0
joi: 17.13.3
launch-editor: 2.10.0
lru-cache: 6.0.0
node-fetch: 2.7.0
open: 8.4.2
ora: 5.4.1
read-pkg: 5.2.0
semver: 7.7.1
strip-ansi: 6.0.1
transitivePeerDependencies:
- encoding
'@vue/cli-shared-utils@5.0.9': '@vue/cli-shared-utils@5.0.9':
dependencies: dependencies:
'@achrinza/node-ipc': 9.2.9 '@achrinza/node-ipc': 9.2.9
@@ -6671,7 +6663,7 @@ snapshots:
acorn-walk@8.3.4: acorn-walk@8.3.4:
dependencies: dependencies:
acorn: 8.14.1 acorn: 8.15.0
acorn@8.14.1: {} acorn@8.14.1: {}
@@ -7138,7 +7130,7 @@ snapshots:
postcss-modules-scope: 3.2.1(postcss@8.5.6) postcss-modules-scope: 3.2.1(postcss@8.5.6)
postcss-modules-values: 4.0.0(postcss@8.5.6) postcss-modules-values: 4.0.0(postcss@8.5.6)
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
semver: 7.7.1 semver: 7.7.3
optionalDependencies: optionalDependencies:
webpack: 5.98.0 webpack: 5.98.0
@@ -7686,9 +7678,9 @@ snapshots:
flatted@3.3.3: {} flatted@3.3.3: {}
follow-redirects@1.15.9(debug@4.4.0): follow-redirects@1.16.0(debug@4.4.3):
optionalDependencies: optionalDependencies:
debug: 4.4.0 debug: 4.4.3
forwarded@0.2.0: {} forwarded@0.2.0: {}
@@ -7831,7 +7823,7 @@ snapshots:
he: 1.2.0 he: 1.2.0
param-case: 3.0.4 param-case: 3.0.4
relateurl: 0.2.7 relateurl: 0.2.7
terser: 5.39.0 terser: 5.46.1
html-tags@2.0.0: {} html-tags@2.0.0: {}
@@ -7871,10 +7863,10 @@ snapshots:
http-parser-js@0.5.9: {} http-parser-js@0.5.9: {}
http-proxy-middleware@2.0.7(@types/express@4.17.21)(debug@4.4.0): http-proxy-middleware@2.0.7(@types/express@4.17.21)(debug@4.4.3):
dependencies: dependencies:
'@types/http-proxy': 1.17.16 '@types/http-proxy': 1.17.16
http-proxy: 1.18.1(debug@4.4.0) http-proxy: 1.18.1(debug@4.4.3)
is-glob: 4.0.3 is-glob: 4.0.3
is-plain-obj: 3.0.0 is-plain-obj: 3.0.0
micromatch: 4.0.8 micromatch: 4.0.8
@@ -7883,10 +7875,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
http-proxy@1.18.1(debug@4.4.0): http-proxy@1.18.1(debug@4.4.3):
dependencies: dependencies:
eventemitter3: 4.0.7 eventemitter3: 4.0.7
follow-redirects: 1.15.9(debug@4.4.0) follow-redirects: 1.16.0(debug@4.4.3)
requires-port: 1.0.0 requires-port: 1.0.0
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
@@ -8579,7 +8571,7 @@ snapshots:
cosmiconfig: 7.1.0 cosmiconfig: 7.1.0
klona: 2.0.6 klona: 2.0.6
postcss: 8.5.6 postcss: 8.5.6
semver: 7.7.1 semver: 7.7.3
webpack: 5.98.0 webpack: 5.98.0
postcss-merge-longhand@5.1.7(postcss@8.5.6): postcss-merge-longhand@5.1.7(postcss@8.5.6):
@@ -9135,7 +9127,7 @@ snapshots:
spdy-transport@3.0.0: spdy-transport@3.0.0:
dependencies: dependencies:
debug: 4.4.0 debug: 4.4.3
detect-node: 2.1.0 detect-node: 2.1.0
hpack.js: 2.1.6 hpack.js: 2.1.6
obuf: 1.1.2 obuf: 1.1.2
@@ -9146,7 +9138,7 @@ snapshots:
spdy@4.0.2: spdy@4.0.2:
dependencies: dependencies:
debug: 4.4.0 debug: 4.4.3
handle-thing: 2.0.1 handle-thing: 2.0.1
http-deceiver: 1.2.7 http-deceiver: 1.2.7
select-hose: 2.0.0 select-hose: 2.0.0
@@ -9245,12 +9237,12 @@ snapshots:
tapable@2.2.1: {} tapable@2.2.1: {}
tdesign-icons-vue-next@0.4.1(vue@3.5.26(typescript@5.9.3)): tdesign-icons-vue-next@0.4.4(vue@3.5.26(typescript@5.9.3)):
dependencies: dependencies:
'@babel/runtime': 7.26.9 '@babel/runtime': 7.26.9
vue: 3.5.26(typescript@5.9.3) vue: 3.5.26(typescript@5.9.3)
tdesign-vue-next@1.18.0(vue@3.5.26(typescript@5.9.3)): tdesign-vue-next@1.18.6(vue@3.5.26(typescript@5.9.3)):
dependencies: dependencies:
'@babel/runtime': 7.26.9 '@babel/runtime': 7.26.9
'@popperjs/core': 2.11.8 '@popperjs/core': 2.11.8
@@ -9262,7 +9254,7 @@ snapshots:
lodash-es: 4.17.21 lodash-es: 4.17.21
mitt: 3.0.1 mitt: 3.0.1
sortablejs: 1.15.6 sortablejs: 1.15.6
tdesign-icons-vue-next: 0.4.1(vue@3.5.26(typescript@5.9.3)) tdesign-icons-vue-next: 0.4.4(vue@3.5.26(typescript@5.9.3))
tinycolor2: 1.6.0 tinycolor2: 1.6.0
validator: 13.15.26 validator: 13.15.26
vue: 3.5.26(typescript@5.9.3) vue: 3.5.26(typescript@5.9.3)
@@ -9273,13 +9265,13 @@ snapshots:
jest-worker: 27.5.1 jest-worker: 27.5.1
schema-utils: 4.3.0 schema-utils: 4.3.0
serialize-javascript: 6.0.2 serialize-javascript: 6.0.2
terser: 5.39.0 terser: 5.46.1
webpack: 5.98.0 webpack: 5.98.0
terser@5.39.0: terser@5.46.1:
dependencies: dependencies:
'@jridgewell/source-map': 0.3.6 '@jridgewell/source-map': 0.3.6
acorn: 8.14.1 acorn: 8.15.0
commander: 2.20.3 commander: 2.20.3
source-map-support: 0.5.21 source-map-support: 0.5.21
@@ -9452,7 +9444,7 @@ snapshots:
vary@1.1.2: {} vary@1.1.2: {}
vite-plugin-dts@4.5.4(@types/node@25.0.8)(rollup@4.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.39.0)): vite-plugin-dts@4.5.4(@types/node@25.0.8)(rollup@4.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.46.1)):
dependencies: dependencies:
'@microsoft/api-extractor': 7.51.1(@types/node@25.0.8) '@microsoft/api-extractor': 7.51.1(@types/node@25.0.8)
'@rollup/pluginutils': 5.1.4(rollup@4.53.5) '@rollup/pluginutils': 5.1.4(rollup@4.53.5)
@@ -9465,19 +9457,19 @@ snapshots:
magic-string: 0.30.17 magic-string: 0.30.17
typescript: 5.9.3 typescript: 5.9.3
optionalDependencies: optionalDependencies:
vite: 7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.39.0) vite: 7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.46.1)
transitivePeerDependencies: transitivePeerDependencies:
- '@types/node' - '@types/node'
- rollup - rollup
- supports-color - supports-color
vite-plugin-vue-setup-extend@0.4.0(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.39.0)): vite-plugin-vue-setup-extend@0.4.0(vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.46.1)):
dependencies: dependencies:
'@vue/compiler-sfc': 3.5.26 '@vue/compiler-sfc': 3.5.26
magic-string: 0.25.9 magic-string: 0.25.9
vite: 7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.39.0) vite: 7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.46.1)
vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.39.0): vite@7.3.1(@types/node@25.0.8)(less@4.5.1)(terser@5.46.1):
dependencies: dependencies:
esbuild: 0.27.2 esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@@ -9489,7 +9481,7 @@ snapshots:
'@types/node': 25.0.8 '@types/node': 25.0.8
fsevents: 2.3.3 fsevents: 2.3.3
less: 4.5.1 less: 4.5.1
terser: 5.39.0 terser: 5.46.1
vscode-uri@3.1.0: {} vscode-uri@3.1.0: {}
@@ -9625,7 +9617,7 @@ snapshots:
webpack-bundle-analyzer@4.10.2: webpack-bundle-analyzer@4.10.2:
dependencies: dependencies:
'@discoveryjs/json-ext': 0.5.7 '@discoveryjs/json-ext': 0.5.7
acorn: 8.14.1 acorn: 8.15.0
acorn-walk: 8.3.4 acorn-walk: 8.3.4
commander: 7.2.0 commander: 7.2.0
debounce: 1.2.1 debounce: 1.2.1
@@ -9654,7 +9646,7 @@ snapshots:
schema-utils: 4.3.0 schema-utils: 4.3.0
webpack: 5.98.0 webpack: 5.98.0
webpack-dev-server@4.15.2(debug@4.4.0)(webpack@5.98.0): webpack-dev-server@4.15.2(debug@4.4.3)(webpack@5.98.0):
dependencies: dependencies:
'@types/bonjour': 3.5.13 '@types/bonjour': 3.5.13
'@types/connect-history-api-fallback': 1.5.4 '@types/connect-history-api-fallback': 1.5.4
@@ -9673,7 +9665,7 @@ snapshots:
express: 4.21.2 express: 4.21.2
graceful-fs: 4.2.11 graceful-fs: 4.2.11
html-entities: 2.5.2 html-entities: 2.5.2
http-proxy-middleware: 2.0.7(@types/express@4.17.21)(debug@4.4.0) http-proxy-middleware: 2.0.7(@types/express@4.17.21)(debug@4.4.3)
ipaddr.js: 2.2.0 ipaddr.js: 2.2.0
launch-editor: 2.10.0 launch-editor: 2.10.0
open: 8.4.2 open: 8.4.2
+6
View File
@@ -0,0 +1,6 @@
.t-table {
.t-table.t-size-s td {
padding: var(--td-comp-paddingTB-s);
}
}
-264
View File
@@ -1,264 +0,0 @@
/* TDesign 腾讯云样式附加修改 */
@popupShadowColor: rgba(0, 0, 0, .1);
@popupShadow: 3px 3px 0 @popupShadowColor;
.t-button {
cursor: var(--tui-cur-pointer) !important;
}
.t-card {
box-shadow: var(--tui-shadow) !important;
.t-card__title {
display: block;
}
.t-card__body,
.t-card__header {
margin: 14px 0;
padding: 0 16px;
}
}
.t-link {
cursor: var(--tui-cur-pointer) !important;
font-size: 100%;
}
.t-input .t-input__inner {
cursor: var(--tui-cur-text) !important;
}
.t-select .t-input__inner {
cursor: var(--tui-cur-default) !important;
}
.t-textarea__inner {
cursor: var(--tui-cur-text) !important;
word-wrap: normal;
}
.t-checkbox {
cursor: var(--tui-cur-pointer) !important;
}
.t-tabs {
.t-tabs__nav-item {
cursor: var(--tui-cur-pointer) !important;
}
}
.t-pagination {
font-size: 12px;
--td-comp-size-m: 28px;
.t-pagination__pager {
.t-pagination__number {
cursor: var(--tui-cur-pointer) !important;
&:hover {
z-index: 1;
}
&:not(:last-child) {
margin-right: -1px;
}
}
}
}
.t-breadcrumb {
.t-breadcrumb__item {
cursor: var(--tui--pointer);
.t-breadcrumb__inner-text {
cursor: var(--tui--pointer);
}
a {
color: inherit;
text-decoration: none;
}
&:last-child {
.t-breadcrumb__inner {
&:hover,
&:active {
color: var(--td-text-color-primary);
}
&:active {
animation: none;
}
}
.t-breadcrumb__inner-text {
cursor: var(--tui--default);
}
}
}
}
.t-list {
.t-list-item {
background: transparent;
&:last-child::after {
background: transparent;
}
}
}
.t-tree {
.t-tree__item,
.t-tree__icon,
.t-tree__label {
cursor: var(--tui--pointer) !important;
font-size: 100%;
}
.t-tree__label {
padding: 3px var(--td-comp-paddingLR-xs);
}
}
.t-timeline-item__wrapper {
.t-timeline-item__dot {
border: none;
width: 8px;
height: 8px;
margin: 2px 0 0 1px;
background: var(--td-brand-color);
border-radius: 0;
}
.t-timeline-item__tail {
border-left: 2px solid var(--td-component-border);
}
}
.t-layout {
background: transparent;
.t-layout__sider,
.t-layout__header,
.t-layout__content,
.t-layout__footer {
background: transparent;
}
}
.narrow-scrollbar {
&::-webkit-scrollbar {
cursor: var(--tui--default) !important;
}
&::-webkit-scrollbar-thumb {
border: none;
cursor: var(--tui--default);
background: #525870;
border-radius: 0;
}
}
.t-popup {
.t-popup__content {
outline: 1px solid var(--td-component-border);
box-shadow: @popupShadow;
}
.t-select-option {
cursor: var(--tui--pointer);
}
}
.t-popup[data-popper-placement^="left"] {
.t-popup__arrow {
right: calc(-9px / 2);
&::before {
box-shadow: inset -1px 0 0 var(--td-component-border), inset 0 1px 0 var(--td-component-border), 2px -2px 0 rgba(0, 0, 0, .08);
}
}
}
.t-popup[data-popper-placement^="right"] {
.t-popup__arrow {
left: calc(-10px / 2);
&::before {
box-shadow: inset 0 -1px 0 var(--td-component-border), inset 1px 0 0 var(--td-component-border);
}
}
}
.t-popup[data-popper-placement^="bottom"] {
&.t-select__dropdown .t-popup__content {
margin-top: 2px;
}
}
.t-dialog__ctx {
--td-mask-active: rgba(0, 0, 0, .2) !important;
.t-dialog {
background: rgba(255, 255, 255, .8);
box-shadow: 2px 2px 0 rgba(0, 0, 0, .3);
backdrop-filter: blur(10px);
animation-duration: .3s !important;
-webkit-backdrop-filter: blur(10px);
animation-timing-function: cubic-bezier(.23, 1, .32, 1) !important;
}
}
.t-message {
outline: 1px solid var(--td-component-border) !important;
box-shadow: @popupShadow !important;
}
.t-dropdown {
border: none;
}
.t-avatar {
box-sizing: border-box;
}
.t-image-viewer {
.t-swiper-item {
overflow: hidden;
}
}
.t-menu {
.t-head-menu__inner {
height: var(--td-comp-size-xxl);
.t-menu__operations:not(:empty) {
height: var(--td-comp-size-xxl);
line-height: 1;
}
.t-menu__item {
height: 100%;
padding: 0 24px;
margin-left: 0;
&.t-is-active {
color: var(--td-text-color-anti);
background-color: var(--td-brand-color)
}
}
}
}
-380
View File
@@ -1,380 +0,0 @@
:root, :root[theme-mode="light"] {
--td-brand-color-1: #f2f3ff;
--td-brand-color-2: #d9e1ff;
--td-brand-color-3: #b5c7ff;
--td-brand-color-4: #8eabff;
--td-brand-color-5: #618dff;
--td-brand-color-6: #366ef4;
--td-brand-color-7: #0052d9;
--td-brand-color-8: #003cab;
--td-brand-color-9: #002a7c;
--td-brand-color-10: #001a57;
--td-warning-color-1: #fff1e9;
--td-warning-color-2: #ffd9c2;
--td-warning-color-3: #ffb98c;
--td-warning-color-4: #fa9550;
--td-warning-color-5: #e37318;
--td-warning-color-6: #be5a00;
--td-warning-color-7: #954500;
--td-warning-color-8: #713300;
--td-warning-color-9: #532300;
--td-warning-color-10: #3b1700;
--td-error-color-1: #fff0ed;
--td-error-color-2: #ffd8d2;
--td-error-color-3: #ffb9b0;
--td-error-color-4: #ff9285;
--td-error-color-5: #f6685d;
--td-error-color-6: #d54941;
--td-error-color-7: #ad352f;
--td-error-color-8: #881f1c;
--td-error-color-9: #68070a;
--td-error-color-10: #490002;
--td-success-color-1: #e3f9e9;
--td-success-color-2: #c6f3d7;
--td-success-color-3: #92dab2;
--td-success-color-4: #56c08d;
--td-success-color-5: #2ba471;
--td-success-color-6: #008858;
--td-success-color-7: #006c45;
--td-success-color-8: #005334;
--td-success-color-9: #003b23;
--td-success-color-10: #002515;
--td-gray-color-1: #f6f6f6;
--td-gray-color-2: #eeeceb;
--td-gray-color-3: #dddbdb;
--td-gray-color-4: #cac9c9;
--td-gray-color-5: #b6b6b6;
--td-gray-color-6: #a2a2a2;
--td-gray-color-7: #8f8f8f;
--td-gray-color-8: #7b7c7c;
--td-gray-color-9: #6a6b6b;
--td-gray-color-10: #585a5a;
--td-gray-color-11: #494a4a;
--td-gray-color-12: #3a3b3b;
--td-gray-color-13: #2c2e2e;
--td-gray-color-14: #202222;
--td-font-white-1: #fff;
--td-font-white-2: rgba(255, 255, 255, .55);
--td-font-white-3: rgba(255, 255, 255, .35);
--td-font-white-4: rgba(255, 255, 255, .22);
--td-font-gray-1: rgba(0, 0, 0, .9);
--td-font-gray-2: rgba(0, 0, 0, .6);
--td-font-gray-3: rgba(0, 0, 0, .4);
--td-font-gray-4: rgba(0, 0, 0, .26);
--td-brand-color: var(--td-brand-color-7);
--td-warning-color: var(--td-warning-color-5);
--td-error-color: var(--td-error-color-6);
--td-success-color: var(--td-success-color-5);
--td-brand-color-hover: var(--td-brand-color-6);
--td-brand-color-focus: var(--td-brand-color-2);
--td-brand-color-active: var(--td-brand-color-8);
--td-brand-color-disabled: var(--td-brand-color-3);
--td-brand-color-light: var(--td-brand-color-1);
--td-brand-color-light-hover: var(--td-brand-color-2);
--td-warning-color-hover: var(--td-warning-color-4);
--td-warning-color-focus: var(--td-warning-color-2);
--td-warning-color-active: var(--td-warning-color-6);
--td-warning-color-disabled: var(--td-warning-color-3);
--td-warning-color-light: var(--td-warning-color-1);
--td-warning-color-light-hover: var(--td-warning-color-2);
--td-error-color-hover: var(--td-error-color-5);
--td-error-color-focus: var(--td-error-color-2);
--td-error-color-active: var(--td-error-color-7);
--td-error-color-disabled: var(--td-error-color-3);
--td-error-color-light: var(--td-error-color-1);
--td-error-color-light-hover: var(--td-error-color-2);
--td-success-color-hover: var(--td-success-color-4);
--td-success-color-focus: var(--td-success-color-2);
--td-success-color-active: var(--td-success-color-6);
--td-success-color-disabled: var(--td-success-color-3);
--td-success-color-light: var(--td-success-color-1);
--td-success-color-light-hover: var(--td-success-color-2);
--td-mask-active: rgba(0, 0, 0, .6);
--td-mask-disabled: rgba(255, 255, 255, .6);
--td-bg-color-page: var(--td-gray-color-2);
--td-bg-color-container: #fff;
--td-bg-color-container-hover: var(--td-gray-color-1);
--td-bg-color-container-active: var(--td-gray-color-3);
--td-bg-color-container-select: #fff;
--td-bg-color-secondarycontainer: var(--td-gray-color-1);
--td-bg-color-secondarycontainer-hover: var(--td-gray-color-2);
--td-bg-color-secondarycontainer-active: var(--td-gray-color-4);
--td-bg-color-component: var(--td-gray-color-3);
--td-bg-color-component-hover: var(--td-gray-color-4);
--td-bg-color-component-active: var(--td-gray-color-6);
--td-bg-color-secondarycomponent: var(--td-gray-color-4);
--td-bg-color-secondarycomponent-hover: var(--td-gray-color-5);
--td-bg-color-secondarycomponent-active: var(--td-gray-color-6);
--td-bg-color-component-disabled: var(--td-gray-color-2);
--td-bg-color-specialcomponent: #fff;
--td-text-color-primary: var(--td-font-gray-1);
--td-text-color-secondary: var(--td-font-gray-2);
--td-text-color-placeholder: var(--td-font-gray-3);
--td-text-color-disabled: var(--td-font-gray-4);
--td-text-color-anti: #fff;
--td-text-color-brand: var(--td-brand-color-7);
--td-text-color-link: var(--td-brand-color-8);
--td-border-level-1-color: var(--td-gray-color-3);
--td-component-stroke: var(--td-gray-color-3);
--td-border-level-2-color: var(--td-gray-color-4);
--td-component-border: var(--td-gray-color-4);
--td-shadow-1: 0 1px 10px rgba(0, 0, 0, .05), 0 4px 5px rgba(0, 0, 0, .08), 0 2px 4px -1px rgba(0, 0, 0, .12);
--td-shadow-2: 0 3px 14px 2px rgba(0, 0, 0, .05), 0 8px 10px 1px rgba(0, 0, 0, .06), 0 5px 5px -3px rgba(0, 0, 0, .1);
--td-shadow-3: 0 6px 30px 5px rgba(0, 0, 0, .05), 0 16px 24px 2px rgba(0, 0, 0, .04), 0 8px 10px -5px rgba(0, 0, 0, .08);
--td-shadow-inset-top: inset 0 0.5px 0 #e7eaef;
--td-shadow-inset-right: inset 0.5px 0 0 #e7eaef;
--td-shadow-inset-bottom: inset 0 -0.5px 0 #e7eaef;
--td-table-shadow-color: rgba(0, 0, 0, .08);
--td-scrollbar-color: rgba(0, 0, 0, .1);
--td-scrollbar-hover-color: rgba(0, 0, 0, .3);
--td-scroll-track-color: #fff;
}
:root[theme-mode="dark"] {
--td-brand-color-1: #1b2f51;
--td-brand-color-2: #173463;
--td-brand-color-3: #143975;
--td-brand-color-4: #103d88;
--td-brand-color-5: #0d429a;
--td-brand-color-6: #054bbe;
--td-brand-color-7: #2667d4;
--td-brand-color-8: #4582e6;
--td-brand-color-9: #699ef5;
--td-brand-color-10: #96bbf8;
--td-warning-color-1: #3b1700;
--td-warning-color-2: #532300;
--td-warning-color-3: #713300;
--td-warning-color-4: #954500;
--td-warning-color-5: #be5a00;
--td-warning-color-6: #e37318;
--td-warning-color-7: #fa9550;
--td-warning-color-8: #ffb98c;
--td-warning-color-9: #ffd9c2;
--td-warning-color-10: #fff1e9;
--td-error-color-1: #490002;
--td-error-color-2: #68070a;
--td-error-color-3: #881f1c;
--td-error-color-4: #ad352f;
--td-error-color-5: #d54941;
--td-error-color-6: #f6685d;
--td-error-color-7: #ff9285;
--td-error-color-8: #ffb9b0;
--td-error-color-9: #ffd8d2;
--td-error-color-10: #fff0ed;
--td-success-color-1: #002515;
--td-success-color-2: #003b23;
--td-success-color-3: #005334;
--td-success-color-4: #006c45;
--td-success-color-5: #008858;
--td-success-color-6: #2ba471;
--td-success-color-7: #56c08d;
--td-success-color-8: #92dab2;
--td-success-color-9: #c6f3d7;
--td-success-color-10: #e3f9e9;
--td-gray-color-1: #f6f6f6;
--td-gray-color-2: #eeeceb;
--td-gray-color-3: #dddbdb;
--td-gray-color-4: #cac9c9;
--td-gray-color-5: #b6b6b6;
--td-gray-color-6: #a2a2a2;
--td-gray-color-7: #8f8f8f;
--td-gray-color-8: #7b7c7c;
--td-gray-color-9: #6a6b6b;
--td-gray-color-10: #585a5a;
--td-gray-color-11: #494a4a;
--td-gray-color-12: #3a3b3b;
--td-gray-color-13: #2c2e2e;
--td-gray-color-14: #202222;
--td-font-white-1: rgba(255, 255, 255, .9);
--td-font-white-2: rgba(255, 255, 255, .55);
--td-font-white-3: rgba(255, 255, 255, .35);
--td-font-white-4: rgba(255, 255, 255, .22);
--td-font-gray-1: rgba(0, 0, 0, .9);
--td-font-gray-2: rgba(0, 0, 0, .6);
--td-font-gray-3: rgba(0, 0, 0, .4);
--td-font-gray-4: rgba(0, 0, 0, .26);
--td-brand-color: var(--td-brand-color-8);
--td-warning-color: var(--td-warning-color-5);
--td-error-color: var(--td-error-color-6);
--td-success-color: var(--td-success-color-5);
--td-brand-color-hover: var(--td-brand-color-7);
--td-brand-color-focus: var(--td-brand-color-2);
--td-brand-color-active: var(--td-brand-color-9);
--td-brand-color-disabled: var(--td-brand-color-3);
--td-brand-color-light: var(--td-brand-color-1);
--td-brand-color-light-hover: var(--td-brand-color-2);
--td-warning-color-hover: var(--td-warning-color-4);
--td-warning-color-focus: var(--td-warning-color-2);
--td-warning-color-active: var(--td-warning-color-6);
--td-warning-color-disabled: var(--td-warning-color-3);
--td-warning-color-light: var(--td-warning-color-1);
--td-warning-color-light-hover: var(--td-warning-color-2);
--td-error-color-hover: var(--td-error-color-5);
--td-error-color-focus: var(--td-error-color-2);
--td-error-color-active: var(--td-error-color-7);
--td-error-color-disabled: var(--td-error-color-3);
--td-error-color-light: var(--td-error-color-1);
--td-error-color-light-hover: var(--td-error-color-2);
--td-success-color-hover: var(--td-success-color-4);
--td-success-color-focus: var(--td-success-color-2);
--td-success-color-active: var(--td-success-color-6);
--td-success-color-disabled: var(--td-success-color-3);
--td-success-color-light: var(--td-success-color-1);
--td-success-color-light-hover: var(--td-success-color-2);
--td-mask-active: rgba(0, 0, 0, .4);
--td-mask-disabled: rgba(0, 0, 0, .6);
--td-bg-color-page: var(--td-gray-color-14);
--td-bg-color-container: var(--td-gray-color-13);
--td-bg-color-container-hover: var(--td-gray-color-12);
--td-bg-color-container-active: var(--td-gray-color-10);
--td-bg-color-container-select: var(--td-gray-color-9);
--td-bg-color-secondarycontainer: var(--td-gray-color-12);
--td-bg-color-secondarycontainer-hover: var(--td-gray-color-11);
--td-bg-color-secondarycontainer-active: var(--td-gray-color-9);
--td-bg-color-component: var(--td-gray-color-11);
--td-bg-color-component-hover: var(--td-gray-color-10);
--td-bg-color-component-active: var(--td-gray-color-9);
--td-bg-color-secondarycomponent: var(--td-gray-color-10);
--td-bg-color-secondarycomponent-hover: var(--td-gray-color-9);
--td-bg-color-secondarycomponent-active: var(--td-gray-color-8);
--td-bg-color-component-disabled: var(--td-gray-color-12);
--td-bg-color-specialcomponent: transparent;
--td-text-color-primary: var(--td-font-white-1);
--td-text-color-secondary: var(--td-font-white-2);
--td-text-color-placeholder: var(--td-font-white-3);
--td-text-color-disabled: var(--td-font-white-4);
--td-text-color-anti: #fff;
--td-text-color-brand: var(--td-brand-color-8);
--td-text-color-link: var(--td-brand-color-8);
--td-border-level-1-color: var(--td-gray-color-11);
--td-component-stroke: var(--td-gray-color-11);
--td-border-level-2-color: var(--td-gray-color-9);
--td-component-border: var(--td-gray-color-9);
--td-shadow-1: 0 4px 6px rgba(0, 0, 0, .06), 0 1px 10px rgba(0, 0, 0, .08), 0 2px 4px rgba(0, 0, 0, .12);
--td-shadow-2: 0 8px 10px rgba(0, 0, 0, .12), 0 3px 14px rgba(0, 0, 0, .1), 0 5px 5px rgba(0, 0, 0, .16);
--td-shadow-3: 0 16px 24px rgba(0, 0, 0, .14), 0 6px 30px rgba(0, 0, 0, .12), 0 8px 10px rgba(0, 0, 0, .2);
--td-shadow-inset-top: inset 0 0.5px 0 #5e5e5e;
--td-shadow-inset-right: inset 0.5px 0 0 #5e5e5e;
--td-shadow-inset-bottom: inset 0 -0.5px 0 #5e5e5e;
--td-table-shadow-color: rgba(0, 0, 0, .55);
--td-scrollbar-color: rgba(255, 255, 255, .1);
--td-scrollbar-hover-color: rgba(255, 255, 255, .3);
--td-scroll-track-color: #333;
}
:root {
--td-font-family: var(--tui-font) !important;
--td-font-family-medium: var(--tui-font) !important;
--td-font-size-link-small: 12px;
--td-font-size-link-medium: 14px;
--td-font-size-link-large: 16px;
--td-font-size-mark-small: 12px;
--td-font-size-mark-medium: 14px;
--td-font-size-body-small: 12px;
--td-font-size-body-medium: 14px;
--td-font-size-body-large: 16px;
--td-font-size-title-small: 14px;
--td-font-size-title-medium: 16px;
--td-font-size-title-large: 18px;
--td-font-size-title-extraLarge: 20px;
--td-font-size-headline-small: 24px;
--td-font-size-headline-medium: 28px;
--td-font-size-headline-large: 36px;
--td-font-size-display-medium: 48px;
--td-font-size-display-large: 64px;
--td-line-height-link-small: 20px;
--td-line-height-link-medium: 22px;
--td-line-height-link-large: 24px;
--td-line-height-mark-small: 20px;
--td-line-height-mark-medium: 22px;
--td-line-height-body-small: 20px;
--td-line-height-body-medium: 22px;
--td-line-height-body-large: 24px;
--td-line-height-title-small: 22px;
--td-line-height-title-medium: 24px;
--td-line-height-title-large: 26px;
--td-line-height-title-extraLarge: 28px;
--td-line-height-headline-small: 32px;
--td-line-height-headline-medium: 36px;
--td-line-height-headline-large: 44px;
--td-line-height-display-medium: 56px;
--td-line-height-display-large: 72px;
--td-font-link-small: var(--td-font-size-link-small) / var(--td-line-height-link-small) var(--td-font-family);
--td-font-link-medium: var(--td-font-size-link-medium) / var(--td-line-height-link-medium) var(--td-font-family);
--td-font-link-large: var(--td-font-size-link-large) / var(--td-line-height-link-large) var(--td-font-family);
--td-font-mark-small: 600 var(--td-font-size-mark-small) / var(--td-line-height-mark-small) var(--td-font-family);
--td-font-mark-medium: 600 var(--td-font-size-mark-medium) / var(--td-line-height-mark-medium) var(--td-font-family);
--td-font-body-small: var(--td-font-size-body-small) / var(--td-line-height-body-small) var(--td-font-family);
--td-font-body-medium: var(--td-font-size-body-medium) / var(--td-line-height-body-medium) var(--td-font-family);
--td-font-body-large: var(--td-font-size-body-large) / var(--td-line-height-body-large) var(--td-font-family);
--td-font-title-small: 600 var(--td-font-size-title-small) / var(--td-line-height-title-small) var(--td-font-family);
--td-font-title-medium: 600 var(--td-font-size-title-medium) / var(--td-line-height-title-medium) var(--td-font-family);
--td-font-title-large: 600 var(--td-font-size-title-large) / var(--td-line-height-title-large) var(--td-font-family);
--td-font-headline-small: 600 var(--td-font-size-headline-small) / var(--td-line-height-headline-small) var(--td-font-family);
--td-font-headline-medium: 600 var(--td-font-size-headline-medium) / var(--td-line-height-headline-medium) var(--td-font-family);
--td-font-headline-large: 600 var(--td-font-size-headline-large) / var(--td-line-height-headline-large) var(--td-font-family);
--td-font-display-medium: 600 var(--td-font-size-display-medium) / var(--td-line-height-display-medium) var(--td-font-family);
--td-font-display-large: 600 var(--td-font-size-display-large) / var(--td-line-height-display-large) var(--td-font-family);
--td-radius-small: 0px !important;
--td-radius-default: 0px!important;
--td-radius-medium: 0px !important;;
--td-radius-large: 0px !important;;
--td-radius-extraLarge: 0px !important;;
--td-radius-round: 999px !important;;
--td-radius-circle: 50% !important;;
--td-size-1: 2px;
--td-size-2: 4px;
--td-size-3: 6px;
--td-size-4: 8px;
--td-size-5: 12px;
--td-size-6: 16px;
--td-size-7: 20px;
--td-size-8: 24px;
--td-size-9: 28px;
--td-size-10: 32px;
--td-size-11: 36px;
--td-size-12: 40px;
--td-size-13: 48px;
--td-size-14: 56px;
--td-size-15: 64px;
--td-size-16: 72px;
--td-comp-size-xxxs: var(--td-size-6);
--td-comp-size-xxs: var(--td-size-7);
--td-comp-size-xs: var(--td-size-8);
--td-comp-size-s: var(--td-size-9);
--td-comp-size-m: var(--td-size-10);
--td-comp-size-l: var(--td-size-11);
--td-comp-size-xl: var(--td-size-12);
--td-comp-size-xxl: var(--td-size-13);
--td-comp-size-xxxl: var(--td-size-14);
--td-comp-size-xxxxl: var(--td-size-15);
--td-comp-size-xxxxxl: var(--td-size-16);
--td-pop-padding-s: var(--td-size-2);
--td-pop-padding-m: var(--td-size-3);
--td-pop-padding-l: var(--td-size-4);
--td-pop-padding-xl: var(--td-size-5);
--td-pop-padding-xxl: var(--td-size-6);
--td-comp-paddingLR-xxs: var(--td-size-1);
--td-comp-paddingLR-xs: var(--td-size-2);
--td-comp-paddingLR-s: var(--td-size-4);
--td-comp-paddingLR-m: var(--td-size-5);
--td-comp-paddingLR-l: var(--td-size-6);
--td-comp-paddingLR-xl: var(--td-size-8);
--td-comp-paddingLR-xxl: var(--td-size-10);
--td-comp-paddingTB-xxs: var(--td-size-1);
--td-comp-paddingTB-xs: var(--td-size-2);
--td-comp-paddingTB-s: var(--td-size-4);
--td-comp-paddingTB-m: var(--td-size-5);
--td-comp-paddingTB-l: var(--td-size-6);
--td-comp-paddingTB-xl: var(--td-size-8);
--td-comp-paddingTB-xxl: var(--td-size-10);
--td-comp-margin-xxs: var(--td-size-1);
--td-comp-margin-xs: var(--td-size-2);
--td-comp-margin-s: var(--td-size-4);
--td-comp-margin-m: var(--td-size-5);
--td-comp-margin-l: var(--td-size-6);
--td-comp-margin-xl: var(--td-size-7);
--td-comp-margin-xxl: var(--td-size-8);
--td-comp-margin-xxxl: var(--td-size-10);
--td-comp-margin-xxxxl: var(--td-size-12);
}
+153
View File
@@ -0,0 +1,153 @@
import {
ATTACHMENT_ID_ATTR,
TEMP_FILE_ID_ATTR,
isLocalMediaId,
normalizeContent
} from "timi-web/editor";
import type { ArticleContentType, Attachment } from "./types";
/** 文章内容中附件和临时文件的标识前缀。 */
export const ATTACHMENT_ID_PREFIX = "attachment-id:";
export const TEMP_FILE_ID_PREFIX = "temp-file-id:";
const DEFAULT_ARTICLE_CONTENT_TYPE: ArticleContentType = "TIP_TAP";
const ARTICLE_CONTENT_TYPE_LIST: ArticleContentType[] = ["TEXT", "MARKDOWN", "TIP_TAP", "TIPTAP"];
/** 将未知正文类型归一化为组件支持的类型。 */
export function normalizeArticleContentType(contentType?: string): ArticleContentType {
if (!ARTICLE_CONTENT_TYPE_LIST.includes(contentType as ArticleContentType)) {
return DEFAULT_ARTICLE_CONTENT_TYPE;
}
return contentType === "TIPTAP" ? "TIP_TAP" : contentType as ArticleContentType;
}
/** 将空正文转换为空字符串。 */
export function normalizeRawArticleContent(content?: string): string {
return content || "";
}
/** 从媒体地址中读取附件 ID。 */
export function getAttachmentIdFromSrc(src?: string): string {
if (!src?.startsWith(ATTACHMENT_ID_PREFIX)) {
return "";
}
return src.slice(ATTACHMENT_ID_PREFIX.length);
}
/** 从媒体地址中读取临时文件 ID。 */
export function getTempFileIdFromSrc(src?: string): string {
if (!src?.startsWith(TEMP_FILE_ID_PREFIX)) {
return "";
}
return src.slice(TEMP_FILE_ID_PREFIX.length);
}
/** 构造带有附件 ID 的内部媒体地址。 */
function buildAttachmentIdSrc(attachmentId: string): string {
return `${ATTACHMENT_ID_PREFIX}${attachmentId}`;
}
/** 仅为本地预览地址保留预览元数据。 */
function buildAttachmentMetadata(src: string): Record<string, string> | undefined {
if (!src.startsWith("blob:")) {
return undefined;
}
return {
previewUrl: src
};
}
/** 过滤前端本地占位 ID。 */
function normalizeAttachmentTempFileId(tempFileId?: string): string | undefined {
if (!tempFileId || isLocalMediaId(tempFileId)) {
return undefined;
}
return tempFileId;
}
/** 从正文中提取媒体附件,返回组件需要的最小对象。 */
export function buildAttachmentListFromContent(content?: string, sourceList?: readonly Attachment[]): Attachment[] {
const rawContent = normalizeContent(content);
if (!rawContent) {
return [];
}
const parser = new DOMParser();
const doc = parser.parseFromString(rawContent, "text/html");
const sourceMap = new Map<string, Attachment>();
for (const item of sourceList || []) {
if (item.id) {
sourceMap.set(item.id, item);
}
if (item.tempFileId) {
sourceMap.set(item.tempFileId, item);
}
}
const attachmentMap = new Map<string, Attachment>();
for (const media of Array.from(doc.querySelectorAll("img, video"))) {
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;
}
const sourceAttachment = sourceMap.get(key);
const previewUrl = buildAttachmentMetadata(src);
attachmentMap.set(key, {
...(sourceAttachment || {}),
id: attachmentId || sourceAttachment?.id || key,
tempFileId: normalizeAttachmentTempFileId(tempFileId) || normalizeAttachmentTempFileId(sourceAttachment?.tempFileId),
name: media.getAttribute("title") || media.getAttribute("alt") || sourceAttachment?.name || "",
metadata: previewUrl || sourceAttachment?.metadata
});
}
return Array.from(attachmentMap.values());
}
/** 将媒体地址转换为组件统一的附件标识地址。 */
export function normalizeContentForStore(content?: string): string {
const rawContent = normalizeContent(content);
if (!rawContent) {
return "";
}
const parser = new DOMParser();
const doc = parser.parseFromString(rawContent, "text/html");
for (const media of Array.from(doc.querySelectorAll("img, video"))) {
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?: readonly Attachment[], right?: readonly Attachment[]): boolean {
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 || leftItem.id !== rightItem.id) {
return false;
}
if ((leftItem.tempFileId || "") !== (rightItem.tempFileId || "")) {
return false;
}
if ((leftItem.accessKey?.value || "") !== (rightItem.accessKey?.value || "")) {
return false;
}
}
return true;
}
+18
View File
@@ -0,0 +1,18 @@
import view from "./index.vue";
import { WebToolkit } from "timi-web";
import type { Article } from "./types";
export type {
Article,
ArticleContentType,
Attachment,
UploadResult
} from "./types";
export type ArticleEditorExpose = {
submit: () => Promise<Article>;
getPendingMediaCount: () => number;
};
export const ArticleEditor = WebToolkit.withInstall(view);
export default ArticleEditor;
+274
View File
@@ -0,0 +1,274 @@
<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, UploadResult } from "./types";
import { MarkdownEditor } from "timi-web";
import { TiptapEditor } from "../tiptap-editor";
import type { TiptapEditorExpose } from "../tiptap-editor";
import {
buildAttachmentListFromContent,
getAttachmentIdFromSrc,
getTempFileIdFromSrc,
normalizeArticleContentType,
normalizeContentForStore,
normalizeRawArticleContent,
sameAttachmentList
} from "./content";
import {
ATTACHMENT_ID_ATTR,
TEMP_FILE_ID_ATTR,
normalizeContent
} from "timi-web/editor";
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 handleUploadImage(file: File) {
if (!props.uploadImage) {
throw new Error("请为 ArticleEditor 配置 uploadImage");
}
return await props.uploadImage(file);
}
async function handleUploadVideo(file: File) {
if (!props.uploadVideo) {
throw new Error("请为 ArticleEditor 配置 uploadVideo");
}
return await props.uploadVideo(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 = typeof attachment?.metadata?.previewUrl === "string"
? attachment.metadata.previewUrl
: "";
if (previewUrl) {
return previewUrl;
}
return attachment?.url || "";
}
async function resolveTempPreviewUrl(tempFileId: string) {
if (props.resolveTempUrl) {
return await props.resolveTempUrl(tempFileId);
}
return "";
}
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);
const previewUrl = await resolveTempPreviewUrl(tempFileId);
if (previewUrl) {
media.setAttribute("src", previewUrl);
}
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, props.modelValue?.attachmentList);
}
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), 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>
+26
View File
@@ -0,0 +1,26 @@
import type { UploadResult as EditorUploadResult } from "timi-web/editor";
/** 文章编辑器支持的正文类型。 */
export type ArticleContentType = "TEXT" | "MARKDOWN" | "TIP_TAP" | "TIPTAP";
/** 文章编辑器需要的附件最小结构,允许业务对象携带其他字段。 */
export type Attachment = {
[key: string]: any;
id?: string;
tempFileId?: string;
name?: string;
url?: string;
metadata?: Record<string, any>;
accessKey?: any;
};
/** 文章编辑器需要的文章最小结构,业务对象的其他字段会原样保留。 */
export type Article = {
[key: string]: any;
contentType?: any;
content?: string;
attachmentList?: Attachment[];
};
/** 媒体上传结果。 */
export type UploadResult = EditorUploadResult;
+5
View File
@@ -0,0 +1,5 @@
import view from "./index.vue";
import { WebToolkit } from "timi-web";
export const ArticleView = WebToolkit.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 "../article-editor/types";
import { MarkdownView } from "timi-web";
import { TiptapView } from "../tiptap-view";
import {
getAttachmentIdFromSrc,
normalizeArticleContentType,
normalizeRawArticleContent
} from "../article-editor/content";
import { ATTACHMENT_ID_ATTR, normalizeContent } from "timi-web/editor";
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 = typeof attachment?.metadata?.previewUrl === "string"
? attachment.metadata.previewUrl
: "";
if (previewUrl) {
return previewUrl;
}
return attachment?.url || "";
}
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>
+2 -2
View File
@@ -1,5 +1,5 @@
import view from "./index.vue"; import view from "./index.vue";
import { Toolkit } from "timi-web"; import { WebToolkit } from "timi-web";
export const AuthorizeForm = Toolkit.withInstall(view); export const AuthorizeForm = WebToolkit.withInstall(view);
export default AuthorizeForm; export default AuthorizeForm;
+19 -2
View File
@@ -5,8 +5,18 @@
<t-tab-panel :value="Tab.REGISTER" label="注册" /> <t-tab-panel :value="Tab.REGISTER" label="注册" />
</t-tabs> </t-tabs>
<div class="content"> <div class="content">
<login v-if="tabValue == Tab.LOGIN" @login-success="onLoginSuccess" /> <login
<register v-if="tabValue == Tab.REGISTER" @register-success="onRegisterSuccess" /> v-if="tabValue == Tab.LOGIN"
:captcha="props.captcha"
:login="props.login"
@login-success="onLoginSuccess"
/>
<register
v-if="tabValue == Tab.REGISTER"
:captcha="props.captcha"
:register="props.register"
@register-success="onRegisterSuccess"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -15,6 +25,7 @@
import Login from "./login.vue"; import Login from "./login.vue";
import Register from "./register.vue"; import Register from "./register.vue";
import { Emits, useHandler } from "~/components/authorize-form/emits"; import { Emits, useHandler } from "~/components/authorize-form/emits";
import type { CaptchaRequest, LoginHandler, RegisterHandler } from "./types";
import { ref } from "vue"; import { ref } from "vue";
defineOptions({ defineOptions({
@@ -29,6 +40,12 @@ enum Tab {
const emits = defineEmits<Emits>(); const emits = defineEmits<Emits>();
const { onLoginSuccess, onRegisterSuccess } = useHandler(emits); const { onLoginSuccess, onRegisterSuccess } = useHandler(emits);
const props = withDefaults(defineProps<{
captcha?: CaptchaRequest;
login?: LoginHandler;
register?: RegisterHandler;
}>(), {});
const tabValue = ref(Tab.LOGIN); const tabValue = ref(Tab.LOGIN);
</script> </script>
+31 -10
View File
@@ -6,10 +6,10 @@
<t-form-item label="密码" name="password"> <t-form-item label="密码" name="password">
<t-input type="password" v-model="loginRequest.data.password" placeholder="" /> <t-input type="password" v-model="loginRequest.data.password" placeholder="" />
</t-form-item> </t-form-item>
<t-form-item label="验证码" name="captcha"> <t-form-item v-if="captcha" label="验证码" name="captcha">
<div class="captcha-box"> <div class="captcha-box">
<t-input class="captcha" v-model="loginRequest.captcha" placeholder="" /> <t-input class="captcha" v-model="loginRequest.captcha" placeholder="" />
<captcha :api="CommonAPI.getCaptchaAPI()" :width="90" :height="28" :from="CaptchaFrom.LOGIN" /> <captcha :request="captcha" :width="90" :height="28" @update="onCaptchaUpdate" />
</div> </div>
</t-form-item> </t-form-item>
<div class="exec"> <div class="exec">
@@ -24,10 +24,22 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Captcha, CaptchaData, CaptchaFrom, CommonAPI, LoginRequest, UserAPI, userStore } from "timi-web"; import { Captcha } from "timi-web";
import { Emits, useHandler } from "~/components/authorize-form/emits"; import { Emits, useHandler } from "~/components/authorize-form/emits";
import type {
CaptchaData,
CaptchaRequest,
CaptchaResult,
LoginHandler,
LoginRequest
} from "./types";
import { MessagePlugin } from "tdesign-vue-next"; import { MessagePlugin } from "tdesign-vue-next";
const props = defineProps<{
captcha?: CaptchaRequest;
login?: LoginHandler;
}>();
const rules = { const rules = {
user: [ user: [
{ {
@@ -55,7 +67,7 @@ const { onLoginSuccess } = useHandler(emits);
const doSubmitting = ref<boolean>(false); const doSubmitting = ref<boolean>(false);
const loginRequest = reactive<CaptchaData<LoginRequest>>({ const loginRequest = reactive<CaptchaData<LoginRequest>>({
from: CaptchaFrom.LOGIN, captchaId: "",
captcha: "", captcha: "",
data: { data: {
user: "", user: "",
@@ -63,16 +75,25 @@ const loginRequest = reactive<CaptchaData<LoginRequest>>({
} }
}); });
function onCaptchaUpdate(result: CaptchaResult) {
loginRequest.captchaId = result.id;
}
const doSubmit = async () => { const doSubmit = async () => {
const login = props.login;
if (!login) {
MessagePlugin.error("未配置登录处理函数", 5E3);
return;
}
doSubmitting.value = true; doSubmitting.value = true;
UserAPI.login(loginRequest).then(async (response) => { try {
await userStore.updateToken(response); await login(loginRequest);
await onLoginSuccess(); await onLoginSuccess();
} catch (error) {
MessagePlugin.error(String(error), 5E3);
} finally {
doSubmitting.value = false; doSubmitting.value = false;
}).catch(msg => { }
MessagePlugin.error(msg, 5E3);
doSubmitting.value = false;
});
}; };
</script> </script>
+32 -11
View File
@@ -12,10 +12,10 @@
<t-form-item label="邮箱" name="email"> <t-form-item label="邮箱" name="email">
<t-input type="text" v-model="formData.data.email" placeholder="选填" /> <t-input type="text" v-model="formData.data.email" placeholder="选填" />
</t-form-item> </t-form-item>
<t-form-item label="验证码" name="captcha"> <t-form-item v-if="captcha" label="验证码" name="captcha">
<div class="captcha-box"> <div class="captcha-box">
<t-input class="captcha" v-model="formData.captcha" placeholder="" /> <t-input class="captcha" v-model="formData.captcha" placeholder="" />
<captcha :api="CommonAPI.getCaptchaAPI()" :width="90" :height="28" :from="CaptchaFrom.REGISTER" /> <captcha :request="captcha" :width="90" :height="28" @update="onCaptchaUpdate" />
</div> </div>
</t-form-item> </t-form-item>
<div class="exec"> <div class="exec">
@@ -30,9 +30,21 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Captcha, CaptchaData, CaptchaFrom, CommonAPI, RegisterRequest, UserAPI, userStore } from "timi-web"; import { Captcha } from "timi-web";
import { FormRules, MessagePlugin, SubmitContext } from "tdesign-vue-next"; import { FormRules, MessagePlugin, SubmitContext } from "tdesign-vue-next";
import { Emits, useHandler } from "~/components/authorize-form/emits"; import { Emits, useHandler } from "~/components/authorize-form/emits";
import type {
CaptchaData,
CaptchaRequest,
CaptchaResult,
RegisterHandler,
RegisterRequest
} from "./types";
const props = defineProps<{
captcha?: CaptchaRequest;
register?: RegisterHandler;
}>();
type RegisterForm = { type RegisterForm = {
rePassword: string; rePassword: string;
@@ -44,7 +56,7 @@ const { onRegisterSuccess } = useHandler(emits);
const doSubmitting = ref<boolean>(false); const doSubmitting = ref<boolean>(false);
const formData = reactive<CaptchaData<RegisterForm>>({ const formData = reactive<CaptchaData<RegisterForm>>({
from: CaptchaFrom.LOGIN, captchaId: "",
captcha: "", captcha: "",
data: { data: {
name: "", name: "",
@@ -54,6 +66,10 @@ const formData = reactive<CaptchaData<RegisterForm>>({
} }
}); });
function onCaptchaUpdate(result: CaptchaResult) {
formData.captchaId = result.id;
}
const rules = { const rules = {
name: [ name: [
{ {
@@ -94,20 +110,25 @@ const rules = {
] ]
} as FormRules; } as FormRules;
const doSubmit = ({validateResult, e}: SubmitContext) => { const doSubmit = async ({validateResult, e}: SubmitContext) => {
if (e) { if (e) {
e.preventDefault(); e.preventDefault();
} }
if (validateResult === true) { if (validateResult === true) {
const register = props.register;
if (!register) {
MessagePlugin.error("未配置注册处理函数", 5E3);
return;
}
doSubmitting.value = true; doSubmitting.value = true;
UserAPI.register({...formData}).then(async (response) => { try {
await userStore.updateToken(response); await register(formData);
await onRegisterSuccess(); await onRegisterSuccess();
} catch (error) {
MessagePlugin.error(String(error), 5E3);
} finally {
doSubmitting.value = false; doSubmitting.value = false;
}).catch(msg => { }
MessagePlugin.error(msg, 5E3);
doSubmitting.value = false;
});
} }
}; };
</script> </script>
+34
View File
@@ -0,0 +1,34 @@
/** 带验证码的通用请求对象。 */
export type CaptchaData<T> = {
captchaId: string;
captcha: string;
data: T;
};
/** 登录表单数据。 */
export type LoginRequest = {
user: string;
password: string;
};
/** 注册表单数据。 */
export type RegisterRequest = {
name: string;
password: string;
email?: string;
};
/** 验证码展示数据。 */
export type CaptchaResult = {
id: string;
data: string;
};
/** 验证码请求函数。 */
export type CaptchaRequest = (width: number, height: number) => Promise<CaptchaResult>;
/** 登录处理函数。 */
export type LoginHandler = (request: CaptchaData<LoginRequest>) => Promise<unknown>;
/** 注册处理函数。 */
export type RegisterHandler = (request: CaptchaData<RegisterRequest>) => Promise<unknown>;
+25
View File
@@ -0,0 +1,25 @@
import view from "./index.vue";
import { WebToolkit } from "timi-web";
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 = WebToolkit.withInstall(view);
export default CodeEditor;
+434
View File
@@ -0,0 +1,434 @@
<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 EditorSearchBar from "../editor-search/index.vue";
import {
findTextMatches,
replaceAllText,
replaceMatchText,
type TextSearchMatch,
type TextSearchOptions
} from "timi-web/editor";
import { highlightCode, resolveCodeEditorKeydown, resolvePrismLanguage } from "timi-web/editor";
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(() => {
return highlightCode(currentValue.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
});
</script>
+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%);
}
}
}
-17
View File
@@ -1,17 +0,0 @@
import { Comment } from "timi-web";
/**
* emits
*/
export interface Emits {
(event: "submit"): Promise<void>;
}
export const useHandler = (emit: Emits) => {
const onSubmit = async (comment: Comment) => await emit("submit");
return {
onSubmit,
};
};
-5
View File
@@ -1,5 +0,0 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const CommentForm = Toolkit.withInstall(view);
export default CommentForm;
-114
View File
@@ -1,114 +0,0 @@
<template>
<t-form class="tui-comment-form">
<t-form-item label="昵称" name="nick">
<t-input
class="nick"
v-model="formData.data.nick"
:disabled="userStore.isLogged()"
placeholder="昵称"
/>
</t-form-item>
<t-form-item label="评论" name="content">
<markdown-editor v-model:data="formData.data.content" />
</t-form-item>
<t-form-item label="验证码" name="captcha">
<t-input class="captcha" v-model="formData.captcha" placeholder="" />
<captcha
ref="captchaRef"
class="captcha-img"
:api="CommonAPI.getCaptchaAPI()"
:width="90"
:height="28"
:from="CaptchaFrom.COMMENT"
/>
<t-button
type="submit"
:loading="doSubmitting"
:disabled="doSubmitting"
@click="doSubmit"
>提交</t-button>
</t-form-item>
</t-form>
</template>
<script lang="ts" setup>
import {
Captcha,
CaptchaData,
CaptchaFrom,
Comment,
CommentAPI,
CommentBizType,
CommonAPI,
MarkdownEditor,
userStore
} from "timi-web";
import { Emits, useHandler } from "~/components/comment/form/emits";
defineOptions({
name: "CommentForm"
});
const emits = defineEmits<Emits>();
const { onSubmit } = useHandler(emits);
const props = withDefaults(defineProps<{
bizType: CommentBizType,
bizId: number,
}>(), {});
const { bizType, bizId } = toRefs(props);
const captchaRef = ref<{ update: () => void}>();
const formData = reactive<CaptchaData<Comment>>({
from: CaptchaFrom.COMMENT,
captcha: "",
data: {
bizType: bizType.value,
bizId: bizId.value,
nick: "",
content: ""
}
});
const doSubmitting = ref(false);
async function doSubmit() {
doSubmitting.value = true;
const loginUser = userStore.loginUser;
if (userStore.isLogged() && loginUser.user) {
formData.data.nick = loginUser.user.name;
formData.data.userId = loginUser.user.id;
}
await CommentAPI.create(formData);
formData.captcha = "";
formData.data.content = "";
captchaRef.value?.update();
doSubmitting.value = false;
await onSubmit(formData.data);
}
// 登录用户
const updateNick = () => formData.data.nick = userStore.loginUser?.user?.name || "";
watch(userStore.loginUser, updateNick);
onMounted(updateNick);
</script>
<style lang="less" scoped>
.tui-comment-form {
padding: 1rem;
.nick {
width: 12rem;
}
.captcha {
width: 6rem;
}
.captcha-img {
margin: 0 1rem;
}
}
</style>
@@ -1,20 +0,0 @@
import { CommentReply } from "timi-web";
/**
* emits
*/
export interface Emits {
(event: "cancel"): Promise<void>;
(event: "submit", reply: CommentReply): Promise<void>;
}
export const useHandler = (emit: Emits) => {
const onCancel = async () => await emit("cancel");
const onSubmit = async (reply: CommentReply) => await emit("submit", reply);
return {
onCancel,
onSubmit
};
};
@@ -1,5 +0,0 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const CommentReplyForm = Toolkit.withInstall(view);
export default CommentReplyForm;
-168
View File
@@ -1,168 +0,0 @@
<template>
<t-form class="tui-comment-reply-form">
<t-form-item label="昵称" name="nick">
<t-input
ref="nickRef"
class="nick"
v-model="formData.data.senderNick"
placeholder="昵称"
:disabled="userStore.isLogged()"
/>
<span v-if="replyToNick" class="gray selectable clip-text" v-text="`&nbsp;回复 ${replyToNick}`"></span>
</t-form-item>
<t-form-item label="评论" name="content">
<markdown-editor ref="contentRef" v-model:data="formData.data.content" />
</t-form-item>
<t-form-item label="验证码" name="captcha">
<div class="captcha-box">
<div>
<t-input class="captcha" v-model="formData.captcha" placeholder="" />
<captcha
ref="captchaRef"
class="captcha-img"
:api="CommonAPI.getCaptchaAPI()"
:width="90"
:height="28"
:from="CaptchaFrom.COMMENT_REPLY"
/>
</div>
<div>
<t-button
class="submit"
type="submit"
:loading="doSubmitting"
:disabled="doSubmitting"
@click="doSubmit"
>提交</t-button>
<t-button theme="default" @click="onCancel">取消</t-button>
</div>
</div>
</t-form-item>
</t-form>
</template>
<script lang="ts" setup>
import {
Captcha,
CaptchaData,
CaptchaFrom,
CommentAPI,
CommentReply,
CommonAPI,
MarkdownEditor,
Scroller,
userStore
} from "timi-web";
import { Emits, useHandler } from "~/components/comment/form/reply/emits";
defineOptions({
name: "CommentReplyForm"
});
const emits = defineEmits<Emits>();
const { onCancel, onSubmit } = useHandler(emits);
const props = withDefaults(defineProps<{
commentId: number,
replyId?: number,
replyToNick?: string,
}>(), {});
const { commentId, replyId, replyToNick } = toRefs(props);
const doSubmitting = ref(false);
const nickRef = ref();
const contentRef = ref();
const formData = reactive<CaptchaData<CommentReply>>({
from: CaptchaFrom.COMMENT_REPLY,
captcha: "",
data: {
commentId: commentId.value,
replyId: replyId.value,
senderNick: "",
content: ""
}
});
/** 提交回复 */
async function doSubmit() {
doSubmitting.value = true;
formData.data.commentId = commentId.value;
formData.data.replyId = replyId.value;
const loginUser = userStore.loginUser;
if (userStore.isLogged() && loginUser.user) {
formData.data.senderNick = loginUser.user.name;
formData.data.senderId = loginUser.user.id;
}
await CommentAPI.createReply(formData);
formData.captcha = "";
await onSubmit(formData.data);
doSubmitting.value = false;
}
async function focus() {
await nextTick();
let focusEl: HTMLElement;
if (userStore.isLogged()) {
focusEl = nickRef.value[0].textArea;
} else {
focusEl = contentRef.value[0].inputRef;
}
focusEl.focus();
Scroller.toElement(focusEl);
}
// 登录用户
const updateNick = () => formData.data.senderNick = userStore.loginUser?.user?.name || "";
watch(userStore.loginUser, updateNick);
onMounted(updateNick);
defineExpose({
focus
});
</script>
<style lang="less" scoped>
.tui-comment-reply-form {
padding: 1rem 1rem 1rem 0;
.nick {
width: 12rem;
}
.captcha-box {
display: flex;
flex-wrap: wrap;
div {
display: flex;
}
.captcha {
width: 6rem;
}
.captcha-img {
margin: 0 1rem;
}
.submit {
margin-right: 1rem;
}
}
}
@media screen and (max-width: 650px) {
.tui-comment-reply-form {
.captcha-box {
> div:first-child {
margin-bottom: 1rem;
}
}
}
}
</style>
-5
View File
@@ -1,5 +0,0 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const Comment = Toolkit.withInstall(view);
export default Comment;
-153
View File
@@ -1,153 +0,0 @@
<template>
<div
v-if="canComment || (pageResult && 0 < pageResult.total)"
class="tui-comment"
:class="{ 'can-comment': canComment }"
>
<span ref="titleAnchorRef"></span>
<h4
class="title cur-pointer"
@click="Scroller.toElement(titleAnchorRef!)"
>评论</h4>
<comment-form
ref="commentFormRef"
v-if="canComment"
:bizType="bizType" :bizId="bizId"
@submit="onSubmit"
/>
<t-pagination
v-if="pageResult && 16 < pageResult.total"
class="pages top"
:total="pageResult.total"
:pageSize="16"
:showPageSize="false"
v-model:current="pageCurrent"
:onCurrentChange="doFetch"
>
<template #total-content>
<div class="total" v-text="`共 ${pageResult.total} 条评论`"></div>
</template>
</t-pagination>
<comment-list class="list" v-if="pageResult" :items="pageResult.list" />
<t-pagination
v-if="pageResult && 16 < pageResult.total"
class="pages bottom"
:total="pageResult.total"
:pageSize="16"
:showPageSize="false"
v-model:current="pageCurrent"
:onCurrentChange="doFetch"
>
<template #total-content>
<div class="total" v-text="`共 ${pageResult.total} 条评论`"></div>
</template>
</t-pagination>
</div>
</template>
<script lang="ts" setup>
import {
CommentAPI,
CommentBizType,
CommentPage,
CommentReplyBizType,
CommentView,
PageResult,
Scroller
} from "timi-web";
import { CommentForm, CommentList } from "~/components";
// 评论总成
defineOptions({
name: "Comment"
});
const props = withDefaults(defineProps<{
bizType: CommentBizType,
bizId: number,
canComment?: boolean,
titleStickyOffset?: number,
}>(), {
canComment: true,
titleStickyOffset: 0
});
const { bizType, bizId, canComment } = toRefs(props);
// 数据列表
const page = reactive<CommentPage>({
bizType: bizType.value,
bizId: bizId.value,
index: 0,
size: 16
});
// 当前页
const pageCurrent = ref(1);
const pageResult = ref<PageResult<CommentView>>();
async function doFetch() {
page.index = pageCurrent.value - 1;
pageResult.value = await CommentAPI.page(page);
for (let i = 0; i < pageResult.value.list.length; i++) {
const item = pageResult.value.list[i];
item.repliesCurrent = 1;
item.repliesPage = {
bizId: item.id!,
bizType: CommentReplyBizType.COMMENT,
index: 0,
size: 6
}
}
}
onMounted(doFetch);
const titleAnchorRef = ref<HTMLSpanElement>();
/** 提交评论 */
async function onSubmit() {
page.index = 0;
await doFetch();
}
</script>
<style lang="less" scoped>
.tui-comment {
.title {
top: v-bind("titleStickyOffset + 'px'");
margin: 0;
padding: .5rem 1rem;
z-index: 2;
position: sticky;
background: #E4EFFA;
box-shadow: 0 -1px 0 var(--tui-light-gray);
border-bottom: var(--tui-border);
}
&.can-comment {
.title {
margin: 1rem 0;
}
.list {
border-top: 2px solid #CDDEF0;
}
}
.pages {
padding: 4px 4px 4px 1rem;
&.top {
border-top: 1px solid #E4EFFA;
}
&.bottom {
border-bottom: 1px solid #E4EFFA;
}
.total {
flex: 1;
}
}
}
</style>
-11
View File
@@ -1,11 +0,0 @@
/**
* emits
*/
export interface Emits {
}
export const useHandler = (emit: Emits) => {
return {
};
};
-5
View File
@@ -1,5 +0,0 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const CommentList = Toolkit.withInstall(view);
export default CommentList;
-241
View File
@@ -1,241 +0,0 @@
<template>
<div v-if="items" class="tui-comment-list">
<div class="comment" v-for="comment in items" :key="comment.id">
<!-- 主评论 -->
<div class="user">
<!-- 主评论头像 -->
<template v-if="comment.user">
<a
:href="`/user/space/${comment.userId}`"
target="_blank"
v-popup:config="popupUserStore.config"
@mouseenter="popupUserStore.user.value = comment.user"
>
<img
class="avatar"
:class="(<any>ImageType)[comment.user.profile.avatarType]"
:src="UserAPI.getAvatarURL(comment.user.profile)"
alt="主评论头像"
/>
</a>
<a
class="name pink selectable"
:href="`/user/space/${comment.userId}`"
v-text="comment.user.name"
v-popup:config="popupUserStore.config"
@mouseenter="popupUserStore.user.value = comment.user"
></a>
</template>
<!-- 游客的 -->
<template v-else>
<img class="avatar ir-pixelated" :src="defAvatarURL" alt="头像" />
<div class="name gray selectable clip-text" v-text="comment.nick"></div>
</template>
</div>
<div class="content">
<!-- 主评论内容 -->
<markdown-view class="value" :content="comment.content" />
<div class="reply-to">
<a class="button" href="javascript:" @click="replyTo(comment)">回复</a>
<span
class="light-gray"
v-text="Time.toPassedDate(comment.createdAt)"
v-popup:text="Time.toDateTime(comment.createdAt)"
></span>
</div>
<!-- 子评论 -->
<comment-reply-list :comment="comment" @reply="replyTo" />
<t-pagination
class="pages"
v-if="6 < comment.repliesLength"
:total="comment.repliesLength"
:pageSize="6"
:showPageSize="false"
v-model:current="comment.repliesCurrent"
@change="() => doFetchReply(comment)"
>
<template #total-content>
<div class="total" v-text="`共 ${comment.repliesLength} 条回复`"></div>
</template>
</t-pagination>
<!-- 回复表单,此表单跟随(页面上)激活的回复对象 -->
<comment-reply-form
v-if="canReply && comment.id && activeReply === comment.id"
:commentId="comment.id"
:replyId="replyId"
:replyToNick="replyToNick"
@submit="onSubmitReply"
@cancel="activeReply = -1"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {
CommentAPI,
CommentReply,
CommentReplyView,
CommentView,
CommonAPI,
ImageType,
MarkdownView,
SettingKey,
SettingMapper,
Time,
UserAPI
} from "timi-web";
import { CommentReplyForm, CommentReplyList } from "~/components";
import { popupUserStore } from "timi-tdesign-pc";
defineOptions({
name: "CommentList"
});
const props = withDefaults(defineProps<{
items: CommentView[],
canReply?: boolean,
}>(), {
canReply: true
});
const { items, canReply } = toRefs(props);
// 默认头像
const defAvatarURL = ref();
onMounted(async () => {
const res = JSON.parse(SettingMapper.getValue(SettingKey.PUBLIC_RESOURCES) as string);
defAvatarURL.value = CommonAPI.getAttachmentReadAPI(res.user.avatar);
});
// 激活的回复评论下标
const activeReply = ref();
const replyId = ref();
// 被回复昵称
const replyToNick = ref();
/**
* 获取回复
*
* @param comment 所属评论
*/
async function doFetchReply(comment: CommentView) {
// 组件下标以 1 开始,分页参数以 0 开始
comment.repliesPage.index = comment.repliesCurrent - 1;
const result = await CommentAPI.pageReply(comment.repliesPage);
comment.replies.length = 0;
comment.replies.push(...result.list);
}
/**
* 触发回复表单(表单会跟随评论)
*
* @param comment 跟随的评论
* @param replyTo 被回复对象
*/
async function replyTo(comment: CommentView, replyTo?: CommentReplyView) {
activeReply.value = comment.id;
if (replyTo) {
// 被回复对象
replyToNick.value = replyTo.sender?.name || replyTo.senderNick;
replyId.value = replyTo.id;
} else {
replyToNick.value = comment.user?.name || comment.nick;
}
}
function onSubmitReply(reply: CommentReply) {
activeReply.value = -1;
const comment = items.value.find(comment => comment.id === reply.commentId)!;
comment.repliesLength++;
comment.repliesCurrent = Math.ceil(comment.repliesLength / 6); // 上取整最后一页
doFetchReply(comment);
}
</script>
<style lang="less" scoped>
.tui-comment-list {
font-size: 13px;
line-height: 1.5;
.comment {
display: flex;
line-height: 1.5;
border-bottom: 2px solid #CDDEF0;
.user {
width: 120px;
padding: 16px 0;
text-align: center;
background: #E4EFFA;
transition: .5s var(--tui-bezier);
.avatar {
width: 64px;
border: 1px solid #FBC7D4;
margin: 0 auto 10px auto;
padding: 3px;
background: #FFF;
transition: .5s var(--tui-bezier);
}
}
.content {
width: calc(100% - 120px);
transition: .5s var(--tui-bezier);
.value {
width: calc(100% - 2rem);
padding: .5rem;
min-height: 92px;
}
.pages {
padding: 4px 4px 4px 1rem;
border-top: 1px solid #E4EFFA;
.total {
flex: 1;
}
}
.reply-to {
display: flex;
padding-right: .5rem;
justify-content: end;
> a {
margin-right: .5rem;
}
}
}
}
}
@media screen and (max-width: 650px) {
.tui-comment-list {
.comment {
.user {
width: 60px;
padding: 8px 0;
transition: .5s var(--tui-bezier);
.avatar {
width: 32px;
margin: 0 auto 4px auto;
padding: 2px;
transition: .5s var(--tui-bezier);
}
}
.content {
width: calc(100% - 60px);
transition: .5s var(--tui-bezier);
}
}
}
}
</style>
@@ -1,17 +0,0 @@
import { CommentReplyView, CommentView } from "timi-web";
/**
* emits
*/
export interface Emits {
(event: "reply", comment: CommentView, replyTo?: CommentReplyView): Promise<void>;
}
export const useHandler = (emit: Emits) => {
const onReply = async (comment: CommentView, replyTo?: CommentReplyView) => await emit("reply", comment, replyTo);
return {
onReply,
};
};
@@ -1,5 +0,0 @@
import view from "./index.vue";
import { Toolkit } from "timi-web";
export const CommentReplyList = Toolkit.withInstall(view);
export default CommentReplyList;
-179
View File
@@ -1,179 +0,0 @@
<template>
<div class="tui-comment-reply-list">
<div v-if="comment && comment.replies" class="reply" v-for="reply in comment.replies" :key="reply.id">
<div class="header">
<!-- 发送者头像 -->
<div class="sender-avatar">
<a
v-if="reply.sender"
:href="`/user/space/${reply.senderId}`"
v-popup:config="popupUserStore.config"
@mouseenter="popupUserStore.user.value = reply.sender"
>
<img
:class="(<any>ImageType)[reply.sender.profile.avatarType]"
:src="UserAPI.getAvatarURL(reply.sender.profile)"
alt="回复发送者头像"
/>
</a>
<img v-else class="ir-pixelated" :src="defAvatarURL" alt="回复发送者头像" />
</div>
<!-- 被回复用户 -->
<a
v-if="reply.sender"
class="pink bold selectable"
:href="`/user/space/${reply.senderId}`"
target="_blank"
v-text="reply.sender.name"
v-popup:config="popupUserStore.config"
@mouseenter="popupUserStore.user.value = reply.sender"
></a>
<span
v-else
class="gray selectable"
v-text="reply.senderNick"
></span>
<!-- 非回复层主时才显示被回复用户 -->
<template v-if="!!reply.replyId">
<span>&nbsp;回复&nbsp;</span>
<!-- 被回复用户 -->
<a
v-if="reply.receiver"
class="pink bold selectable"
:href="`/user/space/${reply.receiverId}`"
target="_blank"
v-text="reply.receiver.name"
v-popup:config="popupUserStore.config"
@mouseenter="popupUserStore.user.value = reply.receiver"
></a>
<span
v-else
class="gray selectable"
v-text="reply.receiverNick"
></span>
</template>
</div>
<!-- 回复内容 -->
<markdown-view class="content" :content="reply.content" />
<!-- 其他回复信息 -->
<div class="reply-to">
<a href="javascript:" @click="onReply(comment, reply)">回复</a>
<span
class="light-gray"
v-text="Time.toPassedDate(reply.createdAt)"
v-popup:text="Time.toDateTime(reply.createdAt)"
></span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {
CommentView,
CommonAPI,
ImageType,
MarkdownView,
PublicResources,
SettingKey,
SettingMapper,
Time,
UserAPI
} from "timi-web";
import { Emits, useHandler } from "~/components/comment/list/reply/emits";
import { popupUserStore } from "~/store/popupUser";
defineOptions({
name: "CommentReplyList"
});
const props = withDefaults(defineProps<{
comment: CommentView,
}>(), {});
const { comment } = toRefs(props);
const emits = defineEmits<Emits>();
const { onReply } = useHandler(emits);
// 默认头像
const defAvatarURL = ref();
onMounted(async () => {
const res = JSON.parse(SettingMapper.getValue(SettingKey.PUBLIC_RESOURCES) as string) as PublicResources;
defAvatarURL.value = CommonAPI.getAttachmentReadAPI(res.user.avatar);
});
</script>
<style lang="less" scoped>
.tui-comment-reply-list {
.reply {
padding: 6px;
&:nth-child(n + 2) {
border-top: 1px #d7d7d7 dotted;
}
&:first-child {
border-top: 2px solid #E4EFFA;
}
.header {
display: flex;
align-items: center;
.sender-avatar {
border: 1px solid #FBC7D4;
padding: 1px;
margin-right: .5rem;
img {
width: 32px;
height: 32px;
transition: .5s var(--tui-bezier);
}
}
}
.content {
width: 100%;
margin-top: .5rem;
}
.reply-to {
display: flex;
padding-right: 0;
justify-content: end;
> a {
margin-right: .5rem;
}
}
}
}
@media screen and (max-width: 650px) {
.tui-comment-reply-list {
.reply {
padding: 3px;
transition: .5s var(--tui-bezier);
.header {
.sender-avatar {
img {
width: 16px;
height: 16px;
transition: .5s var(--tui-bezier);
}
}
}
}
}
}
</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>
+83 -17
View File
@@ -1,34 +1,100 @@
/** 导出所有组件 */ /** 导出所有组件 */
import AuthorizeForm from "~/components/authorize-form"; import AuthorizeForm from "~/components/authorize-form";
import RootLayout from "~/components/root-layout"; import RootLayout from "~/components/root-layout";
import Comment from "~/components/comment";
import CommentForm from "~/components/comment/form";
import CommentList from "~/components/comment/list";
import CommentReplyList from "~/components/comment/list/reply";
import CommentReplyForm from "~/components/comment/form/reply";
import UserProfile from "~/components/user-profile"; import UserProfile from "~/components/user-profile";
import UserProfilePopup from "~/components/user-profile/popup"; import UserProfilePopup from "~/components/user-profile/popup";
import UserLevel from "~/components/user-level";
import CodeEditor from "~/components/code-editor";
import TiptapEditor from "~/components/tiptap-editor";
import TiptapView from "~/components/tiptap-view";
import ArticleEditor from "~/components/article-editor";
import ArticleView from "~/components/article-view";
import NotifyEditor from "~/components/notify-editor";
import NotifyView from "~/components/notify-view";
export default [ export default [
RootLayout, RootLayout,
AuthorizeForm, AuthorizeForm,
Comment,
CommentForm,
CommentReplyForm,
CommentList,
CommentReplyList,
UserProfile, UserProfile,
UserProfilePopup UserProfilePopup,
UserLevel,
CodeEditor,
TiptapEditor,
TiptapView,
ArticleEditor,
ArticleView,
NotifyEditor,
NotifyView
]; ];
export { export {
RootLayout, RootLayout,
AuthorizeForm, AuthorizeForm,
Comment,
CommentForm,
CommentReplyForm,
CommentList,
CommentReplyList,
UserProfile, UserProfile,
UserProfilePopup UserProfilePopup,
UserLevel,
CodeEditor,
TiptapEditor,
TiptapView,
ArticleEditor,
ArticleView,
NotifyEditor,
NotifyView
}; };
export type {
CodeEditorExpose
} from "./code-editor";
export type {
TiptapEditorExpose
} from "./tiptap-editor";
export type {
ArticleEditorExpose
} from "./article-editor";
export type {
NotifyEditorExpose
} from "./notify-editor";
export type {
Article,
ArticleContentType,
Attachment,
UploadResult
} from "./article-editor";
export type {
NotifyContentType,
NotifyDetail
} from "./notify-editor";
export type {
CaptchaData,
CaptchaRequest,
CaptchaResult,
LoginHandler,
LoginRequest,
RegisterHandler,
RegisterRequest
} from "./authorize-form/types";
export {
ImageType
} from "./user-profile";
export type {
ImageTypeKey,
UserImageResolver,
UserProfileData,
UserView
} from "./user-profile";
export {
toUserLevel
} from "./user-level";
export type {
UserLevelInfo
} from "./user-level";
+7
View File
@@ -0,0 +1,7 @@
import { MessagePlugin } from "tdesign-vue-next";
type MessageType = "info" | "success" | "warning" | "error";
export async function showPlatformMessage(type: MessageType, content: string) {
await MessagePlugin[type](content);
}
+6
View File
@@ -0,0 +1,6 @@
import type { NotifyContentType } from "./types";
/** 将未知通知正文类型归一化为组件支持的类型。 */
export function normalizeNotifyContentType(contentType?: string): NotifyContentType {
return contentType === "TEXT" ? "TEXT" : "TIP_TAP";
}
+13
View File
@@ -0,0 +1,13 @@
import view from "./index.vue";
import { WebToolkit } from "timi-web";
import type { NotifyDetail } from "./types";
export type { NotifyContentType, NotifyDetail } from "./types";
export type NotifyEditorExpose = {
submit: () => Promise<NotifyDetail>;
getPendingMediaCount: () => number;
};
export const NotifyEditor = WebToolkit.withInstall(view);
export default NotifyEditor;
+77
View File
@@ -0,0 +1,77 @@
<template>
<article-editor
ref="articleEditorRef"
:model-value="articleValue"
:placeholder="placeholder"
:upload-image="uploadImage"
:upload-video="uploadVideo"
:resolve-attachment-url="resolveAttachmentUrl"
:resolve-temp-url="resolveTempUrl"
@change="onArticleChange"
@update:model-value="onArticleCommit"
/>
</template>
<script lang="ts" setup>
import type { Article, ArticleContentType, Attachment, UploadResult } from "../article-editor/types";
import type { NotifyDetail } from "./types";
import type { ArticleEditorExpose } from "../article-editor";
import { ArticleEditor } from "../article-editor";
import { normalizeNotifyContentType } from "./content";
defineOptions({
name: "NotifyEditor"
});
const props = withDefaults(defineProps<{
modelValue?: NotifyDetail;
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: NotifyDetail];
"update:modelValue": [value: NotifyDetail];
}>();
const articleEditorRef = ref<ArticleEditorExpose>();
const articleValue = computed<Article>(() => ({
contentType: normalizeNotifyContentType(props.modelValue?.contentType) as ArticleContentType,
content: props.modelValue?.content || "",
attachmentList: props.modelValue?.attachmentList
}));
function buildNotifyValue(value: Article): NotifyDetail {
return {
...(props.modelValue || {}),
contentType: normalizeNotifyContentType(value.contentType),
content: value.content || "",
attachmentList: value.attachmentList || []
};
}
function onArticleChange(value: Article) {
emit("change", buildNotifyValue(value));
}
function onArticleCommit(value: Article) {
emit("update:modelValue", buildNotifyValue(value));
}
async function submit() {
const value = await articleEditorRef.value?.submit();
return buildNotifyValue(value || articleValue.value);
}
defineExpose({
submit,
getPendingMediaCount: () => articleEditorRef.value?.getPendingMediaCount() || 0
});
</script>
+15
View File
@@ -0,0 +1,15 @@
import type { Article, Attachment } from "../article-editor/types";
/** 通知内容支持的正文类型。 */
export type NotifyContentType = "TEXT" | "TIP_TAP" | "TIPTAP";
/** 通知编辑器需要的通知最小结构。 */
export type NotifyDetail = {
[key: string]: any;
content?: string;
contentType?: string;
attachmentList?: Attachment[];
};
/** 通知内部复用文章编辑器的对象结构。 */
export type NotifyArticle = Article;
+5
View File
@@ -0,0 +1,5 @@
import view from "./index.vue";
import { WebToolkit } from "timi-web";
export const NotifyView = WebToolkit.withInstall(view);
export default NotifyView;
+33
View File
@@ -0,0 +1,33 @@
<template>
<article-view
:article="articleValue"
:empty-text="emptyText"
:resolve-attachment-url="resolveAttachmentUrl"
/>
</template>
<script lang="ts" setup>
import type { Article, ArticleContentType, Attachment } from "../article-editor/types";
import type { NotifyDetail } from "../notify-editor/types";
import { ArticleView } from "../article-view";
import { normalizeNotifyContentType } from "../notify-editor/content";
defineOptions({
name: "NotifyView"
});
const props = withDefaults(defineProps<{
detail?: NotifyDetail;
emptyText?: string;
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
}>(), {
detail: () => ({}),
emptyText: "暂无通知内容"
});
const articleValue = computed<Article>(() => ({
contentType: normalizeNotifyContentType(props.detail?.contentType) as ArticleContentType,
content: props.detail?.content || "",
attachmentList: props.detail?.attachmentList
}));
</script>
+2 -2
View File
@@ -1,5 +1,5 @@
import view from "./index.vue"; import view from "./index.vue";
import { Toolkit } from "timi-web"; import { WebToolkit } from "timi-web";
export const RootLayout = Toolkit.withInstall(view); export const RootLayout = WebToolkit.withInstall(view);
export default RootLayout; export default RootLayout;
+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,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 "timi-web/editor";
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,112 @@
<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 { showPlatformMessage } from "../message";
import type { TiptapEditorInstance } from "timi-web/editor";
defineOptions({
name: "TiptapIframeButton"
});
const props = defineProps<{
editor?: TiptapEditorInstance;
active: boolean;
}>();
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 showPlatformMessage("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 "timi-web/editor";
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,182 @@
<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 { showPlatformMessage } from "../message";
import type { TiptapEditorInstance, UploadResult } from "timi-web/editor";
defineOptions({
name: "TiptapMediaButton"
});
const props = defineProps<{
editor?: TiptapEditorInstance;
active: boolean;
type: "image" | "video";
upload?: (file: File) => Promise<UploadResult>;
}>();
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 showPlatformMessage("warning", `${typeLabel.value}上传功能未启用`);
return;
}
if (!selectedFile.value) {
await showPlatformMessage("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 showPlatformMessage("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 "timi-web/editor";
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 "timi-web/editor";
import type { TiptapEditorInstance } from "timi-web/editor";
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,429 @@
<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, TiptapEditorInstance } from "timi-web/editor";
import { buildHeadingOptions } from "timi-web/editor";
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>
+19
View File
@@ -0,0 +1,19 @@
import view from "./index.vue";
import { WebToolkit } from "timi-web";
import type { TiptapEditorInstance } from "timi-web/editor";
export * from "timi-web/editor";
export type TiptapEditorExpose = {
submit: () => Promise<string>;
getPendingMediaCount: () => number;
openSearch: (showReplace: boolean) => void;
closeSearch: () => void;
findNext: () => boolean;
findPrevious: () => boolean;
replaceCurrent: () => boolean;
replaceAll: () => number;
editor?: TiptapEditorInstance;
};
export const TiptapEditor = WebToolkit.withInstall(view);
export default TiptapEditor;
+624
View File
@@ -0,0 +1,624 @@
<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 "timi-web/editor";
import { Toolkit } from "timi-web";
import type { PendingMediaItem, UploadMediaType, UploadResult, TiptapEditorInstance } from "timi-web/editor";
import TiptapToolbar from "./TiptapToolbar.vue";
import { useTiptapEditor } from "timi-web/editor";
import {
ATTACHMENT_ID_ATTR,
LOCAL_MEDIA_ID_PREFIX,
TEMP_FILE_ID_ATTR,
isLocalMediaId,
normalizeContent
} from "timi-web/editor";
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,
(event, value) => {
if (event === "update:paddingTB") {
emit("update:paddingTB", value);
return;
}
emit("update:paddingLR", value);
}
);
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>
+5
View File
@@ -0,0 +1,5 @@
import view from "./index.vue";
import { WebToolkit } from "timi-web";
export const TiptapView = WebToolkit.withInstall(view);
export default TiptapView;
+51
View File
@@ -0,0 +1,51 @@
<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 { normalizeContent, useTiptapView } from "timi-web/editor";
defineOptions({
name: "TiptapView"
});
const props = withDefaults(defineProps<{
content?: string;
emptyText?: string;
maxHeight?: string;
}>(), {
content: "",
emptyText: "暂无内容",
maxHeight: "400px"
});
const renderContent = computed(() => normalizeContent(props.content));
const { EditorContent, contentRef, editor } = useTiptapView(renderContent, toRef(props, "maxHeight"));
</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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 745 B

+42
View File
@@ -0,0 +1,42 @@
import view from "./index.vue";
import { WebToolkit } from "timi-web";
import type { UserLevelInfo } from "./types";
const LEVEL_EXP = [0, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096];
function toUserLevel(exp = 0): UserLevelInfo {
const currentExp = Math.max(0, exp);
let value = 0;
for (let index = 0; index < LEVEL_EXP.length; index++) {
if (LEVEL_EXP[index] > currentExp) {
break;
}
value = index;
}
const currentLevelExp = LEVEL_EXP[value];
const nextLevelExp = LEVEL_EXP[value + 1] || currentLevelExp;
const levelExp = nextLevelExp - currentLevelExp;
const percent = levelExp > 0
? Math.min(100, (currentExp - currentLevelExp) / levelExp * 100)
: 100;
return {
value,
percent,
nextLevelUp: Math.max(0, nextLevelExp - currentExp)
};
}
export const UserLevel = WebToolkit.withInstall(view);
export default UserLevel;
export {
toUserLevel
};
export type {
UserLevelInfo
} from "./types";
+25
View File
@@ -0,0 +1,25 @@
<template>
<div class="tui-user-level" />
</template>
<script lang="ts" setup>
defineOptions({
name: "UserLevel"
});
const props = withDefaults(defineProps<{
value: number;
}>(), {});
const { value } = toRefs(props);
const offset = computed(() => -9 * value.value + "px");
</script>
<style lang="less" scoped>
.tui-user-level {
width: 19px;
height: 9px;
background: url("./icon.png");
background-position-y: v-bind(offset);
}
</style>
+9
View File
@@ -0,0 +1,9 @@
type UserLevelInfo = {
value: number;
percent: number;
nextLevelUp: number;
};
export type {
UserLevelInfo
};
+11 -2
View File
@@ -1,5 +1,14 @@
import view from "./index.vue"; import view from "./index.vue";
import { Toolkit } from "timi-web"; import { WebToolkit } from "timi-web";
export const UserProfile = Toolkit.withInstall(view); export const UserProfile = WebToolkit.withInstall(view);
export default UserProfile; export default UserProfile;
export { ImageType } from "./types";
export type {
ImageTypeKey,
UserImageResolver,
UserProfile as UserProfileData,
UserView
} from "./types";
+45 -7
View File
@@ -2,23 +2,23 @@
<div class="tui-user-profile"> <div class="tui-user-profile">
<template v-if="user"> <template v-if="user">
<img <img
:class="(<any>ImageType)[user.profile.wrapperType]" :class="getImageClass(user.profile.wrapperType)"
:src="UserAPI.getWrapperURL(user.profile)" :src="wrapperUrl"
width="320" width="320"
alt="背景" alt="背景"
/> />
<img <img
class="avatar" class="avatar"
:class="(<any>ImageType)[user.profile.avatarType]" :class="getImageClass(user.profile.avatarType)"
width="64" width="64"
height="64" height="64"
:src="UserAPI.getAvatarURL(user.profile)" :src="avatarUrl"
alt="头像" alt="头像"
/> />
<div class="info"> <div class="info">
<div class="name"> <div class="name">
<h3 class="value pink" v-text="user.name"></h3> <h3 class="value pink" v-text="user.name"></h3>
<user-level :value="Toolkit.toUserLevel(user.profile.exp).value" /> <user-level :value="toUserLevel(user.profile.exp).value" />
</div> </div>
<template v-if="user.profile.sex"> <template v-if="user.profile.sex">
<icon <icon
@@ -43,17 +43,55 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Icon, ImageType, Toolkit, UserAPI, UserLevel, UserView } from "timi-web"; import { Icon } from "timi-web";
import { toUserLevel } from "../user-level";
import { ImageType } from "./types";
import type {
ImageTypeKey,
UserImageResolver,
UserView
} from "./types";
defineOptions({ defineOptions({
name: "UserProfile" name: "UserProfile"
}); });
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
user?: UserView, user?: UserView;
resolveAvatarUrl?: UserImageResolver;
resolveWrapperUrl?: UserImageResolver;
}>(), { }>(), {
}); });
const { user } = toRefs(props); const { user } = toRefs(props);
const wrapperUrl = ref("");
const avatarUrl = ref("");
async function updateImageUrls(value?: UserView) {
const profile = value?.profile;
wrapperUrl.value = profile?.wrapperUrl || "";
avatarUrl.value = profile?.avatarUrl || "";
if (!profile) {
return;
}
if (props.resolveWrapperUrl) {
wrapperUrl.value = await props.resolveWrapperUrl(profile) || wrapperUrl.value;
}
if (props.resolveAvatarUrl) {
avatarUrl.value = await props.resolveAvatarUrl(profile) || avatarUrl.value;
}
}
watch(() => props.user, async value => await updateImageUrls(value), {
deep: true,
immediate: true
});
function getImageClass(type?: string) {
return type && type in ImageType ? ImageType[type as ImageTypeKey] : "";
}
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
+2 -2
View File
@@ -1,5 +1,5 @@
import view from "./index.vue"; import view from "./index.vue";
import { Toolkit } from "timi-web"; import { WebToolkit } from "timi-web";
export const UserProfilePopup = Toolkit.withInstall(view); export const UserProfilePopup = WebToolkit.withInstall(view);
export default UserProfilePopup; export default UserProfilePopup;
+44
View File
@@ -0,0 +1,44 @@
import type { Attachment } from "../article-editor/types";
const ImageType = {
"IR-AUTO": "ir-auto",
"IR-SMOOTH": "ir-smooth",
"IR-PIXELATED": "ir-pixelated",
AUTO: "ir-auto",
SMOOTH: "ir-smooth",
PIXELATED: "ir-pixelated"
} as const;
type ImageTypeKey = keyof typeof ImageType;
type UserProfile = {
avatarType?: string;
description?: string;
exp?: number;
sex?: number;
wrapperType?: string;
avatarUrl?: string;
wrapperUrl?: string;
attachmentList?: Attachment[];
};
type UserView = {
id?: string;
name: string;
profile: UserProfile;
};
type UserImageResolver = (
profile: UserProfile
) => string | undefined | Promise<string | undefined>;
export {
ImageType
};
export type {
ImageTypeKey,
UserImageResolver,
UserProfile,
UserView
};
+4 -2
View File
@@ -1,6 +1,8 @@
import "./assets/style/tencent-cloud.less"; import "./assets/style/tdesign-custom.less";
import "./assets/style/tencent-cloud-custom.less";
import { popupUserStore } from "./store/popupUser"; import { popupUserStore } from "./store/popupUser";
import "./components/tiptap-content.less";
import "timi-web/style.css";
import "../../timi-web/src/components/prism-code-block.less";
export * from "./components"; export * from "./components";
+2 -1
View File
@@ -1,4 +1,5 @@
import { PopupConfig, PopupType, UserView } from "timi-web"; import { PopupConfig, PopupType } from "timi-web";
import type { UserView } from "~/components/user-profile/types";
const user = ref<UserView>(); const user = ref<UserView>();
+3
View File
@@ -23,6 +23,9 @@
"@/*": [ "@/*": [
"./examples/*" "./examples/*"
], ],
"timi-web/editor": [
"../timi-web/src/editor/index.ts"
],
"timi-tdesign-pc": [ "timi-tdesign-pc": [
"./src/index.ts" "./src/index.ts"
] ]
+8 -1
View File
@@ -20,6 +20,10 @@ const alias: Alias[] = [
find: "*", find: "*",
replacement: resolve("") replacement: resolve("")
}, },
{
find: /^timi-web\/editor$/,
replacement: resolve(__dirname, "../timi-web/src/editor/index.ts")
},
{ {
find: /^timi-tdesign-pc(\/(es|lib))?$/, find: /^timi-tdesign-pc(\/(es|lib))?$/,
replacement: resolve(__dirname, "./src/index.ts") replacement: resolve(__dirname, "./src/index.ts")
@@ -68,7 +72,10 @@ export default defineConfig({
include: [/\.vue$/, /\.md$/] include: [/\.vue$/, /\.md$/]
}), }),
VueSetupExtend(), VueSetupExtend(),
dts(), dts({
pathsToAliases: false,
aliasesExclude: [/^timi-web\/editor$/]
}),
AutoImport({ AutoImport({
imports: [ imports: [
"vue" "vue"