Initial commit: AI chat assistant with workflow chat, workspace, and profile tabs

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-12 17:23:00 +08:00
commit 3fc394921e
34 changed files with 3449 additions and 0 deletions

208
components/AppDrawer.uvue Normal file
View File

@@ -0,0 +1,208 @@
<template>
<!-- Overlay -->
<view
v-if="visible"
class="drawer-overlay"
:class="{ 'drawer-overlay--visible': visible }"
@tap="close"
@touchmove.stop.prevent="() => {}"
></view>
<!-- Drawer -->
<view
class="drawer"
:class="{ 'drawer--open': visible }"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<!-- Header -->
<DrawerHeader />
<!-- Scrollable content -->
<scroll-view class="drawer__content" scroll-y="true" show-scrollbar="false">
<!-- New conversation button -->
<view class="drawer__new-chat" @tap="onNewConversation">
<text class="drawer__new-chat-icon">+</text>
<text class="drawer__new-chat-text">新建对话</text>
</view>
<!-- History section -->
<HistoryList
:conversations="conversations"
@select="onSelectConversation"
@delete="onDeleteConversation"
/>
<!-- Divider -->
<view class="drawer__divider"></view>
<!-- Workspace section -->
<WorkspaceView
:items="workspaceItems"
@select="onSelectWorkspace"
/>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import type { Conversation, WorkspaceItem } from '@/types/index'
import DrawerHeader from './DrawerHeader.uvue'
import HistoryList from './HistoryList.uvue'
import WorkspaceView from './WorkspaceView.uvue'
const props = defineProps<{
visible: boolean
conversations: Conversation[]
workspaceItems: WorkspaceItem[]
}>()
const emit = defineEmits<{
close: []
'new-conversation': []
'select-conversation': [id: string]
'delete-conversation': [id: string]
'select-workspace': [id: string]
}>()
// Swipe-to-close tracking
const touchStartX = ref(0)
const touchStartY = ref(0)
const drawerTranslateX = ref(0)
const isDragging = ref(false)
function close(): void {
emit('close')
}
function onTouchStart(e: any): void {
const touch = e.touches[0]
touchStartX.value = touch.clientX
touchStartY.value = touch.clientY
isDragging.value = true
}
function onTouchMove(e: any): void {
if (!isDragging.value) return
const touch = e.touches[0]
const deltaX = touch.clientX - touchStartX.value
const deltaY = touch.clientY - touchStartY.value
// Only track horizontal swipes (left direction)
if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX < 0) {
drawerTranslateX.value = deltaX
}
}
function onTouchEnd(_e: any): void {
isDragging.value = false
// If swiped more than 80rpx left, close drawer
if (drawerTranslateX.value < -80) {
close()
}
drawerTranslateX.value = 0
}
function onNewConversation(): void {
emit('new-conversation')
close()
}
function onSelectConversation(id: string): void {
emit('select-conversation', id)
close()
}
function onDeleteConversation(id: string): void {
emit('delete-conversation', id)
}
function onSelectWorkspace(id: string): void {
emit('select-workspace', id)
close()
}
</script>
<style scoped>
/* Overlay */
.drawer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 999;
opacity: 0;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.drawer-overlay--visible {
opacity: 1;
}
/* Drawer */
.drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 600rpx;
background: #1a1a2e;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
box-shadow: 8rpx 0 40rpx rgba(0, 0, 0, 0.5);
}
.drawer--open {
transform: translateX(0);
}
/* Content area */
.drawer__content {
flex: 1;
overflow-y: auto;
}
/* New chat button */
.drawer__new-chat {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 24rpx;
margin: 8rpx 20rpx;
border-radius: 16rpx;
background: linear-gradient(135deg, rgba(108, 92, 231, 0.2), rgba(162, 155, 254, 0.1));
border: 1px solid rgba(108, 92, 231, 0.25);
transition: all 0.2s ease;
}
.drawer__new-chat:active {
background: linear-gradient(135deg, rgba(108, 92, 231, 0.35), rgba(162, 155, 254, 0.2));
border-color: rgba(108, 92, 231, 0.45);
}
.drawer__new-chat-icon {
font-size: 36rpx;
color: #a29bfe;
font-weight: 300;
margin-right: 12rpx;
}
.drawer__new-chat-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
/* Divider between history and workspace */
.drawer__divider {
height: 1px;
background: rgba(255, 255, 255, 0.06);
margin: 16rpx 24rpx;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<view class="tabbar">
<view
v-for="tab in tabs"
:key="tab.key"
class="tabbar__item"
@tap="onTap(tab.key)"
>
<text class="tabbar__icon">{{ tab.icon }}</text>
<text
class="tabbar__label"
:class="{ 'tabbar__label--active': activeKey === tab.key }"
>{{ tab.label }}</text>
<view v-if="activeKey === tab.key" class="tabbar__indicator"></view>
</view>
</view>
</template>
<script setup lang="uts">
import type { TabKey } from '@/types/index'
const props = defineProps<{
activeKey: TabKey
}>()
const emit = defineEmits<{
change: [key: TabKey]
}>()
const tabs = [
{ key: 'workspace' as TabKey, icon: '📂', label: '工作空间' },
{ key: 'chat' as TabKey, icon: '💬', label: '对话' },
{ key: 'profile' as TabKey, icon: '👤', label: '我的' },
]
function onTap(key: TabKey): void {
if (key !== props.activeKey) {
emit('change', key)
}
}
</script>
<style scoped>
.tabbar {
display: flex;
flex-direction: row;
align-items: center;
background: rgba(15, 15, 26, 0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-bottom: env(safe-area-inset-bottom);
}
.tabbar__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 14rpx 0 10rpx;
position: relative;
transition: opacity 0.2s ease;
}
.tabbar__item:active {
opacity: 0.7;
}
.tabbar__icon {
font-size: 40rpx;
margin-bottom: 4rpx;
}
.tabbar__label {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.4);
transition: color 0.2s ease;
}
.tabbar__label--active {
color: #a29bfe;
font-weight: 600;
}
.tabbar__indicator {
position: absolute;
top: 0;
width: 40rpx;
height: 4rpx;
border-radius: 2rpx;
background: linear-gradient(90deg, #6c5ce7, #a29bfe);
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<view class="drawer-header">
<!-- Avatar -->
<view class="drawer-header__avatar">
<text class="drawer-header__avatar-emoji">🤖</text>
</view>
<!-- App info -->
<text class="drawer-header__name">{{ appName }}</text>
<text class="drawer-header__subtitle">{{ appSubtitle }}</text>
</view>
</template>
<script setup lang="uts">
import { APP_NAME, APP_SUBTITLE } from '@/constants/index'
const appName = APP_NAME
const appSubtitle = APP_SUBTITLE
</script>
<style scoped>
.drawer-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 32rpx 32rpx;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.drawer-header__avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(108, 92, 231, 0.3);
}
.drawer-header__avatar-emoji {
font-size: 48rpx;
}
.drawer-header__name {
font-size: 36rpx;
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
margin-bottom: 6rpx;
}
.drawer-header__subtitle {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
}
</style>

149
components/HistoryItem.uvue Normal file
View File

@@ -0,0 +1,149 @@
<template>
<view class="history-item" @tap="onTap">
<!-- Workflow color indicator -->
<view class="history-item__dot" :style="{ background: workflowColor }"></view>
<!-- Content -->
<view class="history-item__content">
<text class="history-item__title">{{ conversation.title }}</text>
<text class="history-item__preview">{{ lastMessagePreview }}</text>
</view>
<!-- Right side: time + delete -->
<view class="history-item__right">
<text class="history-item__time">{{ relativeTime }}</text>
<view class="history-item__delete" @tap.stop="onDelete">
<text class="history-item__delete-icon">🗑</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type { Conversation } from '@/types/index'
import { WORKFLOWS } from '@/constants/index'
const props = defineProps<{
conversation: Conversation
}>()
const emit = defineEmits<{
tap: []
delete: []
}>()
// Find the workflow color for the dot
const workflowColor = computed<string>(() => {
const wf = WORKFLOWS.find((w) => w.id === props.conversation.workflowId)
return wf ? wf.color : '#6c5ce7'
})
// Last message preview (first 30 chars of last message)
const lastMessagePreview = computed<string>(() => {
const msgs = props.conversation.messages
if (msgs.length === 0) return '暂无消息'
const last = msgs[msgs.length - 1]
let preview = last.content.replace(/\n/g, ' ').replace(/```/g, '')
if (preview.length > 30) {
preview = preview.substring(0, 30) + '...'
}
return preview
})
// Relative time display
const relativeTime = computed<string>(() => {
const now = Date.now()
const diff = now - props.conversation.updatedAt
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
const d = new Date(props.conversation.updatedAt)
return `${d.getMonth() + 1}/${d.getDate()}`
})
function onTap(): void {
emit('tap')
}
function onDelete(): void {
emit('delete')
}
</script>
<style scoped>
.history-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 24rpx;
border-radius: 16rpx;
margin-bottom: 8rpx;
background: rgba(255, 255, 255, 0.03);
transition: background 0.2s ease;
}
.history-item:active {
background: rgba(255, 255, 255, 0.06);
}
.history-item__dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
flex-shrink: 0;
margin-right: 16rpx;
}
.history-item__content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.history-item__title {
font-size: 28rpx;
font-weight: 500;
color: rgba(255, 255, 255, 0.88);
margin-bottom: 6rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item__preview {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item__right {
display: flex;
flex-direction: column;
align-items: flex-end;
margin-left: 16rpx;
flex-shrink: 0;
}
.history-item__time {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.3);
margin-bottom: 8rpx;
}
.history-item__delete {
padding: 4rpx;
}
.history-item__delete-icon {
font-size: 28rpx;
opacity: 0.6;
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<view class="history-list">
<!-- Section header -->
<view class="history-list__header">
<text class="history-list__header-icon">💬</text>
<text class="history-list__header-title">历史对话</text>
</view>
<!-- Empty state -->
<view v-if="conversations.length === 0" class="history-list__empty">
<text class="history-list__empty-text">暂无历史对话</text>
</view>
<!-- List -->
<view v-else class="history-list__items">
<HistoryItem
v-for="conv in conversations"
:key="conv.id"
:conversation="conv"
@tap="onSelect(conv.id)"
@delete="onDelete(conv.id)"
/>
</view>
</view>
</template>
<script setup lang="uts">
import type { Conversation } from '@/types/index'
import HistoryItem from './HistoryItem.uvue'
const props = defineProps<{
conversations: Conversation[]
}>()
const emit = defineEmits<{
select: [id: string]
delete: [id: string]
}>()
function onSelect(id: string): void {
emit('select', id)
}
function onDelete(id: string): void {
emit('delete', id)
}
</script>
<style scoped>
.history-list {
padding: 16rpx 0;
}
.history-list__header {
display: flex;
flex-direction: row;
align-items: center;
padding: 16rpx 24rpx 8rpx;
}
.history-list__header-icon {
font-size: 28rpx;
margin-right: 10rpx;
}
.history-list__header-title {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
}
.history-list__empty {
padding: 40rpx 24rpx;
text-align: center;
}
.history-list__empty-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.3);
}
.history-list__items {
padding: 8rpx 24rpx;
}
</style>

126
components/InputArea.uvue Normal file
View File

@@ -0,0 +1,126 @@
<template>
<view class="input-area">
<view class="input-area__container">
<!-- Text input -->
<input
class="input-area__field"
:class="{ 'input-area__field--has-text': hasText }"
type="text"
:value="inputText"
:placeholder="placeholder"
:disabled="disabled"
:maxlength="2000"
placeholder-style="color: rgba(255, 255, 255, 0.3);"
@input="onInput"
@confirm="onSend"
confirm-type="send"
/>
<!-- Send button -->
<view
class="input-area__send-btn"
:class="{ 'input-area__send-btn--active': hasText && !disabled }"
@tap="onSend"
>
<text class="input-area__send-icon">↑</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
const props = defineProps<{
disabled: boolean
placeholder: string
}>()
const emit = defineEmits<{
send: [text: string]
}>()
const inputText = ref('')
const hasText = computed<boolean>(() => {
return inputText.value.trim().length > 0
})
function onInput(e: any): void {
inputText.value = e.detail.value
}
function onSend(): void {
const text = inputText.value.trim()
if (text && !props.disabled) {
emit('send', text)
inputText.value = ''
}
}
</script>
<style scoped>
.input-area {
position: relative;
z-index: 100;
background: rgba(15, 15, 26, 0.9);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 16rpx 24rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
}
.input-area__container {
display: flex;
flex-direction: row;
align-items: center;
}
.input-area__field {
flex: 1;
height: 72rpx;
border-radius: 36rpx;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 0 24rpx;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
transition: border-color 0.25s ease;
}
.input-area__field--has-text {
border-color: rgba(108, 92, 231, 0.4);
}
.input-area__field:focus {
border-color: rgba(108, 92, 231, 0.6);
}
.input-area__send-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
margin-left: 16rpx;
flex-shrink: 0;
transition: all 0.25s ease;
}
.input-area__send-btn--active {
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
box-shadow: 0 4rpx 16rpx rgba(108, 92, 231, 0.35);
}
.input-area__send-icon {
font-size: 40rpx;
color: rgba(255, 255, 255, 0.5);
font-weight: 600;
line-height: 1;
}
.input-area__send-btn--active .input-area__send-icon {
color: #ffffff;
}
</style>

View File

@@ -0,0 +1,288 @@
<template>
<view
class="message-bubble"
:class="{
'message-bubble--user': message.role === 'user',
'message-bubble--ai': message.role === 'assistant',
'message-bubble--error': message.status === 'error',
}"
>
<!-- AI Avatar placeholder -->
<view v-if="message.role === 'assistant'" class="message-bubble__avatar">
<text class="message-bubble__avatar-text">AI</text>
</view>
<view class="message-bubble__content-wrapper">
<!-- Bubble -->
<view class="message-bubble__bubble">
<!-- Parsed content segments -->
<view v-for="(segment, idx) in parsedSegments" :key="idx">
<!-- Code block -->
<view v-if="segment.type === 'code'" class="message-bubble__code-block">
<text class="message-bubble__code-text">{{ segment.value }}</text>
</view>
<!-- Regular text (with inline bold handled) -->
<text v-else class="message-bubble__text">{{ segment.value }}</text>
</view>
</view>
<!-- Timestamp & Status -->
<view class="message-bubble__meta" :class="{ 'message-bubble__meta--user': message.role === 'user' }">
<text class="message-bubble__time">{{ formatTime(message.timestamp) }}</text>
<text v-if="message.role === 'user' && message.status === 'sending'" class="message-bubble__status">⏳</text>
<text v-if="message.role === 'user' && message.status === 'sent'" class="message-bubble__status">✓</text>
<text v-if="message.role === 'user' && message.status === 'error'" class="message-bubble__status message-bubble__status--error">⚠️</text>
</view>
<!-- Error retry -->
<view v-if="message.status === 'error'" class="message-bubble__retry" @tap="onRetry">
<text class="message-bubble__retry-text">发送失败,点击重试</text>
</view>
</view>
<!-- User Avatar placeholder -->
<view v-if="message.role === 'user'" class="message-bubble__avatar message-bubble__avatar--user">
<text class="message-bubble__avatar-text">我</text>
</view>
</view>
</template>
<script setup lang="uts">
import type { Message, ParsedSegment } from '@/types/index'
const props = defineProps<{
message: Message
}>()
const emit = defineEmits<{
retry: []
}>()
/**
* Parse message content into segments: text, code, bold.
* Handles ``` code fences and **bold** markers.
*/
const parsedSegments = computed<ParsedSegment[]>(() => {
return parseContent(props.message.content)
})
function parseContent(content: string): ParsedSegment[] {
const segments: ParsedSegment[] = []
let remaining = content
while (remaining.length > 0) {
const codeStart = remaining.indexOf('```')
if (codeStart === -1) {
// No more code blocks — add remaining as text
if (remaining.trim()) {
segments.push({ type: 'text', value: remaining.trim() })
}
break
}
// Text before code block
if (codeStart > 0) {
const beforeText = remaining.substring(0, codeStart).trim()
if (beforeText) {
segments.push({ type: 'text', value: beforeText })
}
}
// Find code block end
const afterFence = remaining.substring(codeStart + 3)
const codeEnd = afterFence.indexOf('```')
if (codeEnd === -1) {
// Unclosed code block — treat rest as text
segments.push({ type: 'text', value: remaining.substring(codeStart).trim() })
break
}
const codeContent = afterFence.substring(0, codeEnd).trim()
if (codeContent) {
segments.push({ type: 'code', value: codeContent })
}
remaining = afterFence.substring(codeEnd + 3)
}
// If no segments at all, return the whole content as text
if (segments.length === 0 && content.trim()) {
segments.push({ type: 'text', value: content.trim() })
}
return segments
}
/**
* Format timestamp to HH:mm.
*/
function formatTime(timestamp: number): string {
const d = new Date(timestamp)
const h = d.getHours().toString().padStart(2, '0')
const m = d.getMinutes().toString().padStart(2, '0')
return `${h}:${m}`
}
function onRetry(): void {
emit('retry')
}
</script>
<style scoped>
.message-bubble {
display: flex;
flex-direction: row;
align-items: flex-end;
margin-bottom: 20rpx;
padding: 0 24rpx;
animation: messageEnter 0.3s ease-out;
}
.message-bubble--user {
justify-content: flex-end;
}
.message-bubble--ai {
justify-content: flex-start;
}
/* Avatar */
.message-bubble__avatar {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 12rpx;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
}
.message-bubble__avatar--user {
margin-right: 0;
margin-left: 12rpx;
background: linear-gradient(135deg, #00b894, #55efc4);
}
.message-bubble__avatar-text {
font-size: 22rpx;
font-weight: 700;
color: #ffffff;
}
/* Content wrapper */
.message-bubble__content-wrapper {
display: flex;
flex-direction: column;
max-width: 80%;
}
.message-bubble--ai .message-bubble__content-wrapper {
max-width: 78%;
}
/* Bubble box */
.message-bubble__bubble {
padding: 20rpx 24rpx;
border-radius: 24rpx 24rpx 4rpx 24rpx;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.message-bubble--user .message-bubble__bubble {
border-radius: 24rpx 24rpx 24rpx 4rpx;
background: linear-gradient(135deg, #6c5ce7 0%, #5a4bd1 100%);
border: none;
}
.message-bubble--error .message-bubble__bubble {
border-color: rgba(255, 82, 82, 0.4);
}
/* Text */
.message-bubble__text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
line-height: 1.6;
word-break: break-word;
}
.message-bubble--ai .message-bubble__text {
color: rgba(255, 255, 255, 0.88);
}
.message-bubble--user .message-bubble__text {
color: #ffffff;
}
/* Code block */
.message-bubble__code-block {
background: rgba(0, 0, 0, 0.35);
border-radius: 12rpx;
padding: 16rpx 20rpx;
margin: 12rpx 0;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.message-bubble__code-text {
font-size: 24rpx;
font-family: 'Courier New', Courier, monospace;
color: #a29bfe;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
/* Meta (time + status) */
.message-bubble__meta {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 6rpx;
padding: 0 8rpx;
}
.message-bubble__meta--user {
justify-content: flex-end;
}
.message-bubble__time {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.3);
}
.message-bubble__status {
font-size: 18rpx;
margin-left: 8rpx;
color: rgba(255, 255, 255, 0.4);
}
.message-bubble__status--error {
color: #ff5252;
}
/* Retry */
.message-bubble__retry {
margin-top: 8rpx;
padding: 8rpx 16rpx;
align-self: flex-end;
}
.message-bubble__retry-text {
font-size: 22rpx;
color: #ff5252;
}
/* Enter animation */
@keyframes messageEnter {
from {
opacity: 0;
transform: translateY(16rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

175
components/MessageList.uvue Normal file
View File

@@ -0,0 +1,175 @@
<template>
<view class="message-list">
<!-- Empty state -->
<view v-if="messages.length === 0 && !isLoading" class="message-list__empty">
<text class="message-list__empty-icon">💡</text>
<text class="message-list__empty-title">{{ emptyTitle }}</text>
<text class="message-list__empty-subtitle">{{ emptySubtitle }}</text>
</view>
<!-- Messages -->
<scroll-view
v-if="messages.length > 0 || isLoading"
class="message-list__scroll"
ref="scrollViewRef"
scroll-y="true"
show-scrollbar="false"
scroll-with-animation="true"
:scroll-top="scrollToBottom"
>
<view class="message-list__inner">
<MessageBubble
v-for="msg in messages"
:key="msg.id"
:message="msg"
@retry="onRetry(msg.id)"
/>
<!-- Typing indicator -->
<TypingIndicator :visible="isLoading" />
</view>
<!-- Bottom spacer for comfortable scrolling -->
<view class="message-list__spacer"></view>
</scroll-view>
<!-- Scroll-to-bottom FAB -->
<view
v-if="showScrollFab"
class="message-list__fab"
@tap="scrollToLatest"
>
<text class="message-list__fab-icon">↓</text>
</view>
</view>
</template>
<script setup lang="uts">
import type { Message } from '@/types/index'
import MessageBubble from './MessageBubble.uvue'
import TypingIndicator from './TypingIndicator.uvue'
const props = defineProps<{
messages: Message[]
isLoading: boolean
emptyTitle: string
emptySubtitle: string
}>()
const emit = defineEmits<{
retry: [messageId: string]
}>()
// Auto-scroll: increment this to scroll to bottom
const scrollToBottom = ref(0)
const showScrollFab = ref(false)
// Watch for new messages to auto-scroll
watch(
() => props.messages.length,
() => {
// Trigger scroll to bottom on new message
nextTick(() => {
scrollToBottom.value = Date.now()
})
}
)
// Watch for isLoading to auto-scroll when typing starts
watch(
() => props.isLoading,
(loading: boolean) => {
if (loading) {
nextTick(() => {
scrollToBottom.value = Date.now()
})
}
}
)
function scrollToLatest(): void {
scrollToBottom.value = Date.now()
showScrollFab.value = false
}
function onRetry(messageId: string): void {
emit('retry', messageId)
}
</script>
<style scoped>
.message-list {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
/* Empty state */
.message-list__empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64rpx;
}
.message-list__empty-icon {
font-size: 80rpx;
margin-bottom: 24rpx;
opacity: 0.6;
}
.message-list__empty-title {
font-size: 34rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 12rpx;
}
.message-list__empty-subtitle {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.3);
text-align: center;
line-height: 1.5;
}
/* Scroll area */
.message-list__scroll {
flex: 1;
}
.message-list__inner {
padding-top: 16rpx;
padding-bottom: 8rpx;
}
.message-list__spacer {
height: 32rpx;
}
/* Scroll-to-bottom FAB */
.message-list__fab {
position: absolute;
bottom: 16rpx;
right: 24rpx;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(108, 92, 231, 0.85);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.4);
z-index: 10;
transition: opacity 0.25s ease, transform 0.25s ease;
}
.message-list__fab-icon {
font-size: 36rpx;
color: #ffffff;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<!-- Overlay -->
<view
v-if="visible"
class="profile-overlay"
:class="{ 'profile-overlay--visible': visible }"
@tap="close"
@touchmove.stop.prevent="() => {}"
></view>
<!-- Panel (slides from right) -->
<view class="profile-panel" :class="{ 'profile-panel--open': visible }">
<!-- Header: Avatar + Name -->
<view class="profile-panel__header">
<view class="profile-panel__avatar">
<text class="profile-panel__avatar-emoji">🧑‍💻</text>
</view>
<text class="profile-panel__name">用户</text>
<text class="profile-panel__id">ID: AI助手用户</text>
</view>
<!-- Stats -->
<view class="profile-panel__stats">
<view class="profile-panel__stat">
<text class="profile-panel__stat-value">{{ conversationsCount }}</text>
<text class="profile-panel__stat-label">对话数</text>
</view>
<view class="profile-panel__stat-divider"></view>
<view class="profile-panel__stat">
<text class="profile-panel__stat-value">{{ workspaceCount }}</text>
<text class="profile-panel__stat-label">工作结果</text>
</view>
</view>
<!-- Menu items -->
<view class="profile-panel__menu">
<view
v-for="item in menuItems"
:key="item.key"
class="profile-panel__menu-item"
@tap="onMenuItem(item.key)"
>
<text class="profile-panel__menu-icon">{{ item.icon }}</text>
<text class="profile-panel__menu-label">{{ item.label }}</text>
<text class="profile-panel__menu-arrow"></text>
</view>
</view>
<!-- Version -->
<text class="profile-panel__version">AI助手 v1.0.0</text>
</view>
</template>
<script setup lang="uts">
const props = defineProps<{
visible: boolean
conversationsCount: number
workspaceCount: number
}>()
const emit = defineEmits<{
close: []
}>()
const menuItems = [
{ key: 'settings', icon: '⚙️', label: '设置' },
{ key: 'about', icon: '', label: '关于' },
{ key: 'clear', icon: '🗑', label: '清除数据' },
]
function close(): void {
emit('close')
}
function onMenuItem(key: string): void {
if (key === 'clear') {
uni.showModal({
title: '确认清除',
content: '将清除所有对话历史和工作空间数据,此操作不可恢复。',
success: (res: any) => {
if (res.confirm) {
uni.clearStorageSync()
uni.showToast({
title: '数据已清除',
icon: 'success',
})
close()
}
},
})
} else {
uni.showToast({
title: `功能开发中: ${key}`,
icon: 'none',
})
}
}
</script>
<style scoped>
/* Overlay */
.profile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 999;
opacity: 0;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.profile-overlay--visible {
opacity: 1;
}
/* Panel */
.profile-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 600rpx;
background: #1a1a2e;
z-index: 1000;
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
box-shadow: -8rpx 0 40rpx rgba(0, 0, 0, 0.5);
}
.profile-panel--open {
transform: translateX(0);
}
/* Header */
.profile-panel__header {
display: flex;
flex-direction: column;
align-items: center;
padding: 64rpx 32rpx 32rpx;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.profile-panel__avatar {
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(108, 92, 231, 0.3);
}
.profile-panel__avatar-emoji {
font-size: 56rpx;
}
.profile-panel__name {
font-size: 36rpx;
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
margin-bottom: 8rpx;
}
.profile-panel__id {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.35);
}
/* Stats */
.profile-panel__stats {
display: flex;
flex-direction: row;
align-items: center;
padding: 28rpx 32rpx;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.profile-panel__stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.profile-panel__stat-value {
font-size: 40rpx;
font-weight: 700;
color: #a29bfe;
margin-bottom: 4rpx;
}
.profile-panel__stat-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
}
.profile-panel__stat-divider {
width: 1px;
height: 48rpx;
background: rgba(255, 255, 255, 0.1);
}
/* Menu */
.profile-panel__menu {
padding: 16rpx 0;
flex: 1;
}
.profile-panel__menu-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 28rpx 32rpx;
transition: background 0.2s ease;
}
.profile-panel__menu-item:active {
background: rgba(255, 255, 255, 0.04);
}
.profile-panel__menu-icon {
font-size: 32rpx;
margin-right: 20rpx;
}
.profile-panel__menu-label {
flex: 1;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
}
.profile-panel__menu-arrow {
font-size: 36rpx;
color: rgba(255, 255, 255, 0.25);
font-weight: 300;
}
/* Version */
.profile-panel__version {
text-align: center;
padding: 24rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.2);
}
</style>

129
components/TopBar.uvue Normal file
View File

@@ -0,0 +1,129 @@
<template>
<view class="topbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="topbar__inner">
<!-- Menu button -->
<view class="topbar__btn" @tap="onMenuClick">
<text class="topbar__menu-icon">☰</text>
</view>
<!-- Title -->
<view class="topbar__title-area">
<text class="topbar__title">{{ title }}</text>
</view>
<!-- Profile button -->
<view class="topbar__profile-btn" @tap="onProfileClick">
<text class="topbar__profile-text">我的</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
const props = defineProps<{
title: string
}>()
const emit = defineEmits<{
'menu-click': []
'profile-click': []
}>()
// Try to get status bar height from system info
const statusBarHeight = ref(0)
// Get system info on mount
uni.getSystemInfo({
success: (res) => {
statusBarHeight.value = res.statusBarHeight ?? 24
},
fail: () => {
statusBarHeight.value = 24
},
})
function onMenuClick(): void {
emit('menu-click')
}
function onProfileClick(): void {
emit('profile-click')
}
</script>
<style scoped>
.topbar {
position: relative;
z-index: 100;
background: rgba(15, 15, 26, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.topbar__inner {
display: flex;
flex-direction: row;
align-items: center;
height: 88rpx;
padding: 0 16rpx;
}
.topbar__btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s ease;
}
.topbar__btn:active {
background: rgba(255, 255, 255, 0.08);
}
.topbar__menu-icon {
font-size: 38rpx;
color: rgba(255, 255, 255, 0.8);
font-weight: 300;
}
.topbar__profile-btn {
height: 60rpx;
padding: 0 20rpx;
border-radius: 30rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.06);
transition: background 0.2s ease;
}
.topbar__profile-btn:active {
background: rgba(255, 255, 255, 0.12);
}
.topbar__profile-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
}
.topbar__title-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.topbar__title {
font-size: 32rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400rpx;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<view v-if="visible" class="typing-indicator">
<view class="typing-indicator__bubble">
<view class="typing-indicator__dot" style="animation-delay: 0ms;"></view>
<view class="typing-indicator__dot" style="animation-delay: 150ms;"></view>
<view class="typing-indicator__dot" style="animation-delay: 300ms;"></view>
</view>
</view>
</template>
<script setup lang="uts">
defineProps<{
visible: boolean
}>()
</script>
<style scoped>
.typing-indicator {
display: flex;
align-items: flex-start;
padding: 8rpx 24rpx;
}
.typing-indicator__bubble {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 28rpx;
border-radius: 24rpx 24rpx 24rpx 4rpx;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
gap: 8rpx;
}
.typing-indicator__dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #6c5ce7;
animation: dotBounce 0.6s ease-in-out infinite;
}
@keyframes dotBounce {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-10rpx);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<view
class="workflow-chip"
:class="{ 'workflow-chip--active': active }"
:style="active ? { background: workflow.color, boxShadow: `0 4rpx 16rpx ${workflow.color}44` } : {}"
@tap="onTap"
>
<text class="workflow-chip__icon">{{ workflow.icon }}</text>
<text class="workflow-chip__label" :class="{ 'workflow-chip__label--active': active }">{{ workflow.name }}</text>
</view>
</template>
<script setup lang="uts">
import type { WorkflowDef } from '@/types/index'
const props = defineProps<{
workflow: WorkflowDef
active: boolean
}>()
const emit = defineEmits<{
tap: []
}>()
function onTap(): void {
emit('tap')
}
</script>
<style scoped>
.workflow-chip {
display: flex;
flex-direction: row;
align-items: center;
height: 64rpx;
padding: 0 24rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.06);
margin-right: 16rpx;
flex-shrink: 0;
transition: all 0.25s ease;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.workflow-chip--active {
border-color: transparent;
}
.workflow-chip__icon {
font-size: 28rpx;
margin-right: 8rpx;
}
.workflow-chip__label {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.55);
white-space: nowrap;
transition: color 0.25s ease;
}
.workflow-chip__label--active {
color: #ffffff;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<view class="workflow-selector">
<view class="workflow-selector__scroll">
<view class="workflow-selector__inner">
<WorkflowChip
v-for="wf in workflows"
:key="wf.id"
:workflow="wf"
:active="wf.id === activeId"
@tap="onSelectChip(wf.id)"
/>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type { WorkflowDef, WorkflowId } from '@/types/index'
import WorkflowChip from './WorkflowChip.uvue'
const props = defineProps<{
workflows: WorkflowDef[]
activeId: WorkflowId
}>()
const emit = defineEmits<{
select: [id: WorkflowId]
}>()
function onSelectChip(id: WorkflowId): void {
if (id !== props.activeId) {
emit('select', id)
}
}
</script>
<style scoped>
.workflow-selector {
padding: 8rpx 0;
background: transparent;
}
/* Outer: clips and scrolls */
.workflow-selector__scroll {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
/* Inner: forced wider than screen, flex row, no wrap */
.workflow-selector__inner {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
padding: 0 24rpx;
width: max-content;
min-width: 1100rpx;
}
/* Hide scrollbar */
.workflow-selector__scroll::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<view class="workspace-item" :style="{ borderLeftColor: accentColor }" @tap="onTap">
<!-- Header row -->
<view class="workspace-item__header">
<text class="workspace-item__icon">{{ typeIcon }}</text>
<text class="workspace-item__type">{{ typeLabel }}</text>
<text class="workspace-item__time">{{ formatTime(item.createdAt) }}</text>
</view>
<!-- Title -->
<text class="workspace-item__title">{{ item.title }}</text>
<!-- Summary -->
<text class="workspace-item__summary">{{ item.summary }}</text>
</view>
</template>
<script setup lang="uts">
import type { WorkspaceItem, WorkspaceItemType } from '@/types/index'
import { WORKFLOWS } from '@/constants/index'
const props = defineProps<{
item: WorkspaceItem
}>()
const emit = defineEmits<{
tap: []
}>()
// Type icon mapping
const typeIcon = computed<string>(() => {
const icons: Record<WorkspaceItemType, string> = {
code_snippet: '💻',
document: '📝',
analysis_report: '📊',
creative_output: '✨',
}
return icons[props.item.type] ?? '📄'
})
// Type label mapping
const typeLabel = computed<string>(() => {
const labels: Record<WorkspaceItemType, string> = {
code_snippet: '代码',
document: '文档',
analysis_report: '报告',
creative_output: '创意',
}
return labels[props.item.type] ?? '结果'
})
// Accent color from workflow
const accentColor = computed<string>(() => {
const wf = WORKFLOWS.find((w) => w.id === props.item.workflowId)
return wf ? wf.color : '#6c5ce7'
})
function formatTime(timestamp: number): string {
const d = new Date(timestamp)
const m = (d.getMonth() + 1).toString().padStart(2, '0')
const day = d.getDate().toString().padStart(2, '0')
const h = d.getHours().toString().padStart(2, '0')
const min = d.getMinutes().toString().padStart(2, '0')
return `${m}/${day} ${h}:${min}`
}
function onTap(): void {
emit('tap')
}
</script>
<style scoped>
.workspace-item {
display: flex;
flex-direction: column;
padding: 20rpx 24rpx;
border-radius: 12rpx;
margin-bottom: 16rpx;
background: rgba(255, 255, 255, 0.04);
border-left: 4rpx solid #6c5ce7;
transition: background 0.2s ease;
}
.workspace-item:active {
background: rgba(255, 255, 255, 0.07);
}
.workspace-item__header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 10rpx;
}
.workspace-item__icon {
font-size: 24rpx;
margin-right: 8rpx;
}
.workspace-item__type {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.5);
font-weight: 500;
flex: 1;
}
.workspace-item__time {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.3);
}
.workspace-item__title {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 8rpx;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.workspace-item__summary {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.45);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<view class="workspace-view">
<!-- Section header -->
<view class="workspace-view__header">
<text class="workspace-view__header-icon">📂</text>
<text class="workspace-view__header-title">工作空间</text>
</view>
<!-- Empty state -->
<view v-if="items.length === 0" class="workspace-view__empty">
<text class="workspace-view__empty-text">暂无工作结果</text>
</view>
<!-- Items list -->
<view v-else class="workspace-view__items">
<WorkspaceItem
v-for="item in items"
:key="item.id"
:item="item"
@tap="onSelect(item.id)"
/>
</view>
</view>
</template>
<script setup lang="uts">
import type { WorkspaceItem } from '@/types/index'
import WorkspaceItemComponent from './WorkspaceItem.uvue'
const props = defineProps<{
items: WorkspaceItem[]
}>()
const emit = defineEmits<{
select: [id: string]
}>()
function onSelect(id: string): void {
emit('select', id)
}
</script>
<style scoped>
.workspace-view {
padding: 16rpx 0;
}
.workspace-view__header {
display: flex;
flex-direction: row;
align-items: center;
padding: 16rpx 24rpx 4rpx;
}
.workspace-view__header-icon {
font-size: 28rpx;
margin-right: 10rpx;
}
.workspace-view__header-title {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
}
/* Empty */
.workspace-view__empty {
padding: 40rpx 24rpx;
text-align: center;
}
.workspace-view__empty-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.3);
}
/* Items */
.workspace-view__items {
padding: 8rpx 24rpx;
}
</style>