Initial commit: AI chat assistant with workflow chat, workspace, and profile tabs
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
208
components/AppDrawer.uvue
Normal file
208
components/AppDrawer.uvue
Normal 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>
|
||||
93
components/BottomTabBar.uvue
Normal file
93
components/BottomTabBar.uvue
Normal 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>
|
||||
57
components/DrawerHeader.uvue
Normal file
57
components/DrawerHeader.uvue
Normal 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
149
components/HistoryItem.uvue
Normal 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>
|
||||
85
components/HistoryList.uvue
Normal file
85
components/HistoryList.uvue
Normal 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
126
components/InputArea.uvue
Normal 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>
|
||||
288
components/MessageBubble.uvue
Normal file
288
components/MessageBubble.uvue
Normal 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
175
components/MessageList.uvue
Normal 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>
|
||||
251
components/ProfilePanel.uvue
Normal file
251
components/ProfilePanel.uvue
Normal 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
129
components/TopBar.uvue
Normal 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>
|
||||
53
components/TypingIndicator.uvue
Normal file
53
components/TypingIndicator.uvue
Normal 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>
|
||||
65
components/WorkflowChip.uvue
Normal file
65
components/WorkflowChip.uvue
Normal 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>
|
||||
67
components/WorkflowSelector.uvue
Normal file
67
components/WorkflowSelector.uvue
Normal 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>
|
||||
132
components/WorkspaceItem.uvue
Normal file
132
components/WorkspaceItem.uvue
Normal 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>
|
||||
81
components/WorkspaceView.uvue
Normal file
81
components/WorkspaceView.uvue
Normal 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>
|
||||
Reference in New Issue
Block a user