更新若干功能

This commit is contained in:
李琦
2026-08-14 07:51:46 +08:00
parent d2aeb13a09
commit 153c7ed448
48 changed files with 5567 additions and 2673 deletions

View File

@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>view</title>
<link rel="icon" type="image/png" href="/favicon.png"/>
<title>年糕崽崽项目管理PMS</title>
</head>
<body>
<div id="app"></div>

View File

@@ -8,8 +8,12 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@wailsio/runtime": "^3.0.0-beta.5",
"echarts": "^6.1.0",
"highlight.js": "^11.11.2",
"lucide-vue-next": "^1.0.0",
"lunar-javascript": "^1.7.7",
"marked": "^18.0.9",
"pinia": "^4.0.1",
"vue": "^3.2.37",
"vue-i18n": "^9.14.5",
@@ -271,6 +275,12 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="
},
"node_modules/@wailsio/runtime": {
"version": "3.0.0-beta.5",
"resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-beta.5.tgz",
"integrity": "sha512-WPJcKfD/iN5EbzLAE0B2AjwRVVSSnhpGQSjgFORtIA3h1bkiw/k7rTd06emNsI1ZFGFgMiUEEmXgEcVfeWoRVQ==",
"license": "MIT"
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
@@ -711,6 +721,15 @@
"node": ">= 0.4"
}
},
"node_modules/highlight.js": {
"version": "11.11.2",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz",
"integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
@@ -741,6 +760,12 @@
"vue": ">=3.0.1"
}
},
"node_modules/lunar-javascript": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/lunar-javascript/-/lunar-javascript-1.7.7.tgz",
"integrity": "sha512-u/KYiwPIBo/0bT+WWfU7qO1d+aqeB90Tuy4ErXenr2Gam0QcWeezUvtiOIyXR7HbVnW2I1DKfU0NBvzMZhbVQw==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -749,6 +774,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.9",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.9.tgz",
"integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",

View File

