Browse Source

feat(editor): replace textarea with Quill.js WYSIWYG editor

- Install quill + @types/quill dependencies
- Add QuillEditor.vue component with snow theme, toolbar, image upload
- Rebuild ArticleEditorView with left/right split layout (editor + settings panel)
- Remove old markdown paste handler, Quill output is HTML directly
main
Mohan 1 month ago
parent
commit
abeb140e43
  1. 99
      package-lock.json
  2. 3
      package.json
  3. 99
      src/components/QuillEditor.vue
  4. 318
      src/views/admin/ArticleEditorView.vue

99
package-lock.json

@ -10,11 +10,13 @@
"dependencies": { "dependencies": {
"axios": "^1.7.9", "axios": "^1.7.9",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"github-markdown-css": "^5.9.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"lxgw-wenkai-webfont": "^1.7.0", "lxgw-wenkai-webfont": "^1.7.0",
"marked": "^18.0.9", "marked": "^18.0.9",
"marked-highlight": "^2.2.4", "marked-highlight": "^2.2.4",
"pinia": "^2.3.0", "pinia": "^2.3.0",
"quill": "^2.0.3",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
@ -23,6 +25,7 @@
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/marked": "^5.0.2", "@types/marked": "^5.0.2",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/quill": "^2.0.14",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"playwright": "^1.62.1", "playwright": "^1.62.1",
"typescript": "~5.6.3", "typescript": "~5.6.3",
@ -1178,6 +1181,24 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/quill": {
"version": "2.0.14",
"resolved": "https://registry.npmmirror.com/@types/quill/-/quill-2.0.14.tgz",
"integrity": "sha512-zvoXCRnc2Dl8g+7/9VSAmRWPN6oH+MVhTPizmCR+GJCITplZ5VRVzMs4+a/nOE3yzNwEZqylJJrMB07bwbM1/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"parchment": "^1.1.2",
"quill-delta": "^5.1.0"
}
},
"node_modules/@types/quill/node_modules/parchment": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@ -2116,6 +2137,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"license": "Apache-2.0"
},
"node_modules/fast-safe-stringify": { "node_modules/fast-safe-stringify": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "resolved": "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@ -2308,6 +2341,18 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/github-markdown-css": {
"version": "5.9.0",
"resolved": "https://registry.npmmirror.com/github-markdown-css/-/github-markdown-css-5.9.0.tgz",
"integrity": "sha512-tmT5sY+zvg2302XLYEfH2mtkViIM1SWf2nvYoF5N1ZsO0V6B2qZTiw3GOzw4vpjLygK/KG35qRlPFweHqfzz5w==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glob": { "node_modules/glob": {
"version": "13.0.6", "version": "13.0.6",
"resolved": "https://registry.npmmirror.com/glob/-/glob-13.0.6.tgz", "resolved": "https://registry.npmmirror.com/glob/-/glob-13.0.6.tgz",
@ -2535,6 +2580,25 @@
"node": ">=13.2.0" "node": ">=13.2.0"
} }
}, },
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "11.5.2", "version": "11.5.2",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.2.tgz", "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.2.tgz",
@ -2771,6 +2835,12 @@
"quickjs-wasi": "^2.2.0" "quickjs-wasi": "^2.2.0"
} }
}, },
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
},
"node_modules/path-browserify": { "node_modules/path-browserify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@ -2998,6 +3068,35 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"license": "BSD-3-Clause",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
},
"engines": {
"npm": ">=8.2.3"
}
},
"node_modules/quill-delta": {
"version": "5.1.0",
"resolved": "https://registry.npmmirror.com/quill-delta/-/quill-delta-5.1.0.tgz",
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"license": "MIT",
"dependencies": {
"fast-diff": "^1.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/reflect-metadata": { "node_modules/reflect-metadata": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz",

3
package.json

@ -12,11 +12,13 @@
"dependencies": { "dependencies": {
"axios": "^1.7.9", "axios": "^1.7.9",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"github-markdown-css": "^5.9.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"lxgw-wenkai-webfont": "^1.7.0", "lxgw-wenkai-webfont": "^1.7.0",
"marked": "^18.0.9", "marked": "^18.0.9",
"marked-highlight": "^2.2.4", "marked-highlight": "^2.2.4",
"pinia": "^2.3.0", "pinia": "^2.3.0",
"quill": "^2.0.3",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
@ -25,6 +27,7 @@
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/marked": "^5.0.2", "@types/marked": "^5.0.2",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/quill": "^2.0.14",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"playwright": "^1.62.1", "playwright": "^1.62.1",
"typescript": "~5.6.3", "typescript": "~5.6.3",

99
src/components/QuillEditor.vue

@ -0,0 +1,99 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import Quill from 'quill'
import 'quill/dist/quill.snow.css'
import { mediaApi } from '@/api/client'
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const editorContainer = ref<HTMLDivElement>()
let quill: Quill | null = null
onMounted(() => {
if (!editorContainer.value) return
quill = new Quill(editorContainer.value, {
theme: 'snow',
placeholder: '开始写作...',
modules: {
toolbar: [
[{ header: [1, 2, false] }],
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ indent: '-1' }, { indent: '+1' }],
['link', 'image'],
['clean']
]
}
})
quill.root.innerHTML = props.modelValue
quill.on('text-change', () => {
emit('update:modelValue', quill!.root.innerHTML)
})
const toolbar = quill.getModule('toolbar') as any
toolbar.addHandler('image', () => {
const input = document.createElement('input')
input.setAttribute('type', 'file')
input.setAttribute('accept', 'image/*')
input.click()
input.onchange = async () => {
const file = input.files?.[0]
if (file) {
try {
const resp = await mediaApi.apiMediaUploadPost(file, 'images')
const stored = resp.data.data
const url = stored?.url
if (url && quill) {
const sel = quill.getSelection()
const index = sel ? sel.index : 0
quill.insertEmbed(index, 'image', url)
}
} catch (e) {
console.error(e)
}
}
}
})
})
watch(() => props.modelValue, (val) => {
if (quill && val !== quill.root.innerHTML) {
quill.root.innerHTML = val
}
})
</script>
<template>
<div ref="editorContainer" class="quill-editor" />
</template>
<style scoped>
.quill-editor {
min-height: 400px;
background: var(--ac-bg-card, #fff);
}
.quill-editor :deep(.ql-editor) {
min-height: 400px;
font-size: 16px;
line-height: 1.8;
}
.quill-editor :deep(.ql-toolbar) {
border-color: var(--ac-border, #d4c4b0);
background: var(--ac-surface, #faf8f5);
}
.quill-editor :deep(.ql-container) {
border-color: var(--ac-border, #d4c4b0);
}
.quill-editor :deep(.ql-editor.ql-blank::before) {
color: var(--ac-text-disabled);
}
</style>

318
src/views/admin/ArticleEditorView.vue

@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { articleAdminApi, articleApi, tagApi, mediaApi } from '@/api/client' import { articleAdminApi, articleApi, tagApi, mediaApi } from '@/api/client'
import type { ArticleDetail, Tag } from '@/generated/api' import type { ArticleDetail, Tag } from '@/generated/api'
import QuillEditor from '@/components/QuillEditor.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -19,45 +20,6 @@ const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const uploading = ref(false) const uploading = ref(false)
const pasting = ref(false)
async function uploadFile(file: File): Promise<string | null> {
try {
const resp = await mediaApi.apiMediaUploadPost(file, 'images')
const stored = resp.data.data
return stored?.url ?? null
} catch (e) {
console.error(e)
error.value = '图片上传失败'
return null
}
}
async function onPaste(event: ClipboardEvent) {
const items = event.clipboardData?.items
if (!items) return
const files: File[] = []
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) files.push(file)
}
}
if (files.length === 0) return
event.preventDefault()
pasting.value = true
error.value = null
try {
for (const file of files) {
const url = await uploadFile(file)
if (url) {
content.value += `\n![${file.name}](${url})\n`
}
}
} finally {
pasting.value = false
}
}
async function loadTags() { async function loadTags() {
try { try {
@ -128,6 +90,10 @@ async function onUpload(event: Event) {
} }
} }
async function removeCover() {
coverImage.value = ''
}
async function onSubmit() { async function onSubmit() {
if (!title.value.trim() || !content.value.trim()) { if (!title.value.trim() || !content.value.trim()) {
error.value = '标题和内容为必填项' error.value = '标题和内容为必填项'
@ -170,39 +136,56 @@ onMounted(() => {
</script> </script>
<template> <template>
<div>
<h1 class="page-title">{{ isEdit ? '编辑文章' : '新建文章' }}</h1>
<div v-if="loading" class="hint-text">加载中...</div>
<div v-else class="editor-wrapper">
<p v-if="error" class="error-text">{{ error }}</p> <p v-if="error" class="error-text">{{ error }}</p>
<p v-if="loading" class="hint-text">加载中...</p>
<form v-else class="editor-form" @submit.prevent="onSubmit">
<label class="field-label" for="title">标题</label>
<input id="title" v-model="title" class="form-input" type="text" maxlength="200" />
<div class="editor-layout">
<div class="editor-main">
<input
id="title"
v-model="title"
class="title-input"
type="text"
placeholder="文章标题"
maxlength="200"
/>
<QuillEditor v-model="content" />
</div>
<label class="field-label" for="summary">摘要</label>
<textarea id="summary" v-model="summary" class="form-textarea" rows="3" maxlength="500"></textarea>
<aside class="editor-sidebar">
<div class="side-section">
<h3 class="side-title">发布设置</h3>
<button class="btn-primary" type="button" :disabled="saving" @click="onSubmit">
{{ saving ? '保存中...' : isEdit ? '更新文章' : '发布文章' }}
</button>
</div>
<label class="field-label" for="content">内容</label>
<div class="side-section">
<h3 class="side-title">摘要</h3>
<textarea <textarea
id="content"
v-model="content"
class="form-textarea"
rows="16"
:placeholder="pasting ? '图片上传中...' : '支持粘贴图片自动上传为 Markdown 图片语法'"
@paste="onPaste"
></textarea>
<label class="field-label" for="cover">封面图 URL</label>
<div class="cover-row">
<input id="cover" v-model="coverImage" class="form-input" type="text" />
v-model="summary"
class="summary-input"
rows="4"
maxlength="500"
placeholder="文章摘要(可选)"
/>
</div>
<div class="side-section">
<h3 class="side-title">封面图</h3>
<div v-if="coverImage" class="cover-preview-wrap">
<img :src="coverImage" class="cover-preview" alt="封面" />
<button class="btn-remove" type="button" @click="removeCover">移除</button>
</div>
<label class="upload-btn"> <label class="upload-btn">
{{ uploading ? '上传中...' : '上传' }}
{{ uploading ? '上传中...' : '选择图片' }}
<input type="file" accept="image/*" class="upload-input" @change="onUpload" /> <input type="file" accept="image/*" class="upload-input" @change="onUpload" />
</label> </label>
</div> </div>
<img v-if="coverImage" :src="coverImage" class="cover-preview" alt="封面预览" />
<label class="field-label">标签</label>
<div class="side-section">
<h3 class="side-title">标签</h3>
<div class="tag-picker"> <div class="tag-picker">
<label v-for="tag in tags" :key="tag.id" class="tag-option"> <label v-for="tag in tags" :key="tag.id" class="tag-option">
<input type="checkbox" :checked="selectedTagIds.includes(tag.id)" @change="toggleTag(tag.id)" /> <input type="checkbox" :checked="selectedTagIds.includes(tag.id)" @change="toggleTag(tag.id)" />
@ -210,81 +193,161 @@ onMounted(() => {
</label> </label>
<span v-if="tags.length === 0" class="hint-text">暂无标签</span> <span v-if="tags.length === 0" class="hint-text">暂无标签</span>
</div> </div>
</div>
<div class="form-actions">
<button class="btn-primary" type="submit" :disabled="saving">
{{ saving ? '保存中...' : '保存' }}
</button>
<router-link to="/admin/articles" class="btn-plain">取消</router-link>
<div class="side-section">
<router-link to="/admin/articles" class="btn-cancel">取消</router-link>
</div>
</aside>
</div> </div>
</form>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page-title {
font-family: var(--song-font-display);
font-weight: 400;
margin: 0 0 16px;
.editor-wrapper {
max-width: 1200px;
}
.error-text {
color: var(--ac-danger);
margin-bottom: 12px;
}
.hint-text {
color: var(--ac-text-disabled);
}
.editor-layout {
display: flex;
gap: 24px;
align-items: flex-start;
}
.editor-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.title-input {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--ac-border);
border-radius: var(--song-radius-l);
font-family: 'Noto Serif SC', 'SimSun', 'STSong', serif;
font-size: 22px; font-size: 22px;
font-weight: 600;
background: var(--ac-bg-card);
color: var(--ac-text);
outline: none;
transition: border-color var(--song-dur-1) var(--song-ease);
} }
.editor-form {
.title-input:focus {
border-color: var(--ac-primary);
}
.title-input::placeholder {
color: var(--ac-text-disabled);
}
.editor-sidebar {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.side-section {
background: var(--ac-bg-card); background: var(--ac-bg-card);
border: 1px solid var(--ac-border); border: 1px solid var(--ac-border);
border-radius: var(--song-radius-l); border-radius: var(--song-radius-l);
padding: 20px;
padding: 16px;
} }
.field-label {
display: block;
margin: 14px 0 6px;
.side-title {
font-family: var(--song-font-serif);
font-size: 14px; font-size: 14px;
color: var(--ac-text-secondary);
font-weight: 600;
margin: 0 0 12px;
color: var(--ac-text);
} }
.form-input {
.btn-primary {
width: 100%; width: 100%;
padding: 10px 12px;
border: 1px solid var(--ac-input-border);
padding: 10px 22px;
border: none;
border-radius: var(--song-radius-l); border-radius: var(--song-radius-l);
background: var(--ac-primary-strong);
color: var(--ac-primary-text);
cursor: pointer;
font-size: 14px; font-size: 14px;
background: var(--ac-bg-card);
color: var(--ac-text);
transition: border-color var(--song-dur-1) var(--song-ease);
transition: opacity var(--song-dur-1) var(--song-ease);
} }
.form-input:focus,
.form-textarea:focus {
border-color: var(--ac-primary);
outline: none;
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
} }
.form-textarea {
.btn-primary:disabled {
opacity: 0.6;
}
.summary-input {
width: 100%; width: 100%;
padding: 10px 12px;
padding: 8px 12px;
border: 1px solid var(--ac-input-border); border: 1px solid var(--ac-input-border);
border-radius: var(--song-radius-l); border-radius: var(--song-radius-l);
font-size: 14px;
font-size: 13px;
line-height: 1.5;
resize: vertical; resize: vertical;
font-family: inherit; font-family: inherit;
background: var(--ac-bg-card); background: var(--ac-bg-card);
color: var(--ac-text); color: var(--ac-text);
outline: none;
} }
.cover-row {
display: flex;
gap: 10px;
.summary-input:focus {
border-color: var(--ac-primary);
}
.cover-preview-wrap {
margin-bottom: 8px;
}
.cover-preview {
width: 100%;
max-height: 120px;
object-fit: cover;
border-radius: var(--song-radius-m);
border: 1px solid var(--ac-border);
margin-bottom: 6px;
}
.btn-remove {
border: none;
background: none;
color: var(--ac-danger-ink);
font-size: 12px;
cursor: pointer;
padding: 0;
}
.btn-remove:hover {
text-decoration: underline;
} }
.upload-btn { .upload-btn {
flex-shrink: 0;
padding: 10px 16px;
display: inline-block;
padding: 8px 14px;
background: var(--ac-primary-bg); background: var(--ac-primary-bg);
color: var(--ac-primary-ink); color: var(--ac-primary-ink);
border-radius: var(--song-radius-l);
border-radius: var(--song-radius-m);
cursor: pointer; cursor: pointer;
font-size: 14px;
font-size: 13px;
transition: background var(--song-dur-1) var(--song-ease); transition: background var(--song-dur-1) var(--song-ease);
} }
@ -296,73 +359,34 @@ onMounted(() => {
display: none; display: none;
} }
.cover-preview {
max-width: 220px;
margin-top: 10px;
border-radius: var(--song-radius-l);
border: 1px solid var(--ac-border);
}
.tag-picker { .tag-picker {
display: flex; display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 4px 0;
flex-direction: column;
gap: 6px;
} }
.tag-option { .tag-option {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px;
font-size: 14px;
}
.form-actions {
margin-top: 20px;
display: flex;
gap: 12px;
align-items: center;
}
.btn-primary {
padding: 10px 22px;
border: none;
border-radius: var(--song-radius-l);
background: var(--ac-primary-strong);
color: var(--ac-primary-text);
gap: 6px;
font-size: 13px;
cursor: pointer; cursor: pointer;
font-size: 14px;
transition: opacity var(--song-dur-1) var(--song-ease);
} }
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.6;
}
.btn-plain {
padding: 9px 16px;
.btn-cancel {
display: block;
text-align: center;
padding: 8px;
border: 1px solid var(--ac-input-border); border: 1px solid var(--ac-input-border);
border-radius: var(--song-radius-l); border-radius: var(--song-radius-l);
color: var(--ac-text-secondary); color: var(--ac-text-secondary);
text-decoration: none; text-decoration: none;
font-size: 14px;
font-size: 13px;
transition: border-color var(--song-dur-1) var(--song-ease), color var(--song-dur-1) var(--song-ease); transition: border-color var(--song-dur-1) var(--song-ease), color var(--song-dur-1) var(--song-ease);
} }
.btn-plain:hover {
.btn-cancel:hover {
border-color: var(--ac-primary); border-color: var(--ac-primary);
color: var(--ac-primary-ink); color: var(--ac-primary-ink);
} }
.error-text {
color: var(--ac-danger);
}
.hint-text {
color: var(--ac-text-disabled);
}
</style> </style>
Loading…
Cancel
Save