This commit is contained in:
2025-09-24 16:43:10 +08:00
parent 12f46e6f8e
commit 9dbc054483
130 changed files with 16474 additions and 4660 deletions

97
src/apps/AppRegistry.ts Normal file
View File

@@ -0,0 +1,97 @@
import type { AppRegistration } from './types/AppManifest'
import { reactive, markRaw } from 'vue'
/**
* 应用注册中心
* 管理所有内置应用的注册和获取
*/
export class AppRegistry {
private static instance: AppRegistry | null = null
private apps = reactive(new Map<string, AppRegistration>())
private constructor() {}
static getInstance(): AppRegistry {
if (!AppRegistry.instance) {
AppRegistry.instance = new AppRegistry()
}
return AppRegistry.instance
}
/**
* 注册内置应用
*/
registerApp(registration: AppRegistration): void {
// 使用 markRaw 标记组件,避免被设为响应式
const safeRegistration = {
...registration,
component: registration.component ? markRaw(registration.component) : registration.component
}
this.apps.set(registration.manifest.id, safeRegistration)
console.log(`已注册内置应用: ${registration.manifest.name}`)
}
/**
* 获取应用
*/
getApp(appId: string): AppRegistration | undefined {
return this.apps.get(appId)
}
/**
* 获取所有应用
*/
getAllApps(): AppRegistration[] {
return Array.from(this.apps.values())
}
/**
* 获取所有内置应用
*/
getBuiltInApps(): AppRegistration[] {
return Array.from(this.apps.values()).filter(app => app.isBuiltIn)
}
/**
* 检查应用是否存在
*/
hasApp(appId: string): boolean {
return this.apps.has(appId)
}
/**
* 按类别获取应用
*/
getAppsByCategory(category: string): AppRegistration[] {
return Array.from(this.apps.values()).filter(
app => app.manifest.category === category
)
}
/**
* 搜索应用
*/
searchApps(query: string): AppRegistration[] {
const lowercaseQuery = query.toLowerCase()
return Array.from(this.apps.values()).filter(app => {
const manifest = app.manifest
return (
manifest.name.toLowerCase().includes(lowercaseQuery) ||
manifest.description.toLowerCase().includes(lowercaseQuery) ||
manifest.keywords?.some(keyword =>
keyword.toLowerCase().includes(lowercaseQuery)
)
)
})
}
/**
* 清空所有应用
*/
clear(): void {
this.apps.clear()
}
}
// 导出单例实例
export const appRegistry = AppRegistry.getInstance()

View File

