使用 AI
代理技能
[!重要] 🧪 实验性质:VueUse 技能目前处于实验阶段,正在积极开发中,欢迎反馈。
VueUse Skills 是由 VueUse 团队维护的 AI 代理技能。
安装技能后,当你使用 AI 代理协助开发 Vue 或 Nuxt 应用时,它可以自动利用 VueUse 提供的丰富功能集。
这使得代理能够准确地使用 VueUse 函数,无需联网或额外权限。
安装
bash
npx skills add vueuse/skills使用
使用代理开发 Vue 应用
示例提示:
创建一个具有以下功能的待办事项应用:
- 将待办事项保存到本地存储
- 在浏览器标题上显示剩余待办数量
- 为每个待办事项添加复制按钮
- 待办列表支持无限滚动
- 支持暗黑 / 亮色模式输出代码片段
vue
<script setup lang="ts">
import {
useClipboard,
useColorMode,
useInfiniteScroll,
useLocalStorage,
useTitle,
} from '@vueuse/core'
import { computed, ref } from 'vue'
interface Todo {
id: number
text: string
done: boolean
}
const seedTexts = [
'Review project goals',
'Plan the next sprint',
'Reply to client email',
]
const defaultTodos = Array.from({ length: 36 }, (_, index) => ({
id: index + 1,
text:
seedTexts[index % seedTexts.length]
+ (index >= seedTexts.length ? ` #${index + 1}` : ''),
done: index % 7 === 0,
}))
const todos = useLocalStorage<Todo[]>('focus-flow-todos', defaultTodos)
const nextId = ref(
todos.value.reduce((max, todo) => Math.max(max, todo.id), 0) + 1,
)
const newTodo = ref('')
const totalCount = computed(() => todos.value.length)
const remainingCount = computed(() =>
todos.value.filter(todo => !todo.done).length,
)
const completedCount = computed(
() => totalCount.value - remainingCount.value,
)
useTitle(computed(() => `Todos (${remainingCount.value})`))
const mode = useColorMode({
attribute: 'data-theme',
disableTransition: false,
})
const isDark = computed(() => mode.value === 'dark')
function toggleMode() {
mode.value = isDark.value ? 'light' : 'dark'
}
const { copy, copied, isSupported } = useClipboard()
const lastCopiedId = ref<number | null>(null)
async function handleCopy(todo: Todo) {
await copy(todo.text)
lastCopiedId.value = todo.id
}
const pageSize = 8
const visibleCount = ref(Math.min(pageSize, todos.value.length))
const visibleTodos = computed(() => todos.value.slice(0, visibleCount.value))
const listRef = ref<HTMLElement | null>(null)
const { isLoading } = useInfiniteScroll(
listRef,
() => {
if (visibleCount.value < todos.value.length) {
visibleCount.value = Math.min(
visibleCount.value + pageSize,
todos.value.length,
)
}
},
{
distance: 120,
canLoadMore: () => visibleCount.value < todos.value.length,
},
)
function syncVisibleCount() {
if (todos.value.length <= pageSize) {
visibleCount.value = todos.value.length
return
}
if (visibleCount.value === 0) {
visibleCount.value = pageSize
return
}
if (visibleCount.value > todos.value.length) {
visibleCount.value = todos.value.length
}
}
function addTodo() {
const value = newTodo.value.trim()
if (!value)
return
todos.value.unshift({
id: nextId.value++,
text: value,
done: false,
})
newTodo.value = ''
syncVisibleCount()
}
function removeTodo(id: number) {
todos.value = todos.value.filter(todo => todo.id !== id)
syncVisibleCount()
}
</script>
<template>
<div class="page">
<div class="shell">
<header class="header">
<div>
<p class="eyebrow">
极简待办追踪器
</p>
<h1>专注流</h1>
<p class="subtitle">
保持轻量列表,点击即可复制任务,列表增长时支持滚动加载。
</p>
</div>
<button class="btn ghost mode-toggle" type="button" @click="toggleMode">
<span class="mode-dot" :class="{ dark: isDark }" />
<span>{{ isDark ? '暗黑' : '亮色' }} 模式</span>
</button>
</header>
<form class="composer" @submit.prevent="addTodo">
<div class="input-wrap">
<input
v-model="newTodo"
type="text"
maxlength="120"
placeholder="添加新任务"
aria-label="添加新任务"
>
<button class="btn primary" type="submit" :disabled="!newTodo.trim()">
添加任务
</button>
</div>
<div class="stats">
<div class="stat">
<span>总计</span>
<strong>{{ totalCount }}</strong>
</div>
<div class="stat">
<span>剩余</span>
<strong>{{ remainingCount }}</strong>
</div>
<div v-if="completedCount" class="stat">
<span>已完成</span>
<strong>{{ completedCount }}</strong>
</div>
</div>
</form>
<section class="list-card">
<div class="list-head">
<h2>待办列表</h2>
<span class="list-hint">
{{ visibleTodos.length }} / {{ totalCount }} 显示中
</span>
</div>
<div ref="listRef" class="todo-list" aria-live="polite">
<article
v-for="(todo, index) in visibleTodos"
:key="todo.id"
class="todo-item"
:class="{ done: todo.done }"
:style="{ animationDelay: `${index * 0.03}s` }"
>
<label class="todo-check">
<input v-model="todo.done" type="checkbox">
<span class="checkmark" />
</label>
<p class="todo-text">
{{ todo.text }}
</p>
<div class="todo-actions">
<button
class="btn ghost"
type="button"
:disabled="!isSupported"
:title="isSupported ? '复制任务文本' : '剪贴板不支持'"
@click="handleCopy(todo)"
>
{{ copied && lastCopiedId === todo.id ? '已复制' : '复制' }}
</button>
<button class="btn danger" type="button" @click="removeTodo(todo.id)">
移除
</button>
</div>
</article>
<p v-if="!visibleTodos.length" class="empty">
还没有任务。请在上方添加你的第一个待办。
</p>
<div v-if="visibleTodos.length" class="list-footer">
<span class="footer-status">
<span v-if="isLoading">加载更多中...</span>
<span v-else-if="visibleTodos.length < totalCount">
滚动以加载更多
</span>
<span v-else>已全部加载</span>
</span>
</div>
</div>
</section>
</div>
</div>
</template>
<style>
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap');
:root {
color-scheme: light;
--bg: #f6f7fb;
--bg-spot: rgba(59, 130, 246, 0.18);
--bg-spot-2: rgba(34, 197, 94, 0.18);
--card: rgba(255, 255, 255, 0.92);
--surface: rgba(255, 255, 255, 0.84);
--border: rgba(148, 163, 184, 0.35);
--text: #0f172a;
--muted: #64748b;
--accent: #2563eb;
--accent-strong: #1d4ed8;
--accent-soft: rgba(37, 99, 235, 0.18);
--danger: #ef4444;
--danger-soft: rgba(239, 68, 68, 0.15);
--shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
--radius: 22px;
}
:root[data-theme='dark'] {
color-scheme: dark;
--bg: #0b1222;
--bg-spot: rgba(56, 189, 248, 0.18);
--bg-spot-2: rgba(16, 185, 129, 0.16);
--card: rgba(15, 23, 42, 0.86);
--surface: rgba(15, 23, 42, 0.7);
--border: rgba(148, 163, 184, 0.25);
--text: #f8fafc;
--muted: #94a3b8;
--accent: #38bdf8;
--accent-strong: #0ea5e9;
--accent-soft: rgba(56, 189, 248, 0.2);
--danger: #f87171;
--danger-soft: rgba(248, 113, 113, 0.2);
--shadow: 0 26px 70px rgba(2, 6, 23, 0.55);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family:
'Sora',
system-ui,
-apple-system,
sans-serif;
color: var(--text);
background:
radial-gradient(900px circle at top left, var(--bg-spot), transparent 55%),
radial-gradient(700px circle at bottom right, var(--bg-spot-2), transparent 50%), var(--bg);
transition:
background 0.4s ease,
color 0.4s ease;
}
#app {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(20px, 4vw, 48px);
}
.page {
width: min(980px, 100%);
}
.shell {
display: grid;
gap: clamp(20px, 3vw, 28px);
padding: clamp(20px, 4vw, 36px);
border-radius: var(--radius);
background: var(--card);
border: 1px solid var(--border);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.72rem;
color: var(--muted);
margin: 0 0 10px;
}
h1 {
margin: 0;
font-size: clamp(2rem, 3vw, 2.6rem);
}
.subtitle {
margin: 10px 0 0;
color: var(--muted);
max-width: 520px;
}
.composer {
display: grid;
gap: 16px;
}
.input-wrap {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
}
input[type='text'] {
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 1rem;
transition:
border 0.2s ease,
box-shadow 0.2s ease;
}
input[type='text']:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.stats {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.stat {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border);
font-size: 0.9rem;
color: var(--muted);
}
.stat strong {
font-family: 'Space Mono', ui-monospace, SFMono-Regular, monospace;
color: var(--text);
font-size: 0.95rem;
}
.list-card {
display: grid;
gap: 16px;
}
.list-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.list-head h2 {
margin: 0;
font-size: 1.2rem;
}
.list-hint {
font-size: 0.85rem;
color: var(--muted);
font-family: 'Space Mono', ui-monospace, SFMono-Regular, monospace;
}
.todo-list {
max-height: clamp(320px, 55vh, 520px);
overflow-y: auto;
display: grid;
gap: 12px;
padding: 6px;
margin: -6px;
}
.todo-item {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
align-items: center;
padding: 14px 16px;
border-radius: 16px;
background: var(--surface);
border: 1px solid var(--border);
animation: fadeUp 0.4s ease both;
}
.todo-item.done {
opacity: 0.7;
}
.todo-text {
margin: 0;
font-size: 0.98rem;
}
.todo-item.done .todo-text {
text-decoration: line-through;
color: var(--muted);
}
.todo-check {
display: inline-flex;
align-items: center;
}
.todo-check input {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.checkmark {
display: none;
}
.todo-actions {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
}
.btn {
border: 1px solid var(--border);
background: transparent;
color: var(--text);
padding: 8px 14px;
border-radius: 999px;
font-size: 0.88rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
background: var(--accent-strong);
border-color: var(--accent-strong);
}
.btn.ghost:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.btn.danger {
border-color: transparent;
color: var(--danger);
background: var(--danger-soft);
}
.mode-toggle {
white-space: nowrap;
}
.mode-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #facc15;
box-shadow: 0 0 0 3px rgba(250, 204, 21, 0.2);
}
.mode-dot.dark {
background: #38bdf8;
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.2);
}
.empty {
text-align: center;
padding: 32px 12px;
color: var(--muted);
border-radius: 16px;
border: 1px dashed var(--border);
}
.list-footer {
text-align: center;
font-size: 0.85rem;
color: var(--muted);
padding: 8px 0 12px;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 820px) {
.header {
flex-direction: column;
align-items: flex-start;
}
.input-wrap {
grid-template-columns: 1fr;
}
.todo-item {
grid-template-columns: auto 1fr;
}
.todo-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
}
</style>询问 AI 代理如何使用 VueUse
示例提示:
我可以用哪个 vueuse 函数来操作本地存储?输出代码片段
使用 useStorage(默认使用 localStorage),或者使用方便的封装 useLocalStorage。
示例:
ts
import { useLocalStorage, useStorage } from '@vueuse/core'
const state = useStorage('my-key', { hello: 'hi' }) // 默认是 localStorage
const theme = useLocalStorage('theme', 'light')