Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef71681bd5 | ||
|
|
f790122c01 | ||
|
|
d8fbbbe616 | ||
|
|
b004299763 |
+6
-4
@@ -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
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
@@ -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: []
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+69
-77
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.t-table {
|
||||||
|
|
||||||
|
.t-table.t-size-s td {
|
||||||
|
padding: var(--td-comp-paddingTB-s);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import { WebToolkit } from "timi-web";
|
||||||
|
|
||||||
|
export const ArticleView = WebToolkit.withInstall(view);
|
||||||
|
export default ArticleView;
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import view from "./index.vue";
|
|
||||||
import { Toolkit } from "timi-web";
|
|
||||||
|
|
||||||
export const CommentForm = Toolkit.withInstall(view);
|
|
||||||
export default CommentForm;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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="` 回复 ${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>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import view from "./index.vue";
|
|
||||||
import { Toolkit } from "timi-web";
|
|
||||||
|
|
||||||
export const Comment = Toolkit.withInstall(view);
|
|
||||||
export default Comment;
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* emits
|
|
||||||
*/
|
|
||||||
export interface Emits {
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useHandler = (emit: Emits) => {
|
|
||||||
|
|
||||||
return {
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import view from "./index.vue";
|
|
||||||
import { Toolkit } from "timi-web";
|
|
||||||
|
|
||||||
export const CommentList = Toolkit.withInstall(view);
|
|
||||||
export default CommentList;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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> 回复 </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>
|
|
||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { NotifyContentType } from "./types";
|
||||||
|
|
||||||
|
/** 将未知通知正文类型归一化为组件支持的类型。 */
|
||||||
|
export function normalizeNotifyContentType(contentType?: string): NotifyContentType {
|
||||||
|
return contentType === "TEXT" ? "TEXT" : "TIP_TAP";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import { WebToolkit } from "timi-web";
|
||||||
|
|
||||||
|
export const NotifyView = WebToolkit.withInstall(view);
|
||||||
|
export default NotifyView;
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import view from "./index.vue";
|
||||||
|
import { WebToolkit } from "timi-web";
|
||||||
|
|
||||||
|
export const TiptapView = WebToolkit.withInstall(view);
|
||||||
|
export default TiptapView;
|
||||||
@@ -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 |
@@ -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";
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
type UserLevelInfo = {
|
||||||
|
value: number;
|
||||||
|
percent: number;
|
||||||
|
nextLevelUp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type {
|
||||||
|
UserLevelInfo
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user