802 lines
20 KiB
Vue
802 lines
20 KiB
Vue
<template>
|
||
<div class="page-wrap">
|
||
<div class="container">
|
||
<!-- 顶部:左右对称 -->
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||
<!-- 左:标题说明 -->
|
||
<div class="panel h-full">
|
||
<h1 class="text-2xl font-semibold text-slate-900">
|
||
{{ isEditing ? '编辑资讯文章' : '发布资讯文章' }}
|
||
</h1>
|
||
<p class="text-sm text-slate-500 mt-2">
|
||
支持粘贴图片、丰富排版、表格与高亮。可直接粘贴截图或拖拽图片到编辑区。
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 右:当前用户 -->
|
||
<div class="panel h-full">
|
||
<div class="flex items-start justify-between">
|
||
<div>
|
||
<div class="text-sm text-slate-500">当前用户</div>
|
||
<div v-if="me" class="mt-1.5">
|
||
<div class="text-slate-900 font-medium leading-6 truncate">
|
||
昵称:{{ me.username }}
|
||
</div>
|
||
<div class="text-slate-500 text-sm leading-5 truncate">
|
||
邮箱:{{ me.email }}
|
||
</div>
|
||
</div>
|
||
<div v-else class="mt-1.5 text-slate-400 text-sm">读取中…</div>
|
||
</div>
|
||
<span
|
||
v-if="me"
|
||
class="inline-flex items-center gap-2 text-xs text-emerald-600"
|
||
>
|
||
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||
已登录
|
||
</span>
|
||
</div>
|
||
|
||
<div class="mt-4 grid grid-cols-2 gap-3 text-xs text-slate-500">
|
||
<div>自动保存:开启</div>
|
||
<div v-if="autoSavedAt" class="text-right">
|
||
最近:{{ fromNow(autoSavedAt) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 提示条 -->
|
||
<transition name="fade">
|
||
<div
|
||
v-if="tip"
|
||
class="rounded-md p-3 text-sm border mb-4"
|
||
:class="
|
||
tip.type === 'error'
|
||
? 'bg-rose-50 border-rose-200 text-rose-700'
|
||
: 'bg-emerald-50 border-emerald-200 text-emerald-700'
|
||
"
|
||
>
|
||
{{ tip.message }}
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- 表单卡片 -->
|
||
<div class="panel shadow-card">
|
||
<div class="grid gap-6">
|
||
<!-- 标题 -->
|
||
<div>
|
||
<label class="label">
|
||
标题 <span class="text-rose-500">*</span>
|
||
</label>
|
||
<div class="relative">
|
||
<input
|
||
v-model.trim="form.title"
|
||
type="text"
|
||
required
|
||
class="ui-input"
|
||
placeholder="例如:AI 在营销自动化中的 5 个落地案例"
|
||
maxlength="120"
|
||
/>
|
||
<span class="counter">{{ form.title?.length || 0 }}/120</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 摘要 -->
|
||
<div>
|
||
<div class="flex items-baseline justify-between">
|
||
<label class="label">
|
||
摘要 <span class="text-slate-400 text-xs">(选填)</span>
|
||
</label>
|
||
<span class="text-xs text-slate-400">
|
||
{{ form.description?.length || 0 }}/200
|
||
</span>
|
||
</div>
|
||
<textarea
|
||
v-model.trim="form.description"
|
||
rows="3"
|
||
maxlength="200"
|
||
class="ui-textarea"
|
||
placeholder="一句话或一小段话,概括文章要点…"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 封面图片(选填) -->
|
||
<div>
|
||
<label class="label">
|
||
封面图片 <span class="text-slate-400 text-xs">(选填)</span>
|
||
</label>
|
||
|
||
<div class="cover-upload">
|
||
<!-- 预览:本地 blob 优先,其次使用已保存 URL -->
|
||
<div v-if="form.coverPreview || form.cover" class="cover-preview">
|
||
<img
|
||
:src="form.coverPreview || form.cover"
|
||
alt="封面预览"
|
||
class="cover-img"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 选择封面(仅本地预览,不立刻上传) -->
|
||
<label class="btn btn-outline btn-xs">
|
||
选择封面
|
||
<input
|
||
class="cover-input"
|
||
type="file"
|
||
accept="image/*"
|
||
@change="onPickCover"
|
||
/>
|
||
</label>
|
||
|
||
<!-- 移除封面 -->
|
||
<button
|
||
v-if="form.coverPreview || form.cover"
|
||
type="button"
|
||
class="btn btn-ghost btn-xs"
|
||
@click="removeCover"
|
||
>
|
||
移除
|
||
</button>
|
||
</div>
|
||
|
||
<p class="mt-1 text-[10px] text-slate-400">
|
||
建议尺寸 800x400 或相似比例,体积 < 500KB。
|
||
封面文件仅在文章发布成功时上传保存。
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 正文 -->
|
||
<div>
|
||
<div class="flex items-center justify-between mb-2">
|
||
<label class="label">
|
||
正文 <span class="text-rose-500">*</span>
|
||
</label>
|
||
<div class="text-xs text-slate-400">字数:{{ wordCount }}</div>
|
||
</div>
|
||
|
||
<RichEditor v-model="form.body" />
|
||
|
||
<p class="mt-2 text-xs text-slate-500">
|
||
小技巧:截屏后直接
|
||
<kbd class="kbd">Ctrl</kbd>+<kbd class="kbd">V</kbd>
|
||
即可插入图片(正文内图片会即时上传)。
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 标签 -->
|
||
<div>
|
||
<label class="label mb-1">标签</label>
|
||
<div class="flex gap-2">
|
||
<input
|
||
v-model.trim="tagInput"
|
||
@keydown.enter.prevent="addTag()"
|
||
@keydown.,.prevent="addTag()"
|
||
class="ui-input flex-1"
|
||
placeholder="例如:AI, 营销, OpenAI"
|
||
/>
|
||
<button type="button" class="btn btn-outline" @click="addTag()">
|
||
添加
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="form.tagList.length" class="mt-3 flex flex-wrap gap-2">
|
||
<span v-for="(t, i) in form.tagList" :key="t + i" class="chip">
|
||
{{ t }}
|
||
<button type="button" class="chip-close" @click="removeTag(i)">
|
||
✕
|
||
</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作区 -->
|
||
<div class="actions">
|
||
<span v-if="autoSavedAt" class="text-xs text-emerald-600 mr-auto">
|
||
已自动保存 {{ fromNow(autoSavedAt) }}
|
||
</span>
|
||
|
||
<button
|
||
type="button"
|
||
class="btn btn-ghost"
|
||
@click="saveDraftManually"
|
||
>
|
||
手动保存草稿
|
||
</button>
|
||
<button type="button" class="btn btn-ghost" @click="clearDraft">
|
||
清空草稿
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
class="btn btn-primary"
|
||
:disabled="submitting"
|
||
@click="submit"
|
||
>
|
||
<svg v-if="submitting" class="spinner" viewBox="0 0 50 50">
|
||
<circle
|
||
class="path"
|
||
cx="25"
|
||
cy="25"
|
||
r="20"
|
||
fill="none"
|
||
stroke-width="5"
|
||
/>
|
||
</svg>
|
||
<span>
|
||
{{
|
||
submitting
|
||
? (isEditing ? '保存中…' : '发布中…')
|
||
: (isEditing ? '保存修改' : '发布文章')
|
||
}}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 额外说明 -->
|
||
<p class="text-xs text-slate-500 mt-3">
|
||
发布须知:请确保不含敏感信息;正文图片为即时上传,封面图片在发布时统一写入后端。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
|
||
import { navigateTo } from '#app'
|
||
import RichEditor from '@/components/RichEditor.vue'
|
||
import { useAuthToken, useApi } from '@/composables/useApi'
|
||
import { useUpload } from '@/composables/useUpload'
|
||
import { useImageExtractor } from '@/composables/useImageExtractor'
|
||
|
||
const route = useRoute()
|
||
const { token } = useAuthToken()
|
||
const api = useApi()
|
||
const { uploadImage } = useUpload()
|
||
const { extractFirstImage } = useImageExtractor()
|
||
const editSlug = computed(() =>
|
||
typeof route.query.slug === 'string' ? route.query.slug : null,
|
||
)
|
||
const isEditing = computed(() => Boolean(editSlug.value))
|
||
|
||
// 当前用户
|
||
const me = ref(null)
|
||
async function fetchMe() {
|
||
if (!token.value) return
|
||
try {
|
||
const resp = await api.get('/user')
|
||
me.value = resp?.user ?? null
|
||
} catch {
|
||
me.value = null
|
||
}
|
||
}
|
||
|
||
// 表单
|
||
const form = reactive({
|
||
title: '',
|
||
description: '',
|
||
body: '',
|
||
tagList: [],
|
||
cover: '', // 最终写入后端的 URL
|
||
coverPreview: '', // 本地预览 blob
|
||
})
|
||
const coverFile = ref(null)
|
||
|
||
const tagInput = ref('')
|
||
const submitting = ref(false)
|
||
const tip = ref(null)
|
||
const previewHtml = computed(() => form.body || '')
|
||
|
||
function resetForm() {
|
||
form.title = ''
|
||
form.description = ''
|
||
form.body = ''
|
||
form.tagList = []
|
||
form.cover = ''
|
||
if (form.coverPreview && form.coverPreview.startsWith('blob:')) {
|
||
URL.revokeObjectURL(form.coverPreview)
|
||
}
|
||
form.coverPreview = ''
|
||
coverFile.value = null
|
||
}
|
||
|
||
async function loadArticleForEdit(slug) {
|
||
try {
|
||
const res = await api.get(`/articles/${slug}`)
|
||
const data = res?.article
|
||
if (!data) return
|
||
form.title = data.title || ''
|
||
form.description = data.description || ''
|
||
form.body = data.body || ''
|
||
const tags = Array.isArray(data.tagList) ? data.tagList : data.tags
|
||
form.tagList = Array.isArray(tags) ? [...tags] : []
|
||
form.cover = data.cover || ''
|
||
form.coverPreview = ''
|
||
coverFile.value = null
|
||
autoSavedAt.value = Date.now()
|
||
} catch (error) {
|
||
console.error('[edit-article] load article failed:', error)
|
||
tip.value = { type: 'error', message: '加载文章详情失败' }
|
||
}
|
||
}
|
||
|
||
// 生命周期
|
||
function applyDraftData(draft = {}) {
|
||
form.title = draft.title || ''
|
||
form.description = draft.description || ''
|
||
form.body = draft.body || ''
|
||
form.tagList = Array.isArray(draft.tagList) ? [...draft.tagList] : []
|
||
form.cover = draft.cover || ''
|
||
if (form.coverPreview && form.coverPreview.startsWith('blob:')) {
|
||
URL.revokeObjectURL(form.coverPreview)
|
||
}
|
||
form.coverPreview = ''
|
||
coverFile.value = null
|
||
autoSavedAt.value = Date.now()
|
||
}
|
||
|
||
function readDraft() {
|
||
if (!process.client) return { loaded: false, hasContent: false, data: null }
|
||
const raw = localStorage.getItem(draftKey.value)
|
||
if (!raw) return { loaded: false, hasContent: false, data: null }
|
||
try {
|
||
const data = JSON.parse(raw) || {}
|
||
const hasContent = Boolean(
|
||
data.title ||
|
||
data.description ||
|
||
data.body ||
|
||
(Array.isArray(data.tagList) && data.tagList.length) ||
|
||
data.cover,
|
||
)
|
||
return { loaded: true, hasContent, data }
|
||
} catch {
|
||
return { loaded: false, hasContent: false, data: null }
|
||
}
|
||
}
|
||
|
||
async function initEditor(slug) {
|
||
resetForm()
|
||
if (slug) {
|
||
await loadArticleForEdit(slug)
|
||
} else {
|
||
const draftInfo = readDraft()
|
||
if (draftInfo.hasContent && draftInfo.data) {
|
||
applyDraftData(draftInfo.data)
|
||
}
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if (!token.value) {
|
||
navigateTo('/login')
|
||
return
|
||
}
|
||
await fetchMe()
|
||
await initEditor(editSlug.value)
|
||
})
|
||
|
||
onBeforeRouteUpdate(async (to, from, next) => {
|
||
const slug =
|
||
typeof to.query.slug === 'string'
|
||
? to.query.slug
|
||
: null
|
||
await initEditor(slug)
|
||
next()
|
||
})
|
||
|
||
// 正文字数
|
||
const wordCount = computed(
|
||
() => form.body.replace(/<[^>]+>/g, '').trim().length,
|
||
)
|
||
|
||
// 标签
|
||
function addTag() {
|
||
if (!tagInput.value) return
|
||
tagInput.value
|
||
.split(/[,,]/)
|
||
.map(s => s.trim())
|
||
.filter(Boolean)
|
||
.forEach((t) => {
|
||
if (!form.tagList.includes(t)) form.tagList.push(t)
|
||
})
|
||
tagInput.value = ''
|
||
}
|
||
function removeTag(i) {
|
||
form.tagList.splice(i, 1)
|
||
}
|
||
|
||
// 选择封面:仅更新本地预览 & 待上传文件
|
||
function onPickCover(e) {
|
||
const file = e.target.files?.[0]
|
||
e.target.value = ''
|
||
if (!file) return
|
||
|
||
if (form.coverPreview && form.coverPreview.startsWith('blob:')) {
|
||
URL.revokeObjectURL(form.coverPreview)
|
||
}
|
||
|
||
coverFile.value = file
|
||
form.coverPreview = URL.createObjectURL(file)
|
||
}
|
||
|
||
// 移除封面
|
||
function removeCover() {
|
||
if (form.coverPreview && form.coverPreview.startsWith('blob:')) {
|
||
URL.revokeObjectURL(form.coverPreview)
|
||
}
|
||
coverFile.value = null
|
||
form.coverPreview = ''
|
||
form.cover = ''
|
||
}
|
||
|
||
// 草稿
|
||
const draftKey = computed(() =>
|
||
editSlug.value ? `article:edit:${editSlug.value}` : 'article:new:draft:v2',
|
||
)
|
||
const autoSavedAt = ref(0)
|
||
|
||
function loadDraft() {
|
||
const info = readDraft()
|
||
if (!editSlug.value && info.hasContent && info.data) {
|
||
applyDraftData(info.data)
|
||
return { loaded: true, hasContent: true }
|
||
}
|
||
return info
|
||
}
|
||
|
||
function saveDraft() {
|
||
if (!process.client) return
|
||
const payload = {
|
||
title: form.title,
|
||
description: form.description,
|
||
body: form.body,
|
||
tagList: form.tagList,
|
||
cover: form.cover,
|
||
}
|
||
localStorage.setItem(draftKey.value, JSON.stringify(payload))
|
||
autoSavedAt.value = Date.now()
|
||
}
|
||
|
||
function saveDraftManually() {
|
||
saveDraft()
|
||
tip.value = { type: 'success', message: '草稿已保存到本地' }
|
||
setTimeout(() => { tip.value = null }, 1500)
|
||
}
|
||
|
||
function clearDraft() {
|
||
if (process.client) localStorage.removeItem(draftKey.value)
|
||
if (form.coverPreview && form.coverPreview.startsWith('blob:')) {
|
||
URL.revokeObjectURL(form.coverPreview)
|
||
}
|
||
form.title = ''
|
||
form.description = ''
|
||
form.body = ''
|
||
form.tagList = []
|
||
form.cover = ''
|
||
form.coverPreview = ''
|
||
coverFile.value = null
|
||
tip.value = { type: 'success', message: '草稿已清空' }
|
||
setTimeout(() => { tip.value = null }, 1500)
|
||
}
|
||
|
||
let t = null
|
||
watch(
|
||
() => ({
|
||
title: form.title,
|
||
description: form.description,
|
||
body: form.body,
|
||
tagList: form.tagList,
|
||
cover: form.cover,
|
||
}),
|
||
() => {
|
||
clearTimeout(t)
|
||
t = setTimeout(saveDraft, 500)
|
||
},
|
||
{ deep: true },
|
||
)
|
||
|
||
function fromNow(ts) {
|
||
const s = Math.max(1, Math.round((Date.now() - ts) / 1000))
|
||
if (s < 60) return `${s}s 前`
|
||
const m = Math.round(s / 60)
|
||
if (m < 60) return `${m} 分钟前`
|
||
return `${Math.round(m / 60)} 小时前`
|
||
}
|
||
|
||
// 提交
|
||
async function submit() {
|
||
if (!form.title?.trim() || !form.body?.trim()) {
|
||
tip.value = { type: 'error', message: '标题与正文为必填项' }
|
||
return
|
||
}
|
||
if (!token.value) {
|
||
tip.value = { type: 'error', message: '请先登录' }
|
||
navigateTo('/login')
|
||
return
|
||
}
|
||
|
||
submitting.value = true
|
||
tip.value = null
|
||
|
||
try {
|
||
// 1. 有新封面则先上传
|
||
let coverUrl = form.cover || ''
|
||
if (coverFile.value) {
|
||
const url = await uploadImage(coverFile.value)
|
||
coverUrl = url
|
||
form.cover = url // ✅ 回写,确保 payload / 草稿里都有
|
||
}
|
||
|
||
// 2. 如果没有封面,尝试从正文中提取第一张图片
|
||
if (!coverUrl && form.body) {
|
||
const firstImage = extractFirstImage(form.body)
|
||
if (firstImage) {
|
||
coverUrl = firstImage
|
||
console.log('📸 自动提取封面图片:', firstImage)
|
||
}
|
||
}
|
||
|
||
// 3. 创建文章
|
||
const payload = {
|
||
article: {
|
||
title: form.title.trim(),
|
||
description: form.description?.trim() || '',
|
||
body: form.body,
|
||
tagList: form.tagList,
|
||
cover: coverUrl || null,
|
||
},
|
||
}
|
||
|
||
let res
|
||
if (isEditing.value && editSlug.value) {
|
||
res = await api.put(`/articles/${editSlug.value}`, payload)
|
||
} else {
|
||
res = await api.post('/articles', payload)
|
||
}
|
||
|
||
clearDraft()
|
||
tip.value = {
|
||
type: 'success',
|
||
message: isEditing.value ? '修改已保存!' : '发布成功!',
|
||
}
|
||
|
||
const slug = res?.article?.slug || editSlug.value
|
||
setTimeout(() => {
|
||
navigateTo(slug ? `/articles/${slug}` : '/')
|
||
}, 600)
|
||
} catch (e) {
|
||
console.error('[new-article] submit error:', e)
|
||
tip.value = {
|
||
type: 'error',
|
||
message:
|
||
e?.statusMessage ||
|
||
e?.data?.detail ||
|
||
e?.data?.errors?.[0] ||
|
||
'发布失败,请稍后重试',
|
||
}
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 样式保持你的原版,这里不改动 */
|
||
.page-wrap {
|
||
margin: 100px 5%;
|
||
}
|
||
.container {
|
||
max-width: 1200px;
|
||
margin-left: auto;
|
||
margin-right: auto;
|
||
padding: 0 20px;
|
||
}
|
||
.panel {
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 16px;
|
||
padding: 16px 18px;
|
||
min-height: 120px;
|
||
}
|
||
.shadow-card {
|
||
box-shadow: 0 8px 28px rgba(10, 18, 33, 0.12);
|
||
overflow: hidden;
|
||
}
|
||
.actions {
|
||
position: sticky;
|
||
bottom: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding-top: 12px;
|
||
margin-top: 8px;
|
||
background: linear-gradient(
|
||
180deg,
|
||
rgba(255, 255, 255, 0) 0%,
|
||
#fff 24px
|
||
);
|
||
}
|
||
.label {
|
||
display: block;
|
||
font-size: 0.875rem;
|
||
line-height: 1.25rem;
|
||
font-weight: 500;
|
||
color: #334155;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
.ui-input,
|
||
.ui-textarea {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
box-sizing: border-box;
|
||
border-radius: 14px;
|
||
border: 1px solid #e5e7eb;
|
||
padding: 10px 14px;
|
||
outline: none;
|
||
background: #fff;
|
||
transition: box-shadow 0.15s ease, border-color 0.15s ease,
|
||
transform 0.02s ease;
|
||
box-shadow: 0 1px 0 rgba(2, 6, 23, 0.02) inset;
|
||
}
|
||
.ui-input::placeholder,
|
||
.ui-textarea::placeholder {
|
||
color: #9aa3b2;
|
||
}
|
||
.ui-input:hover,
|
||
.ui-textarea:hover {
|
||
border-color: #d2d6dc;
|
||
}
|
||
.ui-input:focus,
|
||
.ui-textarea:focus {
|
||
border-color: #6366f1;
|
||
box-shadow: inset 0 0 0 1px #6366f1;
|
||
}
|
||
.ui-textarea {
|
||
min-height: 110px;
|
||
resize: vertical;
|
||
}
|
||
.counter {
|
||
position: absolute;
|
||
right: 10px;
|
||
bottom: 8px;
|
||
font-size: 11px;
|
||
color: #98a2b3;
|
||
}
|
||
.cover-upload {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.cover-input {
|
||
display: none;
|
||
}
|
||
.cover-preview {
|
||
width: 110px;
|
||
height: 66px;
|
||
border-radius: 10px;
|
||
border: 1px solid #e5e7eb;
|
||
background: #f8fafc;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
}
|
||
.cover-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.btn-xs {
|
||
height: 30px;
|
||
padding: 0 10px;
|
||
font-size: 12px;
|
||
}
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
border-radius: 12px;
|
||
line-height: 1;
|
||
border: 1px solid transparent;
|
||
cursor: pointer;
|
||
transition: transform 0.02s ease, box-shadow 0.15s ease,
|
||
background 0.15s ease, border-color 0.15s ease;
|
||
}
|
||
.btn {
|
||
height: 40px;
|
||
padding: 0 14px;
|
||
font-size: 14px;
|
||
}
|
||
.btn:active {
|
||
transform: translateY(0.5px);
|
||
}
|
||
.btn:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
.btn-primary {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #4f46e5, #2563eb);
|
||
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.25);
|
||
}
|
||
.btn-primary:hover {
|
||
filter: brightness(1.03);
|
||
box-shadow: 0 8px 22px rgba(37, 99, 235, 0.28);
|
||
}
|
||
.btn-outline {
|
||
background: #fff;
|
||
border-color: #e5e7eb;
|
||
color: #334155;
|
||
}
|
||
.btn-outline:hover {
|
||
border-color: #cbd5e1;
|
||
box-shadow: 0 6px 16px rgba(2, 6, 23, 0.06);
|
||
}
|
||
.btn-ghost {
|
||
background: #fff;
|
||
border-color: #e5e7eb;
|
||
color: #475569;
|
||
}
|
||
.btn-ghost:hover {
|
||
background: #f8fafc;
|
||
}
|
||
.chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
padding: 6px 8px 6px 12px;
|
||
background: #f1f5f9;
|
||
color: #334155;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 999px;
|
||
}
|
||
.chip-close {
|
||
color: #64748b;
|
||
}
|
||
.chip-close:hover {
|
||
color: #ef4444;
|
||
}
|
||
.kbd {
|
||
padding: 0 0.4rem;
|
||
border: 1px solid #cbd5e1;
|
||
border-radius: 6px;
|
||
background: #f8fafc;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
}
|
||
.spinner {
|
||
width: 18px;
|
||
height: 18px;
|
||
}
|
||
.spinner .path {
|
||
stroke: #fff;
|
||
stroke-linecap: round;
|
||
animation: dash 1.2s ease-in-out infinite;
|
||
}
|
||
@keyframes dash {
|
||
0% {
|
||
stroke-dasharray: 1, 150;
|
||
stroke-dashoffset: 0;
|
||
}
|
||
50% {
|
||
stroke-dasharray: 90, 150;
|
||
stroke-dashoffset: -35;
|
||
}
|
||
100% {
|
||
stroke-dasharray: 90, 150;
|
||
stroke-dashoffset: -124;
|
||
}
|
||
}
|
||
.fade-enter-active,
|
||
.fade-leave-active {
|
||
transition: opacity 0.18s ease;
|
||
}
|
||
.fade-enter-from,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
}
|
||
</style>
|