@@ -0,0 +1,348 @@
<template>
<BuiltInApp app-id="calculator" title="计算器">
<div class="calculator">
<div class="display">
<input
v-model="displayValue"
type="text"
readonly
class="display-input"
:class="{ 'error': hasError }"
>
</div>
<div class="buttons">
<!-- 第一行 -->
<button @click="clear" class="btn btn-clear">C</button>
<button @click="deleteLast" class="btn btn-operation"></button>
<button @click="appendOperation('/')" class="btn btn-operation">÷</button>
<button @click="appendOperation('*')" class="btn btn-operation">×</button>
<!-- 第二行 -->
<button @click="appendNumber('7')" class="btn btn-number">7</button>
<button @click="appendNumber('8')" class="btn btn-number">8</button>
<button @click="appendNumber('9')" class="btn btn-number">9</button>
<button @click="appendOperation('-')" class="btn btn-operation">-</button>
<!-- 第三行 -->
<button @click="appendNumber('4')" class="btn btn-number">4</button>
<button @click="appendNumber('5')" class="btn btn-number">5</button>
<button @click="appendNumber('6')" class="btn btn-number">6</button>
<button @click="appendOperation('+')" class="btn btn-operation">+</button>
<!-- 第四行 -->
<button @click="appendNumber('1')" class="btn btn-number">1</button>
<button @click="appendNumber('2')" class="btn btn-number">2</button>
<button @click="appendNumber('3')" class="btn btn-number">3</button>
<button @click="calculate" class="btn btn-equals" rowspan="2">=</button>
<!-- 第五行 -->
<button @click="appendNumber('0')" class="btn btn-number btn-zero">0</button>
<button @click="appendNumber('.')" class="btn btn-number">.</button>
</div>
</div>
</BuiltInApp>
</template>
<script setup lang="ts">
import { ref, onMounted, inject } from 'vue'
import BuiltInApp from '../components/BuiltInApp.vue'
import type { SystemServiceIntegration } from '@/services/SystemServiceIntegration'
// 直接获取系统服务 - 无需通过SDK
const systemService = inject<SystemServiceIntegration>('systemService')
const displayValue = ref('0')
const hasError = ref(false)
const lastResult = ref<number | null>(null)
const shouldResetDisplay = ref(false)
// 直接使用系统存储服务保存历史记录
const saveHistory = async (expression: string, result: string) => {
try {
if (systemService) {
const resourceService = systemService.getResourceService()
const history = await resourceService.getStorage('calculator', 'history') || []
history.push({
expression,
result,
timestamp: new Date().toISOString()
})
// 只保存最近30条记录
if (history.length > 30) {
history.shift()
}
await resourceService.setStorage('calculator', 'history', history)
}
} catch (error) {
console.error('保存历史记录失败:', error)
}
}
// 直接使用事件服务发送通知
const showNotification = (message: string) => {
if (systemService) {
const eventService = systemService.getEventService()
eventService.sendMessage('calculator', 'user-interaction', {
type: 'notification',
message,
timestamp: new Date()
})
}
}
// 添加数字
const appendNumber = (num: string) => {
hasError.value = false
if (shouldResetDisplay.value) {
displayValue.value = '0'
shouldResetDisplay.value = false
}
if (num === '.') {
if (!displayValue.value.includes('.')) {
displayValue.value += num
}
} else {
if (displayValue.value === '0') {
displayValue.value = num
} else {
displayValue.value += num
}
}
}
// 添加运算符
const appendOperation = (op: string) => {
hasError.value = false
shouldResetDisplay.value = false
const lastChar = displayValue.value.slice(-1)
const operations = ['+', '-', '*', '/']
// 如果最后一个字符是运算符,替换它
if (operations.includes(lastChar)) {
displayValue.value = displayValue.value.slice(0, -1) + op
} else {
displayValue.value += op
}
}
// 计算结果
const calculate = async () => {
try {
hasError.value = false
let expression = displayValue.value
.replace(/×/g, '*')
.replace(/÷/g, '/')
// 简单的表达式验证
if (/[+\-*/]$/.test(expression)) {
return // 以运算符结尾,不计算
}
const originalExpression = displayValue.value
const result = eval(expression)
if (!isFinite(result)) {
throw new Error('除零错误')
}
displayValue.value = result.toString()
lastResult.value = result
shouldResetDisplay.value = true
// 保存历史记录
await saveHistory(originalExpression, result.toString())
// 发送通知
showNotification(`计算结果: ${result}`)
} catch (error) {
hasError.value = true
displayValue.value = '错误'
setTimeout(() => {
clear()
}, 1000)
}
}
// 清空
const clear = () => {
displayValue.value = '0'
hasError.value = false
lastResult.value = null
shouldResetDisplay.value = false
}
// 删除最后一个字符
const deleteLast = () => {
hasError.value = false
if (shouldResetDisplay.value) {
clear()
return
}
if (displayValue.value.length > 1) {
displayValue.value = displayValue.value.slice(0, -1)
} else {
displayValue.value = '0'
}
}
// 键盘事件处理
const handleKeyboard = (event: KeyboardEvent) => {
event.preventDefault()
const key = event.key
if (/[0-9.]/.test(key)) {
appendNumber(key)
} else if (['+', '-', '*', '/'].includes(key)) {
appendOperation(key)
} else if (key === 'Enter' || key === '=') {
calculate()
} else if (key === 'Escape' || key === 'c' || key === 'C') {
clear()
} else if (key === 'Backspace') {
deleteLast()
}
}
onMounted(() => {
// 添加键盘事件监听
document.addEventListener('keydown', handleKeyboard)
})
</script>
<style scoped>
.calculator {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
max-width: 320px;
margin: 20px auto;
}
.display {
margin-bottom: 20px;
}
.display-input {
width: 100%;
height: 80px;
font-size: 32px;
text-align: right;
padding: 0 20px;
border: 2px solid #e0e0e0;
border-radius: 8px;
background: #f8f9fa;
color: #333;
outline: none;
font-family: 'Courier New', monospace;
box-sizing: border-box;
}
.display-input.error {
color: #e74c3c;
border-color: #e74c3c;
}
.buttons {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(5, 1fr);
gap: 12px;
height: 320px;
}
.btn {
border: none;
border-radius: 8px;
font-size: 20px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
display: flex;
align-items: center;
justify-content: center;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn:active {
transform: translateY(0);
}
.btn-number {
background: #ffffff;
color: #333;
border: 2px solid #e0e0e0;
}
.btn-number:hover {
background: #f8f9fa;
border-color: #007bff;
}
.btn-operation {
background: #007bff;
color: white;
}
.btn-operation:hover {
background: #0056b3;
}
.btn-clear {
background: #dc3545;
color: white;
}
.btn-clear:hover {
background: #c82333;
}
.btn-equals {
background: #28a745;
color: white;
grid-row: span 2;
}
.btn-equals:hover {
background: #218838;
}
.btn-zero {
grid-column: span 2;
}
/* 响应式设计 */
@media (max-width: 400px) {
.calculator {
margin: 10px;
padding: 15px;
}
.display-input {
height: 60px;
font-size: 24px;
}
.buttons {
height: 280px;
gap: 8px;
}
.btn {
font-size: 16px;
}
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div class="built-in-app" :class="[`app-${appId}`, { 'fullscreen': isFullscreen }]">
<div class="app-container">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { inject, onMounted, onUnmounted, ref, provide } from 'vue'
import type { SystemServiceIntegration } from '@/services/SystemServiceIntegration'
const props = defineProps<{
appId: string
title?: string
}>()
const isFullscreen = ref(false)
const systemService = inject<SystemServiceIntegration>('systemService')
// 为子组件提供应用上下文
const appContext = {
appId: props.appId,
title: props.title || props.appId,
systemService,
// 直接暴露系统服务的方法,简化调用
storage: systemService?.getResourceService(),
events: systemService?.getEventService(),
lifecycle: systemService?.getLifecycleManager(),
window: {
setTitle: (title: string) => {
if (window.SystemSDK) {
window.SystemSDK.window.setTitle(title)
}
},
toggleFullscreen: () => {
isFullscreen.value = !isFullscreen.value
}
}
}
// 提供应用上下文给子组件
provide('appContext', appContext)
// SDK初始化 - 简化版本
onMounted(async () => {
try {
if (window.SystemSDK) {
await window.SystemSDK.init({
appId: props.appId,
appName: props.title || props.appId,
version: '1.0.0',
permissions: ['storage', 'notification']
})
if (props.title) {
await window.SystemSDK.window.setTitle(props.title)
}
console.log(`内置应用 ${props.appId} 初始化成功`)
}
} catch (error) {
console.error(`内置应用 ${props.appId} 初始化失败:`, error)
}
})
// 清理
onUnmounted(() => {
if (window.SystemSDK && window.SystemSDK.initialized) {
window.SystemSDK.destroy?.()
}
})
// 切换全屏模式
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value
}
// 暴露方法给父组件
defineExpose({
toggleFullscreen,
appContext
})
</script>
<style scoped>
.built-in-app {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: white;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.app-container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
}
/* 应用特定样式 */
.app-calculator {
max-width: 400px;
margin: 0 auto;
}
.app-notepad {
padding: 10px;
}
.app-todo {
/* 待办事项应用样式 */
}
</style>

90
src/apps/index.ts Normal file
View File

@@ -0,0 +1,90 @@
import { appRegistry } from './AppRegistry'
import { markRaw } from 'vue'
import Calculator from './calculator/Calculator.vue'
import Notepad from './notepad/Notepad.vue'
import Todo from './todo/Todo.vue'
/**
* 注册所有内置应用
*/
export function registerBuiltInApps() {
// 注册计算器应用
appRegistry.registerApp({
manifest: {
id: 'calculator',
name: '计算器',
version: '1.0.0',
description: '简单而功能强大的计算器,支持基本数学运算',
author: 'System',
icon: '🧮',
permissions: ['storage'],
window: {
width: 400,
height: 600,
minWidth: 320,
minHeight: 480,
resizable: true,
minimizable: true,
maximizable: false
},
category: 'utilities',
keywords: ['计算器', '数学', '运算', 'calculator', 'math']
},
component: markRaw(Calculator),
isBuiltIn: true
})
// 注册记事本应用
appRegistry.registerApp({
manifest: {
id: 'notepad',
name: '记事本',
version: '1.0.0',
description: '功能丰富的文本编辑器,支持文件管理和多种编辑选项',
author: 'System',
icon: '📝',
permissions: ['storage', 'notification'],
window: {
width: 800,
height: 600,
minWidth: 400,
minHeight: 300,
resizable: true
},
category: 'productivity',
keywords: ['记事本', '文本编辑', '笔记', 'notepad', 'text', 'editor']
},
component: markRaw(Notepad),
isBuiltIn: true
})
// 注册待办事项应用
appRegistry.registerApp({
manifest: {
id: 'todo',
name: '待办事项',
version: '1.0.0',
description: '高效的任务管理工具,帮助您组织和跟踪日常任务',
author: 'System',
icon: '✅',
permissions: ['storage', 'notification'],
window: {
width: 600,
height: 700,
minWidth: 400,
minHeight: 500,
resizable: true
},
category: 'productivity',
keywords: ['待办事项', '任务管理', 'todo', 'task', 'productivity']
},
component: markRaw(Todo),
isBuiltIn: true
})
console.log('内置应用注册完成')
}
// 导出应用注册中心
export { appRegistry } from './AppRegistry'
export type { InternalAppManifest, AppRegistration } from './types/AppManifest'

View File

@@ -0,0 +1,527 @@
<template>
<BuiltInApp app-id="notepad" title="记事本">
<div class="notepad">
<div class="toolbar">
<div class="toolbar-group">
<button @click="newFile" class="btn btn-secondary">
<span class="icon">📄</span>
新建
</button>
<button @click="saveFile" class="btn btn-primary" :disabled="!hasChanges">
<span class="icon">💾</span>
保存
</button>
<button @click="openFile" class="btn btn-secondary">
<span class="icon">📂</span>
打开
</button>
</div>
<div class="toolbar-group">
<select v-model="fontSize" @change="updateFontSize" class="font-size-select">
<option value="12">12px</option>
<option value="14">14px</option>
<option value="16">16px</option>
<option value="18">18px</option>
<option value="20">20px</option>
<option value="24">24px</option>
</select>
<button
@click="toggleWordWrap"
class="btn btn-secondary"
:class="{ active: wordWrap }"
>
<span class="icon">📄</span>
自动换行
</button>
</div>
<div class="file-info">
<span class="filename">{{ currentFileName }}</span>
<span v-if="hasChanges" class="unsaved">*</span>
</div>
</div>
<div class="editor-container">
<textarea
ref="editorRef"
v-model="content"
class="editor"
:style="editorStyle"
placeholder="开始输入文本..."
@input="onContentChange"
@keydown="handleKeydown"
></textarea>
</div>
<div class="status-bar">
<div class="status-left">
<span>: {{ currentLine }}</span>
<span>: {{ currentColumn }}</span>
<span>字符数: {{ characterCount }}</span>
</div>
<div class="status-right">
<span>{{ fileEncoding }}</span>
<span>{{ lastSaved ? `上次保存: ${formatTime(lastSaved)}` : '未保存' }}</span>
</div>
</div>
</div>
<!-- 文件列表对话框 -->
<div v-if="showFileList" class="modal-overlay" @click="showFileList = false">
<div class="modal" @click.stop>
<h3>选择文件</h3>
<div class="file-list">
<div
v-for="file in savedFiles"
:key="file.name"
class="file-item"
@click="loadFile(file.name)"
>
<span class="file-icon">📄</span>
<span class="file-name">{{ file.name }}</span>
<span class="file-date">{{ formatTime(file.date) }}</span>
<button @click.stop="deleteFile(file.name)" class="btn-delete">🗑</button>
</div>
</div>
<div class="modal-actions">
<button @click="showFileList = false" class="btn btn-secondary">取消</button>
</div>
</div>
</div>
</BuiltInApp>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import BuiltInApp from '../components/BuiltInApp.vue'
const editorRef = ref<HTMLTextAreaElement>()
const content = ref('')
const currentFileName = ref('新文档.txt')
const hasChanges = ref(false)
const fontSize = ref('16')
const wordWrap = ref(true)
const lastSaved = ref<Date | null>(null)
const fileEncoding = ref('UTF-8')
const showFileList = ref(false)
const savedFiles = ref<Array<{name: string, date: Date}>>([])
// 计算属性
const editorStyle = computed(() => ({
fontSize: fontSize.value + 'px',
whiteSpace: wordWrap.value ? 'pre-wrap' : 'pre'
}))
const characterCount = computed(() => content.value.length)
const currentLine = ref(1)
const currentColumn = ref(1)
// 获取光标位置
const updateCursorPosition = () => {
if (!editorRef.value) return
const textarea = editorRef.value
const text = textarea.value
const position = textarea.selectionStart
const lines = text.substring(0, position).split('\n')
currentLine.value = lines.length
currentColumn.value = lines[lines.length - 1].length + 1
}
// 内容变化处理
const onContentChange = () => {
hasChanges.value = true
updateCursorPosition()
}
// 新建文件
const newFile = async () => {
if (hasChanges.value) {
if (confirm('当前文档未保存,是否保存?')) {
await saveFile()
}
}
content.value = ''
currentFileName.value = '新文档.txt'
hasChanges.value = false
lastSaved.value = null
updateTitle()
}
// 保存文件
const saveFile = async () => {
try {
if (window.SystemSDK && window.SystemSDK.initialized) {
await window.SystemSDK.storage.set(`notepad_${currentFileName.value}`, content.value)
hasChanges.value = false
lastSaved.value = new Date()
// 更新文件列表
await updateFilesList()
// 显示通知
if (window.SystemSDK.ui?.showNotification) {
await window.SystemSDK.ui.showNotification({
title: '保存成功',
body: `文件 "${currentFileName.value}" 已保存`,
duration: 2000
})
}
} else {
alert('系统SDK未初始化无法保存文件')
}
} catch (error) {
console.error('保存文件失败:', error)
alert('保存文件失败: ' + (error as Error).message)
}
}
// 打开文件
const openFile = async () => {
await updateFilesList()
showFileList.value = true
}
// 加载文件
const loadFile = async (fileName: string) => {
try {
if (window.SystemSDK && window.SystemSDK.initialized) {
const result = await window.SystemSDK.storage.get(`notepad_${fileName}`)
if (result.success && result.data) {
content.value = result.data
currentFileName.value = fileName
hasChanges.value = false
lastSaved.value = new Date()
showFileList.value = false
updateTitle()
} else {
alert('文件不存在或为空')
}
}
} catch (error) {
console.error('打开文件失败:', error)
alert('打开文件失败: ' + (error as Error).message)
}
}
// 删除文件
const deleteFile = async (fileName: string) => {
if (confirm(`确定要删除文件 "${fileName}" 吗?`)) {
try {
if (window.SystemSDK && window.SystemSDK.initialized) {
await window.SystemSDK.storage.remove(`notepad_${fileName}`)
await updateFilesList()
}
} catch (error) {
console.error('删除文件失败:', error)
alert('删除文件失败: ' + (error as Error).message)
}
}
}
// 更新文件列表
const updateFilesList = async () => {
// 这里需要实现获取所有以 notepad_ 开头的存储键
// 由于当前SDK没有提供列出所有键的功能我们先使用一个简化的实现
savedFiles.value = []
// 临时实现:尝试加载一些常见的文件名
const commonNames = ['新文档.txt', '笔记.txt', '备忘录.txt', '临时.txt']
for (const name of commonNames) {
try {
if (window.SystemSDK && window.SystemSDK.initialized) {
const result = await window.SystemSDK.storage.get(`notepad_${name}`)
if (result.success && result.data) {
savedFiles.value.push({
name,
date: new Date() // 暂时使用当前时间
})
}
}
} catch (error) {
// 忽略错误
}
}
}
// 更新字体大小
const updateFontSize = () => {
if (editorRef.value) {
editorRef.value.focus()
}
}
// 切换自动换行
const toggleWordWrap = () => {
wordWrap.value = !wordWrap.value
if (editorRef.value) {
editorRef.value.focus()
}
}
// 更新标题
const updateTitle = () => {
if (window.SystemSDK && window.SystemSDK.initialized) {
const title = `记事本 - ${currentFileName.value}${hasChanges.value ? ' *' : ''}`
window.SystemSDK.window.setTitle(title)
}
}
// 格式化时间
const formatTime = (date: Date) => {
return date.toLocaleString('zh-CN')
}
// 处理键盘快捷键
const handleKeydown = (event: KeyboardEvent) => {
if (event.ctrlKey) {
switch (event.key) {
case 's':
event.preventDefault()
saveFile()
break
case 'n':
event.preventDefault()
newFile()
break
case 'o':
event.preventDefault()
openFile()
break
}
}
// 更新光标位置
nextTick(() => {
updateCursorPosition()
})
}
onMounted(() => {
// 初始化光标位置
updateCursorPosition()
// 监听编辑器点击和键盘事件
if (editorRef.value) {
editorRef.value.addEventListener('click', updateCursorPosition)
editorRef.value.addEventListener('keyup', updateCursorPosition)
}
})
</script>
<style scoped>
.notepad {
height: 100%;
display: flex;
flex-direction: column;
background: white;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
flex-wrap: wrap;
gap: 10px;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 8px;
}
.btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: 1px solid #dee2e6;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.btn:hover:not(:disabled) {
background: #e9ecef;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #007bff;
color: white;
border-color: #007bff;
}
.btn-primary:hover:not(:disabled) {
background: #0056b3;
}
.btn.active {
background: #007bff;
color: white;
}
.font-size-select {
padding: 4px 8px;
border: 1px solid #dee2e6;
border-radius: 4px;
font-size: 12px;
}
.file-info {
display: flex;
align-items: center;
font-size: 12px;
color: #6c757d;
}
.filename {
font-weight: 500;
}
.unsaved {
color: #dc3545;
margin-left: 4px;
}
.editor-container {
flex: 1;
position: relative;
}
.editor {
width: 100%;
height: 100%;
border: none;
padding: 16px;
font-family: 'Courier New', 'Consolas', monospace;
font-size: 16px;
line-height: 1.5;
resize: none;
outline: none;
background: white;
color: #333;
}
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 12px;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
font-size: 11px;
color: #6c757d;
}
.status-left,
.status-right {
display: flex;
gap: 12px;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
padding: 20px;
min-width: 400px;
max-width: 600px;
max-height: 80vh;
overflow: auto;
}
.modal h3 {
margin: 0 0 16px 0;
color: #333;
}
.file-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid #e9ecef;
border-radius: 4px;
}
.file-item {
display: flex;
align-items: center;
padding: 12px;
border-bottom: 1px solid #f8f9fa;
cursor: pointer;
transition: background 0.2s;
}
.file-item:hover {
background: #f8f9fa;
}
.file-item:last-child {
border-bottom: none;
}
.file-icon {
margin-right: 8px;
font-size: 16px;
}
.file-name {
flex: 1;
font-weight: 500;
}
.file-date {
font-size: 11px;
color: #6c757d;
margin-right: 8px;
}
.btn-delete {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
opacity: 0.7;
transition: opacity 0.2s;
}
.btn-delete:hover {
opacity: 1;
}
.modal-actions {
margin-top: 16px;
text-align: right;
}
.icon {
font-size: 14px;
}
</style>

