Compare commits
15 Commits
refactor/r
...
main
Author | SHA1 | Date | |
---|---|---|---|
8b16bf4b0e | |||
1845416f72 | |||
03fb3e38ae | |||
b1e91c9c3e | |||
49b9e97ee8 | |||
20471bfbe3 | |||
92fc748a57 | |||
3958fbc1f0 | |||
3c27bbecef | |||
50294b1519 | |||
cab76c5abd | |||
b2d5f5260c | |||
b03b2d9273 | |||
6221602d5e | |||
6fee735df5 |
43
.gitea/workflows/ci.yml
Normal file
43
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
name: 'CI'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: 🛠 setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4.0.0
|
||||||
|
|
||||||
|
- name: ⚙ Install dependencies
|
||||||
|
run: pnpm i
|
||||||
|
|
||||||
|
- name: 📏 Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: 🛠 setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4.0.0
|
||||||
|
|
||||||
|
- name: ⚙ Install dependencies
|
||||||
|
run: pnpm i
|
||||||
|
|
||||||
|
- name: 🧪 Test
|
||||||
|
run: pnpm test
|
12
.vscode/mcp.json
vendored
Normal file
12
.vscode/mcp.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"xtms - API 文档": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"apifox-mcp-server@latest",
|
||||||
|
"--site-id=5442896"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
26
api/aifn.ts
Normal file
26
api/aifn.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { IResponse } from '.'
|
||||||
|
import type { AIGeneratedContent } from '~/components/ai'
|
||||||
|
|
||||||
|
export type AIGeneratedContentResponse = IResponse<{
|
||||||
|
data: AIGeneratedContent & {
|
||||||
|
raw: string
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
|
||||||
|
export const generateLessonPlan = async (params: {
|
||||||
|
query: string
|
||||||
|
}) => {
|
||||||
|
return await http<AIGeneratedContentResponse>(`/ai/lesson-plan-design/text-block`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateCase = async (params: {
|
||||||
|
query: string
|
||||||
|
}) => {
|
||||||
|
return await http<AIGeneratedContentResponse>(`/ai/case-design/text-block`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: params,
|
||||||
|
})
|
||||||
|
}
|
@ -10,7 +10,7 @@
|
|||||||
--card-foreground: 0 0% 3.9%;
|
--card-foreground: 0 0% 3.9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 0 0% 3.9%;
|
--popover-foreground: 0 0% 3.9%;
|
||||||
--primary: 0 0% 9%;
|
--primary: 238.73deg 83.53% 66.67%;
|
||||||
--primary-foreground: 0 0% 98%;
|
--primary-foreground: 0 0% 98%;
|
||||||
--secondary: 0 0% 96.1%;
|
--secondary: 0 0% 96.1%;
|
||||||
--secondary-foreground: 0 0% 9%;
|
--secondary-foreground: 0 0% 9%;
|
||||||
@ -46,7 +46,7 @@
|
|||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
--popover: 0 0% 3.9%;
|
--popover: 0 0% 3.9%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 0 0% 98%;
|
||||||
--primary: 0 0% 98%;
|
--primary: 234.45deg 89.47% 73.92%;
|
||||||
--primary-foreground: 0 0% 9%;
|
--primary-foreground: 0 0% 9%;
|
||||||
--secondary: 0 0% 14.9%;
|
--secondary: 0 0% 14.9%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
49
components/MarkdownRenderer.vue
Normal file
49
components/MarkdownRenderer.vue
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import md from 'markdown-it'
|
||||||
|
import hljs from 'highlight.js'
|
||||||
|
import 'highlight.js/styles/github-dark-dimmed.min.css'
|
||||||
|
|
||||||
|
const renderer = md({
|
||||||
|
html: true,
|
||||||
|
linkify: true,
|
||||||
|
typographer: true,
|
||||||
|
breaks: true,
|
||||||
|
highlight: function (str, lang) {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
try {
|
||||||
|
return `<pre class="hljs" style="overflow-x: auto"><code>${
|
||||||
|
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
|
||||||
|
}</code></pre>`
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
'<pre class="hljs"><code>' + md().utils.escapeHtml(str) + '</code></pre>'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
source: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
class="prose dark:prose-invert max-w-none prose-sm prose-neutral"
|
||||||
|
v-html="
|
||||||
|
renderer.render(source.replaceAll('\t', ' '))
|
||||||
|
"
|
||||||
|
></article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
think {
|
||||||
|
@apply block my-4 p-3 bg-blue-500/5 border-l-4 border-primary/30 rounded italic text-xs;
|
||||||
|
}
|
||||||
|
</style>
|
280
components/ai/Conversation.vue
Normal file
280
components/ai/Conversation.vue
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
<!-- eslint-disable @typescript-eslint/no-explicit-any -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { FormContext } from 'vee-validate'
|
||||||
|
import type { AnyZodObject } from 'zod'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import type { LLMConversation } from '.'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
formSchema: AnyZodObject
|
||||||
|
form: FormContext<any>
|
||||||
|
formFieldConfig?: {
|
||||||
|
[key: string]: {
|
||||||
|
component?: string
|
||||||
|
props?: Record<string, any>
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conversations?: LLMConversation[]
|
||||||
|
activeConversationId?: string | null
|
||||||
|
disableUserInput?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const currentConversation = computed(() => {
|
||||||
|
const { conversations, activeConversationId } = props
|
||||||
|
if (conversations && activeConversationId) {
|
||||||
|
return conversations.find((m) => m.id === activeConversationId)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = computed(() => {
|
||||||
|
const { conversations, activeConversationId } = props
|
||||||
|
if (conversations && activeConversationId) {
|
||||||
|
const conversation = conversations.find(
|
||||||
|
(m) => m.id === activeConversationId,
|
||||||
|
)
|
||||||
|
return conversation ? conversation.messages : []
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'submit', values: FormContext<any>['values']): void
|
||||||
|
(e: 'update:conversationId', conversationId: string | null): void
|
||||||
|
(e: 'deleteConversation', conversationId: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const onDeleteConversation = (conversationId: string) => {
|
||||||
|
emit('deleteConversation', conversationId)
|
||||||
|
if (conversationId === props.activeConversationId) {
|
||||||
|
emit('update:conversationId', null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col gap-4">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
v-if="activeConversationId"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
@click="$emit('update:conversationId', null)"
|
||||||
|
>
|
||||||
|
<Icon name="tabler:arrow-back" />
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<!-- Histories -->
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Icon name="tabler:history" />
|
||||||
|
历史记录
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="end"
|
||||||
|
class="p-0"
|
||||||
|
>
|
||||||
|
<ScrollArea
|
||||||
|
v-if="conversations?.length"
|
||||||
|
class="flex flex-col gap-2 p-3 h-[320px]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(conversation, i) in props.conversations"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-start gap-2 p-2 rounded-md cursor-pointer relative"
|
||||||
|
:class="`${conversation.id === activeConversationId ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`"
|
||||||
|
@click="$emit('update:conversationId', conversation.id)"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="absolute top-1.5 right-2"
|
||||||
|
@click.stop="onDeleteConversation(conversation.id)"
|
||||||
|
>
|
||||||
|
<Icon name="tabler:trash" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
name="tabler:history"
|
||||||
|
class="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 text-sm font-medium flex flex-col">
|
||||||
|
<span class="w-2/3 text-ellipsis line-clamp-1">
|
||||||
|
{{ conversation.title || '历史对话' }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="text-xs"
|
||||||
|
:class="`${conversation.id === activeConversationId ? 'text-primary-foreground/60' : 'text-muted-foreground'}`"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
dayjs((conversation.created_at || 0) * 1000).format(
|
||||||
|
'YYYY-MM-DD HH:mm:ss',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-xs text-muted-foreground/60 font-medium text-center pt-2"
|
||||||
|
>
|
||||||
|
到底了
|
||||||
|
</p>
|
||||||
|
</ScrollArea>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-col items-center justify-center gap-2 h-[320px]"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="tabler:history"
|
||||||
|
class="text-3xl text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<p class="text-sm text-muted-foreground">暂无历史记录</p>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<!-- 消息区域调整 -->
|
||||||
|
<div
|
||||||
|
v-if="activeConversationId"
|
||||||
|
class="flex flex-col flex-1 gap-4 max-h-[calc(100vh-280px)]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex-1 flex flex-col gap-6 overflow-y-auto scroll-smooth px-2 pb-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(message, i) in messages"
|
||||||
|
:key="i"
|
||||||
|
class="w-full flex"
|
||||||
|
:class="`${message.role == 'user' ? 'justify-end' : 'justify-start'}`"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="gradient-border"
|
||||||
|
:class="
|
||||||
|
message.role === 'assistant' && !currentConversation?.finished_at && message.content
|
||||||
|
? ''
|
||||||
|
: 'inactive'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-fit px-4 py-3 rounded-lg max-w-prose shadow bg-white dark:bg-gray-800 border"
|
||||||
|
:class="[
|
||||||
|
message.role == 'user' ? 'rounded-br-none' : 'rounded-bl-none',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<MarkdownRenderer
|
||||||
|
v-if="!!message.content"
|
||||||
|
:source="message.content"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex items-center gap-2 text-foreground/60 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="svg-spinners:270-ring-with-bg"
|
||||||
|
class="text-lg"
|
||||||
|
/>
|
||||||
|
思考中
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!disableUserInput"
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
class="w-full h-12 pr-24 shadow-lg"
|
||||||
|
placeholder="请告诉我额外的补充需求,我会尽量满足您的要求"
|
||||||
|
/>
|
||||||
|
<div class="absolute right-2 bottom-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Icon name="tabler:send" />
|
||||||
|
发送
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表单区域不变 -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-lg p-6 bg-primary/5"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<AutoForm
|
||||||
|
v-if="formSchema && form"
|
||||||
|
:schema="formSchema"
|
||||||
|
:form="form"
|
||||||
|
:field-config="formFieldConfig"
|
||||||
|
class="space-y-2"
|
||||||
|
@submit="(values: any) => $emit('submit', values)"
|
||||||
|
>
|
||||||
|
<div class="w-full flex justify-center gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Icon name="mage:stars-c-fill" />
|
||||||
|
一键生成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AutoForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@property --angle {
|
||||||
|
syntax: '<angle>';
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-border {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: conic-gradient(
|
||||||
|
from var(--angle),
|
||||||
|
hsla(188, 86%, 53%, 0.8),
|
||||||
|
hsla(142, 69%, 58%, 0.8),
|
||||||
|
hsla(48, 96%, 53%, 0.8),
|
||||||
|
hsla(329, 86%, 70%, 0.8),
|
||||||
|
hsla(188, 86%, 53%, 0.8)
|
||||||
|
);
|
||||||
|
animation: rotate 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-border.inactive {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-border > * {
|
||||||
|
border-radius: calc(0.5rem - 2px);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
to {
|
||||||
|
--angle: 360deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
110
components/ai/GeneratedContent.vue
Normal file
110
components/ai/GeneratedContent.vue
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { AIGeneratedContent } from '.'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
data?: AIGeneratedContent | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'regenerate' | 'delete', itemIndex: number): void
|
||||||
|
(e: 'regenerateAll' | 'download' | 'save'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="data"
|
||||||
|
class="flex flex-col gap-4 rounded-md border p-4 overflow-hidden relative"
|
||||||
|
>
|
||||||
|
<!-- header -->
|
||||||
|
<div class="flex justify-between items-start gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon
|
||||||
|
name="mage:stars-c-fill"
|
||||||
|
class="text-xl text-amber-500"
|
||||||
|
/>
|
||||||
|
<h1 class="text-lg font-medium">
|
||||||
|
{{ data.title }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="$emit('regenerateAll')"
|
||||||
|
>
|
||||||
|
<Icon name="tabler:reload" />
|
||||||
|
重新生成
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="$emit('download')"
|
||||||
|
>
|
||||||
|
<Icon name="tabler:download" />
|
||||||
|
下载
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
@click="$emit('save')"
|
||||||
|
>
|
||||||
|
<Icon name="tabler:copy" />
|
||||||
|
存入教案库
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- items -->
|
||||||
|
<div
|
||||||
|
v-for="(item, i) in data.sections"
|
||||||
|
:key="i"
|
||||||
|
class="flex flex-col rounded-md border overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex justify-between items-center gap-2 p-2 px-4 bg-gradient-to-r from-primary/15 to-muted text-background"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-foreground font-medium">
|
||||||
|
{{ item.title }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<!-- <Button
|
||||||
|
variant="link"
|
||||||
|
size="xs"
|
||||||
|
class="text-muted-foreground"
|
||||||
|
@click="$emit('regenerate', i)"
|
||||||
|
>
|
||||||
|
<Icon name="tabler:reload" />
|
||||||
|
重新生成
|
||||||
|
</Button> -->
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="xs"
|
||||||
|
class="text-muted-foreground"
|
||||||
|
@click="$emit('delete', i)"
|
||||||
|
>
|
||||||
|
<Icon name="tabler:trash" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 py-2 bg-background">
|
||||||
|
<MarkdownRenderer :source="item.content" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<slot
|
||||||
|
v-else
|
||||||
|
name="empty"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col justify-center items-center gap-2 py-8 rounded-md border"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="lucide:text-cursor-input"
|
||||||
|
class="text-3xl opacity-50"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-medium opacity-50">等待生成内容</span>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
22
components/ai/index.ts
Normal file
22
components/ai/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
export interface AIGeneratedContent {
|
||||||
|
title: string
|
||||||
|
sections: AIGeneratedContentItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIGeneratedContentItem {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMConversation {
|
||||||
|
id: string
|
||||||
|
created_at?: number
|
||||||
|
finished_at?: number
|
||||||
|
title: string
|
||||||
|
messages: LLMMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMMessage {
|
||||||
|
role: 'user' | 'assistant' | 'system'
|
||||||
|
content: string
|
||||||
|
}
|
@ -1,20 +1,25 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { SubNavItem } from '../SubNav.vue'
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
import type { NavSecondaryItem } from '../nav/Secondary.vue'
|
||||||
|
|
||||||
defineProps<{ subnavs?: SubNavItem[] }>()
|
defineProps<{
|
||||||
|
navSecondary?: NavSecondaryItem[]
|
||||||
|
contentClass?: string
|
||||||
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-1 flex-col p-8 page-bg-gradient">
|
<div class="flex flex-1 flex-col p-8 page-bg-gradient">
|
||||||
<!-- <h1 class="pl-2 text-xl font-medium">外部标题</h1> -->
|
<!-- <h1 class="pl-2 text-xl font-medium">外部标题</h1> -->
|
||||||
<slot name="subnav">
|
<slot name="subnav">
|
||||||
<SubNav
|
<NavSecondary
|
||||||
v-if="subnavs && subnavs.length"
|
v-if="navSecondary && navSecondary.length"
|
||||||
:navs="subnavs"
|
:navs="navSecondary"
|
||||||
/>
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
<div
|
<div
|
||||||
class="bg-white h-full rounded-lg shadow-sm p-8 dark:bg-neutral-900 z-20"
|
class="h-full rounded-lg shadow-sm overflow-hidden relative"
|
||||||
|
:class="twMerge('bg-white dark:bg-neutral-900 p-8 z-20', contentClass)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
@ -52,30 +52,39 @@ export const topbarNavDefaults = [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header
|
<header
|
||||||
class="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear border-b sticky top-0 z-30 bg-background">
|
class="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear border-b sticky top-0 z-30 bg-background"
|
||||||
|
>
|
||||||
<!-- group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12 -->
|
<!-- group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12 -->
|
||||||
<div class="flex justify-between items-center gap-2 px-4 w-full">
|
<div class="flex justify-between items-center gap-2 px-4 w-full">
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2"
|
class="flex items-center gap-2"
|
||||||
:class="`${hideTrigger ? 'px-7' : ''}`">
|
:class="`${hideTrigger ? 'px-7' : ''}`"
|
||||||
<SidebarTrigger v-if="!hideTrigger" class="-ml-1" />
|
>
|
||||||
|
<SidebarTrigger
|
||||||
|
v-if="!hideTrigger"
|
||||||
|
class="-ml-1"
|
||||||
|
/>
|
||||||
<img
|
<img
|
||||||
v-if="hideTrigger"
|
v-if="hideTrigger"
|
||||||
src="/images/xsh_logo.png"
|
src="/images/xsh_logo.png"
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
class="w-10 aspect-square" />
|
class="w-10 aspect-square"
|
||||||
|
/>
|
||||||
<Separator
|
<Separator
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
class="mr-2 h-4" />
|
class="mr-2 h-4"
|
||||||
|
/>
|
||||||
<Breadcrumb v-if="breadcrumbs.length > 0">
|
<Breadcrumb v-if="breadcrumbs.length > 0">
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<template
|
<template
|
||||||
v-for="(crumb, i) in breadcrumbs"
|
v-for="(crumb, i) in breadcrumbs"
|
||||||
:key="i">
|
:key="i"
|
||||||
|
>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
<BreadcrumbLink
|
<BreadcrumbLink
|
||||||
v-if="crumb.path"
|
v-if="crumb.path"
|
||||||
:href="crumb.path">
|
:href="crumb.path"
|
||||||
|
>
|
||||||
{{ crumb.label }}
|
{{ crumb.label }}
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
<BreadcrumbPage v-else>
|
<BreadcrumbPage v-else>
|
||||||
@ -123,13 +132,16 @@ export const topbarNavDefaults = [
|
|||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon">
|
size="icon"
|
||||||
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name="tabler:moon"
|
name="tabler:moon"
|
||||||
class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"
|
||||||
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
name="tabler:sun"
|
name="tabler:sun"
|
||||||
class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"
|
||||||
|
/>
|
||||||
<span class="sr-only">Toggle theme</span>
|
<span class="sr-only">Toggle theme</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { LucideIcon } from 'lucide-vue-next'
|
import type { LucideIcon } from 'lucide-vue-next'
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
import type { SidebarProps } from '../ui/sidebar'
|
import type { SidebarProps } from '~/components/ui/sidebar'
|
||||||
|
|
||||||
export interface SidebarNavItem {
|
export interface SidebarNavItem {
|
||||||
title: string
|
title: string
|
||||||
@ -46,17 +46,15 @@ const loginState = useLoginState()
|
|||||||
alt="Logo"
|
alt="Logo"
|
||||||
class="w-9 max-w-9 aspect-square group-has-[[data-collapsible=icon]]/sidebar-wrapper:w-full transition-all duration-200 ease-in-out"
|
class="w-9 max-w-9 aspect-square group-has-[[data-collapsible=icon]]/sidebar-wrapper:w-full transition-all duration-200 ease-in-out"
|
||||||
/>
|
/>
|
||||||
<h1 class="text-lg font-medium">
|
<h1 class="text-lg font-medium">智课教学平台</h1>
|
||||||
智课教学平台
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<slot name="extra-header" />
|
<slot name="extra-header" />
|
||||||
<AppNavMain :nav="nav" />
|
<AppSidebarNavMain :nav="nav" />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<AppNavUser :user="loginState.user" />
|
<AppSidebarNavUser :user="loginState.user" />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChevronsUpDown } from 'lucide-vue-next'
|
import { ChevronsUpDown } from 'lucide-vue-next'
|
||||||
import { useSidebar } from '../ui/sidebar'
|
import { useSidebar } from '../../../ui/sidebar'
|
||||||
import type { IUser } from '~/types'
|
import type { IUser } from '~/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
@ -52,17 +52,17 @@ const openCourse = (id: number) => {
|
|||||||
<span>{{ data.teacherName || "未知教师" }}</span>
|
<span>{{ data.teacherName || "未知教师" }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between gap-1 text-xs text-muted-foreground/80">
|
<div class="flex justify-between gap-4 text-xs text-muted-foreground/80">
|
||||||
<div class="flex-1 flex flex-col">
|
<div class=" flex flex-col">
|
||||||
<p>
|
<p>
|
||||||
学期:<span>{{ data.semester || "未知" }}</span>
|
学期 <span>{{ data.semester || "未知" }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
课程ID:<span>{{ data.id }}</span>
|
课程ID:<span>{{ data.id }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-end">
|
<div class="flex-1 flex flex-col items-end">
|
||||||
<p>{{ data.schoolName }}</p>
|
<p class="text-ellipsis line-clamp-1">{{ data.schoolName }}</p>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div
|
<div
|
||||||
v-if="data.status === 0"
|
v-if="data.status === 0"
|
111
components/fn/teach/CaseGen.vue
Normal file
111
components/fn/teach/CaseGen.vue
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { generateCase, type AIGeneratedContentResponse } from '~/api/aifn'
|
||||||
|
import type { AIGeneratedContent } from '~/components/ai'
|
||||||
|
import type { FetchError } from '~/types'
|
||||||
|
|
||||||
|
const tab = ref('text')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const input = ref('')
|
||||||
|
|
||||||
|
const data = ref<AIGeneratedContent | null>(null)
|
||||||
|
|
||||||
|
const onGenerateClick = () => {
|
||||||
|
if (input.value) {
|
||||||
|
loading.value = true
|
||||||
|
toast.promise(
|
||||||
|
generateCase({
|
||||||
|
query: input.value,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: '生成中...',
|
||||||
|
success: (res: AIGeneratedContentResponse) => {
|
||||||
|
data.value = res.data
|
||||||
|
return '生成成功'
|
||||||
|
},
|
||||||
|
error: (err: FetchError) => {
|
||||||
|
data.value = null
|
||||||
|
return err.message
|
||||||
|
},
|
||||||
|
finally: () => {
|
||||||
|
loading.value = false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
data.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Tabs
|
||||||
|
v-model="tab"
|
||||||
|
class="w-[400px]"
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="text">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Icon name="tabler:article" />
|
||||||
|
<span>文本生成</span>
|
||||||
|
</div>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="chapter">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Icon name="tabler:text-plus" />
|
||||||
|
<span>章节生成</span>
|
||||||
|
</div>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="bot"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Icon name="tabler:robot" />
|
||||||
|
<span>课程智能体</span>
|
||||||
|
</div>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
v-if="tab === 'chapter'"
|
||||||
|
class="flex h-20 flex-col justify-center items-center gap-1 px-8 rounded-md border"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="tabler:text-plus"
|
||||||
|
class="text-3xl"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-medium">选择章节</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
v-model="input"
|
||||||
|
placeholder="请输入文本来生成内容"
|
||||||
|
class="h-20 flex-1"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="onGenerateClick"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
v-if="loading"
|
||||||
|
name="svg-spinners:180-ring-with-bg"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else
|
||||||
|
name="mage:stars-c-fill"
|
||||||
|
/>
|
||||||
|
生成案例
|
||||||
|
</Button>
|
||||||
|
<p class="text-xs text-foreground/40">内容由 AI 生成,仅供参考</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AiGeneratedContent :data />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
126
components/fn/teach/CourseChapter.vue
Normal file
126
components/fn/teach/CourseChapter.vue
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const historyStore = useLlmHistories('course-chapter-gen')
|
||||||
|
const { conversations } = storeToRefs(historyStore)
|
||||||
|
const { updateConversation, appendChunkToLast, isConversationExist } =
|
||||||
|
historyStore
|
||||||
|
|
||||||
|
const activeConversationId = ref<string | null>(null)
|
||||||
|
|
||||||
|
watch(activeConversationId, (val) => {
|
||||||
|
if (val) {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: route.query.fn,
|
||||||
|
conversationId: val,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: route.query.fn,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (route.query.conversationId) {
|
||||||
|
if (isConversationExist(route.query.conversationId as string)) {
|
||||||
|
activeConversationId.value = route.query.conversationId as string
|
||||||
|
} else {
|
||||||
|
activeConversationId.value = null
|
||||||
|
toast.error('会话不存在')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
target: z.string({ required_error: '请输入授课对象' }).describe('授课对象'),
|
||||||
|
topic: z.string({ required_error: '请输入课程主题' }).describe('课程主题'),
|
||||||
|
goal: z.string({ required_error: '请输入课程目标' }).describe('课程目标'),
|
||||||
|
requirement: z.string().describe('其他要求').optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: toTypedSchema(schema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (values: z.infer<typeof schema>) => {
|
||||||
|
http_stream<z.infer<typeof schema>>('/ai/course-chapter/stream', values, {
|
||||||
|
onStart(id, created_at) {
|
||||||
|
activeConversationId.value = id
|
||||||
|
updateConversation(id, {
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
title: values.target,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `生成课程章节大纲\n授课对象:${values.target}\n课程主题:${values.topic}\n课程目标:${values.goal}\n其他要求:${values.requirement ?? '无'}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onTextChunk: (chunk) => {
|
||||||
|
appendChunkToLast(activeConversationId.value!, chunk)
|
||||||
|
},
|
||||||
|
onComplete: (id, finished_at) => {
|
||||||
|
updateConversation(id!, {
|
||||||
|
finished_at,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<AiConversation
|
||||||
|
:form
|
||||||
|
:form-schema="schema"
|
||||||
|
:form-field-config="{
|
||||||
|
target: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入授课对象,如:本科生、研究生',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
topic: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入课程主题,如:数据结构与算法',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
goal: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入课程目标,如:掌握数据结构与算法的基本概念',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requirement: {
|
||||||
|
component: 'textarea',
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入其他要求,如:教学重点',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
:conversations
|
||||||
|
:active-conversation-id="activeConversationId"
|
||||||
|
disable-user-input
|
||||||
|
@submit="onSubmit"
|
||||||
|
@update:conversation-id="activeConversationId = $event"
|
||||||
|
@delete-conversation="historyStore.removeConversation($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
133
components/fn/teach/CourseOutline.vue
Normal file
133
components/fn/teach/CourseOutline.vue
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const historyStore = useLlmHistories('course-course-outline')
|
||||||
|
const { conversations } = storeToRefs(historyStore)
|
||||||
|
const { updateConversation, appendChunkToLast, isConversationExist } =
|
||||||
|
historyStore
|
||||||
|
|
||||||
|
const activeConversationId = ref<string | null>(null)
|
||||||
|
|
||||||
|
watch(activeConversationId, (val) => {
|
||||||
|
if (val) {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: route.query.fn,
|
||||||
|
conversationId: val,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: route.query.fn,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (route.query.conversationId) {
|
||||||
|
if (isConversationExist(route.query.conversationId as string)) {
|
||||||
|
activeConversationId.value = route.query.conversationId as string
|
||||||
|
} else {
|
||||||
|
activeConversationId.value = null
|
||||||
|
toast.error('会话不存在')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
courseName: z
|
||||||
|
.string({ required_error: '请输入课程名称' })
|
||||||
|
.describe('课程名称'),
|
||||||
|
targetAudience: z
|
||||||
|
.string({ required_error: '请输入授课对象' })
|
||||||
|
.describe('授课对象'),
|
||||||
|
courseObjective: z
|
||||||
|
.string({ required_error: '请输入课程目标' })
|
||||||
|
.describe('课程目标'),
|
||||||
|
otherRequirement: z.string().describe('其他要求').optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: toTypedSchema(schema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (values: z.infer<typeof schema>) => {
|
||||||
|
http_stream<z.infer<typeof schema>>('/ai/curriculum-outline/stream', values, {
|
||||||
|
onStart(id, created_at) {
|
||||||
|
activeConversationId.value = id
|
||||||
|
updateConversation(id, {
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
title: values.courseName,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `*生成一份 ${values.courseName} 的课程大纲*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onTextChunk: (chunk) => {
|
||||||
|
appendChunkToLast(activeConversationId.value!, chunk)
|
||||||
|
},
|
||||||
|
onComplete: (id, finished_at) => {
|
||||||
|
updateConversation(id!, {
|
||||||
|
finished_at,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<AiConversation
|
||||||
|
:form
|
||||||
|
:form-schema="schema"
|
||||||
|
:form-field-config="{
|
||||||
|
courseName: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入课程名称,如:数据结构与算法',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
targetAudience: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入授课对象,如:大一物联网专业学生',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
courseObjective: {
|
||||||
|
component: 'textarea',
|
||||||
|
inputProps: {
|
||||||
|
placeholder:
|
||||||
|
'例如:知识目标需掌握和理解的理论知识、概念、原理、理论框架等;技能目标需掌握学科中所需的基本操作技能,如实验操作、计算机操作、语言表达等',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
otherRequirement: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入其他要求,如:需要包含案例分析',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
:conversations
|
||||||
|
:active-conversation-id="activeConversationId"
|
||||||
|
disable-user-input
|
||||||
|
@submit="onSubmit"
|
||||||
|
@update:conversation-id="activeConversationId = $event"
|
||||||
|
@delete-conversation="historyStore.removeConversation($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
121
components/fn/teach/KnowledgeDiagram.vue
Normal file
121
components/fn/teach/KnowledgeDiagram.vue
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const historyStore = useLlmHistories('course-std-design')
|
||||||
|
const { conversations } = storeToRefs(historyStore)
|
||||||
|
const {
|
||||||
|
updateConversation,
|
||||||
|
appendChunkToLast,
|
||||||
|
isConversationExist
|
||||||
|
} = historyStore
|
||||||
|
|
||||||
|
const activeConversationId = ref<string | null>(null)
|
||||||
|
|
||||||
|
watch(activeConversationId, (val) => {
|
||||||
|
if (val) {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: route.query.fn,
|
||||||
|
conversationId: val,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: route.query.fn,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (route.query.conversationId) {
|
||||||
|
if (isConversationExist(route.query.conversationId as string)) {
|
||||||
|
activeConversationId.value = route.query.conversationId as string
|
||||||
|
} else {
|
||||||
|
activeConversationId.value = null
|
||||||
|
toast.error('会话不存在')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
file: z.string({ required_error: '请选择课件文件' }).describe('课件文件'),
|
||||||
|
requirement: z.string().describe('其他要求').optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: toTypedSchema(schema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (values: z.infer<typeof schema>) => {
|
||||||
|
http_stream<z.infer<typeof schema>>(
|
||||||
|
'/ai/course-standard/stream',
|
||||||
|
values,
|
||||||
|
{
|
||||||
|
onStart(id, created_at) {
|
||||||
|
activeConversationId.value = id
|
||||||
|
updateConversation(id, {
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
title: `知识图谱`,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `*根据上传的课件生成一份知识图谱*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onTextChunk: (chunk) => {
|
||||||
|
appendChunkToLast(activeConversationId.value!, chunk)
|
||||||
|
},
|
||||||
|
onComplete: (id, finished_at) => {
|
||||||
|
updateConversation(id!, {
|
||||||
|
finished_at,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<AiConversation
|
||||||
|
:form
|
||||||
|
:form-schema="schema"
|
||||||
|
:form-field-config="{
|
||||||
|
file: {
|
||||||
|
component: 'file',
|
||||||
|
inputProps: {
|
||||||
|
accept: 'application/pdf,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requirement: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入其他要求',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
:conversations
|
||||||
|
:active-conversation-id="activeConversationId"
|
||||||
|
disable-user-input
|
||||||
|
@submit="onSubmit"
|
||||||
|
@update:conversation-id="activeConversationId = $event"
|
||||||
|
@delete-conversation="historyStore.removeConversation($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
111
components/fn/teach/LessonPlan.vue
Normal file
111
components/fn/teach/LessonPlan.vue
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { generateLessonPlan, type AIGeneratedContentResponse } from '~/api/aifn'
|
||||||
|
import type { AIGeneratedContent } from '~/components/ai'
|
||||||
|
import type { FetchError } from '~/types'
|
||||||
|
|
||||||
|
const tab = ref('text')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const input = ref('')
|
||||||
|
|
||||||
|
const data = ref<AIGeneratedContent | null>(null)
|
||||||
|
|
||||||
|
const onGenerateClick = () => {
|
||||||
|
if (input.value) {
|
||||||
|
loading.value = true
|
||||||
|
toast.promise(
|
||||||
|
generateLessonPlan({
|
||||||
|
query: input.value,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: '生成中...',
|
||||||
|
success: (res: AIGeneratedContentResponse) => {
|
||||||
|
data.value = res.data
|
||||||
|
return '生成成功'
|
||||||
|
},
|
||||||
|
error: (err: FetchError) => {
|
||||||
|
data.value = null
|
||||||
|
return err.message
|
||||||
|
},
|
||||||
|
finally: () => {
|
||||||
|
loading.value = false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
data.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Tabs
|
||||||
|
v-model="tab"
|
||||||
|
class="w-[400px]"
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="text">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Icon name="tabler:article" />
|
||||||
|
<span>文本生成</span>
|
||||||
|
</div>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="chapter">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Icon name="tabler:text-plus" />
|
||||||
|
<span>章节生成</span>
|
||||||
|
</div>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="bot"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Icon name="tabler:robot" />
|
||||||
|
<span>课程智能体</span>
|
||||||
|
</div>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
v-if="tab === 'chapter'"
|
||||||
|
class="flex h-20 flex-col justify-center items-center gap-1 px-8 rounded-md border"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="tabler:text-plus"
|
||||||
|
class="text-3xl"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-medium">选择章节</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
v-model="input"
|
||||||
|
placeholder="请输入文本来生成内容"
|
||||||
|
class="h-20 flex-1"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="onGenerateClick"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
v-if="loading"
|
||||||
|
name="svg-spinners:180-ring-with-bg"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else
|
||||||
|
name="mage:stars-c-fill"
|
||||||
|
/>
|
||||||
|
生成教案
|
||||||
|
</Button>
|
||||||
|
<p class="text-xs text-foreground/40">内容由 AI 生成,仅供参考</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AiGeneratedContent :data />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
131
components/fn/teach/Plan.vue
Normal file
131
components/fn/teach/Plan.vue
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const historyStore = useLlmHistories('course-teaching-plan')
|
||||||
|
const { conversations } = storeToRefs(historyStore)
|
||||||
|
const { updateConversation, appendChunkToLast, isConversationExist } =
|
||||||
|
historyStore
|
||||||
|
|
||||||
|
const activeConversationId = ref<string | null>(null)
|
||||||
|
|
||||||
|
watch(activeConversationId, (val) => {
|
||||||
|
if (val) {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: route.query.fn,
|
||||||
|
conversationId: val,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: route.query.fn,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (route.query.conversationId) {
|
||||||
|
if (isConversationExist(route.query.conversationId as string)) {
|
||||||
|
activeConversationId.value = route.query.conversationId as string
|
||||||
|
} else {
|
||||||
|
activeConversationId.value = null
|
||||||
|
toast.error('会话不存在')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
identity: z.string({ required_error: '请输入你的身份' }).describe('身份'),
|
||||||
|
mainPlan: z.string({ required_error: '请输入主要计划' }).describe('主要计划'),
|
||||||
|
planTemplate: z
|
||||||
|
.string({ required_error: '请输入教学计划模板' })
|
||||||
|
.describe('教学计划模板'),
|
||||||
|
otherRequirement: z.string().describe('其他要求').optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: toTypedSchema(schema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (values: z.infer<typeof schema>) => {
|
||||||
|
http_stream<z.infer<typeof schema>>('/ai/teaching-plan/stream', values, {
|
||||||
|
onStart(id, created_at) {
|
||||||
|
activeConversationId.value = id
|
||||||
|
updateConversation(id, {
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
title: values.mainPlan,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `*生成一份教学计划*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onTextChunk: (chunk) => {
|
||||||
|
appendChunkToLast(activeConversationId.value!, chunk)
|
||||||
|
},
|
||||||
|
onComplete: (id, finished_at) => {
|
||||||
|
updateConversation(id!, {
|
||||||
|
finished_at,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<AiConversation
|
||||||
|
:form
|
||||||
|
:form-schema="schema"
|
||||||
|
:form-field-config="{
|
||||||
|
identity: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入你的身份,如:高中数学教师、大学物理教师',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mainPlan: {
|
||||||
|
component: 'textarea',
|
||||||
|
inputProps: {
|
||||||
|
placeholder:
|
||||||
|
'请输入主要计划,如:课程目标、教学内容安排、教学方法设计',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
planTemplate: {
|
||||||
|
component: 'textarea',
|
||||||
|
inputProps: {
|
||||||
|
placeholder:
|
||||||
|
'请输入教学计划模板,如:教学目标、教学进度、考核评估方式',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
otherRequirement: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入其他要求',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
:conversations
|
||||||
|
:active-conversation-id="activeConversationId"
|
||||||
|
disable-user-input
|
||||||
|
@submit="onSubmit"
|
||||||
|
@update:conversation-id="activeConversationId = $event"
|
||||||
|
@delete-conversation="historyStore.removeConversation($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
107
components/fn/teach/PoliticalCase.vue
Normal file
107
components/fn/teach/PoliticalCase.vue
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const historyStore = useLlmHistories('course-political-case')
|
||||||
|
const { conversations } = storeToRefs(historyStore)
|
||||||
|
const { updateConversation, appendChunkToLast, isConversationExist } =
|
||||||
|
historyStore
|
||||||
|
|
||||||
|
const activeConversationId = ref<string | null>(null)
|
||||||
|
|
||||||
|
watch(activeConversationId, (val) => {
|
||||||
|
if (val) {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: route.query.fn,
|
||||||
|
conversationId: val,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: route.query.fn,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (route.query.conversationId) {
|
||||||
|
if (isConversationExist(route.query.conversationId as string)) {
|
||||||
|
activeConversationId.value = route.query.conversationId as string
|
||||||
|
} else {
|
||||||
|
activeConversationId.value = null
|
||||||
|
toast.error('会话不存在')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
query: z.string({ required_error: '请输入教学需求' }).describe('教学需求'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: toTypedSchema(schema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (values: z.infer<typeof schema>) => {
|
||||||
|
http_stream<z.infer<typeof schema>>('/ai/ideological-case/stream', values, {
|
||||||
|
onStart(id, created_at) {
|
||||||
|
activeConversationId.value = id
|
||||||
|
updateConversation(id, {
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
title: values.query,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `*生成一份关于 ${values.query} 的思政案例*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onTextChunk: (chunk) => {
|
||||||
|
appendChunkToLast(activeConversationId.value!, chunk)
|
||||||
|
},
|
||||||
|
onComplete: (id, finished_at) => {
|
||||||
|
updateConversation(id!, {
|
||||||
|
finished_at,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<AiConversation
|
||||||
|
:form
|
||||||
|
:form-schema="schema"
|
||||||
|
:form-field-config="{
|
||||||
|
query: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入教学需求和其他要求',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
:conversations
|
||||||
|
:active-conversation-id="activeConversationId"
|
||||||
|
disable-user-input
|
||||||
|
@submit="onSubmit"
|
||||||
|
@update:conversation-id="activeConversationId = $event"
|
||||||
|
@delete-conversation="historyStore.removeConversation($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
137
components/fn/teach/ResearchPlan.vue
Normal file
137
components/fn/teach/ResearchPlan.vue
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const historyStore = useLlmHistories('course-research-plan')
|
||||||
|
const { conversations } = storeToRefs(historyStore)
|
||||||
|
const { updateConversation, appendChunkToLast, isConversationExist } =
|
||||||
|
historyStore
|
||||||
|
|
||||||
|
const activeConversationId = ref<string | null>(null)
|
||||||
|
|
||||||
|
watch(activeConversationId, (val) => {
|
||||||
|
if (val) {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: route.query.fn,
|
||||||
|
conversationId: val,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: route.query.fn,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (route.query.conversationId) {
|
||||||
|
if (isConversationExist(route.query.conversationId as string)) {
|
||||||
|
activeConversationId.value = route.query.conversationId as string
|
||||||
|
} else {
|
||||||
|
activeConversationId.value = null
|
||||||
|
toast.error('会话不存在')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
subject: z.string({ required_error: '请输入学科名称' }).describe('学科'),
|
||||||
|
backgroundAnalysis: z
|
||||||
|
.string({ required_error: '请输入当前学科的现状和背景分析' })
|
||||||
|
.describe('背景及现状分析'),
|
||||||
|
workGoal: z
|
||||||
|
.string({ required_error: '请输入教研计划的工作目标' })
|
||||||
|
.describe('工作目标'),
|
||||||
|
workFocusAndMeasures: z
|
||||||
|
.string({
|
||||||
|
required_error: '请输入教研计划的工作重点和措施',
|
||||||
|
})
|
||||||
|
.describe('工作重点和措施'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: toTypedSchema(schema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (values: z.infer<typeof schema>) => {
|
||||||
|
http_stream<z.infer<typeof schema>>(
|
||||||
|
'/ai/teaching-research-plan/stream',
|
||||||
|
values,
|
||||||
|
{
|
||||||
|
onStart(id, created_at) {
|
||||||
|
activeConversationId.value = id
|
||||||
|
updateConversation(id, {
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
title: values.subject,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `生成教研计划\n学科:${values.subject}\n背景及现状分析:${values.backgroundAnalysis}\n工作目标:${values.workGoal}\n工作重点和措施:${values.workFocusAndMeasures}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onTextChunk: (chunk) => {
|
||||||
|
appendChunkToLast(activeConversationId.value!, chunk)
|
||||||
|
},
|
||||||
|
onComplete: (id, finished_at) => {
|
||||||
|
updateConversation(id!, {
|
||||||
|
finished_at,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<AiConversation
|
||||||
|
:form
|
||||||
|
:form-schema="schema"
|
||||||
|
:form-field-config="{
|
||||||
|
subject: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入学科名称,如:大学英语、电力电子技术',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backgroundAnalysis: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入当前学科的现状和背景分析',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workGoal: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入教研计划的工作目标,如:提升教学质量',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workFocusAndMeasures: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入教研计划的工作重点和措施,如:开展教学研讨',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
:conversations
|
||||||
|
:active-conversation-id="activeConversationId"
|
||||||
|
disable-user-input
|
||||||
|
@submit="onSubmit"
|
||||||
|
@update:conversation-id="activeConversationId = $event"
|
||||||
|
@delete-conversation="historyStore.removeConversation($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
114
components/fn/teach/StdDesign.vue
Normal file
114
components/fn/teach/StdDesign.vue
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const historyStore = useLlmHistories('course-std-design')
|
||||||
|
const { conversations } = storeToRefs(historyStore)
|
||||||
|
const {
|
||||||
|
updateConversation,
|
||||||
|
appendChunkToLast,
|
||||||
|
isConversationExist
|
||||||
|
} = historyStore
|
||||||
|
|
||||||
|
const activeConversationId = ref<string | null>(null)
|
||||||
|
|
||||||
|
watch(activeConversationId, (val) => {
|
||||||
|
if (val) {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: route.query.fn,
|
||||||
|
conversationId: val,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: route.query.fn,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (route.query.conversationId) {
|
||||||
|
if (isConversationExist(route.query.conversationId as string)) {
|
||||||
|
activeConversationId.value = route.query.conversationId as string
|
||||||
|
} else {
|
||||||
|
activeConversationId.value = null
|
||||||
|
toast.error('会话不存在')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
query: z.string({ required_error: '请输入课程名称' }).describe('课程名称'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: toTypedSchema(schema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (values: z.infer<typeof schema>) => {
|
||||||
|
http_stream<z.infer<typeof schema>>(
|
||||||
|
'/ai/course-standard/stream',
|
||||||
|
values,
|
||||||
|
{
|
||||||
|
onStart(id, created_at) {
|
||||||
|
activeConversationId.value = id
|
||||||
|
updateConversation(id, {
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
title: values.query,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `*生成一份 ${values.query} 的课程标准*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onTextChunk: (chunk) => {
|
||||||
|
appendChunkToLast(activeConversationId.value!, chunk)
|
||||||
|
},
|
||||||
|
onComplete: (id, finished_at) => {
|
||||||
|
updateConversation(id!, {
|
||||||
|
finished_at,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<AiConversation
|
||||||
|
:form
|
||||||
|
:form-schema="schema"
|
||||||
|
:form-field-config="{
|
||||||
|
query: {
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入课程名称,如:数据结构与算法',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
:conversations
|
||||||
|
:active-conversation-id="activeConversationId"
|
||||||
|
disable-user-input
|
||||||
|
@submit="onSubmit"
|
||||||
|
@update:conversation-id="activeConversationId = $event"
|
||||||
|
@delete-conversation="historyStore.removeConversation($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export interface SubNavItem {
|
export interface NavSecondaryItem {
|
||||||
label: string
|
label: string
|
||||||
to: string
|
to: string
|
||||||
|
external?: boolean
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
defineProps<{
|
defineProps<{
|
||||||
navs: SubNavItem[]
|
navs: NavSecondaryItem[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -29,7 +30,7 @@ const isCurrentPath = (path: string) => {
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="absolute inset-0 aspect-auto"
|
class="absolute inset-0 aspect-auto top-1"
|
||||||
viewBox="0 0 206.5 72"
|
viewBox="0 0 206.5 72"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@ -62,12 +63,14 @@ const isCurrentPath = (path: string) => {
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
class="text-lg font-medium z-10 select-none"
|
class="text-base font-medium z-10 select-none pb-0.5"
|
||||||
:class="{
|
:class="{
|
||||||
'text-secondary': isCurrentPath(nav.to),
|
'text-secondary': isCurrentPath(nav.to),
|
||||||
'text-neutral-400 dark:text-neutral-500': !isCurrentPath(nav.to),
|
'text-neutral-400 dark:text-neutral-500': !isCurrentPath(nav.to),
|
||||||
}"
|
}"
|
||||||
:to="nav.to"
|
:to="nav.to"
|
||||||
|
:external="nav.external"
|
||||||
|
:target="nav.external ? '_blank' : undefined"
|
||||||
>
|
>
|
||||||
{{ nav.label }}
|
{{ nav.label }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@ -77,13 +80,13 @@ const isCurrentPath = (path: string) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.subnav-item {
|
.subnav-item {
|
||||||
@apply relative flex justify-center items-center px-4 pt-1 drop-shadow-md;
|
@apply relative flex justify-center items-center px-5 pt-2 drop-shadow-lg;
|
||||||
|
|
||||||
--svg-stop1: #ffffff;
|
--svg-stop1: #ffffff;
|
||||||
--svg-stop2: #f3f3f3;
|
--svg-stop2: #f3f3f3;
|
||||||
|
|
||||||
&:not(:first-of-type) {
|
&:not(:first-of-type) {
|
||||||
@apply -ml-3;
|
@apply -ml-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
52
components/nav/Tertiary.vue
Normal file
52
components/nav/Tertiary.vue
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export interface NavTertiaryItem {
|
||||||
|
label: string
|
||||||
|
to?: string
|
||||||
|
component?: string | Component
|
||||||
|
props?: Record<string, unknown>
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
navs: NavTertiaryItem[]
|
||||||
|
modelValue?: number
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', idx: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isActiveItem = (idx: number) => {
|
||||||
|
return props.modelValue === idx
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClickItem = (idx: number) => {
|
||||||
|
if (props.navs[idx].disabled) return
|
||||||
|
emit('update:modelValue', idx)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(nav, i) in navs"
|
||||||
|
:key="i"
|
||||||
|
class="flex justify-center items-center gap-2 p-2.5 rounded-sm cursor-pointer select-none transition-colors duration-75"
|
||||||
|
:class="`${isActiveItem(i) ? 'bg-primary text-primary-foreground' : 'bg-accent text-foreground'} ${nav.disabled ? 'cursor-not-allowed text-foreground/40' : ''}`"
|
||||||
|
@click="onClickItem(i)"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ nav.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
19
components/ui/accordion/Accordion.vue
Normal file
19
components/ui/accordion/Accordion.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
AccordionRoot,
|
||||||
|
type AccordionRootEmits,
|
||||||
|
type AccordionRootProps,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<AccordionRootProps>()
|
||||||
|
const emits = defineEmits<AccordionRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AccordionRoot v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</AccordionRoot>
|
||||||
|
</template>
|
24
components/ui/accordion/AccordionContent.vue
Normal file
24
components/ui/accordion/AccordionContent.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { AccordionContent, type AccordionContentProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AccordionContent
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
class="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||||
|
>
|
||||||
|
<div :class="cn('pb-4 pt-0', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</template>
|
24
components/ui/accordion/AccordionItem.vue
Normal file
24
components/ui/accordion/AccordionItem.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AccordionItem
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('border-b', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AccordionItem>
|
||||||
|
</template>
|
39
components/ui/accordion/AccordionTrigger.vue
Normal file
39
components/ui/accordion/AccordionTrigger.vue
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ChevronDown } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
AccordionHeader,
|
||||||
|
AccordionTrigger,
|
||||||
|
type AccordionTriggerProps,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AccordionHeader class="flex">
|
||||||
|
<AccordionTrigger
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<slot name="icon">
|
||||||
|
<ChevronDown
|
||||||
|
class="h-4 w-4 shrink-0 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</AccordionTrigger>
|
||||||
|
</AccordionHeader>
|
||||||
|
</template>
|
4
components/ui/accordion/index.ts
Normal file
4
components/ui/accordion/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { default as Accordion } from './Accordion.vue'
|
||||||
|
export { default as AccordionContent } from './AccordionContent.vue'
|
||||||
|
export { default as AccordionItem } from './AccordionItem.vue'
|
||||||
|
export { default as AccordionTrigger } from './AccordionTrigger.vue'
|
105
components/ui/auto-form/AutoForm.vue
Normal file
105
components/ui/auto-form/AutoForm.vue
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts" generic="T extends ZodObjectOrWrapped">
|
||||||
|
import type { FormContext, GenericObject } from 'vee-validate'
|
||||||
|
import type { z, ZodAny } from 'zod'
|
||||||
|
import type { Config, ConfigItem, Dependency, Shape } from './interface'
|
||||||
|
import { Form } from '@/components/ui/form'
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import { computed, toRefs } from 'vue'
|
||||||
|
import AutoFormField from './AutoFormField.vue'
|
||||||
|
import { provideDependencies } from './dependencies'
|
||||||
|
import { getBaseSchema, getBaseType, getDefaultValueInZodStack, getObjectFormSchema, type ZodObjectOrWrapped } from './utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
schema: T
|
||||||
|
form?: FormContext<GenericObject>
|
||||||
|
fieldConfig?: Config<z.infer<T>>
|
||||||
|
dependencies?: Dependency<z.infer<T>>[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
submit: [event: z.infer<T>]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { dependencies } = toRefs(props)
|
||||||
|
provideDependencies(dependencies)
|
||||||
|
|
||||||
|
const shapes = computed(() => {
|
||||||
|
// @ts-expect-error ignore {} not assignable to object
|
||||||
|
const val: { [key in keyof T]: Shape } = {}
|
||||||
|
const baseSchema = getObjectFormSchema(props.schema)
|
||||||
|
const shape = baseSchema.shape
|
||||||
|
Object.keys(shape).forEach((name) => {
|
||||||
|
const item = shape[name] as ZodAny
|
||||||
|
const baseItem = getBaseSchema(item) as ZodAny
|
||||||
|
let options = (baseItem && 'values' in baseItem._def) ? baseItem._def.values as string[] : undefined
|
||||||
|
if (!Array.isArray(options) && typeof options === 'object')
|
||||||
|
options = Object.values(options)
|
||||||
|
|
||||||
|
val[name as keyof T] = {
|
||||||
|
type: getBaseType(item),
|
||||||
|
default: getDefaultValueInZodStack(item),
|
||||||
|
options,
|
||||||
|
required: !['ZodOptional', 'ZodNullable'].includes(item._def.typeName),
|
||||||
|
schema: baseItem,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return val
|
||||||
|
})
|
||||||
|
|
||||||
|
const fields = computed(() => {
|
||||||
|
// @ts-expect-error ignore {} not assignable to object
|
||||||
|
const val: { [key in keyof z.infer<T>]: { shape: Shape, fieldName: string, config: ConfigItem } } = {}
|
||||||
|
for (const key in shapes.value) {
|
||||||
|
const shape = shapes.value[key]
|
||||||
|
val[key as keyof z.infer<T>] = {
|
||||||
|
shape,
|
||||||
|
config: props.fieldConfig?.[key] as ConfigItem,
|
||||||
|
fieldName: key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
})
|
||||||
|
|
||||||
|
const formComponent = computed(() => props.form ? 'form' : Form)
|
||||||
|
const formComponentProps = computed(() => {
|
||||||
|
if (props.form) {
|
||||||
|
return {
|
||||||
|
onSubmit: props.form.handleSubmit(val => emits('submit', val)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const formSchema = toTypedSchema(props.schema)
|
||||||
|
return {
|
||||||
|
keepValues: true,
|
||||||
|
validationSchema: formSchema,
|
||||||
|
onSubmit: (val: GenericObject) => emits('submit', val),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="formComponent"
|
||||||
|
v-bind="formComponentProps"
|
||||||
|
>
|
||||||
|
<slot name="customAutoForm" :fields="fields">
|
||||||
|
<template v-for="(shape, key) of shapes" :key="key">
|
||||||
|
<slot
|
||||||
|
:shape="shape"
|
||||||
|
:name="key.toString() as keyof z.infer<T>"
|
||||||
|
:field-name="key.toString()"
|
||||||
|
:config="fieldConfig?.[key as keyof typeof fieldConfig] as ConfigItem"
|
||||||
|
>
|
||||||
|
<AutoFormField
|
||||||
|
:config="fieldConfig?.[key as keyof typeof fieldConfig] as ConfigItem"
|
||||||
|
:field-name="key.toString()"
|
||||||
|
:shape="shape"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<slot :shapes="shapes" />
|
||||||
|
</component>
|
||||||
|
</template>
|
45
components/ui/auto-form/AutoFormField.vue
Normal file
45
components/ui/auto-form/AutoFormField.vue
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts" generic="U extends ZodAny">
|
||||||
|
import type { ZodAny } from 'zod'
|
||||||
|
import type { Config, ConfigItem, Shape } from './interface'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { DEFAULT_ZOD_HANDLERS, INPUT_COMPONENTS } from './constant'
|
||||||
|
import useDependencies from './dependencies'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
fieldName: string
|
||||||
|
shape: Shape
|
||||||
|
config?: ConfigItem | Config<U>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function isValidConfig(config: any): config is ConfigItem {
|
||||||
|
return !!config?.component
|
||||||
|
}
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
if (['ZodObject', 'ZodArray'].includes(props.shape?.type))
|
||||||
|
return { schema: props.shape?.schema }
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const { isDisabled, isHidden, isRequired, overrideOptions } = useDependencies(props.fieldName)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="isValidConfig(config)
|
||||||
|
? typeof config.component === 'string'
|
||||||
|
? INPUT_COMPONENTS[config.component!]
|
||||||
|
: config.component
|
||||||
|
: INPUT_COMPONENTS[DEFAULT_ZOD_HANDLERS[shape.type]] "
|
||||||
|
v-if="!isHidden"
|
||||||
|
:field-name="fieldName"
|
||||||
|
:label="shape.schema?.description"
|
||||||
|
:required="isRequired || shape.required"
|
||||||
|
:options="overrideOptions || shape.options"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
:config="config"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
110
components/ui/auto-form/AutoFormFieldArray.vue
Normal file
110
components/ui/auto-form/AutoFormFieldArray.vue
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts" generic="T extends z.ZodAny">
|
||||||
|
import type { Config, ConfigItem } from './interface'
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { FormItem, FormMessage } from '@/components/ui/form'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { PlusIcon, TrashIcon } from 'lucide-vue-next'
|
||||||
|
import { FieldArray, FieldContextKey, useField } from 'vee-validate'
|
||||||
|
import { computed, provide } from 'vue'
|
||||||
|
import * as z from 'zod'
|
||||||
|
import AutoFormField from './AutoFormField.vue'
|
||||||
|
import AutoFormLabel from './AutoFormLabel.vue'
|
||||||
|
import { beautifyObjectName, getBaseType } from './utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
fieldName: string
|
||||||
|
required?: boolean
|
||||||
|
config?: Config<T>
|
||||||
|
schema?: z.ZodArray<T>
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function isZodArray(
|
||||||
|
item: z.ZodArray<any> | z.ZodDefault<any>,
|
||||||
|
): item is z.ZodArray<any> {
|
||||||
|
return item instanceof z.ZodArray
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZodDefault(
|
||||||
|
item: z.ZodArray<any> | z.ZodDefault<any>,
|
||||||
|
): item is z.ZodDefault<any> {
|
||||||
|
return item instanceof z.ZodDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemShape = computed(() => {
|
||||||
|
if (!props.schema)
|
||||||
|
return
|
||||||
|
|
||||||
|
const schema: z.ZodAny = isZodArray(props.schema)
|
||||||
|
? props.schema._def.type
|
||||||
|
: isZodDefault(props.schema)
|
||||||
|
// @ts-expect-error missing schema
|
||||||
|
? props.schema._def.innerType._def.type
|
||||||
|
: null
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: getBaseType(schema),
|
||||||
|
schema,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fieldContext = useField(props.fieldName)
|
||||||
|
// @ts-expect-error ignore missing `id`
|
||||||
|
provide(FieldContextKey, fieldContext)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FieldArray v-slot="{ fields, remove, push }" as="section" :name="fieldName">
|
||||||
|
<slot v-bind="props">
|
||||||
|
<Accordion type="multiple" class="w-full" collapsible :disabled="disabled" as-child>
|
||||||
|
<FormItem>
|
||||||
|
<AccordionItem :value="fieldName" class="border-none">
|
||||||
|
<AccordionTrigger>
|
||||||
|
<AutoFormLabel class="text-base" :required="required">
|
||||||
|
{{ schema?.description || beautifyObjectName(fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
</AccordionTrigger>
|
||||||
|
|
||||||
|
<AccordionContent>
|
||||||
|
<template v-for="(field, index) of fields" :key="field.key">
|
||||||
|
<div class="mb-4 p-1">
|
||||||
|
<AutoFormField
|
||||||
|
:field-name="`${fieldName}[${index}]`"
|
||||||
|
:label="fieldName"
|
||||||
|
:shape="itemShape!"
|
||||||
|
:config="config as ConfigItem"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="!my-4 flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="secondary"
|
||||||
|
@click="remove(index)"
|
||||||
|
>
|
||||||
|
<TrashIcon :size="16" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Separator v-if="!field.isLast" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
class="mt-4 flex items-center"
|
||||||
|
@click="push(null)"
|
||||||
|
>
|
||||||
|
<PlusIcon class="mr-2" :size="16" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</AccordionContent>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</AccordionItem>
|
||||||
|
</FormItem>
|
||||||
|
</Accordion>
|
||||||
|
</slot>
|
||||||
|
</FieldArray>
|
||||||
|
</template>
|
41
components/ui/auto-form/AutoFormFieldBoolean.vue
Normal file
41
components/ui/auto-form/AutoFormFieldBoolean.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FieldProps } from './interface'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/components/ui/form'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import AutoFormLabel from './AutoFormLabel.vue'
|
||||||
|
import { beautifyObjectName, maybeBooleanishToBoolean } from './utils'
|
||||||
|
|
||||||
|
const props = defineProps<FieldProps>()
|
||||||
|
|
||||||
|
const booleanComponent = computed(() => props.config?.component === 'switch' ? Switch : Checkbox)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormField v-slot="slotProps" :name="fieldName">
|
||||||
|
<FormItem>
|
||||||
|
<div class="space-y-0 mb-3 flex items-center gap-3">
|
||||||
|
<FormControl>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<component
|
||||||
|
:is="booleanComponent"
|
||||||
|
:disabled="maybeBooleanishToBoolean(config?.inputProps?.disabled) ?? disabled"
|
||||||
|
:name="slotProps.componentField.name"
|
||||||
|
:model-value="slotProps.componentField.modelValue"
|
||||||
|
@update:model-value="slotProps.componentField['onUpdate:modelValue']"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</FormControl>
|
||||||
|
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||||
|
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormDescription v-if="config?.description">
|
||||||
|
{{ config.description }}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</template>
|
57
components/ui/auto-form/AutoFormFieldDate.vue
Normal file
57
components/ui/auto-form/AutoFormFieldDate.vue
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FieldProps } from './interface'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Calendar } from '@/components/ui/calendar'
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/components/ui/form'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
|
||||||
|
import { DateFormatter, getLocalTimeZone } from '@internationalized/date'
|
||||||
|
import { CalendarIcon } from 'lucide-vue-next'
|
||||||
|
import AutoFormLabel from './AutoFormLabel.vue'
|
||||||
|
import { beautifyObjectName, maybeBooleanishToBoolean } from './utils'
|
||||||
|
|
||||||
|
defineProps<FieldProps>()
|
||||||
|
|
||||||
|
const df = new DateFormatter('en-US', {
|
||||||
|
dateStyle: 'long',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormField v-slot="slotProps" :name="fieldName">
|
||||||
|
<FormItem>
|
||||||
|
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||||
|
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<div>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger as-child :disabled="maybeBooleanishToBoolean(config?.inputProps?.disabled) ?? disabled">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:class="cn(
|
||||||
|
'w-full justify-start text-left font-normal',
|
||||||
|
!slotProps.componentField.modelValue && 'text-muted-foreground',
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<CalendarIcon class="mr-2 h-4 w-4" :size="16" />
|
||||||
|
{{ slotProps.componentField.modelValue ? df.format(slotProps.componentField.modelValue.toDate(getLocalTimeZone())) : "Pick a date" }}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-auto p-0">
|
||||||
|
<Calendar initial-focus v-bind="slotProps.componentField" />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription v-if="config?.description">
|
||||||
|
{{ config.description }}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</template>
|
49
components/ui/auto-form/AutoFormFieldEnum.vue
Normal file
49
components/ui/auto-form/AutoFormFieldEnum.vue
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FieldProps } from './interface'
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/components/ui/form'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import AutoFormLabel from './AutoFormLabel.vue'
|
||||||
|
import { beautifyObjectName, maybeBooleanishToBoolean } from './utils'
|
||||||
|
|
||||||
|
defineProps<FieldProps & {
|
||||||
|
options?: string[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormField v-slot="slotProps" :name="fieldName">
|
||||||
|
<FormItem>
|
||||||
|
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||||
|
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<RadioGroup v-if="config?.component === 'radio'" :disabled="maybeBooleanishToBoolean(config?.inputProps?.disabled) ?? disabled" :orientation="'vertical'" v-bind="{ ...slotProps.componentField }">
|
||||||
|
<div v-for="(option, index) in options" :key="option" class="mb-2 flex items-center gap-3 space-y-0">
|
||||||
|
<RadioGroupItem :id="`${option}-${index}`" :value="option" />
|
||||||
|
<Label :for="`${option}-${index}`">{{ beautifyObjectName(option) }}</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<Select v-else :disabled="maybeBooleanishToBoolean(config?.inputProps?.disabled) ?? disabled" v-bind="{ ...slotProps.componentField }">
|
||||||
|
<SelectTrigger class="w-full">
|
||||||
|
<SelectValue :placeholder="config?.inputProps?.placeholder" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem v-for="option in options" :key="option" :value="option">
|
||||||
|
{{ beautifyObjectName(option) }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</slot>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription v-if="config?.description">
|
||||||
|
{{ config.description }}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</template>
|
74
components/ui/auto-form/AutoFormFieldFile.vue
Normal file
74
components/ui/auto-form/AutoFormFieldFile.vue
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FieldProps } from './interface'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { TrashIcon } from 'lucide-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import AutoFormLabel from './AutoFormLabel.vue'
|
||||||
|
import { beautifyObjectName } from './utils'
|
||||||
|
|
||||||
|
defineProps<FieldProps>()
|
||||||
|
|
||||||
|
const inputFile = ref<File>()
|
||||||
|
async function parseFileAsString(file: File | undefined): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onloadend = () => {
|
||||||
|
resolve(reader.result as string)
|
||||||
|
}
|
||||||
|
reader.onerror = (err) => {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormField v-slot="slotProps" :name="fieldName">
|
||||||
|
<FormItem v-bind="$attrs">
|
||||||
|
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||||
|
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<Input
|
||||||
|
v-if="!inputFile"
|
||||||
|
type="file"
|
||||||
|
v-bind="{ ...config?.inputProps }"
|
||||||
|
:disabled="config?.inputProps?.disabled ?? disabled"
|
||||||
|
@change="async (ev: InputEvent) => {
|
||||||
|
const file = (ev.target as HTMLInputElement).files?.[0]
|
||||||
|
inputFile = file
|
||||||
|
const parsed = await parseFileAsString(file)
|
||||||
|
slotProps.componentField.onInput(parsed)
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<div v-else class="flex h-10 w-full items-center justify-between rounded-md border border-input bg-transparent pl-3 pr-1 py-1 text-sm shadow-sm transition-colors">
|
||||||
|
<p>{{ inputFile?.name }}</p>
|
||||||
|
<Button
|
||||||
|
:size="'icon'"
|
||||||
|
:variant="'ghost'"
|
||||||
|
class="h-[26px] w-[26px]"
|
||||||
|
aria-label="Remove file"
|
||||||
|
type="button"
|
||||||
|
@click="() => {
|
||||||
|
inputFile = undefined
|
||||||
|
slotProps.componentField.onInput(undefined)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<TrashIcon :size="16" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription v-if="config?.description">
|
||||||
|
{{ config.description }}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</template>
|
36
components/ui/auto-form/AutoFormFieldInput.vue
Normal file
36
components/ui/auto-form/AutoFormFieldInput.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FieldProps } from './interface'
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import AutoFormLabel from './AutoFormLabel.vue'
|
||||||
|
import { beautifyObjectName } from './utils'
|
||||||
|
|
||||||
|
const props = defineProps<FieldProps>()
|
||||||
|
const inputComponent = computed(() => props.config?.component === 'textarea' ? Textarea : Input)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormField v-slot="slotProps" :name="fieldName">
|
||||||
|
<FormItem v-bind="$attrs">
|
||||||
|
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||||
|
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<component
|
||||||
|
:is="inputComponent"
|
||||||
|
type="text"
|
||||||
|
v-bind="{ ...slotProps.componentField, ...config?.inputProps }"
|
||||||
|
:disabled="config?.inputProps?.disabled ?? disabled"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription v-if="config?.description">
|
||||||
|
{{ config.description }}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</template>
|
32
components/ui/auto-form/AutoFormFieldNumber.vue
Normal file
32
components/ui/auto-form/AutoFormFieldNumber.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FieldProps } from './interface'
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import AutoFormLabel from './AutoFormLabel.vue'
|
||||||
|
import { beautifyObjectName } from './utils'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<FieldProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormField v-slot="slotProps" :name="fieldName">
|
||||||
|
<FormItem>
|
||||||
|
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||||
|
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<Input type="number" v-bind="{ ...slotProps.componentField, ...config?.inputProps }" :disabled="config?.inputProps?.disabled ?? disabled" />
|
||||||
|
</slot>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription v-if="config?.description">
|
||||||
|
{{ config.description }}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</template>
|
78
components/ui/auto-form/AutoFormFieldObject.vue
Normal file
78
components/ui/auto-form/AutoFormFieldObject.vue
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts" generic="T extends ZodRawShape">
|
||||||
|
import type { ZodAny, ZodObject, ZodRawShape } from 'zod'
|
||||||
|
import type { Config, ConfigItem, Shape } from './interface'
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||||
|
import { FormItem } from '@/components/ui/form'
|
||||||
|
import { FieldContextKey, useField } from 'vee-validate'
|
||||||
|
import { computed, provide } from 'vue'
|
||||||
|
import AutoFormField from './AutoFormField.vue'
|
||||||
|
import AutoFormLabel from './AutoFormLabel.vue'
|
||||||
|
import { beautifyObjectName, getBaseSchema, getBaseType, getDefaultValueInZodStack } from './utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
fieldName: string
|
||||||
|
required?: boolean
|
||||||
|
config?: Config<T>
|
||||||
|
schema?: ZodObject<T>
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const shapes = computed(() => {
|
||||||
|
// @ts-expect-error ignore {} not assignable to object
|
||||||
|
const val: { [key in keyof T]: Shape } = {}
|
||||||
|
|
||||||
|
if (!props.schema)
|
||||||
|
return
|
||||||
|
const shape = getBaseSchema(props.schema)?.shape
|
||||||
|
if (!shape)
|
||||||
|
return
|
||||||
|
Object.keys(shape).forEach((name) => {
|
||||||
|
const item = shape[name] as ZodAny
|
||||||
|
const baseItem = getBaseSchema(item) as ZodAny
|
||||||
|
let options = (baseItem && 'values' in baseItem._def) ? baseItem._def.values as string[] : undefined
|
||||||
|
if (!Array.isArray(options) && typeof options === 'object')
|
||||||
|
options = Object.values(options)
|
||||||
|
|
||||||
|
val[name as keyof T] = {
|
||||||
|
type: getBaseType(item),
|
||||||
|
default: getDefaultValueInZodStack(item),
|
||||||
|
options,
|
||||||
|
required: !['ZodOptional', 'ZodNullable'].includes(item._def.typeName),
|
||||||
|
schema: item,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return val
|
||||||
|
})
|
||||||
|
|
||||||
|
const fieldContext = useField(props.fieldName)
|
||||||
|
// @ts-expect-error ignore missing `id`
|
||||||
|
provide(FieldContextKey, fieldContext)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<slot v-bind="props">
|
||||||
|
<Accordion type="single" as-child class="w-full" collapsible :disabled="disabled">
|
||||||
|
<FormItem>
|
||||||
|
<AccordionItem :value="fieldName" class="border-none">
|
||||||
|
<AccordionTrigger>
|
||||||
|
<AutoFormLabel class="text-base" :required="required">
|
||||||
|
{{ schema?.description || beautifyObjectName(fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent class="p-1 space-y-5">
|
||||||
|
<template v-for="(shape, key) in shapes" :key="key">
|
||||||
|
<AutoFormField
|
||||||
|
:config="config?.[key as keyof typeof config] as ConfigItem"
|
||||||
|
:field-name="`${fieldName}.${key.toString()}`"
|
||||||
|
:label="key.toString()"
|
||||||
|
:shape="shape"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</FormItem>
|
||||||
|
</Accordion>
|
||||||
|
</slot>
|
||||||
|
</section>
|
||||||
|
</template>
|
14
components/ui/auto-form/AutoFormLabel.vue
Normal file
14
components/ui/auto-form/AutoFormLabel.vue
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { FormLabel } from '@/components/ui/form'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
required?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormLabel>
|
||||||
|
<slot />
|
||||||
|
<span v-if="required" class="text-destructive"> *</span>
|
||||||
|
</FormLabel>
|
||||||
|
</template>
|
40
components/ui/auto-form/constant.ts
Normal file
40
components/ui/auto-form/constant.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { InputComponents } from './interface'
|
||||||
|
import AutoFormFieldArray from './AutoFormFieldArray.vue'
|
||||||
|
import AutoFormFieldBoolean from './AutoFormFieldBoolean.vue'
|
||||||
|
import AutoFormFieldDate from './AutoFormFieldDate.vue'
|
||||||
|
import AutoFormFieldEnum from './AutoFormFieldEnum.vue'
|
||||||
|
import AutoFormFieldFile from './AutoFormFieldFile.vue'
|
||||||
|
import AutoFormFieldInput from './AutoFormFieldInput.vue'
|
||||||
|
import AutoFormFieldNumber from './AutoFormFieldNumber.vue'
|
||||||
|
import AutoFormFieldObject from './AutoFormFieldObject.vue'
|
||||||
|
|
||||||
|
export const INPUT_COMPONENTS: InputComponents = {
|
||||||
|
date: AutoFormFieldDate,
|
||||||
|
select: AutoFormFieldEnum,
|
||||||
|
radio: AutoFormFieldEnum,
|
||||||
|
checkbox: AutoFormFieldBoolean,
|
||||||
|
switch: AutoFormFieldBoolean,
|
||||||
|
textarea: AutoFormFieldInput,
|
||||||
|
number: AutoFormFieldNumber,
|
||||||
|
string: AutoFormFieldInput,
|
||||||
|
file: AutoFormFieldFile,
|
||||||
|
array: AutoFormFieldArray,
|
||||||
|
object: AutoFormFieldObject,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define handlers for specific Zod types.
|
||||||
|
* You can expand this object to support more types.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ZOD_HANDLERS: {
|
||||||
|
[key: string]: keyof typeof INPUT_COMPONENTS
|
||||||
|
} = {
|
||||||
|
ZodString: 'string',
|
||||||
|
ZodBoolean: 'checkbox',
|
||||||
|
ZodDate: 'date',
|
||||||
|
ZodEnum: 'select',
|
||||||
|
ZodNativeEnum: 'select',
|
||||||
|
ZodNumber: 'number',
|
||||||
|
ZodArray: 'array',
|
||||||
|
ZodObject: 'object',
|
||||||
|
}
|
92
components/ui/auto-form/dependencies.ts
Normal file
92
components/ui/auto-form/dependencies.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
import type * as z from 'zod'
|
||||||
|
import { createContext } from 'reka-ui'
|
||||||
|
import { useFieldValue, useFormValues } from 'vee-validate'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { type Dependency, DependencyType, type EnumValues } from './interface'
|
||||||
|
import { getFromPath, getIndexIfArray } from './utils'
|
||||||
|
|
||||||
|
export const [injectDependencies, provideDependencies] = createContext<Ref<Dependency<z.infer<z.ZodObject<any>>>[] | undefined>>('AutoFormDependencies')
|
||||||
|
|
||||||
|
export default function useDependencies(
|
||||||
|
fieldName: string,
|
||||||
|
) {
|
||||||
|
const form = useFormValues()
|
||||||
|
// parsed test[0].age => test.age
|
||||||
|
const currentFieldName = fieldName.replace(/\[\d+\]/g, '')
|
||||||
|
const currentFieldValue = useFieldValue<any>(fieldName)
|
||||||
|
|
||||||
|
if (!form)
|
||||||
|
throw new Error('useDependencies should be used within <AutoForm>')
|
||||||
|
|
||||||
|
const dependencies = injectDependencies()
|
||||||
|
const isDisabled = ref(false)
|
||||||
|
const isHidden = ref(false)
|
||||||
|
const isRequired = ref(false)
|
||||||
|
const overrideOptions = ref<EnumValues | undefined>()
|
||||||
|
|
||||||
|
const currentFieldDependencies = computed(() => dependencies.value?.filter(
|
||||||
|
dependency => dependency.targetField === currentFieldName,
|
||||||
|
))
|
||||||
|
|
||||||
|
function getSourceValue(dep: Dependency<any>) {
|
||||||
|
const source = dep.sourceField as string
|
||||||
|
const index = getIndexIfArray(fieldName) ?? -1
|
||||||
|
const [sourceLast, ...sourceInitial] = source.split('.').toReversed()
|
||||||
|
const [_targetLast, ...targetInitial] = (dep.targetField as string).split('.').toReversed()
|
||||||
|
|
||||||
|
if (index >= 0 && sourceInitial.join(',') === targetInitial.join(',')) {
|
||||||
|
const [_currentLast, ...currentInitial] = fieldName.split('.').toReversed()
|
||||||
|
return getFromPath(form.value, currentInitial.join('.') + sourceLast);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getFromPath(form.value, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFieldValues = computed(() => currentFieldDependencies.value?.map(dep => getSourceValue(dep)))
|
||||||
|
|
||||||
|
const resetConditionState = () => {
|
||||||
|
isDisabled.value = false
|
||||||
|
isHidden.value = false
|
||||||
|
isRequired.value = false
|
||||||
|
overrideOptions.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([sourceFieldValues, dependencies], () => {
|
||||||
|
resetConditionState()
|
||||||
|
currentFieldDependencies.value?.forEach((dep) => {
|
||||||
|
const sourceValue = getSourceValue(dep)
|
||||||
|
const conditionMet = dep.when(sourceValue, currentFieldValue.value)
|
||||||
|
|
||||||
|
switch (dep.type) {
|
||||||
|
case DependencyType.DISABLES:
|
||||||
|
if (conditionMet)
|
||||||
|
isDisabled.value = true
|
||||||
|
|
||||||
|
break
|
||||||
|
case DependencyType.REQUIRES:
|
||||||
|
if (conditionMet)
|
||||||
|
isRequired.value = true
|
||||||
|
|
||||||
|
break
|
||||||
|
case DependencyType.HIDES:
|
||||||
|
if (conditionMet)
|
||||||
|
isHidden.value = true
|
||||||
|
|
||||||
|
break
|
||||||
|
case DependencyType.SETS_OPTIONS:
|
||||||
|
if (conditionMet)
|
||||||
|
overrideOptions.value = dep.options
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, { immediate: true, deep: true })
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDisabled,
|
||||||
|
isHidden,
|
||||||
|
isRequired,
|
||||||
|
overrideOptions,
|
||||||
|
}
|
||||||
|
}
|
15
components/ui/auto-form/index.ts
Normal file
15
components/ui/auto-form/index.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export { default as AutoForm } from './AutoForm.vue'
|
||||||
|
export { default as AutoFormField } from './AutoFormField.vue'
|
||||||
|
|
||||||
|
export { default as AutoFormFieldArray } from './AutoFormFieldArray.vue'
|
||||||
|
export { default as AutoFormFieldBoolean } from './AutoFormFieldBoolean.vue'
|
||||||
|
export { default as AutoFormFieldDate } from './AutoFormFieldDate.vue'
|
||||||
|
|
||||||
|
export { default as AutoFormFieldEnum } from './AutoFormFieldEnum.vue'
|
||||||
|
export { default as AutoFormFieldFile } from './AutoFormFieldFile.vue'
|
||||||
|
export { default as AutoFormFieldInput } from './AutoFormFieldInput.vue'
|
||||||
|
export { default as AutoFormFieldNumber } from './AutoFormFieldNumber.vue'
|
||||||
|
export { default as AutoFormFieldObject } from './AutoFormFieldObject.vue'
|
||||||
|
export { default as AutoFormLabel } from './AutoFormLabel.vue'
|
||||||
|
export type { Config, ConfigItem, FieldProps } from './interface'
|
||||||
|
export { getBaseSchema, getBaseType, getObjectFormSchema } from './utils'
|
95
components/ui/auto-form/interface.ts
Normal file
95
components/ui/auto-form/interface.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import type { Component, InputHTMLAttributes } from 'vue'
|
||||||
|
import type { z, ZodAny } from 'zod'
|
||||||
|
import type { INPUT_COMPONENTS } from './constant'
|
||||||
|
|
||||||
|
export interface FieldProps {
|
||||||
|
fieldName: string
|
||||||
|
label?: string
|
||||||
|
required?: boolean
|
||||||
|
config?: ConfigItem
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Shape {
|
||||||
|
type: string
|
||||||
|
default?: any
|
||||||
|
required?: boolean
|
||||||
|
options?: string[]
|
||||||
|
schema?: ZodAny
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputComponents {
|
||||||
|
date: Component
|
||||||
|
select: Component
|
||||||
|
radio: Component
|
||||||
|
checkbox: Component
|
||||||
|
switch: Component
|
||||||
|
textarea: Component
|
||||||
|
number: Component
|
||||||
|
string: Component
|
||||||
|
file: Component
|
||||||
|
array: Component
|
||||||
|
object: Component
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigItem {
|
||||||
|
/** Value for the `FormLabel` */
|
||||||
|
label?: string
|
||||||
|
/** Value for the `FormDescription` */
|
||||||
|
description?: string
|
||||||
|
/** Pick which component to be rendered. */
|
||||||
|
component?: keyof typeof INPUT_COMPONENTS | Component
|
||||||
|
/** Hide `FormLabel`. */
|
||||||
|
hideLabel?: boolean
|
||||||
|
inputProps?: InputHTMLAttributes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define a type to unwrap an array
|
||||||
|
type UnwrapArray<T> = T extends (infer U)[] ? U : never
|
||||||
|
|
||||||
|
export type Config<SchemaType extends object> = {
|
||||||
|
// If SchemaType.key is an object, create a nested Config, otherwise ConfigItem
|
||||||
|
[Key in keyof SchemaType]?:
|
||||||
|
SchemaType[Key] extends any[]
|
||||||
|
? UnwrapArray<Config<SchemaType[Key]>>
|
||||||
|
: SchemaType[Key] extends object
|
||||||
|
? Config<SchemaType[Key]>
|
||||||
|
: ConfigItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DependencyType {
|
||||||
|
DISABLES,
|
||||||
|
REQUIRES,
|
||||||
|
HIDES,
|
||||||
|
SETS_OPTIONS,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> {
|
||||||
|
sourceField: keyof SchemaType
|
||||||
|
type: DependencyType
|
||||||
|
targetField: keyof SchemaType
|
||||||
|
when: (sourceFieldValue: any, targetFieldValue: any) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValueDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> =
|
||||||
|
BaseDependency<SchemaType> & {
|
||||||
|
type:
|
||||||
|
| DependencyType.DISABLES
|
||||||
|
| DependencyType.REQUIRES
|
||||||
|
| DependencyType.HIDES
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnumValues = readonly [string, ...string[]]
|
||||||
|
|
||||||
|
export type OptionsDependency<
|
||||||
|
SchemaType extends z.infer<z.ZodObject<any, any>>,
|
||||||
|
> = BaseDependency<SchemaType> & {
|
||||||
|
type: DependencyType.SETS_OPTIONS
|
||||||
|
|
||||||
|
// Partial array of values from sourceField that will trigger the dependency
|
||||||
|
options: EnumValues
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Dependency<SchemaType extends z.infer<z.ZodObject<any, any>>> =
|
||||||
|
| ValueDependency<SchemaType>
|
||||||
|
| OptionsDependency<SchemaType>
|
188
components/ui/auto-form/utils.ts
Normal file
188
components/ui/auto-form/utils.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import type { z } from 'zod'
|
||||||
|
|
||||||
|
// TODO: This should support recursive ZodEffects but TypeScript doesn't allow circular type definitions.
|
||||||
|
export type ZodObjectOrWrapped =
|
||||||
|
| z.ZodObject<any, any>
|
||||||
|
| z.ZodEffects<z.ZodObject<any, any>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beautify a camelCase string.
|
||||||
|
* e.g. "myString" -> "My String"
|
||||||
|
*/
|
||||||
|
export function beautifyObjectName(string: string) {
|
||||||
|
// Remove bracketed indices
|
||||||
|
// if numbers only return the string
|
||||||
|
let output = string.replace(/\[\d+\]/g, '').replace(/([A-Z])/g, ' $1')
|
||||||
|
output = output.charAt(0).toUpperCase() + output.slice(1)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse string and extract the index
|
||||||
|
* @param string
|
||||||
|
* @returns index or undefined
|
||||||
|
*/
|
||||||
|
export function getIndexIfArray(string: string) {
|
||||||
|
const indexRegex = /\[(\d+)\]/
|
||||||
|
// Match the index
|
||||||
|
const match = string.match(indexRegex)
|
||||||
|
// Extract the index (number)
|
||||||
|
const index = match ? Number.parseInt(match[1]) : undefined
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the lowest level Zod type.
|
||||||
|
* This will unpack optionals, refinements, etc.
|
||||||
|
*/
|
||||||
|
export function getBaseSchema<
|
||||||
|
ChildType extends z.ZodAny | z.AnyZodObject = z.ZodAny,
|
||||||
|
>(schema: ChildType | z.ZodEffects<ChildType>): ChildType | null {
|
||||||
|
if (!schema)
|
||||||
|
return null;
|
||||||
|
if ('innerType' in schema._def)
|
||||||
|
return getBaseSchema(schema._def.innerType as ChildType)
|
||||||
|
|
||||||
|
if ('schema' in schema._def)
|
||||||
|
return getBaseSchema(schema._def.schema as ChildType)
|
||||||
|
|
||||||
|
return schema as ChildType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the type name of the lowest level Zod type.
|
||||||
|
* This will unpack optionals, refinements, etc.
|
||||||
|
*/
|
||||||
|
export function getBaseType(schema: z.ZodAny) {
|
||||||
|
const baseSchema = getBaseSchema(schema)
|
||||||
|
return baseSchema ? baseSchema._def.typeName : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a "ZodDefault" in the Zod stack and return its value.
|
||||||
|
*/
|
||||||
|
export function getDefaultValueInZodStack(schema: z.ZodAny): any {
|
||||||
|
const typedSchema = schema as unknown as z.ZodDefault<
|
||||||
|
z.ZodNumber | z.ZodString
|
||||||
|
>
|
||||||
|
|
||||||
|
if (typedSchema._def.typeName === 'ZodDefault')
|
||||||
|
return typedSchema._def.defaultValue()
|
||||||
|
|
||||||
|
if ('innerType' in typedSchema._def) {
|
||||||
|
return getDefaultValueInZodStack(
|
||||||
|
typedSchema._def.innerType as unknown as z.ZodAny,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if ('schema' in typedSchema._def) {
|
||||||
|
return getDefaultValueInZodStack(
|
||||||
|
(typedSchema._def as any).schema as z.ZodAny,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObjectFormSchema(
|
||||||
|
schema: ZodObjectOrWrapped,
|
||||||
|
): z.ZodObject<any, any> {
|
||||||
|
if (schema?._def.typeName === 'ZodEffects') {
|
||||||
|
const typedSchema = schema as z.ZodEffects<z.ZodObject<any, any>>
|
||||||
|
return getObjectFormSchema(typedSchema._def.schema)
|
||||||
|
}
|
||||||
|
return schema as z.ZodObject<any, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIndex(value: unknown): value is number {
|
||||||
|
return Number(value) >= 0;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Constructs a path with dot paths for arrays to use brackets to be compatible with vee-validate path syntax
|
||||||
|
*/
|
||||||
|
export function normalizeFormPath(path: string): string {
|
||||||
|
const pathArr = path.split('.')
|
||||||
|
if (!pathArr.length)
|
||||||
|
return '';
|
||||||
|
|
||||||
|
let fullPath = String(pathArr[0])
|
||||||
|
for (let i = 1; i < pathArr.length; i++) {
|
||||||
|
if (isIndex(pathArr[i])) {
|
||||||
|
fullPath += `[${pathArr[i]}]`
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath += `.${pathArr[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullPath
|
||||||
|
}
|
||||||
|
|
||||||
|
type NestedRecord = Record<string, unknown> | { [k: string]: NestedRecord }
|
||||||
|
/**
|
||||||
|
* Checks if the path opted out of nested fields using `[fieldName]` syntax
|
||||||
|
*/
|
||||||
|
export function isNotNestedPath(path: string) {
|
||||||
|
return /^\[.+\]$/.test(path)
|
||||||
|
}
|
||||||
|
function isObject(obj: unknown): obj is Record<string, unknown> {
|
||||||
|
return obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj);
|
||||||
|
}
|
||||||
|
function isContainerValue(value: unknown): value is Record<string, unknown> {
|
||||||
|
return isObject(value) || Array.isArray(value)
|
||||||
|
}
|
||||||
|
function cleanupNonNestedPath(path: string) {
|
||||||
|
if (isNotNestedPath(path))
|
||||||
|
return path.replace(/\[|\]/g, '');
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a nested property value from an object
|
||||||
|
*/
|
||||||
|
export function getFromPath<TValue = unknown>(object: NestedRecord | undefined, path: string): TValue | undefined
|
||||||
|
export function getFromPath<TValue = unknown, TFallback = TValue>(
|
||||||
|
object: NestedRecord | undefined,
|
||||||
|
path: string,
|
||||||
|
fallback?: TFallback,
|
||||||
|
): TValue | TFallback
|
||||||
|
export function getFromPath<TValue = unknown, TFallback = TValue>(
|
||||||
|
object: NestedRecord | undefined,
|
||||||
|
path: string,
|
||||||
|
fallback?: TFallback,
|
||||||
|
): TValue | TFallback | undefined {
|
||||||
|
if (!object)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
if (isNotNestedPath(path))
|
||||||
|
return object[cleanupNonNestedPath(path)] as TValue | undefined
|
||||||
|
|
||||||
|
const resolvedValue = (path || '')
|
||||||
|
.split(/\.|\[(\d+)\]/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.reduce((acc, propKey) => {
|
||||||
|
if (isContainerValue(acc) && propKey in acc)
|
||||||
|
return acc[propKey]
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}, object as unknown)
|
||||||
|
|
||||||
|
return resolvedValue as TValue | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type Booleanish = boolean | 'true' | 'false'
|
||||||
|
|
||||||
|
export function booleanishToBoolean(value: Booleanish) {
|
||||||
|
switch (value) {
|
||||||
|
case 'true':
|
||||||
|
case true:
|
||||||
|
return true;
|
||||||
|
case 'false':
|
||||||
|
case false:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maybeBooleanishToBoolean(value?: Booleanish) {
|
||||||
|
return value ? booleanishToBoolean(value) : undefined
|
||||||
|
}
|
60
components/ui/calendar/Calendar.vue
Normal file
60
components/ui/calendar/Calendar.vue
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { CalendarRoot, type CalendarRootEmits, type CalendarRootProps, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from '.'
|
||||||
|
|
||||||
|
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const emits = defineEmits<CalendarRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarRoot
|
||||||
|
v-slot="{ grid, weekDays }"
|
||||||
|
:class="cn('p-3', props.class)"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<CalendarHeader>
|
||||||
|
<CalendarPrevButton />
|
||||||
|
<CalendarHeading />
|
||||||
|
<CalendarNextButton />
|
||||||
|
</CalendarHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
|
||||||
|
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
|
||||||
|
<CalendarGridHead>
|
||||||
|
<CalendarGridRow>
|
||||||
|
<CalendarHeadCell
|
||||||
|
v-for="day in weekDays" :key="day"
|
||||||
|
>
|
||||||
|
{{ day }}
|
||||||
|
</CalendarHeadCell>
|
||||||
|
</CalendarGridRow>
|
||||||
|
</CalendarGridHead>
|
||||||
|
<CalendarGridBody>
|
||||||
|
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
|
||||||
|
<CalendarCell
|
||||||
|
v-for="weekDate in weekDates"
|
||||||
|
:key="weekDate.toString()"
|
||||||
|
:date="weekDate"
|
||||||
|
>
|
||||||
|
<CalendarCellTrigger
|
||||||
|
:day="weekDate"
|
||||||
|
:month="month.value"
|
||||||
|
/>
|
||||||
|
</CalendarCell>
|
||||||
|
</CalendarGridRow>
|
||||||
|
</CalendarGridBody>
|
||||||
|
</CalendarGrid>
|
||||||
|
</div>
|
||||||
|
</CalendarRoot>
|
||||||
|
</template>
|
24
components/ui/calendar/CalendarCell.vue
Normal file
24
components/ui/calendar/CalendarCell.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { CalendarCell, type CalendarCellProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarCell
|
||||||
|
:class="cn('relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50', props.class)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</CalendarCell>
|
||||||
|
</template>
|
38
components/ui/calendar/CalendarCellTrigger.vue
Normal file
38
components/ui/calendar/CalendarCellTrigger.vue
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarCellTrigger
|
||||||
|
:class="cn(
|
||||||
|
buttonVariants({ variant: 'ghost' }),
|
||||||
|
'h-9 w-9 p-0 font-normal',
|
||||||
|
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||||
|
// Selected
|
||||||
|
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
|
||||||
|
// Disabled
|
||||||
|
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||||
|
// Unavailable
|
||||||
|
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
|
||||||
|
// Outside months
|
||||||
|
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</CalendarCellTrigger>
|
||||||
|
</template>
|
24
components/ui/calendar/CalendarGrid.vue
Normal file
24
components/ui/calendar/CalendarGrid.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { CalendarGrid, type CalendarGridProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarGrid
|
||||||
|
:class="cn('w-full border-collapse space-y-1', props.class)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</CalendarGrid>
|
||||||
|
</template>
|
11
components/ui/calendar/CalendarGridBody.vue
Normal file
11
components/ui/calendar/CalendarGridBody.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { CalendarGridBody, type CalendarGridBodyProps } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<CalendarGridBodyProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarGridBody v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</CalendarGridBody>
|
||||||
|
</template>
|
11
components/ui/calendar/CalendarGridHead.vue
Normal file
11
components/ui/calendar/CalendarGridHead.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { CalendarGridHead, type CalendarGridHeadProps } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<CalendarGridHeadProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarGridHead v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</CalendarGridHead>
|
||||||
|
</template>
|
21
components/ui/calendar/CalendarGridRow.vue
Normal file
21
components/ui/calendar/CalendarGridRow.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { CalendarGridRow, type CalendarGridRowProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
|
||||||
|
<slot />
|
||||||
|
</CalendarGridRow>
|
||||||
|
</template>
|
21
components/ui/calendar/CalendarHeadCell.vue
Normal file
21
components/ui/calendar/CalendarHeadCell.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { CalendarHeadCell, type CalendarHeadCellProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarHeadCell :class="cn('w-9 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)" v-bind="forwardedProps">
|
||||||
|
<slot />
|
||||||
|
</CalendarHeadCell>
|
||||||
|
</template>
|
21
components/ui/calendar/CalendarHeader.vue
Normal file
21
components/ui/calendar/CalendarHeader.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { CalendarHeader, type CalendarHeaderProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarHeader :class="cn('relative flex w-full items-center justify-between pt-1', props.class)" v-bind="forwardedProps">
|
||||||
|
<slot />
|
||||||
|
</CalendarHeader>
|
||||||
|
</template>
|
31
components/ui/calendar/CalendarHeading.vue
Normal file
31
components/ui/calendar/CalendarHeading.vue
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { CalendarHeading, type CalendarHeadingProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
default: (props: { headingValue: string }) => any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarHeading
|
||||||
|
v-slot="{ headingValue }"
|
||||||
|
:class="cn('text-sm font-medium', props.class)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot :heading-value>
|
||||||
|
{{ headingValue }}
|
||||||
|
</slot>
|
||||||
|
</CalendarHeading>
|
||||||
|
</template>
|
32
components/ui/calendar/CalendarNextButton.vue
Normal file
32
components/ui/calendar/CalendarNextButton.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
import { ChevronRight } from 'lucide-vue-next'
|
||||||
|
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarNext
|
||||||
|
:class="cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</slot>
|
||||||
|
</CalendarNext>
|
||||||
|
</template>
|
32
components/ui/calendar/CalendarPrevButton.vue
Normal file
32
components/ui/calendar/CalendarPrevButton.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
import { ChevronLeft } from 'lucide-vue-next'
|
||||||
|
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarPrev
|
||||||
|
:class="cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
</slot>
|
||||||
|
</CalendarPrev>
|
||||||
|
</template>
|
12
components/ui/calendar/index.ts
Normal file
12
components/ui/calendar/index.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export { default as Calendar } from './Calendar.vue'
|
||||||
|
export { default as CalendarCell } from './CalendarCell.vue'
|
||||||
|
export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue'
|
||||||
|
export { default as CalendarGrid } from './CalendarGrid.vue'
|
||||||
|
export { default as CalendarGridBody } from './CalendarGridBody.vue'
|
||||||
|
export { default as CalendarGridHead } from './CalendarGridHead.vue'
|
||||||
|
export { default as CalendarGridRow } from './CalendarGridRow.vue'
|
||||||
|
export { default as CalendarHeadCell } from './CalendarHeadCell.vue'
|
||||||
|
export { default as CalendarHeader } from './CalendarHeader.vue'
|
||||||
|
export { default as CalendarHeading } from './CalendarHeading.vue'
|
||||||
|
export { default as CalendarNextButton } from './CalendarNextButton.vue'
|
||||||
|
export { default as CalendarPrevButton } from './CalendarPrevButton.vue'
|
33
components/ui/checkbox/Checkbox.vue
Normal file
33
components/ui/checkbox/Checkbox.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Check } from 'lucide-vue-next'
|
||||||
|
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<CheckboxRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CheckboxRoot
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn('peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
|
props.class)"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator class="flex h-full w-full items-center justify-center text-current">
|
||||||
|
<slot>
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</slot>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</template>
|
1
components/ui/checkbox/index.ts
Normal file
1
components/ui/checkbox/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Checkbox } from './Checkbox.vue'
|
25
components/ui/radio-group/RadioGroup.vue
Normal file
25
components/ui/radio-group/RadioGroup.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { RadioGroupRoot, type RadioGroupRootEmits, type RadioGroupRootProps, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<RadioGroupRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<RadioGroupRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RadioGroupRoot
|
||||||
|
:class="cn('grid gap-2', props.class)"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</RadioGroupRoot>
|
||||||
|
</template>
|
41
components/ui/radio-group/RadioGroupItem.vue
Normal file
41
components/ui/radio-group/RadioGroupItem.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RadioGroupItemProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Circle } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
RadioGroupIndicator,
|
||||||
|
RadioGroupItem,
|
||||||
|
|
||||||
|
useForwardProps,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<RadioGroupItemProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RadioGroupItem
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'peer aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<RadioGroupIndicator
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Circle class="h-2.5 w-2.5 fill-current text-current" />
|
||||||
|
</RadioGroupIndicator>
|
||||||
|
</RadioGroupItem>
|
||||||
|
</template>
|
2
components/ui/radio-group/index.ts
Normal file
2
components/ui/radio-group/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default as RadioGroup } from './RadioGroup.vue'
|
||||||
|
export { default as RadioGroupItem } from './RadioGroupItem.vue'
|
29
components/ui/scroll-area/ScrollArea.vue
Normal file
29
components/ui/scroll-area/ScrollArea.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
ScrollAreaCorner,
|
||||||
|
ScrollAreaRoot,
|
||||||
|
type ScrollAreaRootProps,
|
||||||
|
ScrollAreaViewport,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
import ScrollBar from './ScrollBar.vue'
|
||||||
|
|
||||||
|
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ScrollAreaRoot v-bind="delegatedProps" :class="cn('relative overflow-hidden', props.class)">
|
||||||
|
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
|
||||||
|
<slot />
|
||||||
|
</ScrollAreaViewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaCorner />
|
||||||
|
</ScrollAreaRoot>
|
||||||
|
</template>
|
30
components/ui/scroll-area/ScrollBar.vue
Normal file
30
components/ui/scroll-area/ScrollBar.vue
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ScrollAreaScrollbar, type ScrollAreaScrollbarProps, ScrollAreaThumb } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes['class'] }>(), {
|
||||||
|
orientation: 'vertical',
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ScrollAreaScrollbar
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn('flex touch-none select-none transition-colors',
|
||||||
|
orientation === 'vertical'
|
||||||
|
&& 'h-full w-2.5 border-l border-l-transparent p-px',
|
||||||
|
orientation === 'horizontal'
|
||||||
|
&& 'h-2.5 flex-col border-t border-t-transparent p-px',
|
||||||
|
props.class)"
|
||||||
|
>
|
||||||
|
<ScrollAreaThumb class="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaScrollbar>
|
||||||
|
</template>
|
2
components/ui/scroll-area/index.ts
Normal file
2
components/ui/scroll-area/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default as ScrollArea } from './ScrollArea.vue'
|
||||||
|
export { default as ScrollBar } from './ScrollBar.vue'
|
39
components/ui/switch/Switch.vue
Normal file
39
components/ui/switch/Switch.vue
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
SwitchRoot,
|
||||||
|
type SwitchRootEmits,
|
||||||
|
type SwitchRootProps,
|
||||||
|
SwitchThumb,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const emits = defineEmits<SwitchRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SwitchRoot
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn(
|
||||||
|
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<SwitchThumb
|
||||||
|
:class="cn('pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5')"
|
||||||
|
>
|
||||||
|
<slot name="thumb" />
|
||||||
|
</SwitchThumb>
|
||||||
|
</SwitchRoot>
|
||||||
|
</template>
|
1
components/ui/switch/index.ts
Normal file
1
components/ui/switch/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Switch } from './Switch.vue'
|
@ -7,7 +7,11 @@ export default withNuxt(
|
|||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
'vue/html-self-closing': 'off',
|
'vue/html-self-closing': 'off',
|
||||||
|
'vue/no-v-html': 'off',
|
||||||
'vue/singleline-html-element-content-newline': 'off',
|
'vue/singleline-html-element-content-newline': 'off',
|
||||||
|
'@stylistic/brace-style': 'off',
|
||||||
|
'@stylistic/arrow-parens': 'off',
|
||||||
|
'@stylistic/operator-linebreak': 'off',
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
prettier,
|
prettier,
|
||||||
|
@ -1,38 +1,32 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { SidebarNavGroup } from '~/components/app/Sidebar.vue'
|
import type { SidebarNavGroup } from '~/components/app/sidebar/index.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { breadcrumbs } = toRefs(useBreadcrumbs())
|
const { breadcrumbs } = toRefs(useBreadcrumbs())
|
||||||
|
|
||||||
const sidebarNav: SidebarNavGroup[] = [
|
const sidebarNav: SidebarNavGroup[] = [
|
||||||
{
|
{
|
||||||
label: '备课制课',
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: '课程管理',
|
title: '课程管理',
|
||||||
url: `/course`,
|
url: `/course`,
|
||||||
icon: 'tabler:books',
|
icon: 'tabler:book-2',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '课程资源',
|
title: '课程资源',
|
||||||
url: `/course/resources`,
|
url: `/course/resources`,
|
||||||
icon: 'tabler:users-group',
|
icon: 'tabler:books',
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'AI 资源',
|
|
||||||
items: [
|
|
||||||
{
|
{
|
||||||
title: 'AI 备课',
|
title: 'AI 备课',
|
||||||
url: `/course/prep`,
|
url: `/course/prep`,
|
||||||
icon: 'tabler:books',
|
icon: 'tabler:clipboard-list',
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'AI 教科研',
|
title: 'AI 教科研',
|
||||||
url: `/course/research`,
|
url: `/course/research`,
|
||||||
icon: 'tabler:users-group',
|
icon: 'tabler:report-search',
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
<script lang="ts" setup></script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="w-full min-h-screen flex flex-col font-sans">
|
|
||||||
<AppTopbar hide-trigger />
|
|
||||||
<div class="min-h-[100vh] flex-1 md:min-h-min p-4">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
@ -5,7 +5,7 @@ export default defineNuxtConfig({
|
|||||||
'@nuxt/icon',
|
'@nuxt/icon',
|
||||||
'@nuxt/fonts',
|
'@nuxt/fonts',
|
||||||
'@nuxt/image',
|
'@nuxt/image',
|
||||||
'@nuxt/test-utils',
|
'@nuxt/test-utils/module',
|
||||||
'@nuxtjs/tailwindcss',
|
'@nuxtjs/tailwindcss',
|
||||||
'shadcn-nuxt',
|
'shadcn-nuxt',
|
||||||
'@nuxtjs/color-mode',
|
'@nuxtjs/color-mode',
|
||||||
@ -14,6 +14,7 @@ export default defineNuxtConfig({
|
|||||||
'dayjs-nuxt',
|
'dayjs-nuxt',
|
||||||
'@formkit/auto-animate',
|
'@formkit/auto-animate',
|
||||||
'@vueuse/nuxt',
|
'@vueuse/nuxt',
|
||||||
|
'nuxt-lodash',
|
||||||
],
|
],
|
||||||
ssr: false,
|
ssr: false,
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
@ -36,6 +37,7 @@ export default defineNuxtConfig({
|
|||||||
quotes: 'single',
|
quotes: 'single',
|
||||||
semi: false,
|
semi: false,
|
||||||
commaDangle: 'only-multiline',
|
commaDangle: 'only-multiline',
|
||||||
|
braceStyle: '1tbs',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
19
package.json
19
package.json
@ -9,13 +9,13 @@
|
|||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare",
|
"postinstall": "nuxt prepare",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix"
|
"lint:fix": "eslint . --fix",
|
||||||
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/fonts": "0.11.0",
|
"@nuxt/fonts": "0.11.0",
|
||||||
"@nuxt/icon": "1.11.0",
|
"@nuxt/icon": "1.11.0",
|
||||||
"@nuxt/image": "1.10.0",
|
"@nuxt/image": "1.10.0",
|
||||||
"@nuxt/test-utils": "3.17.2",
|
|
||||||
"@tanstack/vue-table": "^8.21.2",
|
"@tanstack/vue-table": "^8.21.2",
|
||||||
"@vee-validate/zod": "^4.15.0",
|
"@vee-validate/zod": "^4.15.0",
|
||||||
"@vue-office/docx": "^1.6.3",
|
"@vue-office/docx": "^1.6.3",
|
||||||
@ -26,8 +26,11 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
|
"dplayer": "^1.27.1",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-vue-next": "^0.484.0",
|
"lucide-vue-next": "^0.484.0",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
"nuxt": "^3.16.1",
|
"nuxt": "^3.16.1",
|
||||||
"reka-ui": "^2.1.1",
|
"reka-ui": "^2.1.1",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
@ -46,16 +49,26 @@
|
|||||||
"@iconify-json/svg-spinners": "^1.2.2",
|
"@iconify-json/svg-spinners": "^1.2.2",
|
||||||
"@iconify-json/tabler": "^1.2.17",
|
"@iconify-json/tabler": "^1.2.17",
|
||||||
"@nuxt/eslint": "1.3.0",
|
"@nuxt/eslint": "1.3.0",
|
||||||
|
"@nuxt/test-utils": "3.17.2",
|
||||||
"@nuxtjs/color-mode": "^3.5.2",
|
"@nuxtjs/color-mode": "^3.5.2",
|
||||||
"@nuxtjs/tailwindcss": "^6.13.2",
|
"@nuxtjs/tailwindcss": "^6.13.2",
|
||||||
"@pinia/nuxt": "^0.10.1",
|
"@pinia/nuxt": "^0.10.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@testing-library/vue": "^8.1.0",
|
||||||
|
"@types/dplayer": "^1.25.5",
|
||||||
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vueuse/nuxt": "^13.0.0",
|
"@vueuse/nuxt": "^13.0.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dayjs-nuxt": "^2.1.11",
|
"dayjs-nuxt": "^2.1.11",
|
||||||
"eslint-plugin-prettier": "^5.2.6",
|
"eslint-plugin-prettier": "^5.2.6",
|
||||||
|
"happy-dom": "^17.4.4",
|
||||||
|
"nuxt-lodash": "^2.5.3",
|
||||||
"pinia-plugin-persistedstate": "^4.2.0",
|
"pinia-plugin-persistedstate": "^4.2.0",
|
||||||
|
"playwright-core": "^1.52.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"shadcn-nuxt": "2.0.1",
|
"shadcn-nuxt": "2.0.1",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.2",
|
||||||
|
"vitest": "^3.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -65,7 +65,7 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppContainer
|
<AppContainer
|
||||||
:subnavs="[
|
:nav-secondary="[
|
||||||
{
|
{
|
||||||
label: '课程章节',
|
label: '课程章节',
|
||||||
to: `/course/${id}/chapters`,
|
to: `/course/${id}/chapters`,
|
||||||
|
@ -98,9 +98,7 @@ const onDeleteResource = (resourceId: number) => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4 px-4 py-2">
|
<div class="flex flex-col gap-4 px-4 py-2">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<h1 class="text-xl font-medium">
|
<h1 class="text-xl font-medium">课程章节管理</h1>
|
||||||
课程章节管理
|
|
||||||
</h1>
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Tooltip :delay-duration="0">
|
<Tooltip :delay-duration="0">
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
|
@ -83,7 +83,7 @@ const onCreateClassSubmit = createClassForm.handleSubmit((values) => {
|
|||||||
<h1 class="text-xl font-medium">
|
<h1 class="text-xl font-medium">
|
||||||
课程班级管理
|
课程班级管理
|
||||||
<span class="block text-sm text-muted-foreground">
|
<span class="block text-sm text-muted-foreground">
|
||||||
课程负责人:{{ course.data.teacherName || "未知" }}
|
课程负责人:{{ course.data.teacherName || '未知' }}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
@ -45,8 +45,7 @@ const {
|
|||||||
const triggerSearch = useDebounceFn(() => {
|
const triggerSearch = useDebounceFn(() => {
|
||||||
if (searchKeyword.value.length > 0) {
|
if (searchKeyword.value.length > 0) {
|
||||||
refreshSearch()
|
refreshSearch()
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
clearSearch()
|
clearSearch()
|
||||||
}
|
}
|
||||||
}, 500)
|
}, 500)
|
||||||
@ -58,7 +57,7 @@ watch(searchKeyword, (newValue) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isInTeam = (userId: number) => {
|
const isInTeam = (userId: number) => {
|
||||||
return teacherTeam?.value?.data?.some(item => item.teacherId === userId)
|
return teacherTeam?.value?.data?.some((item) => item.teacherId === userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onAddTeacherToCourse = (teacherId: number) => {
|
const onAddTeacherToCourse = (teacherId: number) => {
|
||||||
@ -103,7 +102,7 @@ const onDeleteTeacher = (recordId: number) => {
|
|||||||
<h1 class="text-xl font-medium">
|
<h1 class="text-xl font-medium">
|
||||||
教师团队管理
|
教师团队管理
|
||||||
<span class="block text-sm text-muted-foreground">
|
<span class="block text-sm text-muted-foreground">
|
||||||
课程负责人:{{ course.data.teacherName || "未知" }}
|
课程负责人:{{ course.data.teacherName || '未知' }}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
@ -150,9 +149,7 @@ const onDeleteTeacher = (recordId: number) => {
|
|||||||
</FormField>
|
</FormField>
|
||||||
<hr />
|
<hr />
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">搜索结果</p>
|
||||||
搜索结果
|
|
||||||
</p>
|
|
||||||
<div
|
<div
|
||||||
v-if="searchResults?.data && searchResults.data.length > 0"
|
v-if="searchResults?.data && searchResults.data.length > 0"
|
||||||
class="flex flex-col gap-2"
|
class="flex flex-col gap-2"
|
||||||
@ -177,13 +174,13 @@ const onDeleteTeacher = (recordId: number) => {
|
|||||||
<h1
|
<h1
|
||||||
class="text-sm font-medium text-ellipsis line-clamp-1"
|
class="text-sm font-medium text-ellipsis line-clamp-1"
|
||||||
>
|
>
|
||||||
{{ user.userName || "未知教师" }}
|
{{ user.userName || '未知教师' }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-xs text-muted-foreground/80">
|
<p class="text-xs text-muted-foreground/80">
|
||||||
工号:{{ user.employeeId || "未知" }}
|
工号:{{ user.employeeId || '未知' }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-muted-foreground/80">
|
<p class="text-xs text-muted-foreground/80">
|
||||||
{{ user.collegeName || "未知学院" }}
|
{{ user.collegeName || '未知学院' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -200,7 +197,7 @@ const onDeleteTeacher = (recordId: number) => {
|
|||||||
size="16px"
|
size="16px"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
{{ isInTeam(user.id!) ? "已在团队" : "添加" }}
|
{{ isInTeam(user.id!) ? '已在团队' : '添加' }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -308,7 +308,7 @@ const onDeleteCourse = (courseId: number) => {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="coursesList?.rows && coursesList.rows.length > 0"
|
v-if="coursesList?.rows && coursesList.rows.length > 0"
|
||||||
class="grid grid-cols-6 gap-8"
|
class="grid grid-cols-1 lg:grid-cols-4 3xl:grid-cols-5 gap-8"
|
||||||
>
|
>
|
||||||
<CourseCard
|
<CourseCard
|
||||||
v-for="course in coursesList?.rows"
|
v-for="course in coursesList?.rows"
|
||||||
|
@ -1,26 +1,21 @@
|
|||||||
export const nav = [
|
import type { NavSecondaryItem } from '~/components/nav/Secondary.vue'
|
||||||
|
|
||||||
|
export const nav: NavSecondaryItem[] = [
|
||||||
{
|
{
|
||||||
items: [
|
label: '教学设计',
|
||||||
{
|
to: '/course/prep/teach',
|
||||||
title: 'AI 教学设计',
|
|
||||||
url: '/course/prep/teach',
|
|
||||||
icon: 'tabler:calendar-cog',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'AI 课件设计',
|
label: '课件设计',
|
||||||
url: '/course/prep/deck',
|
to: '/course/prep/deck',
|
||||||
icon: 'tabler:book-2',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'AI 出题',
|
label: 'AI 出题',
|
||||||
url: '/course/prep/quiz',
|
to: '/course/prep/quiz',
|
||||||
icon: 'tabler:notebook',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '微视频制作',
|
label: '微视频制作',
|
||||||
url: '/course/prep/test',
|
to: 'https://dh.fenshenzhike.com/',
|
||||||
icon: 'tabler:video',
|
external: true,
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
@ -3,13 +3,32 @@ import { nav } from './config'
|
|||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
hideSidebar: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'AI 课件设计 | 备课',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setBreadcrumbs([
|
||||||
|
{
|
||||||
|
label: 'AI 备课',
|
||||||
|
path: '/course/prep',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'AI 课件设计',
|
||||||
|
},
|
||||||
|
])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppPageWithSidebar :sidebar-nav="nav">
|
<AppContainer :nav-secondary="nav">
|
||||||
<h1>deck</h1>
|
<h1>AI 课件设计</h1>
|
||||||
</AppPageWithSidebar>
|
</AppContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
@ -1,15 +1,116 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
import { nav } from './config'
|
import { nav } from './config'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
hideSidebar: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'AI 出题 | 备课',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs()
|
||||||
|
|
||||||
|
const tab = ref('text')
|
||||||
|
|
||||||
|
const quizFormSchema = z.object({
|
||||||
|
topicScope: z.string().describe('知识点范围'),
|
||||||
|
questionCount: z
|
||||||
|
.number()
|
||||||
|
.min(1, { message: '题目数量必须大于 0' })
|
||||||
|
.max(10, { message: '题目数量必须小于 10' })
|
||||||
|
.describe('题目数量'),
|
||||||
|
questionType: z.enum(['单选题', '判断题', '简答题']).describe('题型'),
|
||||||
|
difficulty: z.enum(['易', '中', '难']).describe('难度'),
|
||||||
|
studentLevel: z.enum(['小学', '中学', '大学']).describe('学生水平'),
|
||||||
|
questionDirection: z.enum(['应用实践']).describe('题目方向').optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type QuizForm = z.infer<typeof quizFormSchema>
|
||||||
|
|
||||||
|
const quizForm = useForm({
|
||||||
|
validationSchema: toTypedSchema(quizFormSchema),
|
||||||
|
initialValues: {
|
||||||
|
topicScope: '',
|
||||||
|
questionCount: 1,
|
||||||
|
questionType: '单选题',
|
||||||
|
difficulty: '中',
|
||||||
|
studentLevel: '中学',
|
||||||
|
questionDirection: '应用实践',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onQuizSubmit = (values: QuizForm) => {
|
||||||
|
console.log(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setBreadcrumbs([
|
||||||
|
{
|
||||||
|
label: 'AI 备课',
|
||||||
|
path: '/course/prep',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'AI 出题',
|
||||||
|
},
|
||||||
|
])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppPageWithSidebar :sidebar-nav="nav">
|
<AppContainer
|
||||||
<h1>quiz</h1>
|
:nav-secondary="nav"
|
||||||
</AppPageWithSidebar>
|
content-class="flex items-start p-0 w-full h-full"
|
||||||
|
>
|
||||||
|
<div class="h-full border-r shadow-xl flex flex-col gap-4">
|
||||||
|
<div class="flex justify-start items-center pl-4 h-16 border-b gap-2">
|
||||||
|
<Icon
|
||||||
|
name="fluent-color:notebook-24"
|
||||||
|
class="text-2xl"
|
||||||
|
/>
|
||||||
|
<h1 class="text-base font-medium text-foreground">AI 出题</h1>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 flex flex-col gap-4">
|
||||||
|
<Tabs v-model="tab">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="text">
|
||||||
|
<div class="flex items-center gap-1 px-12">
|
||||||
|
<Icon name="tabler:article" />
|
||||||
|
<span>文本生成</span>
|
||||||
|
</div>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="chapter">
|
||||||
|
<div class="flex items-center gap-1 px-12">
|
||||||
|
<Icon name="tabler:text-plus" />
|
||||||
|
<span>章节生成</span>
|
||||||
|
</div>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<AutoForm
|
||||||
|
class="space-y-2"
|
||||||
|
:schema="quizFormSchema"
|
||||||
|
:form="quizForm"
|
||||||
|
:field-config="{
|
||||||
|
topicScope: {
|
||||||
|
component: 'textarea',
|
||||||
|
inputProps: {
|
||||||
|
placeholder: '请输入出题的知识点范围',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
@submit="onQuizSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="h-full flex-1 p-4 overflow-hidden overflow-y-auto max-h-[calc(100vh-162px)]"
|
||||||
|
></div>
|
||||||
|
</AppContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
@ -1,19 +1,112 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { nav } from './config'
|
import { nav } from './config'
|
||||||
|
import {
|
||||||
|
FnTeachLessonPlan,
|
||||||
|
FnTeachCaseGen,
|
||||||
|
FnTeachStdDesign,
|
||||||
|
FnTeachKnowledgeDiagram,
|
||||||
|
FnTeachCourseChapter,
|
||||||
|
FnTeachPoliticalCase,
|
||||||
|
FnTeachResearchPlan,
|
||||||
|
FnTeachPlan,
|
||||||
|
FnTeachCourseOutline,
|
||||||
|
} from '#components'
|
||||||
|
import type { NavTertiaryItem } from '~/components/nav/Tertiary.vue'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
hideSidebar: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: 'AI 教学设计 | 备课',
|
title: 'AI 教学设计 | 备课',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs()
|
||||||
|
|
||||||
|
const tertiaryNavs: NavTertiaryItem[] = [
|
||||||
|
{ label: '教案设计', component: FnTeachLessonPlan },
|
||||||
|
{ label: '案例设计', component: FnTeachCaseGen },
|
||||||
|
{ label: '课程标准', component: FnTeachStdDesign },
|
||||||
|
{ label: '知识图谱', component: FnTeachKnowledgeDiagram, disabled: true },
|
||||||
|
{ label: '课程章节', component: FnTeachCourseChapter },
|
||||||
|
{ label: '思政案例', component: FnTeachPoliticalCase },
|
||||||
|
{ label: '教研计划', component: FnTeachResearchPlan },
|
||||||
|
{ label: '教学计划', component: FnTeachPlan },
|
||||||
|
{ label: '课程大纲', component: FnTeachCourseOutline },
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentNav = ref(0)
|
||||||
|
|
||||||
|
watch(currentNav, (val) => {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
fn: val,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (route.query.fn && !isNaN(Number(route.query.fn))) {
|
||||||
|
currentNav.value = Number(route.query.fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
setBreadcrumbs([
|
||||||
|
{
|
||||||
|
label: 'AI 备课',
|
||||||
|
path: '/course/prep',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'AI 教学设计',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppPageWithSidebar :sidebar-nav="nav">
|
<AppContainer
|
||||||
<h1>teach</h1>
|
:nav-secondary="nav"
|
||||||
</AppPageWithSidebar>
|
content-class="flex items-start p-0"
|
||||||
|
>
|
||||||
|
<div class="w-[188px] h-full border-r shadow-xl">
|
||||||
|
<div class="flex justify-center items-center h-16 border-b gap-2">
|
||||||
|
<Icon
|
||||||
|
name="fluent-color:design-ideas-24"
|
||||||
|
class="text-2xl"
|
||||||
|
/>
|
||||||
|
<h1 class="text-base font-medium text-foreground">AI 教学设计</h1>
|
||||||
|
</div>
|
||||||
|
<div class="p-2">
|
||||||
|
<NavTertiary
|
||||||
|
v-model="currentNav"
|
||||||
|
:navs="tertiaryNavs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-full p-6">
|
||||||
|
<Suspense>
|
||||||
|
<KeepAlive v-if="tertiaryNavs[currentNav].component">
|
||||||
|
<component
|
||||||
|
:is="tertiaryNavs[currentNav].component"
|
||||||
|
v-bind="tertiaryNavs[currentNav].props"
|
||||||
|
class="w-full h-full"
|
||||||
|
/>
|
||||||
|
</KeepAlive>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-col items-center justify-center w-full h-full gap-2"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="tabler:mood-sad"
|
||||||
|
class="text-6xl text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<p class="text text-muted-foreground">该功能暂不可用</p>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</AppContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
@ -5,7 +5,9 @@ definePageMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>Resources</div>
|
<AppContainer>
|
||||||
|
<div>课程资源</div>
|
||||||
|
</AppContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
@ -5,6 +5,7 @@ import VueOfficeExcel from '@vue-office/excel'
|
|||||||
import VueOfficePdf from '@vue-office/pdf'
|
import VueOfficePdf from '@vue-office/pdf'
|
||||||
import '@vue-office/docx/lib/index.css'
|
import '@vue-office/docx/lib/index.css'
|
||||||
import '@vue-office/excel/lib/index.css'
|
import '@vue-office/excel/lib/index.css'
|
||||||
|
import DPlayer from 'dplayer'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
hideSidebar: true,
|
hideSidebar: true,
|
||||||
@ -39,6 +40,12 @@ const fileType = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const containerClass = computed(() => {
|
||||||
|
return fileType.value === 'video'
|
||||||
|
? 'max-w-6xl mx-auto'
|
||||||
|
: 'w-full h-full border rounded-lg overflow-hidden'
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
useHead({
|
useHead({
|
||||||
title: `${fileType.value.toUpperCase()} 资源预览`,
|
title: `${fileType.value.toUpperCase()} 资源预览`,
|
||||||
@ -49,6 +56,18 @@ onMounted(() => {
|
|||||||
label: `${fileType.value.toUpperCase()} 资源预览`,
|
label: `${fileType.value.toUpperCase()} 资源预览`,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// initialize video player
|
||||||
|
if (fileType.value === 'video') {
|
||||||
|
const dp = new DPlayer({
|
||||||
|
container: document.getElementById('dplayer'),
|
||||||
|
screenshot: true,
|
||||||
|
video: {
|
||||||
|
url: url.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dp.play()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const vueOfficeOptions = {
|
const vueOfficeOptions = {
|
||||||
@ -70,10 +89,16 @@ const vueOfficeOptions = {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="w-full h-full border rounded-lg overflow-hidden"
|
:class="containerClass"
|
||||||
>
|
>
|
||||||
|
<!-- Video -->
|
||||||
|
<div
|
||||||
|
v-if="fileType === 'video'"
|
||||||
|
id="dplayer"
|
||||||
|
/>
|
||||||
|
<!-- Vue Office -->
|
||||||
<VueOfficeDocx
|
<VueOfficeDocx
|
||||||
v-if="fileType === 'word'"
|
v-else-if="fileType === 'word'"
|
||||||
class="w-full h-full"
|
class="w-full h-full"
|
||||||
:src="url"
|
:src="url"
|
||||||
:options="vueOfficeOptions"
|
:options="vueOfficeOptions"
|
||||||
|
1262
pnpm-lock.yaml
generated
1262
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
98
stores/llmHistories.ts
Normal file
98
stores/llmHistories.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import type { LLMConversation, LLMMessage } from '~/components/ai'
|
||||||
|
|
||||||
|
export function useLlmHistories(key: string) {
|
||||||
|
const useStore = defineStore(
|
||||||
|
`llmConversations/${key}`,
|
||||||
|
() => {
|
||||||
|
const conversations = ref<LLMConversation[]>([])
|
||||||
|
|
||||||
|
const createConversation = (conversation: LLMConversation) => {
|
||||||
|
const index = conversations.value.findIndex(c => c.id === conversation.id)
|
||||||
|
if (index >= 0) {
|
||||||
|
conversations.value[index] = conversation
|
||||||
|
} else {
|
||||||
|
conversations.value.unshift(conversation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConversation = (conversationId: string) => {
|
||||||
|
const index = conversations.value.findIndex(c => c.id === conversationId)
|
||||||
|
return index >= 0 ? conversations.value[index] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateConversation = (conversationId: string, conversation: Partial<LLMConversation>) => {
|
||||||
|
const index = conversations.value.findIndex(c => c.id === conversationId)
|
||||||
|
if (index >= 0) {
|
||||||
|
conversations.value[index] = {
|
||||||
|
...conversations.value[index],
|
||||||
|
...conversation,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
createConversation({
|
||||||
|
id: conversationId,
|
||||||
|
...conversation,
|
||||||
|
} as LLMConversation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeConversation = (conversationId: string) => {
|
||||||
|
const index = conversations.value.findIndex(c => c.id === conversationId)
|
||||||
|
if (index >= 0) {
|
||||||
|
conversations.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConversationExist = (conversationId: string) => {
|
||||||
|
return conversations.value.some(c => c.id === conversationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const putMessageToConv = (conversationId: string, message: LLMMessage) => {
|
||||||
|
const index = conversations.value.findIndex(c => c.id === conversationId)
|
||||||
|
if (index >= 0) {
|
||||||
|
const conversation = conversations.value[index]
|
||||||
|
conversation.messages.push(message)
|
||||||
|
conversations.value[index] = conversation
|
||||||
|
} else {
|
||||||
|
const newConversation: LLMConversation = {
|
||||||
|
id: conversationId,
|
||||||
|
title: '',
|
||||||
|
messages: [message],
|
||||||
|
}
|
||||||
|
conversations.value.unshift(newConversation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendChunkToLast = (conversationId: string, chunk: string) => {
|
||||||
|
const index = conversations.value.findIndex(c => c.id === conversationId)
|
||||||
|
if (index >= 0) {
|
||||||
|
const conversation = conversations.value[index]
|
||||||
|
const lastMessage = conversation.messages[conversation.messages.length - 1]
|
||||||
|
if (lastMessage && lastMessage.role === 'assistant') {
|
||||||
|
lastMessage.content += chunk
|
||||||
|
conversations.value[index] = conversation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversations,
|
||||||
|
createConversation,
|
||||||
|
getConversation,
|
||||||
|
updateConversation,
|
||||||
|
removeConversation,
|
||||||
|
putMessageToConv,
|
||||||
|
appendChunkToLast,
|
||||||
|
isConversationExist
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
persist: {
|
||||||
|
key: `xshic_llm_histories_${key}`,
|
||||||
|
storage: piniaPluginPersistedstate.localStorage(),
|
||||||
|
pick: ['conversations'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return useStore()
|
||||||
|
}
|
@ -1,67 +1,96 @@
|
|||||||
|
/* eslint-disable @stylistic/quote-props */
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
darkMode: ['class'],
|
darkMode: ['class'],
|
||||||
content: [],
|
content: [],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
screens: {
|
||||||
|
'3xl': '1792px',
|
||||||
|
'4xl': '2048px',
|
||||||
|
'5xl': '2560px',
|
||||||
|
'6xl': '3840px'
|
||||||
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: 'var(--radius)',
|
lg: 'var(--radius)',
|
||||||
md: 'calc(var(--radius) - 2px)',
|
md: 'calc(var(--radius) - 2px)',
|
||||||
sm: 'calc(var(--radius) - 4px)',
|
sm: 'calc(var(--radius) - 4px)'
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
background: 'hsl(var(--background))',
|
background: 'hsl(var(--background))',
|
||||||
foreground: 'hsl(var(--foreground))',
|
foreground: 'hsl(var(--foreground))',
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: 'hsl(var(--card))',
|
DEFAULT: 'hsl(var(--card))',
|
||||||
foreground: 'hsl(var(--card-foreground))',
|
foreground: 'hsl(var(--card-foreground))'
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: 'hsl(var(--popover))',
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
foreground: 'hsl(var(--popover-foreground))',
|
foreground: 'hsl(var(--popover-foreground))'
|
||||||
},
|
},
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: 'hsl(var(--primary))',
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
foreground: 'hsl(var(--primary-foreground))',
|
foreground: 'hsl(var(--primary-foreground))'
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
foreground: 'hsl(var(--secondary-foreground))',
|
foreground: 'hsl(var(--secondary-foreground))'
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
foreground: 'hsl(var(--muted-foreground))',
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: 'hsl(var(--accent))',
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
foreground: 'hsl(var(--accent-foreground))',
|
foreground: 'hsl(var(--accent-foreground))'
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
DEFAULT: 'hsl(var(--destructive))',
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
foreground: 'hsl(var(--destructive-foreground))',
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
},
|
},
|
||||||
border: 'hsl(var(--border))',
|
border: 'hsl(var(--border))',
|
||||||
input: 'hsl(var(--input))',
|
input: 'hsl(var(--input))',
|
||||||
ring: 'hsl(var(--ring))',
|
ring: 'hsl(var(--ring))',
|
||||||
chart: {
|
chart: {
|
||||||
1: 'hsl(var(--chart-1))',
|
'1': 'hsl(var(--chart-1))',
|
||||||
2: 'hsl(var(--chart-2))',
|
'2': 'hsl(var(--chart-2))',
|
||||||
3: 'hsl(var(--chart-3))',
|
'3': 'hsl(var(--chart-3))',
|
||||||
4: 'hsl(var(--chart-4))',
|
'4': 'hsl(var(--chart-4))',
|
||||||
5: 'hsl(var(--chart-5))',
|
'5': 'hsl(var(--chart-5))'
|
||||||
},
|
},
|
||||||
sidebar: {
|
sidebar: {
|
||||||
'DEFAULT': 'hsl(var(--sidebar-background))',
|
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||||
'foreground': 'hsl(var(--sidebar-foreground))',
|
foreground: 'hsl(var(--sidebar-foreground))',
|
||||||
'primary': 'hsl(var(--sidebar-primary))',
|
primary: 'hsl(var(--sidebar-primary))',
|
||||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||||
'accent': 'hsl(var(--sidebar-accent))',
|
accent: 'hsl(var(--sidebar-accent))',
|
||||||
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||||
'border': 'hsl(var(--sidebar-border))',
|
border: 'hsl(var(--sidebar-border))',
|
||||||
'ring': 'hsl(var(--sidebar-ring))',
|
ring: 'hsl(var(--sidebar-ring))'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
keyframes: {
|
||||||
|
'accordion-down': {
|
||||||
|
from: {
|
||||||
|
height: '0'
|
||||||
},
|
},
|
||||||
|
to: {
|
||||||
|
height: 'var(--reka-accordion-content-height)'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
'accordion-up': {
|
||||||
|
from: {
|
||||||
|
height: 'var(--reka-accordion-content-height)'
|
||||||
},
|
},
|
||||||
plugins: [require('tailwindcss-animate')],
|
to: {
|
||||||
|
height: '0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
|
||||||
}
|
}
|
||||||
|
30
tests/components/AppContainer.nuxt.spec.ts
Normal file
30
tests/components/AppContainer.nuxt.spec.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { renderSuspended } from '@nuxt/test-utils/runtime'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { AppContainer } from '#components'
|
||||||
|
import type { NavSecondaryItem } from '~/components/nav/Secondary.vue'
|
||||||
|
|
||||||
|
describe('AppContainer', () => {
|
||||||
|
const testNavSecondary: NavSecondaryItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Home',
|
||||||
|
to: '/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Test',
|
||||||
|
to: '/test'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
it('renders properly', async () => {
|
||||||
|
const appContainer = await renderSuspended(AppContainer, {
|
||||||
|
props: {
|
||||||
|
navSecondary: testNavSecondary
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(appContainer).toBeTruthy()
|
||||||
|
expect(appContainer.html()).toContain('Home')
|
||||||
|
expect(appContainer.html()).toContain('Test')
|
||||||
|
expect(appContainer.html()).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
})
|
@ -0,0 +1,34 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`AppContainer > renders properly 1`] = `
|
||||||
|
"<div id="test-wrapper">
|
||||||
|
<div data-v-8083ee16="" class="flex flex-1 flex-col p-8 page-bg-gradient">
|
||||||
|
<!-- <h1 class="pl-2 text-xl font-medium">外部标题</h1> -->
|
||||||
|
<div data-v-77338269="" data-v-8083ee16="" class="flex items-end ml-4 z-0">
|
||||||
|
<div data-v-77338269="" class="subnav-item active" style="z-index: 10;"><svg data-v-77338269="" class="absolute inset-0 aspect-auto top-1" viewBox="0 0 206.5 72" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<defs data-v-77338269="">
|
||||||
|
<linearGradient data-v-77338269="" id="paint_linear_0" gradient-units="objectBoundingBox" x1="0.5" y1="0" x2="0.5" y2="1">
|
||||||
|
<stop data-v-77338269="" stop-color="var(--svg-stop1)"></stop>
|
||||||
|
<stop data-v-77338269="" offset="1" stop-color="var(--svg-stop2)"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g data-v-77338269="">
|
||||||
|
<path data-v-77338269="" id="subnav_item_active" d="M51.9 0L154.6 0C172.19 0 187.72 11.48 192.86 28.31L206.5 72L0 72L13.35 28.31C18.49 11.48 34.02 0 51.9 0Z" fill="url(#paint_linear_0)" fill-opacity="1.000000" fill-rule="evenodd"></path>
|
||||||
|
</g>
|
||||||
|
</svg><a data-v-77338269="" href="/" class="text-base font-medium z-10 select-none pb-0.5 text-secondary">Home</a></div>
|
||||||
|
<div data-v-77338269="" class="subnav-item" style="z-index: -1;"><svg data-v-77338269="" class="absolute inset-0 aspect-auto top-1" viewBox="0 0 206.5 72" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<defs data-v-77338269="">
|
||||||
|
<linearGradient data-v-77338269="" id="paint_linear_1" gradient-units="objectBoundingBox" x1="0.5" y1="0" x2="0.5" y2="1">
|
||||||
|
<stop data-v-77338269="" stop-color="var(--svg-stop1)"></stop>
|
||||||
|
<stop data-v-77338269="" offset="1" stop-color="var(--svg-stop2)"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g data-v-77338269="">
|
||||||
|
<path data-v-77338269="" id="subnav_item_active" d="M51.9 0L154.6 0C172.19 0 187.72 11.48 192.86 28.31L206.5 72L0 72L13.35 28.31C18.49 11.48 34.02 0 51.9 0Z" fill="url(#paint_linear_1)" fill-opacity="1.000000" fill-rule="evenodd"></path>
|
||||||
|
</g>
|
||||||
|
</svg><a data-v-77338269="" href="/test" class="text-base font-medium z-10 select-none pb-0.5 text-neutral-400 dark:text-neutral-500">Test</a></div>
|
||||||
|
</div>
|
||||||
|
<div data-v-8083ee16="" class="h-full rounded-lg shadow-sm overflow-hidden relative bg-white dark:bg-neutral-900 p-8 z-20"></div>
|
||||||
|
</div>
|
||||||
|
</div>"
|
||||||
|
`;
|
@ -39,3 +39,92 @@ export const http = async <T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const http_stream = async <ReqT>(
|
||||||
|
endpoint: string,
|
||||||
|
params: ReqT,
|
||||||
|
events: {
|
||||||
|
onStart?: (id: string, created_at?: number) => void
|
||||||
|
onTextChunk?: (message: string) => void
|
||||||
|
onComplete?: (id: string | null, finished_at?: number) => void
|
||||||
|
}) => {
|
||||||
|
const { onStart, onTextChunk, onComplete } = events
|
||||||
|
|
||||||
|
const loginState = useLoginState()
|
||||||
|
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const baseURL = runtimeConfig.public.baseURL as string
|
||||||
|
|
||||||
|
const response = await fetch(new URL(endpoint, baseURL), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'text/event-stream',
|
||||||
|
'Authorization': `Bearer ${loginState.token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw new Error('Network response was not ok')
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader()
|
||||||
|
const decoder = new TextDecoder('utf-8')
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader?.read() || {}
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
|
||||||
|
const parts = buffer.split('\n\n')
|
||||||
|
buffer = parts.pop()!
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!part.trim().startsWith('data:')) continue
|
||||||
|
const payload = part.replace(/^data:\s*/, '').trim()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(payload)
|
||||||
|
if (obj?.event && obj.event === 'workflow_started') {
|
||||||
|
onStart?.(obj?.data?.id || null, obj?.data?.created_at || 0)
|
||||||
|
}
|
||||||
|
if (obj?.event && obj.event === 'workflow_finished') {
|
||||||
|
onComplete?.(obj?.data?.id || null, obj?.data?.finished_at || 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Workflow
|
||||||
|
if (obj?.event && obj.event === 'text_chunk') {
|
||||||
|
let text_chunk = obj.data?.text as string
|
||||||
|
if (text_chunk) {
|
||||||
|
if (text_chunk.startsWith('<') && text_chunk.endsWith('>')) {
|
||||||
|
const endTag = text_chunk.match(/<\/[^>]*>/)
|
||||||
|
if (endTag) {
|
||||||
|
text_chunk = text_chunk.replace(endTag[0], '\n' + endTag[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onTextChunk?.(text_chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Chatflow
|
||||||
|
if (obj?.event && obj.event === 'message') {
|
||||||
|
let ans = obj.answer
|
||||||
|
if (ans) {
|
||||||
|
if (ans.startsWith('<') && ans.endsWith('>')) {
|
||||||
|
const endTag = ans.match(/<\/[^>]*>/)
|
||||||
|
if (endTag) {
|
||||||
|
ans = ans.replace(endTag[0], '\n' + endTag[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onTextChunk?.(ans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onComplete?.(null)
|
||||||
|
}
|
||||||
|
4
vitest.config.ts
Normal file
4
vitest.config.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { defineVitestConfig } from '@nuxt/test-utils/config'
|
||||||
|
|
||||||
|
export default defineVitestConfig({
|
||||||
|
})
|
Loading…
Reference in New Issue
Block a user