@@ -6,11 +6,16 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development --minify false",
"preview": "vite preview"
},
"dependencies": {
"@wailsio/runtime": "^3.0.0-beta.5",
"echarts": "^6.1.0",
"highlight.js": "^11.11.2",
"lucide-vue-next": "^1.0.0",
"lunar-javascript": "^1.7.7",
"marked": "^18.0.9",
"pinia": "^4.0.1",
"vue": "^3.2.37",
"vue-i18n": "^9.14.5",

View File

@@ -2,12 +2,20 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal } from 'lucide-vue-next'
import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal, Home, ListTodo, TicketCheck, CalendarDays, CalendarCheck2, Sparkles, UserRound, ClipboardList, Wrench, Rocket, ChevronDown, Bell, StickyNote, ListFilter, Palette, Bot, Users, NotebookPen, CloudUpload, Power } from 'lucide-vue-next'
import { useAppStore } from './store'
import DatabaseSetup from './components/DatabaseSetup.vue'
import BrowserBlocked from './components/BrowserBlocked.vue'
import AnalysisCanvas from './components/AnalysisCanvas.vue'
import { isNative } from './api'
import LoginModal from './components/LoginModal.vue'
import CommandPalette from './components/CommandPalette.vue'
import DailyCard from './components/DailyCard.vue'
import AboutModal from './components/AboutModal.vue'
import MessageBell from './components/MessageBell.vue'
import TaskCenter from './components/TaskCenter.vue'
import NoteCenter from './components/NoteCenter.vue'
import TeamSwitcher from './components/TeamSwitcher.vue'
import { call, isNative, on } from './api'
const route = useRoute()
const router = useRouter()
@@ -15,8 +23,11 @@ const store = useAppStore()
const { t, locale } = useI18n()
const native = isNative()
const quickOpen = ref(false)
const aboutOpen = ref(false)
const appVersion = ref('1.0.0')
const displayedTask = ref(null)
let off
let offMenus = []
let loadingHoldTimer
const activeTask = computed(() => Object.values(store.tasks).find(x => !['completed', 'error', 'cancelled'].includes(x.stage)))
@@ -35,19 +46,138 @@ function openSettings() {
router.push('/settings')
}
// 侧边栏底部退出:与原生菜单/托盘「退出」一致,绕过最小化到托盘直接退出应用。
async function quitApp() {
if (!confirm(t('quitConfirm'))) return
try { await call('QuitApp') } catch { /* 预览模式无原生桥 */ }
}
// ---- 双列导航一级分类rail+ 二级菜单sub窄屏点一级弹浮层 ----
// 设置页无 query 时沿用本地记忆的分区,保证二级高亮与页面内容一致。
const settingsTab = r => String(r.query.tab || localStorage.getItem('cc-settings-tab') || 'rules')
const navGroups = [
{ id: 'overview', icon: Home, label: 'navOverview', items: [
{ to: '/', icon: Home, label: 'workbench', match: r => r.path === '/' },
{ to: '/today', icon: CalendarCheck2, label: 'todayTasks', badge: 'todayDue' },
{ to: '/calendar', icon: CalendarDays, label: 'calendar', dot: 'calendarDot' },
{ to: '/messages', icon: Bell, label: 'messages', badge: 'unread' }
] },
{ id: 'projects', icon: LayoutDashboard, label: 'navProjects', items: [
{ to: '/projects', icon: LayoutDashboard, label: 'projects', match: r => r.path === '/projects' || r.path.startsWith('/project/') },
{ to: '/ai', icon: Sparkles, label: 'aiChat' }
] },
{ id: 'work', icon: ClipboardList, label: 'navWork', items: [
{ to: '/todos', icon: ListTodo, label: 'todos', badge: 'todosOpen' },
{ to: '/tickets', icon: TicketCheck, label: 'tickets', badge: 'ticketsActive' },
{ to: '/notes', icon: StickyNote, label: 'notesPage' }
] },
{ id: 'team', icon: Users, label: 'navTeam', items: [
{ to: '/team', icon: Users, label: 'teamHome', match: r => r.path === '/team' },
{ to: '/team/tasks', icon: ClipboardList, label: 'teamTasks', badge: 'teamAssigned' },
{ to: '/team/reports', icon: NotebookPen, label: 'teamReports' }
] },
{ id: 'system', icon: Wrench, label: 'navSystem', items: [
{ to: '/launchpad', icon: Rocket, label: 'launchpad' },
{ to: '/logs', icon: ScrollText, label: 'logs' }
] },
{ id: 'settings', icon: Settings, label: 'settings', items: [
{ to: '/settings?tab=rules', icon: ListFilter, label: 'tabRules', match: r => r.path === '/settings' && settingsTab(r) === 'rules' },
{ to: '/settings?tab=appearance', icon: Palette, label: 'tabAppearance', match: r => r.path === '/settings' && settingsTab(r) === 'appearance' },
{ to: '/settings?tab=ai', icon: Bot, label: 'aiAnalysis', match: r => r.path === '/settings' && settingsTab(r) === 'ai' },
// 全局文件存储配置:权威数据在服务器,仅管理员(云端账号 id=1可见可改
{ to: '/settings?tab=filestorage', icon: CloudUpload, label: 'tabFileStorage', adminOnly: true, match: r => r.path === '/settings' && settingsTab(r) === 'filestorage' },
{ to: '/settings?tab=database', icon: Database, label: 'tabDatabase', match: r => r.path === '/settings' && settingsTab(r) === 'database' }
] }
]
// adminOnly 项只对云端管理员id=1展示
const visibleItems = g => g.items.filter(it => !it.adminOnly || store.syncStatus.userId === 1)
const itemActive = it => it.match ? it.match(route) : route.path === it.to.split('?')[0]
// 导航徽标badge 显示数字dot 只显示小圆点;一级 rail 在组内任一非零时亮点
const badgeVal = it => it.badge ? (store.badges[it.badge] || 0) : 0
const badgeText = it => { const n = badgeVal(it); return n > 99 ? '99+' : String(n) }
const dotVal = it => it.dot ? !!store.badges[it.dot] : false
const groupDot = g => g.items.some(it => badgeVal(it) > 0 || dotVal(it))
const childActive = c => route.path === '/settings' && String(route.query.tab || '') === c.tab
const groupOfRoute = () => navGroups.find(g => g.items.some(it => itemActive(it)))?.id
const activeGroupId = ref(groupOfRoute() || 'overview')
const activeGroup = computed(() => navGroups.find(g => g.id === activeGroupId.value) || navGroups[0])
const expandedParents = ref({})
const railFlyout = ref(null) // 窄屏浮层 { group, top }
watch(() => route.fullPath, () => {
const g = groupOfRoute()
if (g) activeGroupId.value = g
railFlyout.value = null
for (const grp of navGroups) {
for (const it of grp.items) if (it.children && itemActive(it)) expandedParents.value[it.label] = true
}
}, { immediate: true })
const isNarrow = () => window.matchMedia('(max-width: 1150px)').matches
function clickGroup(g, ev) {
if (isNarrow()) {
railFlyout.value = railFlyout.value?.group.id === g.id
? null
: { group: g, top: Math.min(ev.currentTarget.getBoundingClientRect().top, innerHeight - 320) }
return
}
activeGroupId.value = g.id
const first = g.items[0]
if (first && !itemActive(first)) router.push(first.to)
}
function toggleParent(it, ev) {
ev.preventDefault()
ev.stopPropagation()
expandedParents.value[it.label] = !expandedParents.value[it.label]
}
function onFlyoutAway(e) {
if (railFlyout.value && !e.target.closest('.rail-flyout') && !e.target.closest('.rail-item')) railFlyout.value = null
}
// Ctrl+K / Cmd+K 呼出全局搜索面板
function onGlobalKey(e) {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') {
e.preventDefault()
store.paletteOpen = !store.paletteOpen
}
}
onMounted(async () => {
addEventListener('keydown', onGlobalKey)
addEventListener('click', onFlyoutAway)
if (!native) return
off = store.listen()
offMenus = [
on('menu:navigate', p => router.push(String(p))),
on('menu:action', k => {
if (k === 'addProject') {
store.pendingAction = 'addProject'
router.push('/projects')
} else if (k === 'account') {
store.openAccount(router)
} else if (k === 'about') {
aboutOpen.value = true
} else if (k === 'sync-refresh') {
store.refreshSyncStatus()
}
}),
on('menu:set', p => p?.key && updateSetting(p.key, p.value))
]
await store.boot()
locale.value = store.settings.locale || 'zh-CN'
try { appVersion.value = await call('GetAppVersion') } catch { /* keep fallback */ }
})
onUnmounted(() => {
removeEventListener('keydown', onGlobalKey)
removeEventListener('click', onFlyoutAway)
clearTimeout(loadingHoldTimer)
off?.()
offMenus.forEach(f => f?.())
})
watch(() => store.settings.locale, v => {
if (v) locale.value = v
})
// 切页时顺带刷新同步徽标(待推送数在本地增删改后保持新鲜)
watch(() => route.path, () => { if (store.syncStatus.loggedIn) store.refreshSyncStatus() })
watch(activeTask, task => {
clearTimeout(loadingHoldTimer)
if (task) {
@@ -65,8 +195,8 @@ watch(activeTask, task => {
<DatabaseSetup v-else-if="store.bootstrap.state !== 'ready' && store.bootstrap.state !== 'loading'" :status="store.bootstrap" />
<div v-else-if="store.bootstrap.state === 'ready'" class="shell">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark animated-logo" aria-hidden="true">
<div class="side-rail">
<span class="brand-mark animated-logo rail-logo" aria-hidden="true" :title="t('app')">
<svg viewBox="0 0 48 48" role="img">
<defs>
<linearGradient id="logoGlow" x1="0" x2="1" y1="0" y2="1">
@@ -80,17 +210,49 @@ watch(activeTask, task => {
<path class="logo-spark" d="M12 10h8M28 38h8" />
</svg>
</span>
<b>{{ t('app') }}</b>
<nav class="rail-nav" aria-label="Primary">
<button v-for="g in navGroups" :key="g.id" type="button" class="rail-item" :class="{ active: g.id === activeGroupId }" @click="clickGroup(g, $event)">
<component :is="g.icon" /><span>{{ t(g.label) }}</span>
<i v-if="groupDot(g)" class="rail-dot" aria-hidden="true" />
</button>
</nav>
<div class="rail-tools">
<NoteCenter />
<TaskCenter />
<MessageBell />
<TeamSwitcher />
</div>
<button class="rail-user" :class="{ active: route.path === '/profile' }" :title="store.syncStatus.loggedIn ? store.syncStatus.username : t('loginNow')" @click="store.openAccount(router)">
<span class="user-avatar">
<img v-if="store.avatarSrc" :src="store.avatarSrc" alt="" />
<b v-else-if="store.syncStatus.username">{{ store.syncStatus.username[0].toUpperCase() }}</b>
<UserRound v-else />
<i v-if="store.syncStatus.loggedIn" class="user-dot" :class="store.syncStatus.lastError ? 'err' : (store.syncStatus.online ? 'on' : 'off')" :title="store.syncStatus.lastError || ''" />
</span>
<em v-if="store.syncStatus.loggedIn && store.syncStatus.pending > 0" class="user-pending rail-pending" :title="t('pendingSync', { n: store.syncStatus.pending })">{{ store.syncStatus.pending }}</em>
</button>
</div>
<nav aria-label="Primary">
<RouterLink to="/" :class="{ active: route.path === '/' }"><LayoutDashboard /><span>{{ t('dashboard') }}</span></RouterLink>
<RouterLink to="/logs" :class="{ active: route.path === '/logs' }"><ScrollText /><span>{{ t('logs') }}</span></RouterLink>
<RouterLink to="/settings" :class="{ active: route.path === '/settings' }"><Settings /><span>{{ t('settings') }}</span></RouterLink>
</nav>
<div class="sidebar-bottom">
<span class="version"><i />v1.0.0</span>
<button class="quick-settings-btn" :title="t('quickSettings')" @click="quickOpen = !quickOpen"><SlidersHorizontal /></button>
<section v-if="quickOpen" class="quick-settings popover-glass">
<div class="side-sub">
<div class="sub-brand"><b>{{ t('app') }}</b></div>
<div class="sub-title">{{ t(activeGroup.label) }}</div>
<nav class="sub-nav" :aria-label="t(activeGroup.label)">
<template v-for="it in visibleItems(activeGroup)" :key="it.to">
<RouterLink :to="it.to" :class="{ active: itemActive(it) }">
<component :is="it.icon" /><span>{{ t(it.label) }}</span>
<em v-if="badgeVal(it)" class="nav-badge">{{ badgeText(it) }}</em>
<i v-else-if="dotVal(it)" class="nav-dot" aria-hidden="true" />
<button v-if="it.children" type="button" class="sub-caret" :class="{ open: expandedParents[it.label] }" :aria-label="t(it.label)" @click="toggleParent(it, $event)"><ChevronDown /></button>
</RouterLink>
<div v-if="it.children && expandedParents[it.label]" class="sub-children">
<RouterLink v-for="c in it.children" :key="c.to" :to="c.to" :class="{ active: childActive(c) }">{{ t(c.label) }}</RouterLink>
</div>
</template>
</nav>
<div class="sidebar-bottom">
<span class="version"><i />v{{ appVersion }}</span>
<button class="quick-settings-btn quit-app-btn" :title="t('quitApp')" :disabled="!native" @click="quitApp"><Power /></button>
<button class="quick-settings-btn" :title="t('quickSettings')" @click="quickOpen = !quickOpen"><SlidersHorizontal /></button>
<section v-if="quickOpen" class="quick-settings popover-glass">
<header>
<b>{{ t('quickSettings') }}</b>
<button @click="quickOpen = false" aria-label="Close"><X /></button>
@@ -114,8 +276,20 @@ watch(activeTask, task => {
<span>{{ t('glassOpacity') }} · {{ store.settings.glassOpacity }}%</span>
<input type="range" min="30" max="75" :value="store.settings.glassOpacity" @input="updateSetting('glassOpacity', Number($event.target.value))" />
</label>
<button class="btn secondary full" @click="openSettings"><Settings />{{ t('openSettings') }}</button>
</section>
<button class="btn secondary full" @click="openSettings"><Settings />{{ t('openSettings') }}</button>
</section>
</div>
</div>
<div v-if="railFlyout" class="rail-flyout popover-glass" :style="{ top: railFlyout.top + 'px' }">
<b class="fly-title">{{ t(railFlyout.group.label) }}</b>
<template v-for="it in visibleItems(railFlyout.group)" :key="it.to">
<RouterLink :to="it.to" :class="{ active: itemActive(it) }">
<component :is="it.icon" /><span>{{ t(it.label) }}</span>
<em v-if="badgeVal(it)" class="nav-badge">{{ badgeText(it) }}</em>
<i v-else-if="dotVal(it)" class="nav-dot" aria-hidden="true" />
</RouterLink>
<RouterLink v-for="c in it.children || []" :key="c.to" :to="c.to" class="fly-child" :class="{ active: childActive(c) }">{{ t(c.label) }}</RouterLink>
</template>
</div>
</aside>
<main><RouterView /></main>
@@ -137,6 +311,10 @@ watch(activeTask, task => {
{{ store.toast.key ? t(store.toast.key, store.toast.params || {}) : store.toast.text }}
<button @click="store.closeToast()" aria-label="Close"><X /></button>
</div>
<LoginModal v-if="store.loginOpen" />
<CommandPalette v-if="store.paletteOpen" />
<AboutModal v-if="aboutOpen" @close="aboutOpen = false" />
<DailyCard />
</div>
<div v-else class="boot-loading"><Database class="spin" />正在检查数据库...</div>
</template>

View File

@@ -1,12 +1,34 @@
export const isNative=()=>Boolean(window.go?.main?.App)
import { Call, Events } from '@wailsio/runtime'
export async function call(name,...args){
const fn=window.go?.main?.App?.[name]
if(fn)return fn(...args)
throw new Error(`NATIVE_RUNTIME_REQUIRED: ${name}`)
// Wails v3 桌面环境检测Windows 走 WebView2 postMessagemacOS/Linux 走 webkit messageHandlers。
export const isNative = () => Boolean(
window.chrome?.webview?.postMessage || window.webkit?.messageHandlers?.external?.postMessage
)
// call 统一走按名调用main.App.<Method>)。
// 错误统一抛出字符串,保持 `String(e).split(':')[0]` 提取错误码的旧逻辑可用。
export async function call(name, ...args) {
if (!isNative()) throw `NATIVE_RUNTIME_REQUIRED: ${name}`
try {
return await Call.ByName(`main.App.${name}`, ...args)
} catch (e) {
throw String(e?.message ?? e)
}
}
export function on(name,cb){
if(window.runtime?.EventsOn)return window.runtime.EventsOn(name,cb)
return()=>{}
// on 订阅后端事件;回调直接拿到事件数据本体(与 v2 行为一致),返回取消函数。
export function on(name, cb) {
if (!isNative()) return () => {}
return Events.On(name, ev => cb(ev?.data))
}
// 任务数据(待办/工单)跨页面同步:任一入口变更状态后广播,列表页与顶栏任务中心即时刷新。
const TASKS_EVENT = 'cc:tasks-changed'
export const notifyTasksChanged = () => dispatchEvent(new Event(TASKS_EVENT))
export function onTasksChanged(cb) {
addEventListener(TASKS_EVENT, cb)
return () => removeEventListener(TASKS_EVENT, cb)
}
// 调试/自动化钩子:桌面环境内页面 JS 本就能访问 Wails 桥,这里只是给控制台一个入口。
if (typeof window !== 'undefined') window.__cc = { call }

View File

@@ -13,6 +13,11 @@ let dpr = 1
let width = 0
let height = 0
let reduceMotion = false
let lastNow = 0
// 粒子系统状态(尺寸/样式变化时置空重建)
let nebula = null
let stars = null
let rain = null
function resize() {
const el = canvas.value
@@ -25,32 +30,35 @@ function resize() {
el.height = Math.floor(height * dpr)
ctx = el.getContext('2d')
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
nebula = stars = rain = null
}
function draw(now = 0) {
if (!ctx) return
const t = now * 0.001
const dt = Math.min(0.05, Math.max(0.001, (now - lastNow) / 1000))
lastNow = now
const cx = width / 2
const cy = height / 2
const p = Math.max(0, Math.min(100, Number(props.progress || 0))) / 100
ctx.clearRect(0, 0, width, height)
const variant = props.variant === 'fullscreen' ? 'fullscreen-orbit' : props.variant
const bg = ctx.createRadialGradient(cx, cy, 20, cx, cy, Math.max(width, height) * 0.65)
bg.addColorStop(0, 'rgba(115,103,245,.28)')
bg.addColorStop(.52, 'rgba(67,201,150,.12)')
bg.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = bg
ctx.fillRect(0, 0, width, height)
if (variant === 'fullscreen-grid') drawGrid(t, p, cx, cy)
else if (variant === 'fullscreen-warp') drawWarp(t, p, cx, cy)
if (variant === 'fullscreen-grid') drawNebula(t, p, cx, cy)
else if (variant === 'fullscreen-warp') drawStarfield(t, p, cx, cy, dt)
else if (variant === 'fullscreen-matrix') drawMatrix(t, p, cx, cy, dt)
else drawOrbit(t, p, cx, cy)
if (!reduceMotion) frame = requestAnimationFrame(draw)
}
function drawOrbit(t, p, cx, cy) {
const bg = ctx.createRadialGradient(cx, cy, 20, cx, cy, Math.max(width, height) * 0.65)
bg.addColorStop(0, 'rgba(115,103,245,.28)')
bg.addColorStop(.52, 'rgba(67,201,150,.12)')
bg.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = bg
ctx.fillRect(0, 0, width, height)
ctx.save()
ctx.translate(cx, cy)
@@ -106,230 +114,331 @@ function drawOrbit(t, p, cx, cy) {
ctx.shadowBlur = 26
ctx.fill()
ctx.restore()
}
function drawGrid(t, p, cx, cy) {
const gap = 34
const base = Math.min(width, height) * 0.2
const scanY = reduceMotion ? cy : (height * ((t * 0.2) % 1))
const horizon = cy - Math.min(height * 0.12, 90)
ctx.save()
ctx.translate(cx, horizon)
ctx.lineWidth = 1
for (let i = -14; i <= 14; i++) {
const a = i / 14
const x = a * width * 0.74
const g = ctx.createLinearGradient(0, 0, x, height)
g.addColorStop(0, 'rgba(110,231,255,.26)')
g.addColorStop(1, 'rgba(110,231,255,0)')
ctx.strokeStyle = g
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.lineTo(x, height)
ctx.stroke()
// —— 数据星云:粒子盘绕核旋转,进度推进时向中心聚拢 ——
// shadowBlur 逐粒子绘制非常慢,预渲染发光贴图后主循环只做 drawImage
let glowSprites = null
function makeGlowSprite(r, g, b) {
const c = document.createElement('canvas')
c.width = c.height = 64
const x = c.getContext('2d')
const grad = x.createRadialGradient(32, 32, 0, 32, 32, 32)
grad.addColorStop(0, 'rgba(255,255,255,.94)')
grad.addColorStop(.22, `rgba(${r},${g},${b},.85)`)
grad.addColorStop(.55, `rgba(${r},${g},${b},.2)`)
grad.addColorStop(1, `rgba(${r},${g},${b},0)`)
x.fillStyle = grad
x.fillRect(0, 0, 64, 64)
return c
}
function initNebula() {
if (!glowSprites) glowSprites = {
green: makeGlowSprite(93, 226, 168),
cyan: makeGlowSprite(110, 231, 255),
purple: makeGlowSprite(148, 136, 255),
white: makeGlowSprite(222, 230, 255)
}
for (let i = 1; i < 12; i++) {
const y = Math.pow(i / 12, 1.8) * height * 0.78
const w = width * (0.12 + i * 0.075)
ctx.strokeStyle = `rgba(83,214,162,${0.22 - i * 0.012})`
ctx.beginPath()
ctx.moveTo(-w, y)
ctx.lineTo(w, y)
ctx.stroke()
nebula = {
// 星系盘:绕核旋转的主体粒子
disk: Array.from({ length: 300 }, () => ({
a: Math.random() * Math.PI * 2,
r: Math.pow(Math.random(), 0.72),
sp: 0.1 + Math.random() * 0.34,
sz: 1 + Math.random() * 2.1,
hue: Math.random(),
tw: Math.random() * Math.PI * 2
})),
// 星尘:均匀铺满全屏的氛围粒子(归一化坐标)
dust: Array.from({ length: 150 }, () => ({
x: Math.random(),
y: Math.random(),
vx: (Math.random() - 0.5) * 0.014,
vy: (Math.random() - 0.5) * 0.014,
sz: 0.6 + Math.random() * 1.3,
hue: Math.random(),
tw: Math.random() * Math.PI * 2
}))
}
ctx.restore()
}
function nebulaSprite(hue) {
if (hue < 0.48) return glowSprites.cyan
if (hue < 0.8) return glowSprites.purple
return glowSprites.white
}
function drawNebula(t, p, cx, cy) {
if (!nebula) initNebula()
// 盘半径按对角线取值,让粒子铺到屏幕边缘
const R = Math.hypot(width, height) * 0.36
const bg = ctx.createRadialGradient(cx, cy, 10, cx, cy, Math.max(width, height) * 0.72)
bg.addColorStop(0, 'rgba(96,90,240,.22)')
bg.addColorStop(.5, 'rgba(56,180,150,.09)')
bg.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = bg
ctx.fillRect(0, 0, width, height)
ctx.save()
ctx.translate((reduceMotion ? 0 : -t * 34) % gap, (reduceMotion ? 0 : t * 20) % gap)
ctx.lineWidth = 1
for (let x = -gap; x < width + gap; x += gap) {
const hot = Math.max(0, 1 - Math.abs(x - cx) / (width * 0.48))
ctx.strokeStyle = `rgba(110,231,255,${0.04 + hot * 0.16})`
ctx.beginPath(); ctx.moveTo(x, -gap); ctx.lineTo(x, height + gap); ctx.stroke()
}
for (let y = -gap; y < height + gap; y += gap) {
const hot = Math.max(0, 1 - Math.abs(y - cy) / (height * 0.48))
ctx.strokeStyle = `rgba(83,214,162,${0.035 + hot * 0.14})`
ctx.beginPath(); ctx.moveTo(-gap, y); ctx.lineTo(width + gap, y); ctx.stroke()
}
ctx.restore()
const scan = ctx.createLinearGradient(0, scanY - 60, 0, scanY + 60)
scan.addColorStop(0, 'rgba(110,231,255,0)')
scan.addColorStop(.5, 'rgba(110,231,255,.22)')
scan.addColorStop(1, 'rgba(110,231,255,0)')
ctx.fillStyle = scan
ctx.fillRect(0, scanY - 60, width, 120)
const beamX = reduceMotion ? cx : width * ((t * 0.13 + .22) % 1)
const beam = ctx.createLinearGradient(beamX - 90, 0, beamX + 90, 0)
beam.addColorStop(0, 'rgba(83,214,162,0)')
beam.addColorStop(.5, 'rgba(83,214,162,.16)')
beam.addColorStop(1, 'rgba(83,214,162,0)')
ctx.fillStyle = beam
ctx.fillRect(beamX - 90, 0, 180, height)
const count = 132
for (let i = 0; i < count; i++) {
const seed = i * 97.13
const x = (Math.sin(seed) * 0.5 + 0.5) * width
const y = ((Math.cos(seed * 1.7) * 0.5 + 0.5) * height + (reduceMotion ? 0 : t * (18 + i % 7))) % height
const dist = Math.hypot(x - cx, y - cy)
const active = i / count <= p
ctx.beginPath()
ctx.arc(x, y, active ? 2.4 : 1.2, 0, Math.PI * 2)
ctx.fillStyle = active ? 'rgba(83,214,162,.95)' : `rgba(145,136,255,${Math.max(.12, .48 - dist / width)})`
ctx.shadowColor = active ? '#53d6a2' : '#9188ff'
ctx.shadowBlur = active ? 16 : 8
ctx.fill()
const { disk, dust } = nebula
const n = disk.length
const pos = new Array(n)
for (let i = 0; i < n; i++) {
const s = disk[i]
// 内圈转得快,外圈慢,类似星系较差自转
const rot = reduceMotion ? s.tw : t * s.sp * (1.6 - s.r * 0.9) + s.tw
const rr = s.r * R * (1 - p * 0.18)
const wob = Math.sin(t * 1.3 + s.tw * 3) * 7
pos[i] = [cx + Math.cos(s.a + rot) * (rr + wob), cy + Math.sin(s.a + rot) * (rr + wob) * 0.62]
}
// 全屏星尘层
ctx.save()
ctx.globalCompositeOperation = 'lighter'
for (let i = 0; i < 34; i++) {
const a = i * 2.399
const r = base * (1.4 + (i % 9) * 0.13)
const x1 = cx + Math.cos(a + t * 0.18) * r
const y1 = cy + Math.sin(a + t * 0.12) * r * 0.58
const x2 = cx + Math.cos(a + 1.2 + t * 0.1) * (r + base * 0.45)
const y2 = cy + Math.sin(a + 1.2 + t * 0.16) * (r + base * 0.45) * 0.58
const active = i / 34 <= p
ctx.strokeStyle = active ? 'rgba(83,214,162,.34)' : 'rgba(110,231,255,.12)'
ctx.lineWidth = active ? 1.5 : 1
ctx.beginPath()
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.stroke()
}
ctx.restore()
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(reduceMotion ? 0 : t * 0.28)
for (let i = 0; i < 5; i++) {
const r = base + i * 18 + (reduceMotion ? 0 : Math.sin(t * 1.2 + i) * 5)
ctx.beginPath()
for (let v = 0; v < 6; v++) {
const a = Math.PI / 6 + (Math.PI * 2 * v) / 6
const x = Math.cos(a) * r
const y = Math.sin(a) * r
if (v === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
for (let i = 0; i < dust.length; i++) {
const s = dust[i]
if (!reduceMotion) {
s.x = (s.x + s.vx * 0.016 + 1) % 1
s.y = (s.y + s.vy * 0.016 + 1) % 1
}
ctx.closePath()
ctx.strokeStyle = i % 2 ? 'rgba(83,214,162,.46)' : 'rgba(110,231,255,.52)'
ctx.lineWidth = 2
ctx.shadowColor = i % 2 ? '#53d6a2' : '#6ee7ff'
ctx.shadowBlur = 14
ctx.stroke()
const twk = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 1.6 + s.tw * 6))
const d = s.sz * 6
ctx.globalAlpha = 0.34 * twk
ctx.drawImage(nebulaSprite(s.hue), s.x * width - d / 2, s.y * height - d / 2, d, d)
}
ctx.rotate(reduceMotion ? 0 : -t * 0.64)
for (let i = 0; i < 16; i++) {
const a = (Math.PI * 2 * i) / 16
const len = base * (0.42 + (i % 4) * 0.09)
ctx.strokeStyle = i / 16 < p ? 'rgba(83,214,162,.78)' : 'rgba(145,136,255,.22)'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(Math.cos(a) * base * 0.35, Math.sin(a) * base * 0.35)
ctx.lineTo(Math.cos(a) * (base * 0.35 + len), Math.sin(a) * (base * 0.35 + len))
ctx.stroke()
ctx.restore()
// 粒子间短距连线织出星云网
ctx.save()
ctx.lineWidth = 1
for (let i = 0; i < n - 1; i += 2) {
const [x1, y1] = pos[i], [x2, y2] = pos[i + 1]
const dx = x2 - x1, dy = y2 - y1
const d2 = dx * dx + dy * dy
if (d2 > 9216) continue
ctx.strokeStyle = `rgba(126,146,255,${((1 - Math.sqrt(d2) / 96) * 0.18).toFixed(3)})`
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke()
}
const core = ctx.createRadialGradient(0, 0, 0, 0, 0, base * 0.85)
core.addColorStop(0, 'rgba(255,255,255,.9)')
core.addColorStop(.18, 'rgba(110,231,255,.68)')
core.addColorStop(.42, 'rgba(83,214,162,.28)')
core.addColorStop(1, 'rgba(83,214,162,0)')
ctx.restore()
// 星系盘粒子(发光贴图)
ctx.save()
ctx.globalCompositeOperation = 'lighter'
for (let i = 0; i < n; i++) {
const s = disk[i]
const [x, y] = pos[i]
const active = i / n <= p
const twk = 0.55 + 0.45 * Math.sin(t * 2 + s.tw * 5)
const d = s.sz * (active ? 8.5 : 6.5)
ctx.globalAlpha = (active ? 0.95 : 0.62) * twk
ctx.drawImage(active ? glowSprites.green : nebulaSprite(s.hue), x - d / 2, y - d / 2, d, d)
}
ctx.restore()
// 偶发流星滑向核心
ctx.save()
ctx.globalCompositeOperation = 'lighter'
for (let k = 0; k < 4; k++) {
const phase = reduceMotion ? 0.35 : (t * 0.16 + k * 0.31) % 1
const a = k * 2.1 + Math.floor(t * 0.16 + k * 0.31) * 1.7
const r1 = R * (1.15 - phase * 1.02)
const r2 = r1 + R * 0.2
const x1 = cx + Math.cos(a) * r1, y1 = cy + Math.sin(a) * r1 * 0.62
const x2 = cx + Math.cos(a) * r2, y2 = cy + Math.sin(a) * r2 * 0.62
const g = ctx.createLinearGradient(x1, y1, x2, y2)
g.addColorStop(0, `rgba(212,238,255,${(0.5 * Math.sin(phase * Math.PI)).toFixed(3)})`)
g.addColorStop(1, 'rgba(212,238,255,0)')
ctx.strokeStyle = g
ctx.lineWidth = 1.6
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke()
}
ctx.restore()
// 中心光核 + 进度弧(尺寸独立于盘半径,避免占屏过大)
ctx.save()
ctx.translate(cx, cy)
const CR = Math.min(width, height) * 0.2
const core = ctx.createRadialGradient(0, 0, 0, 0, 0, CR)
core.addColorStop(0, 'rgba(255,255,255,.92)')
core.addColorStop(.2, 'rgba(110,231,255,.6)')
core.addColorStop(.55, 'rgba(115,103,245,.22)')
core.addColorStop(1, 'rgba(115,103,245,0)')
ctx.fillStyle = core
ctx.shadowColor = '#6ee7ff'
ctx.shadowBlur = 28
ctx.beginPath(); ctx.arc(0, 0, CR, 0, Math.PI * 2); ctx.fill()
ctx.beginPath()
ctx.arc(0, 0, base * 0.8, 0, Math.PI * 2)
ctx.fill()
ctx.arc(0, 0, CR * 0.85, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * p)
ctx.strokeStyle = 'rgba(93,226,168,.85)'
ctx.lineWidth = 3
ctx.lineCap = 'round'
ctx.shadowColor = '#53d6a2'
ctx.shadowBlur = 16
ctx.stroke()
ctx.restore()
}
function drawWarp(t, p, cx, cy) {
const rays = 144
const maxR = Math.hypot(width, height) * 0.58
// —— 星际穿越:星点从中心向外加速,拉伸成光迹 ——
function initStars() {
const maxR = Math.hypot(width, height) * 0.56
stars = Array.from({ length: 460 }, () => ({
a: Math.random() * Math.PI * 2,
r: 4 + Math.random() * maxR,
sp: 26 + Math.random() * 92,
hue: Math.random()
}))
}
function drawStarfield(t, p, cx, cy, dt) {
if (!stars) initStars()
const maxR = Math.hypot(width, height) * 0.56
const bg = ctx.createRadialGradient(cx, cy, 6, cx, cy, maxR)
bg.addColorStop(0, 'rgba(190,214,255,.2)')
bg.addColorStop(.24, 'rgba(96,110,255,.12)')
bg.addColorStop(.7, 'rgba(20,26,60,.08)')
bg.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = bg
ctx.fillRect(0, 0, width, height)
ctx.save()
ctx.globalCompositeOperation = 'lighter'
ctx.lineCap = 'round'
const n = stars.length
for (let i = 0; i < n; i++) {
const s = stars[i]
// 透视加速:越靠外线速度越大,进度越高整体越快
const v = (s.sp + p * 130) * (0.3 + (s.r / maxR) * 2.4)
if (!reduceMotion) {
s.r += v * dt
if (s.r > maxR) { s.a = Math.random() * Math.PI * 2; s.r = 3 + Math.random() * 24; s.sp = 26 + Math.random() * 92; s.hue = Math.random() }
}
const depth = s.r / maxR
const stretch = Math.max(2.5, v * 0.085 * (0.4 + depth))
const cosA = Math.cos(s.a), sinA = Math.sin(s.a)
const r0 = Math.max(2, s.r - stretch)
const alpha = Math.min(0.92, 0.1 + depth * 1.25)
const active = i / n <= p
let color
if (active) color = `rgba(105,235,190,${alpha.toFixed(3)})`
else if (s.hue < 0.5) color = `rgba(214,232,255,${alpha.toFixed(3)})`
else if (s.hue < 0.78) color = `rgba(150,164,255,${alpha.toFixed(3)})`
else color = `rgba(110,231,255,${alpha.toFixed(3)})`
ctx.strokeStyle = color
ctx.lineWidth = 0.7 + depth * 2.4
ctx.beginPath()
ctx.moveTo(cx + cosA * r0, cy + sinA * r0)
ctx.lineTo(cx + cosA * s.r, cy + sinA * s.r)
ctx.stroke()
}
ctx.restore()
// 跃迁环脉冲
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(reduceMotion ? 0 : Math.sin(t * 0.22) * 0.08)
const tunnel = ctx.createRadialGradient(0, 0, 8, 0, 0, Math.min(width, height) * 0.48)
tunnel.addColorStop(0, 'rgba(255,255,255,.7)')
tunnel.addColorStop(.12, 'rgba(110,231,255,.24)')
tunnel.addColorStop(.55, 'rgba(124,108,255,.1)')
tunnel.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = tunnel
ctx.fillRect(-width / 2, -height / 2, width, height)
for (let i = 0; i < 12; i++) {
const phase = reduceMotion ? 0 : (t * 0.48 + i * 0.09) % 1
const r = 30 + ((i / 12 + phase) % 1) * maxR
const alpha = Math.max(0, 0.5 - r / maxR * 0.45)
for (let i = 0; i < 4; i++) {
const phase = reduceMotion ? i / 4 : (t * 0.34 + i / 4) % 1
const r = 26 + phase * Math.min(width, height) * 0.42
ctx.beginPath()
ctx.ellipse(0, 0, r * 1.34, r * 0.7, t * 0.08 + i * 0.42, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(110,231,255,${alpha})`
ctx.lineWidth = 1 + (1 - r / maxR) * 3
ctx.shadowColor = '#6ee7ff'
ctx.shadowBlur = 14
ctx.arc(0, 0, r, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(140,170,255,${(0.34 * (1 - phase)).toFixed(3)})`
ctx.lineWidth = 1.5
ctx.stroke()
}
for (let i = 0; i < rays; i++) {
const a = (Math.PI * 2 * i) / rays
const speed = reduceMotion ? 0 : (t * (110 + (i % 11) * 10))
const start = 20 + ((i * 23 + speed) % 210)
const len = 80 + p * 170 + (i % 5) * 18
const alpha = 0.1 + p * 0.52
const g = ctx.createLinearGradient(Math.cos(a) * start, Math.sin(a) * start, Math.cos(a) * (start + len), Math.sin(a) * (start + len))
g.addColorStop(0, 'rgba(110,231,255,0)')
g.addColorStop(.45, `rgba(124,108,255,${alpha})`)
g.addColorStop(.78, `rgba(110,231,255,${alpha * .58})`)
g.addColorStop(1, 'rgba(83,214,162,0)')
ctx.strokeStyle = g
ctx.lineWidth = 1 + (i % 3)
ctx.beginPath()
ctx.moveTo(Math.cos(a) * start, Math.sin(a) * start)
ctx.lineTo(Math.cos(a) * (start + len), Math.sin(a) * (start + len))
ctx.stroke()
}
for (let i = 0; i < 9; i++) {
const r = 34 + i * 31 + (reduceMotion ? 0 : Math.sin(t * 1.5 + i) * 7)
ctx.beginPath()
ctx.ellipse(0, 0, r * 1.42, r * 0.68, (reduceMotion ? 0 : t * 0.32) + i, 0, Math.PI * 2)
ctx.strokeStyle = i % 2 ? 'rgba(83,214,162,.38)' : 'rgba(145,136,255,.45)'
ctx.lineWidth = 2
ctx.shadowColor = i % 2 ? '#53d6a2' : '#9188ff'
ctx.shadowBlur = 15
ctx.stroke()
}
ctx.globalCompositeOperation = 'lighter'
for (let i = 0; i < 48; i++) {
const a = (Math.PI * 2 * i) / 48 + (reduceMotion ? 0 : t * 0.24)
const r = 54 + ((i * 41 + (reduceMotion ? 0 : t * 150)) % 340)
const x = Math.cos(a) * r
const y = Math.sin(a) * r * 0.68
const active = i / 48 < p
ctx.beginPath()
ctx.arc(x, y, active ? 3.4 : 1.7, 0, Math.PI * 2)
ctx.fillStyle = active ? 'rgba(110,231,255,.92)' : 'rgba(145,136,255,.3)'
ctx.shadowColor = active ? '#6ee7ff' : '#9188ff'
ctx.shadowBlur = active ? 18 : 10
ctx.fill()
}
const core = ctx.createRadialGradient(0, 0, 0, 0, 0, Math.min(width, height) * 0.16)
core.addColorStop(0, 'rgba(255,255,255,.94)')
core.addColorStop(.16, 'rgba(110,231,255,.9)')
core.addColorStop(.44, 'rgba(124,108,255,.34)')
const core = ctx.createRadialGradient(0, 0, 0, 0, 0, Math.min(width, height) * 0.15)
core.addColorStop(0, 'rgba(255,255,255,.95)')
core.addColorStop(.3, 'rgba(178,206,255,.7)')
core.addColorStop(.7, 'rgba(124,108,255,.2)')
core.addColorStop(1, 'rgba(124,108,255,0)')
ctx.fillStyle = core
ctx.beginPath(); ctx.arc(0, 0, Math.min(width, height) * 0.15, 0, Math.PI * 2); ctx.fill()
ctx.beginPath()
ctx.arc(0, 0, Math.min(width, height) * 0.18, 0, Math.PI * 2)
ctx.fill()
ctx.arc(0, 0, Math.min(width, height) * 0.11, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * p)
ctx.strokeStyle = 'rgba(105,235,190,.9)'
ctx.lineWidth = 3
ctx.lineCap = 'round'
ctx.shadowColor = '#53d6a2'
ctx.shadowBlur = 14
ctx.stroke()
ctx.restore()
}
// —— 黑客帝国:绿色数字雨 ——
const MTX_CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789$+-*/=<>#'
const mtxHash = (i, j) => { const x = Math.sin(i * 127.1 + j * 311.7) * 43758.5453; return x - Math.floor(x) }
function initRain() {
const fs = 18
const count = Math.ceil(width / fs)
rain = {
fs,
cols: Array.from({ length: count }, () => ({
y: -Math.random() * (height / fs),
sp: 4 + Math.random() * 9,
len: 9 + Math.floor(Math.random() * 13)
}))
}
}
function drawMatrix(t, p, cx, cy, dt) {
if (!rain) initRain()
ctx.fillStyle = '#020905'
ctx.fillRect(0, 0, width, height)
const vg = ctx.createRadialGradient(cx, cy, 40, cx, cy, Math.max(width, height) * 0.72)
vg.addColorStop(0, 'rgba(22,86,46,.22)')
vg.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = vg
ctx.fillRect(0, 0, width, height)
const { fs, cols } = rain
const rows = Math.ceil(height / fs)
ctx.font = `600 ${fs - 3}px Consolas,"Courier New",monospace`
ctx.textAlign = 'center'
for (let ci = 0; ci < cols.length; ci++) {
const c = cols[ci]
if (!reduceMotion) c.y += c.sp * (1 + p * 0.9) * dt * 3.4
else if (c.y < 4) c.y = 4 + mtxHash(ci, 1) * rows
if (c.y - c.len > rows + 4) {
c.y = -Math.random() * 18
c.sp = 4 + Math.random() * 9
c.len = 9 + Math.floor(Math.random() * 13)
}
const head = Math.floor(c.y)
const x = ci * fs + fs / 2
for (let k = 0; k < c.len; k++) {
const row = head - k
if (row < -1 || row > rows) continue
// 头部字符高频翻动,尾部低频,制造扫描感
const flick = Math.floor(t * (k === 0 ? 9 : 1.7)) + k
const ch = MTX_CHARS[Math.floor(mtxHash(ci * 3 + 1, row * 7 + flick) * MTX_CHARS.length)]
const fade = 1 - k / c.len
if (k === 0) {
ctx.fillStyle = 'rgba(216,255,230,.96)'
ctx.shadowColor = '#7dffb0'
ctx.shadowBlur = 10
} else {
ctx.fillStyle = `rgba(62,${168 + Math.floor(64 * fade)},${94 + Math.floor(30 * fade)},${(0.07 + fade * 0.62).toFixed(3)})`
ctx.shadowBlur = 0
}
ctx.fillText(ch, x, (row + 1) * fs)
}
}
ctx.shadowBlur = 0
// 中央淡光核与进度弧,让进度在雨幕中可读
ctx.save()
ctx.translate(cx, cy)
const R = Math.min(width, height) * 0.2
const core = ctx.createRadialGradient(0, 0, 0, 0, 0, R)
core.addColorStop(0, 'rgba(8,26,14,.88)')
core.addColorStop(.65, 'rgba(8,26,14,.4)')
core.addColorStop(1, 'rgba(8,26,14,0)')
ctx.fillStyle = core
ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2); ctx.fill()
ctx.beginPath()
ctx.arc(0, 0, R * 0.72, 0, Math.PI * 2)
ctx.strokeStyle = 'rgba(70,190,110,.24)'
ctx.lineWidth = 2
ctx.stroke()
ctx.beginPath()
ctx.arc(0, 0, R * 0.72, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * p)
ctx.strokeStyle = 'rgba(125,255,176,.92)'
ctx.lineWidth = 3
ctx.lineCap = 'round'
ctx.shadowColor = '#7dffb0'
ctx.shadowBlur = 14
ctx.stroke()
ctx.restore()
}
@@ -345,6 +454,7 @@ onUnmounted(() => {
window.removeEventListener('resize', resize)
})
watch(() => props.variant, () => { nebula = stars = rain = null })
watch(() => props.progress, () => {
if (reduceMotion) draw()
})

View File

@@ -1,2 +1,2 @@
<script setup>import{MonitorX,Box}from'lucide-vue-next'</script>
<template><div class="browser-blocked"><section class="blocked-card"><span><MonitorX/></span><small>DESKTOP RUNTIME REQUIRED</small><h1>请在 Code Count 桌面程序中打开</h1><p>当前页面是普通浏览器预览无法访问本地目录SQLite 数据库或 Git请关闭此页面并运行 <code>build/bin/code-count.exe</code></p><div><Box/>本地功能已在浏览器中停用数据不会被模拟或丢弃</div></section></div></template>
<template><div class="browser-blocked"><section class="blocked-card"><span><MonitorX/></span><small>DESKTOP RUNTIME REQUIRED</small><h1>请在年糕崽崽 PMS 桌面程序中打开</h1><p>当前页面是普通浏览器预览无法访问本地目录SQLite 数据库或 Git请关闭此页面并运行 <code>build/bin/code-count.exe</code></p><div><Box/>本地功能已在浏览器中停用数据不会被模拟或丢弃</div></section></div></template>

View File

@@ -6,5 +6,5 @@ const messages={BOOTSTRAP_INVALID:'数据库位置配置已损坏,请重新选
async function browse(){const p=await call('SelectInitialDatabaseFile',path.value);if(p)path.value=p}
async function initialize(retry=false){busy.value=true;error.value='';try{store.bootstrap=retry?await call('RetryDatabase'):await call('InitializeDatabase',path.value);if(store.bootstrap.state==='ready')await store.refresh()}catch(e){error.value=messages[String(e).split(':')[0]]||String(e);store.bootstrap=await call('GetBootstrapStatus')}finally{busy.value=false}}
</script>
<template><div class="setup-screen"><section class="setup-card glass"><div class="setup-icon"><Database/></div><span class="setup-kicker"><ShieldCheck/>本地数据存储</span><h1>{{recovery?'恢复数据库连接':'初始化 Code Count'}}</h1><p>{{recovery?'上次使用的数据库当前不可用。你可以重试,或选择新的 SQLite 数据库位置。':'选择统计数据和项目配置的保存位置。后续启动将自动使用此数据库。'}}</p><div v-if="recovery" class="setup-warning"><TriangleAlert/><div><b>{{messages[status.errorCode]||status.errorCode}}</b><small>{{status.errorDetail}}</small></div></div><label>数据库文件<div class="setup-path"><input v-model="path"/><button title="选择位置" @click="browse"><FolderOpen/></button></div></label><small class="setup-default">默认位置:{{status.defaultPath}}</small><p v-if="error" class="form-error">{{error}}</p><div class="setup-actions"><button v-if="recovery" class="btn secondary" :disabled="busy" @click="initialize(true)"><RefreshCw :class="{spin:busy}"/>重试原位置</button><button class="btn primary" :disabled="busy||!path" @click="initialize(false)"><Database/>{{busy?'正在初始化...':'初始化数据库'}}</button></div></section></div></template>
<template><div class="setup-screen"><section class="setup-card glass"><div class="setup-icon"><Database/></div><span class="setup-kicker"><ShieldCheck/>本地数据存储</span><h1>{{recovery?'恢复数据库连接':'初始化年糕崽崽 PMS'}}</h1><p>{{recovery?'上次使用的数据库当前不可用。你可以重试,或选择新的 SQLite 数据库位置。':'选择统计数据和项目配置的保存位置。后续启动将自动使用此数据库。'}}</p><div v-if="recovery" class="setup-warning"><TriangleAlert/><div><b>{{messages[status.errorCode]||status.errorCode}}</b><small>{{status.errorDetail}}</small></div></div><label>数据库文件<div class="setup-path"><input v-model="path"/><button title="选择位置" @click="browse"><FolderOpen/></button></div></label><small class="setup-default">默认位置:{{status.defaultPath}}</small><p v-if="error" class="form-error">{{error}}</p><div class="setup-actions"><button v-if="recovery" class="btn secondary" :disabled="busy" @click="initialize(true)"><RefreshCw :class="{spin:busy}"/>重试原位置</button><button class="btn primary" :disabled="busy||!path" @click="initialize(false)"><Database/>{{busy?'正在初始化...':'初始化数据库'}}</button></div></section></div></template>
<style scoped>.setup-screen{background-color:var(--bg);background-image:linear-gradient(rgba(123,115,255,.055) 1px,transparent 1px),linear-gradient(90deg,rgba(123,115,255,.045) 1px,transparent 1px),linear-gradient(135deg,rgba(67,201,150,.08),transparent 36%,rgba(91,86,192,.11) 72%,transparent);background-size:40px 40px,40px 40px,100% 100%}</style>

View File

@@ -14,12 +14,13 @@ const matrix = computed(() => {
const start = new Date(end)
start.setDate(start.getDate() - 364)
start.setDate(start.getDate() - start.getDay())
const counts = new Map(props.days.map(x => [x.date, Number(x.count || 0)]))
const counts = new Map(props.days.map(x => [x.date, x]))
const cells = []
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const date = d.toISOString().slice(0, 10)
const count = counts.get(date) || 0
cells.push({ date, count, week: Math.floor((d - start) / weekMs), dow: d.getDay(), level: Math.min(4, count === 0 ? 0 : count < 2 ? 1 : count < 5 ? 2 : count < 10 ? 3 : 4) })
const day = counts.get(date)
const count = Number(day?.count || 0)
cells.push({ date, count, added: Number(day?.added || 0), deleted: Number(day?.deleted || 0), week: Math.floor((d - start) / weekMs), dow: d.getDay(), level: Math.min(4, count === 0 ? 0 : count < 2 ? 1 : count < 5 ? 2 : count < 10 ? 3 : 4) })
}
const labels = []
let lastMonth = -1
@@ -58,7 +59,7 @@ function pick(cell) {
@click="pick(cell)"
/>
</div>
<div v-if="hover" class="heat-tooltip">{{ hover.date }} · {{ hover.count }} {{ locale === 'zh-CN' ? '次提交' : 'commits' }}</div>
<div v-if="hover" class="heat-tooltip">{{ hover.date }} · {{ hover.count }} {{ locale === 'zh-CN' ? '次提交' : 'commits' }}<template v-if="hover.count"> · <b class="positive">+{{ hover.added }}</b> <b class="negative">-{{ hover.deleted }}</b></template></div>
</div>
</div>
</template>

View File

@@ -1,71 +0,0 @@
<script setup>
import {reactive} from 'vue'
import {Greet} from '../../wailsjs/go/main/App'
const data = reactive({
name: "",
resultText: "Please enter your name below 👇",
})
function greet() {
Greet(data.name).then(result => {
data.resultText = result
})
}
</script>
<template>
<main>
<div id="result" class="result">{{ data.resultText }}</div>
<div id="input" class="input-box">
<input id="name" v-model="data.name" autocomplete="off" class="input" type="text"/>
<button class="btn" @click="greet">Greet</button>
</div>
</main>
</template>
<style scoped>
.result {
height: 20px;
line-height: 20px;
margin: 1.5rem auto;
}
.input-box .btn {
width: 60px;
height: 30px;
line-height: 30px;
border-radius: 3px;
border: none;
margin: 0 0 0 20px;
padding: 0 8px;
cursor: pointer;
}
.input-box .btn:hover {
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
color: #333333;
}
.input-box .input {
border: none;
border-radius: 3px;
outline: none;
height: 30px;
line-height: 30px;
padding: 0 10px;
background-color: rgba(240, 240, 240, 1);
-webkit-font-smoothing: antialiased;
}
.input-box .input:hover {
border: none;
background-color: rgba(255, 255, 255, 1);
}
.input-box .input:focus {
border: none;
background-color: rgba(255, 255, 255, 1);
}
</style>

View File

@@ -1,4 +1,4 @@
*{scrollbar-width:thin;scrollbar-color:rgba(145,136,255,.55) transparent}::-webkit-scrollbar{width:9px;height:9px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:rgba(145,136,255,.38);border:2px solid transparent;background-clip:padding-box;border-radius:8px}::-webkit-scrollbar-thumb:hover{background:rgba(145,136,255,.7);border:2px solid transparent;background-clip:padding-box}
.wsl-picker{display:flex;gap:8px;margin:12px 24px 0}.wsl-picker select{flex:1;background:var(--glass-soft);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:0 10px}.icon-actions button:disabled{opacity:.5;cursor:not-allowed}
.page{overflow-x:hidden}.project-title>div:first-child{min-width:0;flex:1}.project-title p{max-width:100%!important}.icon-actions{flex:none}.project-card{min-width:0}
.page{overflow-x:clip}.project-title>div:first-child{min-width:0;flex:1}.project-title p{max-width:100%!important}.icon-actions{flex:none}.project-card{min-width:0}
.git-context{display:flex;align-items:center;gap:18px;margin:-8px 0 18px;padding:11px 15px;border:1px solid var(--glass-border);border-radius:7px;background:var(--glass-soft);color:var(--muted)}.git-context span{display:flex;align-items:center;gap:7px}.git-context svg{width:17px;color:#9188ff}.git-context b{color:var(--text)}.heat-panel{height:255px}.heat-panel .chart{height:185px}.branch.interactive{position:relative;padding-right:52px;cursor:pointer;transition:border-color .2s,background .2s}.branch.interactive:hover,.branch.interactive.selected{background:rgba(123,115,255,.12);outline:1px solid rgba(145,136,255,.35)}.checkout-btn{position:absolute;right:12px;top:20px;width:32px;height:32px;border:0;border-radius:6px;background:rgba(123,115,255,.15);color:#9188ff;display:grid;place-items:center;cursor:pointer}.checkout-btn svg{width:16px}.commit{width:100%;border:0;color:var(--text);text-align:left;cursor:pointer}.commit:hover{outline:1px solid rgba(145,136,255,.3)}.git-trend{position:relative}.git-trend>.segments{position:absolute;right:0;top:-38px;z-index:2}.git-trend .chart{height:280px}.drawer-mask{position:fixed;inset:0;z-index:60;background:rgba(0,0,0,.55);backdrop-filter:blur(5px);display:flex;justify-content:flex-end;animation:overlay-in .2s}.commit-drawer{width:min(620px,90vw);height:100%;overflow:auto;background:var(--glass-strong);border-left:1px solid var(--glass-border);box-shadow:-20px 0 50px rgba(0,0,0,.3);padding:26px;animation:drawer-in .3s cubic-bezier(.16,1,.3,1)}.commit-drawer header{display:flex;justify-content:space-between;gap:20px;border-bottom:1px solid var(--border);padding-bottom:18px}.commit-drawer header small{color:var(--muted)}.commit-drawer h2{margin:7px 0 0;font-size:20px}.commit-drawer header button,.commit-meta button{border:0;background:var(--glass-soft);color:var(--text);width:34px;height:34px;border-radius:6px;display:grid;place-items:center;cursor:pointer}.commit-drawer svg{width:17px}.commit-meta{display:grid;grid-template-columns:1fr auto auto auto;gap:10px;align-items:center;margin:20px 0;padding:15px;background:var(--glass-soft);border-radius:7px}.commit-meta code{min-width:0;overflow:hidden;text-overflow:ellipsis}.commit-meta span,.commit-meta time{grid-column:1/3;color:var(--muted)}.change-file{display:grid;grid-template-columns:22px 1fr auto auto auto;gap:10px;align-items:center;padding:11px;border-bottom:1px solid var(--border)}.change-file span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.change-file small{color:var(--muted)}@keyframes drawer-in{from{transform:translateX(100%)}to{transform:none}}@media(prefers-reduced-motion:reduce){.commit-drawer{animation:none}}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
html{--glass-user-opacity:.55;--glass:rgba(27,35,48,var(--glass-user-opacity));--glass-strong:rgba(32,41,57,calc(var(--glass-user-opacity) + .12));--glass-soft:rgba(40,50,69,calc(var(--glass-user-opacity) - .1));--glass-border:rgba(164,178,211,.18);--glass-highlight:rgba(255,255,255,.07);--glass-shadow:0 18px 42px rgba(0,0,0,.22);--glass-blur:24px}
html[data-theme=light]{--glass:rgba(255,255,255,calc(.72 + var(--glass-user-opacity) * .28));--glass-strong:rgba(255,255,255,calc(.8 + var(--glass-user-opacity) * .2));--glass-soft:rgba(244,247,252,calc(.64 + var(--glass-user-opacity) * .3));--glass-border:rgba(56,70,98,.16);--glass-highlight:rgba(255,255,255,.85);--glass-shadow:0 18px 42px rgba(40,53,78,.12)}
main,.setup-screen{background-color:var(--bg);background-image:linear-gradient(rgba(123,115,255,.055) 1px,transparent 1px),linear-gradient(90deg,rgba(123,115,255,.045) 1px,transparent 1px),linear-gradient(135deg,rgba(67,201,150,.08),transparent 36%,rgba(91,86,192,.11) 72%,transparent);background-size:40px 40px,40px 40px,100% 100%;background-attachment:fixed}main{position:relative;isolation:isolate}.sidebar,.stat-card,.project-card,.panel,.modal,.taskbar,.toast,.tabs,.search,.setup-card{background:linear-gradient(135deg,var(--glass-highlight),transparent 42%),var(--glass);border-color:var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow);backdrop-filter:blur(var(--glass-blur)) saturate(145%);-webkit-backdrop-filter:blur(var(--glass-blur)) saturate(145%)}.sidebar{background:linear-gradient(145deg,var(--glass-highlight),transparent 38%),var(--glass-strong)}.modal,.setup-card{background:linear-gradient(135deg,var(--glass-highlight),transparent 45%),var(--glass-strong)}
.page{animation:page-enter .48s cubic-bezier(.2,.8,.2,1) both}.page-head,.project-head{animation:fade-rise .42s .04s both}.stats-grid>.stat-card,.project-grid>*{animation:card-enter .5s cubic-bezier(.16,1,.3,1) both}.stats-grid>:nth-child(1),.project-grid>:nth-child(1){animation-delay:.08s}.stats-grid>:nth-child(2),.project-grid>:nth-child(2){animation-delay:.13s}.stats-grid>:nth-child(3),.project-grid>:nth-child(3){animation-delay:.18s}.stats-grid>:nth-child(4),.project-grid>:nth-child(4){animation-delay:.23s}.stats-grid>:nth-child(5),.project-grid>:nth-child(5){animation-delay:.28s}.project-grid>:nth-child(n+6){animation-delay:.32s}
.page{animation:page-enter .48s cubic-bezier(.2,.8,.2,1) backwards}.page-head,.project-head{animation:fade-rise .42s .04s both}.stats-grid>.stat-card,.project-grid>*{animation:card-enter .5s cubic-bezier(.16,1,.3,1) both}.stats-grid>:nth-child(1),.project-grid>:nth-child(1){animation-delay:.08s}.stats-grid>:nth-child(2),.project-grid>:nth-child(2){animation-delay:.13s}.stats-grid>:nth-child(3),.project-grid>:nth-child(3){animation-delay:.18s}.stats-grid>:nth-child(4),.project-grid>:nth-child(4){animation-delay:.23s}.stats-grid>:nth-child(5),.project-grid>:nth-child(5){animation-delay:.28s}.project-grid>:nth-child(n+6){animation-delay:.32s}
.stat-card,.panel,.project-card,.add-card{transition:transform .26s cubic-bezier(.2,.8,.2,1),border-color .26s,box-shadow .26s,background-color .26s}.stat-card:hover,.panel:hover{border-color:rgba(130,120,255,.38);box-shadow:inset 0 1px 0 var(--glass-highlight),0 22px 46px rgba(0,0,0,.25);transform:translateY(-2px)}.project-card:hover,.add-card:hover{transform:translateY(-5px);border-color:rgba(130,120,255,.6);box-shadow:inset 0 1px 0 var(--glass-highlight),0 25px 52px rgba(0,0,0,.3);background:var(--glass-strong)}
.stat-card strong{animation:number-arrive .55s .2s both}.overlay{animation:overlay-in .24s both}.modal{animation:modal-in .32s cubic-bezier(.16,1,.3,1) both}.toast{animation:toast-in .36s cubic-bezier(.16,1,.3,1) both}.taskbar{animation:task-in .38s cubic-bezier(.16,1,.3,1) both}.taskbar .progress i{transition:width .4s cubic-bezier(.2,.8,.2,1);position:relative;overflow:hidden}.taskbar .progress i:after{content:"";position:absolute;inset:0;background:rgba(255,255,255,.35);animation:progress-sweep 1.2s linear infinite}.heatmap i[class]:not(.l0){animation:heat-in .38s both}.heatmap i:nth-child(5n){animation-delay:.08s}.heatmap i:nth-child(7n){animation-delay:.14s}.spin{animation:spin .9s linear infinite}.btn,.tabs button,.icon-actions button{transition:background-color .2s,border-color .2s,color .2s,box-shadow .2s,transform .2s}.btn:active,.tabs button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid #9188ff;outline-offset:2px}
.boot-loading,.setup-screen{min-height:100vh;display:grid;place-items:center;background:var(--bg)}.boot-loading{gap:12px;color:var(--muted)}.boot-loading svg{width:28px}.setup-screen{padding:32px}.setup-card{width:min(620px,100%);padding:42px;border:1px solid var(--glass-border);border-radius:10px;animation:setup-in .55s cubic-bezier(.16,1,.3,1) both}.setup-icon{width:62px;height:62px;display:grid;place-items:center;background:rgba(115,103,245,.18);color:#9188ff;border:1px solid rgba(145,136,255,.28);border-radius:10px;margin-bottom:24px}.setup-icon svg{width:30px}.setup-kicker{display:flex;align-items:center;gap:7px;color:#9b94ff;font-size:13px;font-weight:bold}.setup-kicker svg{width:16px}.setup-card h1{font-size:30px;margin:10px 0}.setup-card>p{color:var(--muted);line-height:1.65}.setup-card label{display:block;margin-top:26px;font-size:13px;font-weight:bold}.setup-path{display:grid;grid-template-columns:1fr 44px;margin-top:8px}.setup-path input{height:44px;border:1px solid var(--border);border-radius:7px 0 0 7px;background:var(--glass-soft);color:var(--text);padding:0 13px;min-width:0}.setup-path button{border:1px solid var(--border);border-left:0;border-radius:0 7px 7px 0;background:var(--glass-soft);color:var(--text);cursor:pointer}.setup-path svg{width:19px}.setup-default{display:block;color:var(--muted);margin-top:8px;overflow-wrap:anywhere}.setup-warning{display:flex;gap:12px;background:rgba(240,94,104,.1);border:1px solid rgba(240,94,104,.28);padding:14px;border-radius:8px;margin-top:20px;color:var(--red)}.setup-warning svg{width:20px;flex:none}.setup-warning b,.setup-warning small{display:block}.setup-warning small{margin-top:5px;opacity:.8;overflow-wrap:anywhere}.setup-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:28px}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { call, on } from './api'
import { call, on, onTasksChanged } from './api'
export const useAppStore = defineStore('app', {
state: () => ({
@@ -8,13 +8,29 @@ export const useAppStore = defineStore('app', {
projectGroups: [],
selectedProjectGroupId: Number(localStorage.getItem('cc-project-group-id') || 0),
dashboard: { projects: 0, totalLines: 0, commits: 0 },
settings: { theme: 'dark', locale: 'zh-CN', glassOpacity: 55, gitScope: 'current', autoRefresh: true, loadingStyle: 'fullscreen-orbit' },
settings: { theme: 'dark', locale: 'zh-CN', glassOpacity: 55, gitScope: 'current', autoRefresh: true, loadingStyle: 'fullscreen-orbit', imageMode: 'base64' },
tasks: {},
batchTaskIds: [],
batchSummary: null,
pendingAction: '',
favorites: [],
unreadMessages: 0,
// 左侧导航徽标(消息未读/待办/工单/今日到期/日历红点/团队指派)
badges: { unread: 0, todosOpen: 0, ticketsActive: 0, todayDue: 0, calendarDot: false, teamAssigned: 0 },
toast: null,
toastTimer: null,
toastLeaveTimer: null,
loading: false
loading: false,
syncStatus: { configured: false, loggedIn: false, userId: 0, username: '', online: false, syncing: false },
avatarSrc: '',
loginOpen: false,
paletteOpen: false,
// 日历页"每日心语"入口:设为 'YYYY-MM-DD' 时 DailyCard 打开对应日期的卡片
dailyCardDate: '',
// 节日名 → { mode: 'photo'|'art', image: dataURL }(管理员上传,随同步分发)
festivalImages: {},
// 探测到加载失败的节日图key → true日历格自动回退动态插画
festBroken: {}
}),
actions: {
async boot() {
@@ -22,9 +38,22 @@ export const useAppStore = defineStore('app', {
if (this.bootstrap.state === 'ready') {
this.settings = await call('GetSettings')
this.applyAppearance(this.settings)
try { this.syncStatus = await call('GetSyncStatus') } catch {}
this.loadFestivalImages()
await this.refresh()
}
},
async loadFestivalImages() {
try { this.festivalImages = (await call('ListFestivalImages')) || {} } catch { return }
// 预探测 photo 模式的图片:解码失败的标记为 broken展示时回退动态插画
for (const [key, it] of Object.entries(this.festivalImages)) {
if (it.mode !== 'photo' || !it.image) continue
const probe = new Image()
probe.onerror = () => { this.festBroken = { ...this.festBroken, [key]: true } }
probe.onload = () => { if (this.festBroken[key]) { const n = { ...this.festBroken }; delete n[key]; this.festBroken = n } }
probe.src = it.image
}
},
applyAppearance(settings) {
this.settings = { ...this.settings, ...settings }
let theme = this.settings.theme || 'dark'
@@ -32,6 +61,25 @@ export const useAppStore = defineStore('app', {
document.documentElement.dataset.theme = theme
document.documentElement.style.setProperty('--glass-user-opacity', String((this.settings.glassOpacity || 55) / 100))
localStorage.setItem('cc-settings', JSON.stringify(this.settings))
this.resolveAvatar()
},
// resolveAvatar 把设置中的头像解析为可显示的 <img> 源path 模式需经后端读文件转 dataURL。
async resolveAvatar() {
const { avatarMode, avatarValue } = this.settings
if (!avatarMode || !avatarValue) { this.avatarSrc = ''; return }
if (avatarMode === 'path') {
try { this.avatarSrc = await call('ReadImageAsDataURL', avatarValue) } catch { this.avatarSrc = '' }
return
}
this.avatarSrc = avatarValue
},
async refreshSyncStatus() {
try { this.syncStatus = await call('GetSyncStatus') } catch {}
},
// openAccount 是所有登录入口的统一行为:未登录弹全屏登录层,已登录进个人主页。
openAccount(router) {
if (this.syncStatus.loggedIn) router?.push('/profile')
else this.loginOpen = true
},
async saveSettings(patch) {
const next = { ...this.settings, ...patch }
@@ -63,20 +111,35 @@ export const useAppStore = defineStore('app', {
},
async refresh() {
this.loading = true
this.refreshSyncStatus()
try {
this.projectGroups = await call('ListProjectGroups')
if (this.selectedProjectGroupId && !this.projectGroups.some(g => g.id === this.selectedProjectGroupId)) {
this.setProjectGroup(0)
}
const groupId = this.selectedProjectGroupId || 0
;[this.projects, this.dashboard] = await Promise.all([
;[this.projects, this.dashboard, this.favorites, this.unreadMessages] = await Promise.all([
groupId ? call('ListProjectsByGroup', groupId) : call('ListProjects'),
groupId ? call('GetDashboardByGroup', groupId) : call('GetDashboard')
groupId ? call('GetDashboardByGroup', groupId) : call('GetDashboard'),
call('ListFavorites').catch(() => []),
call('UnreadMessageCount').catch(() => 0)
])
} finally {
this.loading = false
}
},
async toggleFavorite(projectId) {
const fav = await call('ToggleFavorite', projectId)
this.favorites = fav ? [projectId, ...this.favorites.filter(x => x !== projectId)] : this.favorites.filter(x => x !== projectId)
return fav
},
async refreshUnread() {
try { this.unreadMessages = await call('UnreadMessageCount') } catch {}
this.refreshBadges()
},
async refreshBadges() {
try { this.badges = await call('GetNavBadges') } catch {}
},
async changeProjectGroup(groupId) {
this.setProjectGroup(groupId)
await this.refresh()
@@ -86,7 +149,7 @@ export const useAppStore = defineStore('app', {
if (persist) localStorage.setItem('cc-project-group-id', String(this.selectedProjectGroupId))
},
listen() {
return on('analysis:progress', e => {
const offProgress = on('analysis:progress', e => {
delete this.tasks.__batch_pending__
this.tasks[e.taskId] = e
if (['completed', 'error', 'cancelled'].includes(e.stage)) {
@@ -100,6 +163,30 @@ export const useAppStore = defineStore('app', {
this.refresh()
}
})
const offBatch = on('batch:done', s => {
this.batchSummary = s
this.batchTaskIds = []
delete this.tasks.__batch_pending__
this.showToast({ type: s.failed ? 'error' : 'success', key: 'batchDoneToast', params: { completed: s.completed, total: s.total } })
this.refresh()
})
const offMessage = on('message:new', () => { this.unreadMessages += 1; this.refreshBadges() })
const offSync = on('sync:done', async st => {
this.syncStatus = st || this.syncStatus
this.refreshBadges()
// 拉取可能更新了设置(头像 / API Key刷新本地副本
try { this.applyAppearance(await call('GetSettings')) } catch {}
// 拉取可能带来新项目/分组/收藏,刷新项目列表
if (st?.pulled > 0) {
this.refresh().catch(() => {})
this.loadFestivalImages()
}
})
// 徽标:启动即取,任务变更事件 + 60s 轮询兜底
this.refreshBadges()
const offTasks = onTasksChanged(() => this.refreshBadges())
const badgeTimer = setInterval(() => this.refreshBadges(), 60000)
return () => { offProgress?.(); offBatch?.(); offMessage?.(); offSync?.(); offTasks?.(); clearInterval(badgeTimer) }
},
async analyze(id, kind = 'all') {
const task = await call('StartAnalysis', id, kind)

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,12 @@
<script setup>
import { computed, reactive, ref } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Folder, Code2, GitCommitHorizontal, Plus, RefreshCw, Search, Trash2, Pencil, FolderOpen } from 'lucide-vue-next'
import { Folder, Code2, GitCommitHorizontal, Plus, RefreshCw, Search, Trash2, Pencil, FolderOpen, PieChart, X, CheckCircle2, CircleX, Ban, Star, CloudDownload, ChevronUp, ChevronDown } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import ChartView from '../components/ChartView.vue'
import { useAppStore } from '../store'
import { call } from '../api'
import { call, on } from '../api'
const store = useAppStore()
const router = useRouter()
@@ -23,6 +24,8 @@ const wslDistros = ref([])
const wslDistro = ref('')
const form = reactive({ name: '', path: '', description: '', groupId: 1 })
const groupForm = reactive({ name: '' })
const srcMode = ref('local') // 新建项目来源local 本地目录 / git 克隆
const gitForm = reactive({ url: '', parentDir: '' })
const palette = ['#53d6a2', '#5da8ff', '#f7cb4d', '#a78bfa', '#ef6f8f']
const filtered = computed(() => {
@@ -36,11 +39,25 @@ const filteredDashboard = computed(() => filtered.value.reduce((acc, p) => {
acc.commits += Number(p.stats?.commitCount || 0)
return acc
}, { projects: 0, totalLines: 0, commits: 0 }))
const langColors = ['#7b73ff', '#4fd1a1', '#4da5ff', '#f4c84a', '#ef6683', '#23b5d3', '#a78bfa', '#fb923c']
const languages = computed(() => store.dashboard.languages || [])
const langTotal = computed(() => languages.value.reduce((s, x) => s + x.code, 0))
const langPie = computed(() => ({
backgroundColor: 'transparent',
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [{ type: 'pie', radius: ['50%', '74%'], label: { show: false }, data: languages.value.slice(0, 12).map((x, i) => ({ name: x.name, value: x.code, itemStyle: { color: langColors[i % langColors.length] } })) }]
}))
const fmt = n => {
n = +n || 0
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
}
// 顶部统计(大卡片 + 语言分布)可折叠为一行小卡片,状态跨会话记忆
const statsCollapsed = ref(localStorage.getItem('cc-dash-stats-collapsed') === '1')
function toggleStats() {
statsCollapsed.value = !statsCollapsed.value
localStorage.setItem('cc-dash-stats-collapsed', statsCollapsed.value ? '1' : '0')
}
const total = p => p.languages?.reduce((s, x) => s + x.code, 0) || 0
const projectRunning = id => Object.values(store.tasks).some(x => x.projectId === id && !['completed', 'error', 'cancelled'].includes(x.stage))
const defaultGroupId = computed(() => store.projectGroups[0]?.id || 1)
@@ -53,9 +70,21 @@ const errorText = raw => {
function open(p) {
editing.value = p?.id || 0
Object.assign(form, p ? { name: p.name, path: p.path, description: p.description, groupId: p.groupId || defaultGroupId.value } : { name: '', path: '', description: '', groupId: store.selectedProjectGroupId || defaultGroupId.value })
srcMode.value = 'local'
Object.assign(gitForm, { url: '', parentDir: gitForm.parentDir })
error.value = ''
modal.value = true
}
// Git 地址变化时自动推导项目名(用户未手填的情况下)
function onGitUrl() {
const u = gitForm.url.trim().replace(/[/\\]+$/, '').replace(/\.git$/, '')
const seg = u.split(/[/:\\]/).pop() || ''
if (seg && !form.name) form.name = seg
}
async function browseParent() {
const p = await call('SelectDirectory')
if (p) gitForm.parentDir = p
}
function openGroup(g) {
groupEditing.value = g?.id || 0
groupForm.name = g?.name || ''
@@ -117,6 +146,7 @@ async function browseWSL() {
}
async function save() {
if (saving.value) return
if (!editing.value && srcMode.value === 'git') return cloneSave()
error.value = ''
form.path = form.path.trim()
if (!form.path) {
@@ -137,6 +167,29 @@ async function save() {
saving.value = false
}
}
// 从 Git 克隆到指定目录后直接入库;进度经任务事件展示
async function cloneSave() {
error.value = ''
if (!gitForm.url.trim()) { error.value = t('cloneUrlRequired'); return }
if (!gitForm.parentDir.trim()) { error.value = t('cloneDirRequired'); return }
saving.value = true
try {
await call('CloneProject', {
url: gitForm.url.trim(), parentDir: gitForm.parentDir.trim(), name: form.name.trim(),
groupId: Number(form.groupId) || defaultGroupId.value, description: form.description
})
await store.refresh()
modal.value = false
store.showToast({ type: 'success', text: t('cloneDone') })
} catch (e) {
const raw = String(e?.message || e)
const code = ['URL_REQUIRED', 'DIR_NOT_FOUND', 'NAME_REQUIRED', 'TARGET_NOT_EMPTY'].find(x => raw.includes(x))
error.value = code ? t(`errors.${code}`) : raw
store.showToast({ type: 'error', text: error.value })
} finally {
saving.value = false
}
}
async function remove(p) {
if (confirm(`${t('delete')} ${p.name}?`)) {
await call('DeleteProject', p.id)
@@ -153,21 +206,136 @@ async function refreshProject(p) {
store.showToast({ type: 'error', text: errorText(e) })
}
}
function consumePendingAction() {
if (store.pendingAction === 'addProject') {
store.pendingAction = ''
open()
}
}
watch(() => store.pendingAction, consumePendingAction)
// ---- 云端项目待绑定:同账号在其它机器添加的项目,本机需要选目录落地 ----
const cloudPending = ref([])
const bindTarget = ref(null) // { name, description, group } 待绑定项
const bindDir = ref('')
const bindBusy = ref(false)
const bindErr = ref('')
let offSync = null
async function loadPending() {
try { cloudPending.value = (await call('ListCloudPendingProjects')) || [] } catch { cloudPending.value = [] }
}
function openBind(it) {
bindTarget.value = it
bindDir.value = ''
bindErr.value = ''
}
async function browseBind() {
const p = await call('SelectDirectory')
if (p) bindDir.value = p
}
async function bindNow() {
if (bindBusy.value || !bindTarget.value) return
bindErr.value = ''
if (!bindDir.value.trim()) { bindErr.value = t('pathRequired'); return }
bindBusy.value = true
try {
await call('BindCloudProject', bindTarget.value.name, bindDir.value.trim())
bindTarget.value = null
await Promise.all([store.refresh(), loadPending()])
store.showToast({ type: 'success', key: 'cloudBindDone' })
} catch (e) {
bindErr.value = errorText(e)
} finally {
bindBusy.value = false
}
}
onMounted(() => {
consumePendingAction()
loadPending()
offSync = on('sync:done', loadPending)
})
onUnmounted(() => offSync?.())
</script>
<template>
<div class="page dashboard-page">
<header class="page-head sticky-head">
<div><h1>{{ t('dashboard') }}</h1><p>{{ t('dashboardSubtitle') }}</p></div>
<div><h1>{{ t('projects') }}</h1><p>{{ t('dashboardSubtitle') }}</p></div>
<div class="actions">
<button class="btn secondary" @click="batch"><RefreshCw />{{ t('batch') }}</button>
<button class="btn primary" @click="open()"><Plus />{{ t('addProject') }}</button>
</div>
</header>
<div class="stats-grid three">
<StatCard :icon="Folder" :value="filteredDashboard.projects" :label="t('totalProjects')" />
<StatCard :icon="Code2" tone="green" :value="fmt(filteredDashboard.totalLines)" :label="t('totalLines')" />
<StatCard :icon="GitCommitHorizontal" tone="blue" :value="fmt(filteredDashboard.commits)" :label="t('commits')" />
<section v-if="cloudPending.length" class="panel cloud-pending">
<header>
<b><CloudDownload />{{ t('cloudPendingTitle') }}<em>{{ cloudPending.length }}</em></b>
<small>{{ t('cloudPendingDesc') }}</small>
</header>
<div class="cloud-pending-list">
<div v-for="it in cloudPending" :key="it.name" class="cloud-pending-item">
<div class="cp-main">
<b>{{ it.name }}</b>
<small>{{ [it.group, it.description].filter(Boolean).join(' · ') }}</small>
</div>
<button class="btn secondary" @click="openBind(it)"><FolderOpen />{{ t('cloudBindBtn') }}</button>
</div>
</div>
</section>
<!-- 展开三张统计大卡折叠一行小卡片语言分布区一并收起 -->
<template v-if="!statsCollapsed">
<div class="stats-head-bar">
<small>{{ t('statsOverview') }}</small>
<button type="button" class="stats-toggle" @click="toggleStats"><ChevronUp />{{ t('statsCollapseBtn') }}</button>
</div>
<div class="stats-grid three">
<StatCard :icon="Folder" :value="filteredDashboard.projects" :label="t('totalProjects')" />
<StatCard :icon="Code2" tone="green" :value="fmt(filteredDashboard.totalLines)" :label="t('totalLines')" />
<StatCard :icon="GitCommitHorizontal" tone="blue" :value="fmt(filteredDashboard.commits)" :label="t('commits')" />
</div>
</template>
<div v-else class="stats-mini">
<span class="mini-stat"><i class="tone"><Folder /></i><b>{{ filteredDashboard.projects }}</b><small>{{ t('totalProjects') }}</small></span>
<span class="mini-stat"><i class="tone green"><Code2 /></i><b>{{ fmt(filteredDashboard.totalLines) }}</b><small>{{ t('totalLines') }}</small></span>
<span class="mini-stat"><i class="tone blue"><GitCommitHorizontal /></i><b>{{ fmt(filteredDashboard.commits) }}</b><small>{{ t('commits') }}</small></span>
<span v-if="languages.length" class="mini-stat langs"><i class="tone purple"><PieChart /></i>
<em v-for="(x, i) in languages.slice(0, 3)" :key="x.name"><s :style="{ background: langColors[i % langColors.length] }" />{{ x.name }} {{ langTotal ? Math.round(x.code / langTotal * 100) : 0 }}%</em>
</span>
<button type="button" class="stats-toggle" :title="t('statsExpandBtn')" @click="toggleStats"><ChevronDown />{{ t('statsExpandBtn') }}</button>
</div>
<section v-if="store.batchSummary" class="panel batch-summary" :class="{ 'has-failures': store.batchSummary.failed }">
<header>
<h2><CheckCircle2 v-if="!store.batchSummary.failed" class="ok" /><CircleX v-else class="bad" />{{ t('batchSummaryTitle') }}</h2>
<button class="batch-close" :title="t('cancel')" @click="store.batchSummary = null"><X /></button>
</header>
<div class="batch-counts">
<span>{{ t('batchTotal', { n: store.batchSummary.total }) }}</span>
<span class="ok"><CheckCircle2 />{{ store.batchSummary.completed }} {{ t('batchOk') }}</span>
<span class="bad" v-if="store.batchSummary.failed"><CircleX />{{ store.batchSummary.failed }} {{ t('batchFail') }}</span>
<span class="skip" v-if="store.batchSummary.cancelled"><Ban />{{ store.batchSummary.cancelled }} {{ t('batchCancelled') }}</span>
</div>
<ul v-if="store.batchSummary.failures?.length" class="batch-failures">
<li v-for="f in store.batchSummary.failures" :key="f">{{ f }}</li>
</ul>
</section>
<div v-if="!statsCollapsed && languages.length" class="split lang-overview">
<section class="panel shine-card">
<h2><PieChart class="panel-icon" />{{ t('languageDistribution') }}<small>{{ store.selectedProjectGroupId ? groupLabel(store.projectGroups.find(g => g.id === store.selectedProjectGroupId)) : t('allProjectGroups') }}</small></h2>
<ChartView :option="langPie" />
</section>
<section class="panel shine-card">
<h2>{{ t('languageDetails') }}<small>{{ fmt(langTotal) }} {{ t('lines') }}</small></h2>
<div class="lang-rank">
<div v-for="(x, i) in languages.slice(0, 8)" :key="x.name" class="lang-rank-row">
<i :style="{ background: langColors[i % langColors.length] }" />
<b>{{ x.name }}</b>
<span>{{ fmt(x.files) }} {{ t('files') }}</span>
<em><s :style="{ width: (x.code / Math.max(1, languages[0]?.code) * 100) + '%', background: langColors[i % langColors.length] }" /></em>
<strong>{{ fmt(x.code) }}</strong>
<small>{{ langTotal ? Math.round(x.code / langTotal * 100) : 0 }}%</small>
</div>
</div>
</section>
</div>
<div class="section-head">
<h2>{{ t('projects') }}</h2>
@@ -185,10 +353,11 @@ async function refreshProject(p) {
</div>
</div>
<div class="project-grid">
<article v-for="p in filtered" :key="p.id" class="project-card shine-card" @click="router.push('/project/' + p.id)">
<article v-for="p in filtered" :key="p.id" class="project-card shine-card" :class="{ favorited: store.favorites.includes(p.id) }" @click="router.push('/project/' + p.id)">
<div class="project-title">
<div><h3>{{ p.name }}</h3><span class="group-chip">{{ p.groupId === 1 ? t('myProjectGroup') : (p.groupName || t('projectGroup')) }}</span><p :title="p.path">{{ p.path }}</p></div>
<div class="icon-actions">
<button class="wb-star" :class="{ active: store.favorites.includes(p.id) }" :title="store.favorites.includes(p.id) ? t('unfavorite') : t('favorite')" @click.stop="store.toggleFavorite(p.id)"><Star /></button>
<button :title="t('refreshProject')" :disabled="projectRunning(p.id)" @click.stop="refreshProject(p)"><RefreshCw :class="{ spin: projectRunning(p.id) }" /></button>
<button :title="t('edit')" @click.stop="open(p)"><Pencil /></button>
<button :title="t('delete')" @click.stop="remove(p)"><Trash2 /></button>
@@ -213,19 +382,27 @@ async function refreshProject(p) {
<div v-if="modal" class="overlay" @click.self="!saving && (modal = false)">
<form class="modal" @submit.prevent="save">
<header><h2>{{ editing ? t('editProjectTitle') : t('addProjectTitle') }}</h2><button type="button" :disabled="saving" @click="modal = false">×</button></header>
<div v-if="!editing" class="src-switch">
<button type="button" :class="{ active: srcMode === 'local' }" :disabled="saving" @click="srcMode = 'local'"><FolderOpen />{{ t('srcLocal') }}</button>
<button type="button" :class="{ active: srcMode === 'git' }" :disabled="saving" @click="srcMode = 'git'"><GitCommitHorizontal />{{ t('srcGit') }}</button>
</div>
<label v-if="!editing && srcMode === 'git'">{{ t('cloneUrl') }}<input v-model="gitForm.url" placeholder="https://github.com/user/repo.git" :disabled="saving" @change="onGitUrl" /></label>
<label>{{ t('projectName') }}<input v-model="form.name" :disabled="saving" /></label>
<label>{{ t('projectGroup') }}<select v-model.number="form.groupId" :disabled="saving"><option v-for="g in store.projectGroups" :key="g.id" :value="g.id">{{ groupLabel(g) }}</option></select></label>
<label>{{ t('projectPath') }}<div class="browse"><input v-model="form.path" :disabled="saving" required /><button type="button" class="btn secondary" :disabled="saving" @click="browse"><FolderOpen />{{ t('browse') }}</button></div></label>
<div class="wsl-picker hidden-wsl-picker">
<button v-if="!wslDistros.length" type="button" class="btn secondary" @click="loadWSL">{{ t('selectWSL') }}</button>
<template v-else>
<select v-model="wslDistro"><option v-for="d in wslDistros" :key="d">{{ d }}</option></select>
<button type="button" class="btn secondary" @click="browseWSL"><FolderOpen />{{ t('selectWSL') }}</button>
</template>
</div>
<template v-if="editing || srcMode === 'local'">
<label>{{ t('projectPath') }}<div class="browse"><input v-model="form.path" :disabled="saving" required /><button type="button" class="btn secondary" :disabled="saving" @click="browse"><FolderOpen />{{ t('browse') }}</button></div></label>
<div class="wsl-picker hidden-wsl-picker">
<button v-if="!wslDistros.length" type="button" class="btn secondary" @click="loadWSL">{{ t('selectWSL') }}</button>
<template v-else>
<select v-model="wslDistro"><option v-for="d in wslDistros" :key="d">{{ d }}</option></select>
<button type="button" class="btn secondary" @click="browseWSL"><FolderOpen />{{ t('selectWSL') }}</button>
</template>
</div>
</template>
<label v-else>{{ t('cloneDir') }}<div class="browse"><input v-model="gitForm.parentDir" :placeholder="t('cloneDirHint')" :disabled="saving" /><button type="button" class="btn secondary" :disabled="saving" @click="browseParent"><FolderOpen />{{ t('browse') }}</button></div></label>
<label>{{ t('description') }}<textarea v-model="form.description" :disabled="saving" /></label>
<p v-if="error" class="form-error">{{ error }}</p>
<footer><button type="button" class="btn secondary" :disabled="saving" @click="modal = false">{{ t('cancel') }}</button><button class="btn primary" :disabled="saving"><RefreshCw v-if="saving" class="spin" />{{ saving ? t('saving') : t('saveProject') }}</button></footer>
<footer><button type="button" class="btn secondary" :disabled="saving" @click="modal = false">{{ t('cancel') }}</button><button class="btn primary" :disabled="saving"><RefreshCw v-if="saving" class="spin" />{{ saving ? (srcMode === 'git' && !editing ? t('cloning') : t('saving')) : (srcMode === 'git' && !editing ? t('cloneAndAdd') : t('saveProject')) }}</button></footer>
</form>
</div>
<div v-if="groupModal" class="overlay" @click.self="!groupSaving && (groupModal = false)">
@@ -236,5 +413,14 @@ async function refreshProject(p) {
<footer><button type="button" class="btn secondary" :disabled="groupSaving" @click="groupModal = false">{{ t('cancel') }}</button><button class="btn primary" :disabled="groupSaving"><RefreshCw v-if="groupSaving" class="spin" />{{ groupSaving ? t('saving') : t('saveProjectGroup') }}</button></footer>
</form>
</div>
<div v-if="bindTarget" class="overlay" @click.self="!bindBusy && (bindTarget = null)">
<form class="modal compact-modal" @submit.prevent="bindNow">
<header><h2>{{ t('cloudBindTitle') }}</h2><button type="button" :disabled="bindBusy" @click="bindTarget = null">&times;</button></header>
<label>{{ t('projectName') }}<input :value="bindTarget.name" disabled /></label>
<label>{{ t('projectPath') }}<div class="browse"><input v-model="bindDir" :disabled="bindBusy" required /><button type="button" class="btn secondary" :disabled="bindBusy" @click="browseBind"><FolderOpen />{{ t('browse') }}</button></div></label>
<p v-if="bindErr" class="form-error">{{ bindErr }}</p>
<footer><button type="button" class="btn secondary" :disabled="bindBusy" @click="bindTarget = null">{{ t('cancel') }}</button><button class="btn primary" :disabled="bindBusy"><RefreshCw v-if="bindBusy" class="spin" />{{ bindBusy ? t('saving') : t('cloudBindBtn') }}</button></footer>
</form>
</div>
</Teleport>
</template>

View File

@@ -1,47 +1,176 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RefreshCw, Trash2, ScrollText, Info, TriangleAlert, CircleX } from 'lucide-vue-next'
import { RefreshCw, Trash2, ScrollText, Info, TriangleAlert, CircleX, Search, ChevronLeft, ChevronRight, ChevronDown, Copy, Check, Sparkles } from 'lucide-vue-next'
import { call } from '../api'
import StatCard from '../components/StatCard.vue'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
const { t } = useI18n()
const logs = ref([])
const filter = ref('all')
const aiOpen = ref(false)
const page = ref({ items: [], total: 0, info: 0, warning: 0, error: 0 })
const query = ref('')
const level = ref('all')
const category = ref('all')
const categories = ref([])
const pageIndex = ref(0)
const pageSize = 50
const auto = ref(true)
const expanded = ref(new Set())
const loading = ref(false)
const copiedId = ref(0)
let timer
const shown = computed(() => filter.value === 'all' ? logs.value : logs.value.filter(x => filter.value === 'run' ? x.level !== 'error' : x.level === 'error'))
const counts = computed(() => ({
all: logs.value.length,
info: logs.value.filter(x => x.level === 'info').length,
warning: logs.value.filter(x => x.level === 'warning').length,
error: logs.value.filter(x => x.level === 'error').length
}))
async function load() { logs.value = await call('GetLogs', 'all') }
let debounce
let copyTimer
const pageCount = () => Math.max(1, Math.ceil(page.value.total / pageSize))
// 按本地日期分组,头部显示 今天/昨天/日期。
const localDate = s => {
const d = new Date(s)
return isNaN(d) ? String(s).slice(0, 10) : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const groups = computed(() => {
const out = []
let cur = null
for (const x of page.value.items) {
const d = localDate(x.createdAt)
if (!cur || cur.date !== d) { cur = { date: d, items: [] }; out.push(cur) }
cur.items.push(x)
}
return out
})
function dayLabel(d) {
const now = new Date()
const today = localDate(now)
now.setDate(now.getDate() - 1)
if (d === today) return t('today')
if (d === localDate(now)) return t('yesterday')
return d
}
const fullTime = s => String(s || '').replace('T', ' ').slice(0, 19)
function relTime(s) {
const ms = Date.now() - new Date(s).getTime()
if (!isFinite(ms) || ms < 0) return fullTime(s).slice(11, 16)
const m = Math.floor(ms / 60000)
if (m < 1) return t('justNow')
if (m < 60) return t('minAgo', { n: m })
const h = Math.floor(m / 60)
if (h < 24) return t('hourAgo', { n: h })
const d = Math.floor(h / 24)
if (d < 7) return t('dayAgo', { n: d })
return fullTime(s).slice(5, 16)
}
async function load() {
loading.value = true
try {
page.value = await call('SearchLogs', query.value, level.value, category.value, pageIndex.value * pageSize, pageSize)
categories.value = await call('GetLogCategories')
} finally {
loading.value = false
}
}
function setLevel(v) {
level.value = level.value === v ? 'all' : v
pageIndex.value = 0
load()
}
function go(delta) {
const next = pageIndex.value + delta
if (next < 0 || next >= pageCount()) return
pageIndex.value = next
load()
}
function toggle(id) {
const s = new Set(expanded.value)
s.has(id) ? s.delete(id) : s.add(id)
expanded.value = s
}
async function copyDetail(x) {
try {
await navigator.clipboard.writeText(`[${x.level}] ${x.category} ${fullTime(x.createdAt)}\n${x.message}\n${x.detail}`)
copiedId.value = x.id
clearTimeout(copyTimer)
copyTimer = setTimeout(() => { copiedId.value = 0 }, 1500)
} catch {}
}
async function clear() {
if (confirm(t('clearLogs') + '?')) {
await call('ClearLogs')
pageIndex.value = 0
await load()
}
}
watch(query, () => {
clearTimeout(debounce)
debounce = setTimeout(() => { pageIndex.value = 0; load() }, 300)
})
watch(category, () => { pageIndex.value = 0; load() })
onMounted(async () => {
await load()
timer = setInterval(() => auto.value && load(), 5000)
timer = setInterval(() => auto.value && !query.value && pageIndex.value === 0 && load(), 5000)
})
onUnmounted(() => clearInterval(timer))
onUnmounted(() => { clearInterval(timer); clearTimeout(debounce); clearTimeout(copyTimer) })
</script>
<template>
<div class="page">
<div class="page logs-page">
<header class="page-head sticky-head">
<div><h1>{{ t('logs') }}</h1><p>{{ t('logSubtitle') }}</p></div>
<div class="actions"><label class="toggle"><input type="checkbox" v-model="auto" />{{ t('autoRefresh') }}</label><button class="btn secondary" @click="load"><RefreshCw />{{ t('refresh') }}</button><button class="btn danger" @click="clear"><Trash2 />{{ t('clearLogs') }}</button></div>
<div class="actions">
<label class="toggle"><input type="checkbox" v-model="auto" />{{ t('autoRefresh') }}</label>
<button class="btn secondary" @click="aiOpen = true"><Sparkles />{{ t('aiScopeBtn') }}</button>
<button class="btn secondary" @click="load"><RefreshCw :class="{ spin: loading }" />{{ t('refresh') }}</button>
<button class="btn danger" @click="clear"><Trash2 />{{ t('clearLogs') }}</button>
</div>
</header>
<div class="stats-grid four"><StatCard :icon="ScrollText" :value="counts.all" :label="t('totalLogs')" /><StatCard :icon="Info" :value="counts.info" :label="t('info')" /><StatCard :icon="TriangleAlert" :value="counts.warning" :label="t('warning')" /><StatCard :icon="CircleX" :value="counts.error" :label="t('error')" /></div>
<div class="tabs compact"><button :class="{ active: filter === 'all' }" @click="filter = 'all'">{{ t('allLogs') }}</button><button :class="{ active: filter === 'run' }" @click="filter = 'run'">{{ t('runLogs') }}</button><button :class="{ active: filter === 'error' }" @click="filter = 'error'">{{ t('errorLogs') }}</button></div>
<div class="stats-grid four">
<StatCard :icon="ScrollText" :value="page.total" :label="t('totalLogs')" />
<StatCard :icon="Info" tone="blue" :value="page.info" :label="t('info')" />
<StatCard :icon="TriangleAlert" :value="page.warning" :label="t('warning')" />
<StatCard :icon="CircleX" tone="red" :value="page.error" :label="t('error')" />
</div>
<div class="log-toolbar">
<label class="search log-search"><Search /><input v-model="query" :placeholder="t('searchLogs')" /></label>
<div class="log-chips" role="group">
<button class="log-chip info" :class="{ active: level === 'info' }" @click="setLevel('info')"><Info />{{ t('info') }}<b>{{ page.info }}</b></button>
<button class="log-chip warning" :class="{ active: level === 'warning' }" @click="setLevel('warning')"><TriangleAlert />{{ t('warning') }}<b>{{ page.warning }}</b></button>
<button class="log-chip error" :class="{ active: level === 'error' }" @click="setLevel('error')"><CircleX />{{ t('error') }}<b>{{ page.error }}</b></button>
</div>
<select v-model="category" class="log-category">
<option value="all">{{ t('allCategories') }}</option>
<option v-for="c in categories" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<section class="panel log-panel">
<article class="log" v-for="x in shown" :key="x.id" :class="x.level"><span><Info v-if="x.level === 'info'" /><TriangleAlert v-else-if="x.level === 'warning'" /><CircleX v-else /></span><div><small>{{ x.category }}</small><b>{{ x.message }}</b><code v-if="x.detail">{{ x.detail }}</code></div><time>{{ x.createdAt?.replace('T', ' ').slice(0, 19) }}</time></article>
<div v-if="!shown.length" class="empty"><ScrollText />{{ t('noLogs') }}</div>
<div class="log-rows">
<template v-for="g in groups" :key="g.date">
<div class="log-day"><span>{{ dayLabel(g.date) }}</span><i /></div>
<article v-for="x in g.items" :key="x.id" class="log-row" :class="[x.level, { open: expanded.has(x.id), clickable: !!x.detail }]">
<button class="log-row-main" @click="x.detail && toggle(x.id)">
<span class="log-badge"><Info v-if="x.level === 'info'" /><TriangleAlert v-else-if="x.level === 'warning'" /><CircleX v-else /></span>
<b class="log-msg">{{ x.message }}</b>
<small class="log-cat">{{ x.category }}</small>
<time :title="fullTime(x.createdAt)">{{ relTime(x.createdAt) }}</time>
<ChevronDown v-if="x.detail" class="log-caret" />
</button>
<div v-if="x.detail && expanded.has(x.id)" class="log-detail-wrap">
<code class="log-detail">{{ x.detail }}</code>
<button class="log-copy" :class="{ ok: copiedId === x.id }" @click.stop="copyDetail(x)">
<Check v-if="copiedId === x.id" /><Copy v-else />{{ copiedId === x.id ? t('copiedShort') : t('copyBtn') }}
</button>
</div>
</article>
</template>
</div>
<div v-if="!page.items.length" class="empty log-empty"><ScrollText /><b>{{ t('noLogs') }}</b><small>{{ t('noLogsHint') }}</small></div>
<footer v-if="page.total > pageSize" class="log-pager">
<button class="btn secondary" :disabled="pageIndex === 0" @click="go(-1)"><ChevronLeft /></button>
<span>{{ pageIndex + 1 }} / {{ pageCount() }}</span>
<button class="btn secondary" :disabled="pageIndex + 1 >= pageCount()" @click="go(1)"><ChevronRight /></button>
</footer>
</section>
<AIScopeDrawer v-if="aiOpen" kind="logs" :title="t('logs')" @close="aiOpen = false" />
</div>
</template>

View File

@@ -2,12 +2,15 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck } from 'lucide-vue-next'
import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck, Flame, Sparkles, MessageCircleQuestion } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import ChartView from '../components/ChartView.vue'
import GitHeatmap from '../components/GitHeatmap.vue'
import GitTrend from '../components/GitTrend.vue'
import CommitDrawer from '../components/CommitDrawer.vue'
import FilePreviewModal from '../components/FilePreviewModal.vue'
import AIBrief from '../components/AIBrief.vue'
import ProjectAIDrawer from '../components/ProjectAIDrawer.vue'
import { call } from '../api'
import { useAppStore } from '../store'
@@ -29,8 +32,21 @@ const issueSeverity = ref('all')
const issueType = ref('all')
const detail = ref(null)
const detailLoading = ref(false)
const preview = ref('')
const previewLine = ref(0)
// AI 问答抽屉:详情页内的“二级页面”,不跳转
const aiDrawer = ref(false)
const aiAsk = ref('')
const askText = ref('')
// 这些检查项的 path 是真实源码文件folder_concentration 是目录、large_commit 是提交哈希,不可预览)
const fileIssueTypes = ['large_file', 'long_file', 'todo_marker']
const colors = ['#7b73ff', '#4fd1a1', '#4da5ff', '#f4c84a', '#ef6683', '#23b5d3']
function openPreview(path, line = 0) {
previewLine.value = line
preview.value = path
}
const fmt = n => {
n = +n || 0
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
@@ -101,6 +117,11 @@ async function showCommit(c) {
detail.value = { ...c, files: [] }
try { detail.value = await call('GetCommitDetails', +route.params.id, c.hash) } finally { detailLoading.value = false }
}
function openChat(text) {
aiAsk.value = (text || '').trim()
askText.value = ''
aiDrawer.value = true
}
onMounted(load)
</script>
@@ -108,14 +129,16 @@ onMounted(load)
<div class="page detail-page">
<header class="project-head sticky-head detail-sticky-head">
<div class="detail-head-row">
<button class="back" @click="router.push('/')"><ArrowLeft />{{ t('back') }}</button>
<button class="back" @click="router.push('/projects')"><ArrowLeft />{{ t('back') }}</button>
<div><h1>{{ p.name }}</h1><p>{{ p.path }}</p></div>
<button class="btn secondary ai-entry" @click="openChat()"><Sparkles />{{ t('aiAskGo') }}</button>
</div>
<div class="tabs detail-tabs">
<button :class="{ active: tab === 'code' }" @click="tab = 'code'"><Code2 />{{ t('code') }}</button>
<button :class="{ active: tab === 'git' }" @click="tab = 'git'"><GitCommitHorizontal />{{ t('git') }}</button>
<button :class="{ active: tab === 'structure' }" @click="tab = 'structure'"><FolderTree />{{ t('structure') }}</button>
<button :class="{ active: tab === 'insights' }" @click="tab = 'insights'"><ClipboardCheck />{{ t('insights') }}</button>
<button :class="{ active: tab === 'ai' }" @click="tab = 'ai'"><Sparkles />{{ t('aiAnalysis') }}</button>
</div>
</header>
@@ -156,6 +179,19 @@ onMounted(load)
<section class="panel structure-scroll-panel"><h2>{{ t('recentCommits') }}</h2><button class="commit" v-for="c in visibleCommits.slice(0, 30)" :key="c.hash" @click="showCommit(c)"><span class="avatar">{{ c.author?.[0] }}</span><div><b>{{ c.message }}</b><small>{{ c.author }} · {{ c.hash.slice(0, 7) }} · {{ c.date?.slice(0, 10) }}</small></div><span class="positive">+{{ c.added }}</span><span class="negative">-{{ c.deleted }}</span></button><div v-if="!visibleCommits.length" class="empty">{{ t('empty') }}</div></section>
</div>
<section class="panel trend shine-card"><h2>{{ t('commitTrend') }}</h2><GitTrend :commits="git.commits" @select="selectedDate = $event" /></section>
<section v-if="git.hotspots?.length" class="panel shine-card hotspot-panel">
<h2><Flame class="hotspot-icon" />{{ t('fileHotspots') }}<small>{{ t('fileHotspotsHint') }}</small></h2>
<div class="hotspot-list">
<button v-for="(h, i) in git.hotspots.slice(0, 20)" :key="h.path" class="hotspot-row" :title="h.path" @click="openPreview(h.path)">
<b>#{{ i + 1 }}</b>
<span class="hotspot-path">{{ h.path }}</span>
<i class="hotspot-bar"><em :style="{ width: (h.changes / Math.max(1, git.hotspots[0]?.changes) * 100) + '%' }" /></i>
<strong>{{ h.changes }} {{ t('changesUnit') }}</strong>
<span class="positive">+{{ fmt(h.added) }}</span>
<span class="negative">-{{ fmt(h.deleted) }}</span>
</button>
</div>
</section>
<section class="panel shine-card"><h2>{{ t('contributorRanking') }}</h2><div class="contributor" v-for="(c, i) in git.contributors" :key="c.email"><b>#{{ i + 1 }}</b><span class="avatar">{{ c.name?.[0] }}</span><div><strong>{{ c.name }}</strong><small>{{ c.email }}</small></div><span>{{ c.commits }} {{ t('commitsUnit') }}</span><span class="positive">+{{ fmt(c.added) }}</span><span class="negative">-{{ fmt(c.deleted) }}</span></div><div v-if="!git.contributors?.length" class="empty">{{ t('empty') }}</div></section>
<CommitDrawer v-if="detail" :detail="detail" :loading="detailLoading" @close="detail = null" />
</template>
@@ -168,12 +204,12 @@ onMounted(load)
<StatCard :icon="FileWarning" tone="red" :value="structure.largeFiles?.length || 0" :label="t('largeFiles')" />
</div>
<div class="split structure-split">
<section class="panel structure-panel"><h2>{{ t('directoryStructure') }}</h2><div class="file-tree"><div v-for="f in structure.files?.slice(0, 300)" :key="f.path" :style="{ paddingLeft: Math.min((f.path.split('/').length - 1) * 16, 160) + 'px' }"><Folder v-if="f.isDir" /><Files v-else /><span :title="f.path">{{ f.name }}</span><small>{{ f.isDir ? '' : bytes(f.size) }}</small></div></div></section>
<section class="panel structure-panel"><h2>{{ t('directoryStructure') }}</h2><div class="file-tree"><div v-for="f in structure.files?.slice(0, 300)" :key="f.path" :class="{ 'file-clickable': !f.isDir }" :style="{ paddingLeft: Math.min((f.path.split('/').length - 1) * 16, 160) + 'px' }" @click="!f.isDir && openPreview(f.path)"><Folder v-if="f.isDir" /><Files v-else /><span :title="f.path">{{ f.name }}</span><small>{{ f.isDir ? '' : bytes(f.size) }}</small></div></div></section>
<section class="panel structure-panel"><h2>{{ t('folderSize') }}</h2><div class="folder-size" v-for="f in structure.folders" :key="f.name"><b :title="f.name">{{ f.name }}</b><span>{{ f.files }} {{ t('files') }}</span><i><em :style="{ width: (f.size / Math.max(1, structure.folders[0]?.size) * 100) + '%' }" /></i><strong>{{ bytes(f.size) }}</strong></div></section>
</div>
<section class="panel large-file-panel"><h2>{{ t('largeFileDetection') }}</h2><div class="large-file" v-for="f in structure.largeFiles" :key="f.path"><div><b>{{ f.name }}</b><small>{{ f.path }}</small></div><strong>{{ bytes(f.size) }}</strong></div><div v-if="!structure.largeFiles?.length" class="empty">{{ t('noLargeFiles') }}</div></section>
<section class="panel large-file-panel"><h2>{{ t('largeFileDetection') }}</h2><div class="large-file file-clickable" v-for="f in structure.largeFiles" :key="f.path" @click="openPreview(f.path)"><div><b>{{ f.name }}</b><small>{{ f.path }}</small></div><strong>{{ bytes(f.size) }}</strong></div><div v-if="!structure.largeFiles?.length" class="empty">{{ t('noLargeFiles') }}</div></section>
</template>
<template v-else>
<template v-else-if="tab === 'insights'">
<section class="panel insights-hero shine-card">
<div class="score-ring" :style="{ '--score': insights.healthScore || 0 }"><strong>{{ insights.healthScore || 0 }}</strong><span>{{ t('healthScore') }}</span></div>
<div class="insight-summary">
@@ -199,12 +235,27 @@ onMounted(load)
<div class="issue-list">
<article v-for="issue in filteredIssues" :key="issue.type + issue.path + issue.line + issue.title" class="issue-card" :class="issue.severity">
<span class="issue-severity">{{ t('severity.' + issue.severity) }}</span>
<div><h3>{{ issue.title }}</h3><p>{{ issue.detail }}</p><small v-if="issue.path">{{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template></small><code v-if="issue.evidence">{{ issue.evidence }}</code><b>{{ issue.suggestion }}</b></div>
<div><h3>{{ issue.title }}</h3><p>{{ issue.detail }}</p><small v-if="issue.path" :class="{ 'issue-file': fileIssueTypes.includes(issue.type) }" :title="fileIssueTypes.includes(issue.type) ? t('clickPreview') : ''" @click="fileIssueTypes.includes(issue.type) && openPreview(issue.path, issue.line || 0)">{{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template></small><code v-if="issue.evidence">{{ issue.evidence }}</code><b>{{ issue.suggestion }}</b></div>
</article>
<div v-if="!filteredIssues.length" class="empty"><ClipboardCheck />{{ t('noIssues') }}</div>
</div>
</section>
</template>
<template v-else>
<section class="panel ai-ask-bar">
<Sparkles />
<input v-model="askText" :placeholder="t('aiAskThisProject')" @keyup.enter="openChat(askText)" />
<button class="btn primary" @click="openChat(askText)"><MessageCircleQuestion />{{ t('aiAskGo') }}</button>
</section>
<div class="ai-brief-grid">
<AIBrief v-if="p.id" :project-id="p.id" kind="project" />
<AIBrief v-if="p.id" :project-id="p.id" kind="git" />
<AIBrief v-if="p.id" :project-id="p.id" kind="structure" />
<AIBrief v-if="p.id" :project-id="p.id" kind="insights" />
</div>
</template>
<div class="center-action"><button class="btn secondary" @click="analyze()"><RefreshCw />{{ t('analyze') }}</button></div>
<FilePreviewModal v-if="preview" :project-id="+route.params.id" :path="preview" :line="previewLine" @close="preview = ''; previewLine = 0" />
<ProjectAIDrawer v-if="aiDrawer" :project-id="p.id" :project-name="p.name" :ask="aiAsk" @close="aiDrawer = false; aiAsk = ''" />
</div>
</template>

View File

@@ -1,19 +1,31 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { FileSliders, Database, Plus, Trash2, Upload, BarChart3, Folder, Languages, SunMoon, CheckCircle2, Info, Copy } from 'lucide-vue-next'
import { Database, Plus, Trash2, Upload, BarChart3, Folder, Languages, CheckCircle2, Info, Copy, Timer, Rocket, Sparkles, CloudUpload, Wifi } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
const tab=ref(new URLSearchParams(location.search).get('settingsTab')||'rules'),rules=ref([]),form=reactive({pattern:'',category:'custom'})
const settings=reactive({theme:'dark',locale:'zh-CN',gitScope:'current',databasePath:'',autoRefresh:true,glassOpacity:55,loadingStyle:'fullscreen-orbit'})
const store=useAppStore(),{locale}=useI18n(),native=isNative(),dbMessage=ref('')
const route=useRoute()
// tab 记忆:优先 URL 深链,其次上次停留的 tabsync 已迁往个人主页,做合法性回退)
// 分区切换入口在侧边栏“设置”一级导航的二级菜单,页内不再有 tabbar。
const TABS=['rules','appearance','ai','filestorage','database']
const TAB_TITLE={rules:'tabRules',appearance:'tabAppearance',ai:'aiAnalysis',filestorage:'tabFileStorage',database:'tabDatabase'}
const pickTab=v=>TABS.includes(String(v))?String(v):''
const tab=ref(pickTab(route.query.tab)||pickTab(localStorage.getItem('cc-settings-tab'))||'rules'),rules=ref([]),form=reactive({pattern:'',category:'custom'})
const aiOpen=ref(false)
watch(tab,v=>localStorage.setItem('cc-settings-tab',v))
const settings=reactive({theme:'dark',locale:'zh-CN',gitScope:'current',databasePath:'',autoRefresh:true,glassOpacity:55,loadingStyle:'fullscreen-orbit',minimizeToTray:true,autoUpdateEnabled:false,autoUpdateMode:'daily',autoUpdateInterval:1,autoUpdateTime:'09:00',aiProvider:'spark',sparkKey:'',deepSeekKey:'',syncApiKeys:false,avatarMode:'',avatarValue:'',imageMode:'base64'})
const store=useAppStore(),{locale,t}=useI18n(),native=isNative(),dbMessage=ref('')
const autostart=ref(false),autostartBusy=ref(false)
const groups=computed(()=>Object.groupBy?Object.groupBy(rules.value,x=>x.category):rules.value.reduce((a,x)=>((a[x.category]??=[]).push(x),a),{}))
const loadingOptions=[
{value:'fullscreen-orbit',title:'能量轨道',desc:'环形粒子、扫描光束和能量核心'},
{value:'fullscreen-grid',title:'数据矩阵',desc:'流动数据网格和聚合节点'},
{value:'fullscreen-warp',title:'光速跃迁',desc:'深空隧道、放射光束和跃迁环'},
{value:'bar',title:'底部进度条',desc:'保留当前页面,只显示底部进度'}
{value:'fullscreen-orbit',title:'loadingStyleOrbit',desc:'loadingStyleOrbitDesc'},
{value:'fullscreen-grid',title:'loadingStyleGrid',desc:'loadingStyleGridDesc'},
{value:'fullscreen-warp',title:'loadingStyleWarp',desc:'loadingStyleWarpDesc'},
{value:'fullscreen-matrix',title:'loadingStyleMatrix',desc:'loadingStyleMatrixDesc'},
{value:'bar',title:'loadingStyleBar',desc:'loadingStyleBarDesc'}
]
async function load(){
@@ -21,7 +33,50 @@ async function load(){
const [saved,bootstrap]=await Promise.all([call('GetSettings'),call('GetBootstrapStatus')])
Object.assign(settings,saved)
settings.databasePath=bootstrap.databasePath||saved.databasePath||bootstrap.defaultPath||''
try{autostart.value=await call('GetAutostart')}catch{autostart.value=false}
}
async function toggleAutostart(){
if(autostartBusy.value)return
autostartBusy.value=true
try{
await call('SetAutostart',!autostart.value)
autostart.value=!autostart.value
store.showToast({type:'success',key:autostart.value?'autostartOnToast':'autostartOffToast'})
}catch(e){store.showToast({type:'error',key:'autostartFail',params:{err:String(e)}})}
finally{autostartBusy.value=false}
}
// ---- 全局文件存储(仅管理员 id=1权威配置存远端 MySQL保存即全员生效 ----
const isAdmin=computed(()=>store.syncStatus.userId===1)
const fsCfg=reactive({mode:'local',baseUrl:'',apiKey:''})
const fsBusy=ref(''),fsLoaded=ref(false)
async function loadFileStorage(){
try{
const c=await call('GetFileStorageConfig')
Object.assign(fsCfg,{mode:c.mode||'local',baseUrl:c.baseUrl||'',apiKey:c.apiKey||''})
}catch{}
fsLoaded.value=true
}
async function saveFileStorage(){
if(fsBusy.value)return
fsBusy.value='save'
try{
await call('SaveFileStorageConfig',{...fsCfg})
store.showToast({type:'success',key:'fsSavedToast'})
}catch(e){store.showToast({type:'error',text:errText(e)})}
finally{fsBusy.value=''}
}
async function testFileStorage(){
if(fsBusy.value)return
fsBusy.value='test'
try{
await call('TestFileStorage',{...fsCfg})
store.showToast({type:'success',key:'fsTestOkToast'})
}catch(e){store.showToast({type:'error',text:errText(e)})}
finally{fsBusy.value=''}
}
const errText=e=>{const code=String(e).split(':')[0].trim();return t('errors.'+code)!=='errors.'+code?t('errors.'+code):String(e)}
watch(tab,v=>{if(v==='filestorage')loadFileStorage()})
async function add(){if(!form.pattern)return;await call('AddRule',form.pattern,form.category);form.pattern='';await load()}
async function remove(r){if(!r.builtin){await call('DeleteRule',r.id);await load()}}
async function save(){await call('SaveSettings',{...settings,databasePath:''});store.applyAppearance(settings);apply();localStorage.setItem('cc-settings',JSON.stringify(settings))}
@@ -34,21 +89,68 @@ async function migrate(){
await call('MigrateDatabase',p)
const status=await call('GetBootstrapStatus')
settings.databasePath=status.databasePath
dbMessage.value='数据库已迁移并切换到新位置'
dbMessage.value=t('dbMigratedMsg')
store.showToast({type:'success',text:dbMessage.value})
}catch(e){dbMessage.value=String(e);store.showToast({type:'error',text:'数据库迁移失败'})}
}catch(e){dbMessage.value=String(e);store.showToast({type:'error',key:'dbMigrateFail'})}
}
async function copyPath(){try{await navigator.clipboard.writeText(settings.databasePath);store.showToast({type:'success',text:'数据库路径已复制'})}catch{store.showToast({type:'error',text:'无法复制路径'})}}
async function clear(mode){const id=mode==='project'?Number(prompt('项目 ID')):0;if(mode==='project'&&!id)return;if(confirm('此操作不可恢复,确认继续?')){await call('ClearData',mode,id);await store.refresh()}}
watch(()=>[settings.theme,settings.locale,settings.glassOpacity,settings.loadingStyle],save)
onMounted(async()=>{await load();apply()})
async function copyPath(){try{await navigator.clipboard.writeText(settings.databasePath);store.showToast({type:'success',key:'dbPathCopied'})}catch{store.showToast({type:'error',key:'dbCopyFail'})}}
async function clear(mode){const id=mode==='project'?Number(prompt(t('projectIdPrompt'))):0;if(mode==='project'&&!id)return;if(confirm(t('confirmIrreversible'))){await call('ClearData',mode,id);await store.refresh()}}
watch(()=>[settings.theme,settings.locale,settings.glassOpacity,settings.loadingStyle,settings.gitScope,settings.minimizeToTray,settings.autoUpdateEnabled,settings.autoUpdateMode,settings.autoUpdateInterval,settings.autoUpdateTime,settings.aiProvider,settings.syncApiKeys,settings.avatarMode,settings.avatarValue,settings.imageMode],save)
let aiKeyTimer=null
watch(()=>[settings.sparkKey,settings.deepSeekKey],()=>{clearTimeout(aiKeyTimer);aiKeyTimer=setTimeout(save,600)})
watch(()=>route.query.tab,v=>{const n=pickTab(v);if(n)tab.value=n})
onMounted(async()=>{await load();apply();if(tab.value==='filestorage')loadFileStorage()})
onUnmounted(()=>{clearTimeout(aiKeyTimer)})
</script>
<template><div class="page settings-page">
<header class="page-head"><div><h1>设置</h1><p>管理排除规则界面和数据库配置</p></div></header>
<div class="tabs settings-tabs"><button :class="{active:tab==='rules'}" @click="tab='rules'"><FileSliders/>排除规则</button><button :class="{active:tab==='appearance'}" @click="tab='appearance'"><SunMoon/>界面设置</button><button :class="{active:tab==='database'}" @click="tab='database'"><Database/>数据管理</button></div>
<template v-if="tab==='rules'"><section class="panel rule-add"><h2><Plus/>添加排除规则</h2><div><input v-model="form.pattern" placeholder="例如 *.log, cache, temp" @keyup.enter="add"/><select v-model="form.category"><option value="general">通用</option><option value="php">PHP</option><option value="go">Go</option><option value="vue">Vue/JS</option><option value="custom">自定义</option></select><button class="btn primary" @click="add"><Plus/>添加</button></div><small>支持 * 通配符;目录名会在任意层级匹配</small></section><section v-for="(items,name) in groups" :key="name" class="panel rule-group"><h2>{{name}} <small>{{items.length}} 条规则</small></h2><div><button v-for="r in items" :key="r.id" :class="{builtin:r.builtin}" @click="remove(r)">{{r.pattern}}<small v-if="r.builtin">默认</small><Trash2 v-else/></button></div></section></template>
<template v-else-if="tab==='appearance'"><section class="panel form-panel"><h2><Languages/>语言与主题</h2><label>界面语言<select v-model="settings.locale"><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label>主题<select v-model="settings.theme"><option value="dark">暗色</option><option value="light">浅色</option><option value="system">跟随系统</option></select></label><label>Git 默认范围<select v-model="settings.gitScope"><option value="current">当前分支</option><option value="all">所有分支</option></select></label><div class="loading-style-setting"><span>统计 Loading 样式</span><div class="loading-style-grid" role="radiogroup" aria-label="统计 Loading 样式"><button v-for="option in loadingOptions" :key="option.value" type="button" role="radio" :aria-checked="settings.loadingStyle===option.value" :class="['loading-style-card',option.value,{active:settings.loadingStyle===option.value}]" @click="settings.loadingStyle=option.value"><span class="loading-style-preview" aria-hidden="true"><i/></span><b>{{option.title}}</b><small>{{option.desc}}</small></button></div></div><label class="opacity-setting"><span>卡片透明度 <b>{{settings.glassOpacity}}%</b></span><input v-model.number="settings.glassOpacity" type="range" min="30" max="75" step="1"/></label></section></template>
<template v-else><section class="panel database-panel"><div class="db-title"><h2><Database/>数据库位置</h2><span class="db-connected"><CheckCircle2/>已连接</span></div><div v-if="!native" class="preview-notice"><Info/><div><b>当前是浏览器预览模式</b><small>浏览器无法访问本地数据库和文件选择器,请运行 code-count.exe 使用数据库功能。</small></div></div><div class="db-path"><span>当前位置</span><code :title="settings.databasePath">{{settings.databasePath||'未获取到数据库路径'}}</code><button title="复制路径" :disabled="!settings.databasePath" @click="copyPath"><Copy/></button></div><p v-if="dbMessage" class="db-message">{{dbMessage}}</p><button class="btn secondary migrate" :disabled="!native" @click="migrate"><Upload/>选择新位置并迁移</button></section>
<section class="panel danger-zone"><h2><Trash2/>清空数据</h2><p>选择要清空的数据类型此操作不可恢复</p><div><button @click="clear('stats')"><BarChart3/><span><b>清空统计数据</b><small>删除分析记录保留项目配置</small></span></button><button @click="clear('project')"><Folder/><span><b>清空单个项目</b><small>删除指定项目的统计数据</small></span></button><button class="danger" @click="clear('all')"><Trash2/><span><b>清空所有数据</b><small>删除所有项目和分析记录</small></span></button></div></section></template>
<header class="page-head sticky-head"><div><h1>{{t('settings')}} · {{t(TAB_TITLE[tab])}}</h1><p>{{t('settingsSubtitle')}}</p></div>
<div class="actions"><button class="btn secondary" @click="aiOpen=true"><Sparkles/>{{t('aiScopeBtn')}}</button></div>
</header>
<template v-if="tab==='rules'"><section class="panel rule-add"><h2><Plus/>{{t('addRule')}}</h2><div><input v-model="form.pattern" :placeholder="t('rulePatternPh')" @keyup.enter="add"/><select v-model="form.category"><option value="general">{{t('catGeneral')}}</option><option value="php">PHP</option><option value="go">Go</option><option value="vue">Vue/JS</option><option value="custom">{{t('catCustom')}}</option></select><button class="btn primary" @click="add"><Plus/>{{t('add')}}</button></div><small>{{t('ruleHint')}}</small></section><section v-for="(items,name) in groups" :key="name" class="panel rule-group"><h2>{{name}} <small>{{t('rulesCount',{n:items.length})}}</small></h2><div><button v-for="r in items" :key="r.id" :class="{builtin:r.builtin}" @click="remove(r)">{{r.pattern}}<small v-if="r.builtin">{{t('builtinTag')}}</small><Trash2 v-else/></button></div></section></template>
<template v-else-if="tab==='appearance'"><section class="panel form-panel"><h2><Languages/>{{t('langThemeTitle')}}</h2><label>{{t('uiLanguage')}}<select v-model="settings.locale"><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label>{{t('theme')}}<select v-model="settings.theme"><option value="dark">{{t('themeDark')}}</option><option value="light">{{t('themeLight')}}</option><option value="system">{{t('themeSystem')}}</option></select></label><label>{{t('gitScopeDefault')}}<select v-model="settings.gitScope"><option value="current">{{t('scopeCurrent')}}</option><option value="all">{{t('scopeAll')}}</option></select></label><div class="loading-style-setting"><span>{{t('loadingStyle')}}</span><div class="loading-style-grid" role="radiogroup" :aria-label="t('loadingStyle')"><button v-for="option in loadingOptions" :key="option.value" type="button" role="radio" :aria-checked="settings.loadingStyle===option.value" :class="['loading-style-card',option.value,{active:settings.loadingStyle===option.value}]" @click="settings.loadingStyle=option.value"><span class="loading-style-preview" aria-hidden="true"><i/></span><b>{{t(option.title)}}</b><small>{{t(option.desc)}}</small></button></div></div><label class="opacity-setting"><span>{{t('glassOpacity')}} <b>{{settings.glassOpacity}}%</b></span><input v-model.number="settings.glassOpacity" type="range" min="30" max="75" step="1"/></label></section>
<section class="panel form-panel"><h2><Rocket/>{{t('sysIntegration')}}</h2>
<label>{{t('onWindowClose')}}<select v-model="settings.minimizeToTray"><option :value="true">{{t('closeToTray')}}</option><option :value="false">{{t('closeQuit')}}</option></select></label>
<label>{{t('autostartLabel')}}<div class="autostart-row"><button type="button" class="btn secondary" :disabled="autostartBusy||!native" @click="toggleAutostart">{{autostart?t('autostartOnBtn'):t('autostartOffBtn')}}</button><small>{{autostart?t('autostartOnHint'):t('autostartOffHint')}}</small></div></label>
</section>
<section class="panel form-panel"><h2><Timer/>{{t('autoUpdateTitle')}}</h2>
<label>{{t('autoUpdateEnable')}}<select v-model="settings.autoUpdateEnabled"><option :value="false">{{t('optOff')}}</option><option :value="true">{{t('optOn')}}</option></select></label>
<template v-if="settings.autoUpdateEnabled">
<label>{{t('updateFreq')}}<select v-model="settings.autoUpdateMode"><option value="daily">{{t('freqDaily')}}</option><option value="everyNDays">{{t('freqNDays')}}</option><option value="everyNHours">{{t('freqNHours')}}</option></select></label>
<label v-if="settings.autoUpdateMode!=='daily'">{{t('intervalN')}}<input v-model.number="settings.autoUpdateInterval" type="number" min="1" max="720" class="interval-input"/></label>
<label v-if="settings.autoUpdateMode!=='everyNHours'">{{t('triggerTime')}}<input v-model="settings.autoUpdateTime" type="time" class="interval-input"/></label>
<p class="auto-update-hint">{{t('autoUpdateHint')}}</p>
</template>
</section></template>
<template v-else-if="tab==='ai'">
<section class="panel form-panel"><h2><Sparkles/>{{t('aiProviderTitle')}}</h2>
<label>{{t('aiCurrentProvider')}}<select v-model="settings.aiProvider"><option value="spark">{{t('aiSparkOption')}}</option><option value="deepseek">DeepSeek</option></select></label>
<label>{{t('sparkKeyLabel')}}<input v-model="settings.sparkKey" type="password" :placeholder="t('sparkKeyPh')"/></label>
<label>{{t('deepseekKeyLabel')}}<input v-model="settings.deepSeekKey" type="password" :placeholder="t('deepseekKeyPh')"/></label>
<p class="auto-update-hint">{{t('aiKeyHint')}}</p>
<label>{{t('syncApiKeysLabel')}}<select v-model="settings.syncApiKeys"><option :value="false">{{t('syncApiKeysOff')}}</option><option :value="true">{{t('syncApiKeysOn')}}</option></select></label>
<p class="auto-update-hint">{{t('syncApiKeysHint')}}</p>
</section>
</template>
<template v-else-if="tab==='filestorage'">
<section class="panel form-panel">
<h2><CloudUpload/>{{t('fsTitle')}}</h2>
<template v-if="isAdmin">
<label>{{t('fsMode')}}<select v-model="fsCfg.mode"><option value="local">{{t('fsModeLocal')}}</option><option value="server">{{t('fsModeServer')}}</option></select></label>
<template v-if="fsCfg.mode==='server'">
<label>{{t('fsBaseUrl')}}<input v-model.trim="fsCfg.baseUrl" placeholder="http://192.168.1.10:8788"/></label>
<label>{{t('fsApiKey')}}<input v-model.trim="fsCfg.apiKey" type="password" :placeholder="t('fsApiKeyPh')"/></label>
</template>
<p class="auto-update-hint">{{t('fsPageHint')}}</p>
<div class="fs-page-actions">
<button v-if="fsCfg.mode==='server'" class="btn secondary" :disabled="!!fsBusy||!fsLoaded" @click="testFileStorage"><Wifi/>{{fsBusy==='test'?t('fsTesting'):t('fsTestBtn')}}</button>
<button class="btn primary" :disabled="!!fsBusy||!fsLoaded" @click="saveFileStorage"><CheckCircle2/>{{fsBusy==='save'?t('saving'):t('fsSaveBtn')}}</button>
</div>
</template>
<div v-else class="preview-notice"><Info/><div><b>{{t('fsAdminOnlyTitle')}}</b><small>{{t('fsAdminOnlyDesc')}}</small></div></div>
</section>
</template>
<template v-else><section class="panel database-panel"><div class="db-title"><h2><Database/>{{t('dbLocation')}}</h2><span class="db-connected"><CheckCircle2/>{{t('dbConnected')}}</span></div><div v-if="!native" class="preview-notice"><Info/><div><b>{{t('previewModeTitle')}}</b><small>{{t('previewModeDb')}}</small></div></div><div class="db-path"><span>{{t('dbCurrentLoc')}}</span><code :title="settings.databasePath">{{settings.databasePath||t('dbNoPath')}}</code><button :title="t('copyPathTitle')" :disabled="!settings.databasePath" @click="copyPath"><Copy/></button></div><p v-if="dbMessage" class="db-message">{{dbMessage}}</p><button class="btn secondary migrate" :disabled="!native" @click="migrate"><Upload/>{{t('migrateBtn')}}</button></section>
<section class="panel danger-zone"><h2><Trash2/>{{t('dangerZone')}}</h2><p>{{t('dangerDesc')}}</p><div><button @click="clear('stats')"><BarChart3/><span><b>{{t('clearStats')}}</b><small>{{t('clearStatsDesc')}}</small></span></button><button @click="clear('project')"><Folder/><span><b>{{t('clearProject')}}</b><small>{{t('clearProjectDesc')}}</small></span></button><button class="danger" @click="clear('all')"><Trash2/><span><b>{{t('clearAllData')}}</b><small>{{t('clearAllDesc')}}</small></span></button></div></section></template>
<AIScopeDrawer v-if="aiOpen" kind="config" :title="t('settings')" @close="aiOpen=false"/>
</div></template>

View File

@@ -1,84 +0,0 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {model} from '../models';
import {main} from '../models';
export function AddRule(arg1:string,arg2:string):Promise<model.ExclusionRule>;
export function CancelAnalysis(arg1:string):Promise<void>;
export function CheckoutBranch(arg1:number,arg2:string):Promise<model.CheckoutResult>;
export function ClearData(arg1:string,arg2:number):Promise<void>;
export function ClearLogs():Promise<void>;
export function DeleteProject(arg1:number):Promise<void>;
export function DeleteProjectGroup(arg1:number):Promise<void>;
export function DeleteRule(arg1:number):Promise<void>;
export function GetBootstrapStatus():Promise<main.BootstrapStatus>;
export function GetCommitDetails(arg1:number,arg2:string):Promise<model.GitCommitDetail>;
export function GetDashboard():Promise<model.Dashboard>;
export function GetDashboardByGroup(arg1:number):Promise<model.Dashboard>;
export function GetGitDiagnostics(arg1:number):Promise<model.GitDiagnostics>;
export function GetGitStats(arg1:number):Promise<model.GitStats>;
export function GetGitStatsForRef(arg1:number,arg2:string):Promise<model.GitStats>;
export function GetLogs(arg1:string):Promise<Array<model.LogEntry>>;
export function GetProject(arg1:number):Promise<model.Project>;
export function GetProjectInsights(arg1:number):Promise<model.ProjectInsights>;
export function GetRules():Promise<Array<model.ExclusionRule>>;
export function GetSettings():Promise<model.AppSettings>;
export function GetStructure(arg1:number):Promise<model.StructureStats>;
export function InitializeDatabase(arg1:string):Promise<main.BootstrapStatus>;
export function ListProjectGroups():Promise<Array<model.ProjectGroup>>;
export function ListProjects():Promise<Array<model.Project>>;
export function ListProjectsByGroup(arg1:number):Promise<Array<model.Project>>;
export function ListWSLDistros():Promise<Array<string>>;
export function MigrateDatabase(arg1:string):Promise<void>;
export function RefreshProjectInsights(arg1:number):Promise<model.ProjectInsights>;
export function ReportClientError(arg1:string,arg2:string,arg3:string):Promise<void>;
export function RetryDatabase():Promise<main.BootstrapStatus>;
export function SaveProject(arg1:number,arg2:model.ProjectInput):Promise<model.Project>;
export function SaveProjectGroup(arg1:number,arg2:string):Promise<model.ProjectGroup>;
export function SaveSettings(arg1:model.AppSettings):Promise<void>;
export function SelectDatabaseFile():Promise<string>;
export function SelectDirectory():Promise<string>;
export function SelectInitialDatabaseFile(arg1:string):Promise<string>;
export function SelectWSLDirectory(arg1:string):Promise<string>;
export function StartAnalysis(arg1:number,arg2:string):Promise<string>;
export function StartBatchAnalysis():Promise<Array<string>>;
export function StartBatchAnalysisByGroup(arg1:number):Promise<Array<string>>;

View File

@@ -1,163 +0,0 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AddRule(arg1, arg2) {
return window['go']['main']['App']['AddRule'](arg1, arg2);
}
export function CancelAnalysis(arg1) {
return window['go']['main']['App']['CancelAnalysis'](arg1);
}
export function CheckoutBranch(arg1, arg2) {
return window['go']['main']['App']['CheckoutBranch'](arg1, arg2);
}
export function ClearData(arg1, arg2) {
return window['go']['main']['App']['ClearData'](arg1, arg2);
}
export function ClearLogs() {
return window['go']['main']['App']['ClearLogs']();
}
export function DeleteProject(arg1) {
return window['go']['main']['App']['DeleteProject'](arg1);
}
export function DeleteProjectGroup(arg1) {
return window['go']['main']['App']['DeleteProjectGroup'](arg1);
}
export function DeleteRule(arg1) {
return window['go']['main']['App']['DeleteRule'](arg1);
}
export function GetBootstrapStatus() {
return window['go']['main']['App']['GetBootstrapStatus']();
}
export function GetCommitDetails(arg1, arg2) {
return window['go']['main']['App']['GetCommitDetails'](arg1, arg2);
}
export function GetDashboard() {
return window['go']['main']['App']['GetDashboard']();
}
export function GetDashboardByGroup(arg1) {
return window['go']['main']['App']['GetDashboardByGroup'](arg1);
}
export function GetGitDiagnostics(arg1) {
return window['go']['main']['App']['GetGitDiagnostics'](arg1);
}
export function GetGitStats(arg1) {
return window['go']['main']['App']['GetGitStats'](arg1);
}
export function GetGitStatsForRef(arg1, arg2) {
return window['go']['main']['App']['GetGitStatsForRef'](arg1, arg2);
}
export function GetLogs(arg1) {
return window['go']['main']['App']['GetLogs'](arg1);
}
export function GetProject(arg1) {
return window['go']['main']['App']['GetProject'](arg1);
}
export function GetProjectInsights(arg1) {
return window['go']['main']['App']['GetProjectInsights'](arg1);
}
export function GetRules() {
return window['go']['main']['App']['GetRules']();
}
export function GetSettings() {
return window['go']['main']['App']['GetSettings']();
}
export function GetStructure(arg1) {
return window['go']['main']['App']['GetStructure'](arg1);
}
export function InitializeDatabase(arg1) {
return window['go']['main']['App']['InitializeDatabase'](arg1);
}
export function ListProjectGroups() {
return window['go']['main']['App']['ListProjectGroups']();
}
export function ListProjects() {
return window['go']['main']['App']['ListProjects']();
}
export function ListProjectsByGroup(arg1) {
return window['go']['main']['App']['ListProjectsByGroup'](arg1);
}
export function ListWSLDistros() {
return window['go']['main']['App']['ListWSLDistros']();
}
export function MigrateDatabase(arg1) {
return window['go']['main']['App']['MigrateDatabase'](arg1);
}
export function RefreshProjectInsights(arg1) {
return window['go']['main']['App']['RefreshProjectInsights'](arg1);
}
export function ReportClientError(arg1, arg2, arg3) {
return window['go']['main']['App']['ReportClientError'](arg1, arg2, arg3);
}
export function RetryDatabase() {
return window['go']['main']['App']['RetryDatabase']();
}
export function SaveProject(arg1, arg2) {
return window['go']['main']['App']['SaveProject'](arg1, arg2);
}
export function SaveProjectGroup(arg1, arg2) {
return window['go']['main']['App']['SaveProjectGroup'](arg1, arg2);
}
export function SaveSettings(arg1) {
return window['go']['main']['App']['SaveSettings'](arg1);
}
export function SelectDatabaseFile() {
return window['go']['main']['App']['SelectDatabaseFile']();
}
export function SelectDirectory() {
return window['go']['main']['App']['SelectDirectory']();
}
export function SelectInitialDatabaseFile(arg1) {
return window['go']['main']['App']['SelectInitialDatabaseFile'](arg1);
}
export function SelectWSLDirectory(arg1) {
return window['go']['main']['App']['SelectWSLDirectory'](arg1);
}
export function StartAnalysis(arg1, arg2) {
return window['go']['main']['App']['StartAnalysis'](arg1, arg2);
}
export function StartBatchAnalysis() {
return window['go']['main']['App']['StartBatchAnalysis']();
}
export function StartBatchAnalysisByGroup(arg1) {
return window['go']['main']['App']['StartBatchAnalysisByGroup'](arg1);
}

View File

@@ -1,663 +0,0 @@
export namespace main {
export class BootstrapStatus {
state: string;
databasePath: string;
defaultPath: string;
errorCode?: string;
errorDetail?: string;
static createFrom(source: any = {}) {
return new BootstrapStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.state = source["state"];
this.databasePath = source["databasePath"];
this.defaultPath = source["defaultPath"];
this.errorCode = source["errorCode"];
this.errorDetail = source["errorDetail"];
}
}
}
export namespace model {
export class AppSettings {
theme: string;
locale: string;
gitScope: string;
databasePath: string;
autoRefresh: boolean;
glassOpacity: number;
loadingStyle: string;
static createFrom(source: any = {}) {
return new AppSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.theme = source["theme"];
this.locale = source["locale"];
this.gitScope = source["gitScope"];
this.databasePath = source["databasePath"];
this.autoRefresh = source["autoRefresh"];
this.glassOpacity = source["glassOpacity"];
this.loadingStyle = source["loadingStyle"];
}
}
export class CheckoutResult {
branch: string;
message: string;
static createFrom(source: any = {}) {
return new CheckoutResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.branch = source["branch"];
this.message = source["message"];
}
}
export class Contributor {
name: string;
email: string;
commits: number;
added: number;
deleted: number;
static createFrom(source: any = {}) {
return new Contributor(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.email = source["email"];
this.commits = source["commits"];
this.added = source["added"];
this.deleted = source["deleted"];
}
}
export class Dashboard {
projects: number;
totalLines: number;
commits: number;
static createFrom(source: any = {}) {
return new Dashboard(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.projects = source["projects"];
this.totalLines = source["totalLines"];
this.commits = source["commits"];
}
}
export class ExclusionRule {
id: number;
pattern: string;
category: string;
builtin: boolean;
static createFrom(source: any = {}) {
return new ExclusionRule(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.pattern = source["pattern"];
this.category = source["category"];
this.builtin = source["builtin"];
}
}
export class ExtensionStat {
extension: string;
files: number;
size: number;
static createFrom(source: any = {}) {
return new ExtensionStat(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.extension = source["extension"];
this.files = source["files"];
this.size = source["size"];
}
}
export class FileEntry {
path: string;
name: string;
extension: string;
size: number;
isDir: boolean;
parent: string;
static createFrom(source: any = {}) {
return new FileEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
this.name = source["name"];
this.extension = source["extension"];
this.size = source["size"];
this.isDir = source["isDir"];
this.parent = source["parent"];
}
}
export class FolderStat {
name: string;
files: number;
size: number;
static createFrom(source: any = {}) {
return new FolderStat(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.files = source["files"];
this.size = source["size"];
}
}
export class GitCommit {
hash: string;
author: string;
email: string;
message: string;
date: string;
added: number;
deleted: number;
static createFrom(source: any = {}) {
return new GitCommit(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.hash = source["hash"];
this.author = source["author"];
this.email = source["email"];
this.message = source["message"];
this.date = source["date"];
this.added = source["added"];
this.deleted = source["deleted"];
}
}
export class GitFileChange {
path: string;
status: string;
added: number;
deleted: number;
static createFrom(source: any = {}) {
return new GitFileChange(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
this.status = source["status"];
this.added = source["added"];
this.deleted = source["deleted"];
}
}
export class GitCommitDetail {
hash: string;
author: string;
email: string;
message: string;
date: string;
added: number;
deleted: number;
files: GitFileChange[];
static createFrom(source: any = {}) {
return new GitCommitDetail(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.hash = source["hash"];
this.author = source["author"];
this.email = source["email"];
this.message = source["message"];
this.date = source["date"];
this.added = source["added"];
this.deleted = source["deleted"];
this.files = this.convertValues(source["files"], GitFileChange);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class GitDiagnostics {
available: boolean;
errorCode?: string;
detail?: string;
isWsl: boolean;
distro?: string;
linuxPath?: string;
workspaceBranch?: string;
viewRef?: string;
refCount: number;
static createFrom(source: any = {}) {
return new GitDiagnostics(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"];
this.errorCode = source["errorCode"];
this.detail = source["detail"];
this.isWsl = source["isWsl"];
this.distro = source["distro"];
this.linuxPath = source["linuxPath"];
this.workspaceBranch = source["workspaceBranch"];
this.viewRef = source["viewRef"];
this.refCount = source["refCount"];
}
}
export class GitRef {
name: string;
hash: string;
kind: string;
current: boolean;
static createFrom(source: any = {}) {
return new GitRef(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.hash = source["hash"];
this.kind = source["kind"];
this.current = source["current"];
}
}
export class HeatDay {
date: string;
count: number;
static createFrom(source: any = {}) {
return new HeatDay(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.date = source["date"];
this.count = source["count"];
}
}
export class GitStats {
available: boolean;
error?: string;
currentBranch: string;
workspaceBranch: string;
viewRef: string;
commitCount: number;
added: number;
deleted: number;
contributorCount: number;
commits: GitCommit[];
refs: GitRef[];
contributors: Contributor[];
heatmap: HeatDay[];
static createFrom(source: any = {}) {
return new GitStats(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"];
this.error = source["error"];
this.currentBranch = source["currentBranch"];
this.workspaceBranch = source["workspaceBranch"];
this.viewRef = source["viewRef"];
this.commitCount = source["commitCount"];
this.added = source["added"];
this.deleted = source["deleted"];
this.contributorCount = source["contributorCount"];
this.commits = this.convertValues(source["commits"], GitCommit);
this.refs = this.convertValues(source["refs"], GitRef);
this.contributors = this.convertValues(source["contributors"], Contributor);
this.heatmap = this.convertValues(source["heatmap"], HeatDay);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class InsightIssue {
severity: string;
type: string;
title: string;
detail: string;
path?: string;
line?: number;
suggestion: string;
evidence?: string;
static createFrom(source: any = {}) {
return new InsightIssue(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.severity = source["severity"];
this.type = source["type"];
this.title = source["title"];
this.detail = source["detail"];
this.path = source["path"];
this.line = source["line"];
this.suggestion = source["suggestion"];
this.evidence = source["evidence"];
}
}
export class InsightSummary {
high: number;
medium: number;
low: number;
todoCount: number;
longFiles: number;
largeFiles: number;
static createFrom(source: any = {}) {
return new InsightSummary(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.high = source["high"];
this.medium = source["medium"];
this.low = source["low"];
this.todoCount = source["todoCount"];
this.longFiles = source["longFiles"];
this.largeFiles = source["largeFiles"];
}
}
export class LanguageStat {
name: string;
files: number;
code: number;
comments: number;
blanks: number;
static createFrom(source: any = {}) {
return new LanguageStat(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.files = source["files"];
this.code = source["code"];
this.comments = source["comments"];
this.blanks = source["blanks"];
}
}
export class LogEntry {
id: number;
level: string;
category: string;
message: string;
detail: string;
createdAt: string;
static createFrom(source: any = {}) {
return new LogEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.level = source["level"];
this.category = source["category"];
this.message = source["message"];
this.detail = source["detail"];
this.createdAt = source["createdAt"];
}
}
export class ProjectStats {
totalLines: number;
codeLines: number;
commentLines: number;
blankLines: number;
fileCount: number;
commitCount: number;
addedLines: number;
deletedLines: number;
contributorCount: number;
lastAnalyzed: string;
static createFrom(source: any = {}) {
return new ProjectStats(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.totalLines = source["totalLines"];
this.codeLines = source["codeLines"];
this.commentLines = source["commentLines"];
this.blankLines = source["blankLines"];
this.fileCount = source["fileCount"];
this.commitCount = source["commitCount"];
this.addedLines = source["addedLines"];
this.deletedLines = source["deletedLines"];
this.contributorCount = source["contributorCount"];
this.lastAnalyzed = source["lastAnalyzed"];
}
}
export class Project {
id: number;
name: string;
path: string;
description: string;
groupId: number;
groupName: string;
createdAt: string;
updatedAt: string;
stats: ProjectStats;
languages: LanguageStat[];
static createFrom(source: any = {}) {
return new Project(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.path = source["path"];
this.description = source["description"];
this.groupId = source["groupId"];
this.groupName = source["groupName"];
this.createdAt = source["createdAt"];
this.updatedAt = source["updatedAt"];
this.stats = this.convertValues(source["stats"], ProjectStats);
this.languages = this.convertValues(source["languages"], LanguageStat);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ProjectGroup {
id: number;
name: string;
createdAt: string;
updatedAt: string;
static createFrom(source: any = {}) {
return new ProjectGroup(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.createdAt = source["createdAt"];
this.updatedAt = source["updatedAt"];
}
}
export class ProjectInput {
name: string;
path: string;
description: string;
groupId: number;
static createFrom(source: any = {}) {
return new ProjectInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.path = source["path"];
this.description = source["description"];
this.groupId = source["groupId"];
}
}
export class ProjectInsights {
projectId: number;
healthScore: number;
generatedAt: string;
summary: InsightSummary;
issues: InsightIssue[];
static createFrom(source: any = {}) {
return new ProjectInsights(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.projectId = source["projectId"];
this.healthScore = source["healthScore"];
this.generatedAt = source["generatedAt"];
this.summary = this.convertValues(source["summary"], InsightSummary);
this.issues = this.convertValues(source["issues"], InsightIssue);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class StructureStats {
files: FileEntry[];
totalFiles: number;
totalDirs: number;
totalSize: number;
largeFiles: FileEntry[];
folders: FolderStat[];
extensions: ExtensionStat[];
static createFrom(source: any = {}) {
return new StructureStats(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.files = this.convertValues(source["files"], FileEntry);
this.totalFiles = source["totalFiles"];
this.totalDirs = source["totalDirs"];
this.totalSize = source["totalSize"];
this.largeFiles = this.convertValues(source["largeFiles"], FileEntry);
this.folders = this.convertValues(source["folders"], FolderStat);
this.extensions = this.convertValues(source["extensions"], ExtensionStat);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}

View File

@@ -1,24 +0,0 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}

View File

@@ -1,330 +0,0 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View File

@@ -1,298 +0,0 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}