658
src/apps/todo/Todo.vue Normal file
View File

@@ -0,0 +1,658 @@
<template>
<BuiltInApp app-id="todo" title="待办事项">
<div class="todo-app">
<div class="header">
<h1>📝 待办事项</h1>
<div class="stats">
总计: <span class="count">{{ todos.length }}</span> |
已完成: <span class="count">{{ completedCount }}</span> |
待办: <span class="count">{{ pendingCount }}</span>
</div>
</div>
<div class="add-todo">
<div class="input-group">
<input
v-model="newTodoText"
type="text"
class="todo-input"
placeholder="添加新的待办事项..."
maxlength="200"
@keypress.enter="addTodo"
>
<button @click="addTodo" class="add-btn" :disabled="!newTodoText.trim()">
添加
</button>
</div>
</div>
<div class="filters">
<button
v-for="filter in filters"
:key="filter.key"
class="filter-btn"
:class="{ active: currentFilter === filter.key }"
@click="setFilter(filter.key)"
>
{{ filter.label }}
</button>
</div>
<div class="todo-list" v-if="filteredTodos.length > 0">
<TransitionGroup name="todo-list" tag="div">
<div
v-for="todo in filteredTodos"
:key="todo.id"
class="todo-item"
:class="{ 'completed': todo.completed }"
>
<input
type="checkbox"
class="todo-checkbox"
:checked="todo.completed"
@change="toggleTodo(todo.id)"
>
<div class="todo-content" v-if="!todo.editing">
<span class="todo-text" :class="{ 'completed': todo.completed }">
{{ todo.text }}
</span>
<span class="todo-date">{{ formatDate(todo.createdAt) }}</span>
</div>
<div class="todo-edit" v-else>
<input
v-model="todo.editText"
type="text"
class="edit-input"
@keypress.enter="saveEdit(todo)"
@keyup.esc="cancelEdit(todo)"
@blur="saveEdit(todo)"
ref="editInputs"
>
</div>
<div class="todo-actions">
<button
v-if="!todo.editing"
@click="startEdit(todo)"
class="edit-btn"
title="编辑"
>
</button>
<button
@click="deleteTodo(todo.id)"
class="delete-btn"
title="删除"
>
🗑
</button>
</div>
</div>
</TransitionGroup>
</div>
<div v-else class="empty-state">
<div class="empty-icon">📋</div>
<h3>{{ getEmptyMessage() }}</h3>
<p>{{ getEmptyDescription() }}</p>
</div>
<!-- 批量操作 -->
<div class="bulk-actions" v-if="todos.length > 0">
<button @click="markAllCompleted" class="bulk-btn">
全部完成
</button>
<button @click="clearCompleted" class="bulk-btn" v-if="completedCount > 0">
清除已完成 ({{ completedCount }})
</button>
<button @click="clearAll" class="bulk-btn bulk-danger">
清空全部
</button>
</div>
</div>
</BuiltInApp>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import BuiltInApp from '../components/BuiltInApp.vue'
interface Todo {
id: string
text: string
completed: boolean
createdAt: Date
completedAt?: Date
editing?: boolean
editText?: string
}
type FilterType = 'all' | 'pending' | 'completed'
const newTodoText = ref('')
const todos = ref<Todo[]>([])
const currentFilter = ref<FilterType>('all')
const editInputs = ref<HTMLInputElement[]>([])
const filters = [
{ key: 'all' as FilterType, label: '全部' },
{ key: 'pending' as FilterType, label: '待办' },
{ key: 'completed' as FilterType, label: '已完成' }
]
// 计算属性
const completedCount = computed(() =>
todos.value.filter(todo => todo.completed).length
)
const pendingCount = computed(() =>
todos.value.filter(todo => !todo.completed).length
)
const filteredTodos = computed(() => {
switch (currentFilter.value) {
case 'completed':
return todos.value.filter(todo => todo.completed)
case 'pending':
return todos.value.filter(todo => !todo.completed)
default:
return todos.value
}
})
// 方法
const addTodo = async () => {
const text = newTodoText.value.trim()
if (!text) return
const todo: Todo = {
id: Date.now().toString(),
text,
completed: false,
createdAt: new Date()
}
todos.value.unshift(todo)
newTodoText.value = ''
await saveTodos()
showNotification('✅ 待办事项已添加', `"${text}" 已添加到您的待办列表`)
}
const toggleTodo = async (id: string) => {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed
todo.completedAt = todo.completed ? new Date() : undefined
await saveTodos()
if (todo.completed) {
showNotification('🎉 任务完成', `"${todo.text}" 已完成!`)
}
}
}
const startEdit = (todo: Todo) => {
todo.editing = true
todo.editText = todo.text
nextTick(() => {
const input = editInputs.value?.find(input =>
input && input.closest('.todo-item')?.getAttribute('data-id') === todo.id
)
input?.focus()
input?.select()
})
}
const saveEdit = async (todo: Todo) => {
if (todo.editText && todo.editText.trim() !== todo.text) {
todo.text = todo.editText.trim()
await saveTodos()
}
todo.editing = false
todo.editText = undefined
}
const cancelEdit = (todo: Todo) => {
todo.editing = false
todo.editText = undefined
}
const deleteTodo = async (id: string) => {
if (confirm('确定要删除这个待办事项吗?')) {
todos.value = todos.value.filter(t => t.id !== id)
await saveTodos()
}
}
const setFilter = (filter: FilterType) => {
currentFilter.value = filter
}
const markAllCompleted = async () => {
todos.value.forEach(todo => {
if (!todo.completed) {
todo.completed = true
todo.completedAt = new Date()
}
})
await saveTodos()
showNotification('✅ 全部完成', '所有待办事项已标记为完成')
}
const clearCompleted = async () => {
if (confirm(`确定要删除 ${completedCount.value} 个已完成的待办事项吗?`)) {
todos.value = todos.value.filter(todo => !todo.completed)
await saveTodos()
showNotification('🗑️ 清理完成', '已清除所有已完成的待办事项')
}
}
const clearAll = async () => {
if (confirm('确定要清空所有待办事项吗?此操作不可恢复。')) {
todos.value = []
await saveTodos()
showNotification('🗑️ 清空完成', '所有待办事项已清空')
}
}
const loadTodos = async () => {
try {
if (window.SystemSDK && window.SystemSDK.initialized) {
const result = await window.SystemSDK.storage.get('todos')
if (result.success && result.data) {
const savedTodos = JSON.parse(result.data)
todos.value = savedTodos.map((todo: any) => ({
...todo,
createdAt: new Date(todo.createdAt),
completedAt: todo.completedAt ? new Date(todo.completedAt) : undefined
}))
}
}
} catch (error) {
console.error('加载待办事项失败:', error)
}
}
const saveTodos = async () => {
try {
if (window.SystemSDK && window.SystemSDK.initialized) {
await window.SystemSDK.storage.set('todos', JSON.stringify(todos.value))
}
} catch (error) {
console.error('保存待办事项失败:', error)
}
}
const showNotification = async (title: string, message: string) => {
try {
if (window.SystemSDK?.ui?.showNotification) {
await window.SystemSDK.ui.showNotification({
title,
body: message,
duration: 3000
})
}
} catch (error) {
console.error('显示通知失败:', error)
}
}
const formatDate = (date: Date) => {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffMins < 1) return '刚刚'
if (diffMins < 60) return `${diffMins}分钟前`
if (diffHours < 24) return `${diffHours}小时前`
if (diffDays < 7) return `${diffDays}天前`
return date.toLocaleDateString('zh-CN')
}
const getEmptyMessage = () => {
switch (currentFilter.value) {
case 'completed':
return '还没有已完成的任务'
case 'pending':
return '没有待办事项'
default:
return '还没有待办事项'
}
}
const getEmptyDescription = () => {
switch (currentFilter.value) {
case 'completed':
return '完成一些任务后,它们会出现在这里'
case 'pending':
return '所有任务都已完成,真棒!'
default:
return '添加一个新的待办事项开始管理您的任务吧!'
}
}
onMounted(async () => {
await loadTodos()
})
</script>
<style scoped>
.todo-app {
height: 100%;
display: flex;
flex-direction: column;
background: #f8f9fa;
}
.header {
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
}
.header h1 {
font-size: 24px;
margin-bottom: 10px;
font-weight: 600;
}
.stats {
font-size: 14px;
opacity: 0.9;
}
.count {
font-weight: 600;
}
.add-todo {
padding: 20px;
background: white;
border-bottom: 1px solid #e9ecef;
}
.input-group {
display: flex;
gap: 10px;
}
.todo-input {
flex: 1;
padding: 12px 16px;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 16px;
outline: none;
transition: border-color 0.3s;
}
.todo-input:focus {
border-color: #667eea;
}
.add-btn {
padding: 12px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
font-weight: 500;
}
.add-btn:hover:not(:disabled) {
background: #5a67d8;
}
.add-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.filters {
padding: 0 20px 20px;
display: flex;
gap: 10px;
justify-content: center;
background: white;
}
.filter-btn {
padding: 8px 16px;
border: 1px solid #e9ecef;
background: white;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s;
font-size: 14px;
}
.filter-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.filter-btn:hover:not(.active) {
background: #f8f9fa;
}
.todo-list {
flex: 1;
overflow-y: auto;
padding: 0 20px;
background: white;
}
.todo-item {
display: flex;
align-items: center;
padding: 16px;
margin-bottom: 10px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: all 0.3s ease;
border-left: 4px solid #667eea;
}
.todo-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.todo-item.completed {
opacity: 0.7;
border-left-color: #28a745;
}
.todo-checkbox {
margin-right: 12px;
width: 20px;
height: 20px;
cursor: pointer;
accent-color: #28a745;
}
.todo-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.todo-text {
font-size: 16px;
transition: color 0.3s;
line-height: 1.4;
}
.todo-text.completed {
text-decoration: line-through;
color: #6c757d;
}
.todo-date {
font-size: 12px;
color: #6c757d;
}
.todo-edit {
flex: 1;
margin-right: 12px;
}
.edit-input {
width: 100%;
padding: 8px 12px;
border: 2px solid #667eea;
border-radius: 4px;
font-size: 16px;
outline: none;
}
.todo-actions {
display: flex;
gap: 8px;
}
.edit-btn,
.delete-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
opacity: 0.7;
transition: opacity 0.2s;
padding: 4px;
}
.edit-btn:hover,
.delete-btn:hover {
opacity: 1;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: #6c757d;
background: white;
padding: 40px;
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
}
.empty-state h3 {
margin-bottom: 8px;
color: #495057;
}
.bulk-actions {
padding: 20px;
background: white;
border-top: 1px solid #e9ecef;
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.bulk-btn {
padding: 8px 16px;
border: 1px solid #dee2e6;
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.bulk-btn:hover {
background: #f8f9fa;
}
.bulk-danger {
color: #dc3545;
border-color: #dc3545;
}
.bulk-danger:hover {
background: #dc3545;
color: white;
}
/* 动画 */
.todo-list-enter-active,
.todo-list-leave-active {
transition: all 0.3s ease;
}
.todo-list-enter-from {
opacity: 0;
transform: translateY(20px);
}
.todo-list-leave-to {
opacity: 0;
transform: translateX(-100%);
}
.todo-list-move {
transition: transform 0.3s ease;
}
/* 响应式设计 */
@media (max-width: 768px) {
.header {
padding: 15px;
}
.header h1 {
font-size: 20px;
}
.add-todo {
padding: 15px;
}
.input-group {
flex-direction: column;
}
.filters {
padding: 0 15px 15px;
flex-wrap: wrap;
}
.todo-list {
padding: 0 15px;
}
.todo-item {
padding: 12px;
}
.bulk-actions {
padding: 15px;
}
}
</style>

View File

@@ -0,0 +1,35 @@
/**
* 内置应用清单接口
*/
export interface InternalAppManifest {
id: string
name: string
version: string
description: string
author: string
icon: string
permissions: string[]
window: {
width: number
height: number
minWidth?: number
minHeight?: number
maxWidth?: number
maxHeight?: number
resizable?: boolean
minimizable?: boolean
maximizable?: boolean
closable?: boolean
}
category?: string
keywords?: string[]
}
/**
* 应用注册信息
*/
export interface AppRegistration {
manifest: InternalAppManifest
component: any // Vue组件
isBuiltIn: boolean
}