保存
This commit is contained in:
97
src/apps/AppRegistry.ts
Normal file
97
src/apps/AppRegistry.ts
Normal 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()
|
||||
348
src/apps/calculator/Calculator.vue
Normal file
348
src/apps/calculator/Calculator.vue
Normal 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>
|
||||
125
src/apps/components/BuiltInApp.vue
Normal file
125
src/apps/components/BuiltInApp.vue
Normal 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
90
src/apps/index.ts
Normal 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'
|
||||
527
src/apps/notepad/Notepad.vue
Normal file
527
src/apps/notepad/Notepad.vue
Normal 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
658
src/apps/todo/Todo.vue
Normal 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>
|
||||
35
src/apps/types/AppManifest.ts
Normal file
35
src/apps/types/AppManifest.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user