初始化

This commit is contained in:
李琦
2026-08-11 19:07:05 +08:00
commit d2aeb13a09
94 changed files with 8704 additions and 0 deletions

142
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,142 @@
<script setup>
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 { 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'
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const { t, locale } = useI18n()
const native = isNative()
const quickOpen = ref(false)
const displayedTask = ref(null)
let off
let loadingHoldTimer
const activeTask = computed(() => Object.values(store.tasks).find(x => !['completed', 'error', 'cancelled'].includes(x.stage)))
const visibleTask = computed(() => activeTask.value || displayedTask.value)
const activeTaskProject = computed(() => visibleTask.value?.params?.project || store.projects.find(p => p.id === visibleTask.value?.projectId)?.name || '')
const loadingStyle = computed(() => store.settings.loadingStyle === 'fullscreen' ? 'fullscreen-orbit' : (store.settings.loadingStyle || 'fullscreen-orbit'))
const useFullscreenLoading = computed(() => loadingStyle.value !== 'bar')
async function updateSetting(key, value) {
const next = await store.saveSettings({ [key]: value })
if (key === 'locale') locale.value = next.locale
}
function openSettings() {
quickOpen.value = false
router.push('/settings')
}
onMounted(async () => {
if (!native) return
off = store.listen()
await store.boot()
locale.value = store.settings.locale || 'zh-CN'
})
onUnmounted(() => {
clearTimeout(loadingHoldTimer)
off?.()
})
watch(() => store.settings.locale, v => {
if (v) locale.value = v
})
watch(activeTask, task => {
clearTimeout(loadingHoldTimer)
if (task) {
displayedTask.value = task
return
}
loadingHoldTimer = setTimeout(() => {
displayedTask.value = null
}, 900)
}, { immediate: true })
</script>
<template>
<BrowserBlocked v-if="!native" />
<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">
<svg viewBox="0 0 48 48" role="img">
<defs>
<linearGradient id="logoGlow" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#6ee7ff" />
<stop offset="48%" stop-color="#8b5cf6" />
<stop offset="100%" stop-color="#34d399" />
</linearGradient>
</defs>
<rect class="logo-frame" x="6" y="6" width="36" height="36" rx="9" />
<path class="logo-track" d="M17 18l-6 6 6 6M31 18l6 6-6 6M27 14l-6 20" />
<path class="logo-spark" d="M12 10h8M28 38h8" />
</svg>
</span>
<b>{{ t('app') }}</b>
</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">
<header>
<b>{{ t('quickSettings') }}</b>
<button @click="quickOpen = false" aria-label="Close"><X /></button>
</header>
<label>
<span>{{ t('language') }}</span>
<select :value="store.settings.locale" @change="updateSetting('locale', $event.target.value)">
<option value="zh-CN">中文</option>
<option value="en">English</option>
</select>
</label>
<label>
<span>{{ t('theme') }}</span>
<select :value="store.settings.theme" @change="updateSetting('theme', $event.target.value)">
<option value="dark">{{ t('themeDark') }}</option>
<option value="light">{{ t('themeLight') }}</option>
<option value="system">{{ t('themeSystem') }}</option>
</select>
</label>
<label>
<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>
</div>
</aside>
<main><RouterView /></main>
<div v-if="visibleTask && !useFullscreenLoading" class="taskbar">
<div><b>{{ activeTaskProject || visibleTask.stage }}</b><span>{{ t(visibleTask.messageKey || 'task.start', visibleTask.params || {}) }}</span></div>
<div class="progress"><i :style="{ width: visibleTask.progress + '%' }" /></div>
<b>{{ visibleTask.progress }}%</b>
</div>
<div v-if="visibleTask && useFullscreenLoading" class="analysis-loading" :class="[loadingStyle, { holding: !activeTask }]">
<AnalysisCanvas :progress="visibleTask.progress" :variant="loadingStyle" />
<section>
<b>{{ activeTaskProject || t('analyzingProject') }}</b>
<span>{{ t(visibleTask.messageKey || 'task.start', visibleTask.params || {}) }}</span>
<div class="progress"><i :style="{ width: visibleTask.progress + '%' }" /></div>
<strong>{{ visibleTask.progress }}%</strong>
</section>
</div>
<div v-if="store.toast" class="toast" :class="[store.toast.type, { muted: store.toast.muted, leaving: store.toast.leaving }]">
{{ store.toast.key ? t(store.toast.key, store.toast.params || {}) : store.toast.text }}
<button @click="store.closeToast()" aria-label="Close"><X /></button>
</div>
</div>
<div v-else class="boot-loading"><Database class="spin" />正在检查数据库...</div>
</template>

12
frontend/src/api.js Normal file
View File

@@ -0,0 +1,12 @@
export const isNative=()=>Boolean(window.go?.main?.App)
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}`)
}
export function on(name,cb){
if(window.runtime?.EventsOn)return window.runtime.EventsOn(name,cb)
return()=>{}
}

View File

@@ -0,0 +1,93 @@
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View File

@@ -0,0 +1,355 @@
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps({
progress: { type: Number, default: 0 },
variant: { type: String, default: 'fullscreen-orbit' }
})
const canvas = ref(null)
let frame = 0
let ctx
let dpr = 1
let width = 0
let height = 0
let reduceMotion = false
function resize() {
const el = canvas.value
if (!el) return
dpr = Math.min(window.devicePixelRatio || 1, 2)
const box = el.getBoundingClientRect()
width = Math.max(1, Math.floor(box.width))
height = Math.max(1, Math.floor(box.height))
el.width = Math.floor(width * dpr)
el.height = Math.floor(height * dpr)
ctx = el.getContext('2d')
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
function draw(now = 0) {
if (!ctx) return
const t = now * 0.001
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)
else drawOrbit(t, p, cx, cy)
if (!reduceMotion) frame = requestAnimationFrame(draw)
}
function drawOrbit(t, p, cx, cy) {
ctx.save()
ctx.translate(cx, cy)
const base = Math.min(width, height) * 0.24
for (let ring = 0; ring < 3; ring++) {
const radius = base + ring * 22
const phase = reduceMotion ? ring * 1.7 : t * (0.55 + ring * 0.12) + ring * 1.7
ctx.beginPath()
ctx.arc(0, 0, radius, phase, phase + Math.PI * (1.2 + p * 0.7))
ctx.strokeStyle = ring === 1 ? 'rgba(83,214,162,.7)' : 'rgba(125,113,255,.72)'
ctx.lineWidth = ring === 0 ? 4 : 2
ctx.shadowColor = ring === 1 ? '#53d6a2' : '#7d71ff'
ctx.shadowBlur = 16
ctx.stroke()
}
const nodes = 34
for (let i = 0; i < nodes; i++) {
const a = (Math.PI * 2 * i) / nodes + (reduceMotion ? 0 : t * 0.45)
const pulse = Math.sin(t * 2 + i) * 0.5 + 0.5
const r = base + 58 + Math.sin(i * 1.9 + t) * 8
const x = Math.cos(a) * r
const y = Math.sin(a) * r
ctx.beginPath()
ctx.arc(x, y, 1.5 + pulse * 2.2, 0, Math.PI * 2)
ctx.fillStyle = i / nodes <= p ? 'rgba(83,214,162,.95)' : 'rgba(145,136,255,.38)'
ctx.shadowColor = i / nodes <= p ? '#53d6a2' : '#9188ff'
ctx.shadowBlur = 12
ctx.fill()
}
const beamAngle = reduceMotion ? -0.8 : t * 1.2
const beam = ctx.createLinearGradient(-base, 0, base, 0)
beam.addColorStop(0, 'rgba(110,231,255,0)')
beam.addColorStop(.5, 'rgba(110,231,255,.52)')
beam.addColorStop(1, 'rgba(110,231,255,0)')
ctx.rotate(beamAngle)
ctx.fillStyle = beam
ctx.fillRect(-base * 1.6, -8, base * 3.2, 16)
ctx.restore()
ctx.save()
ctx.translate(cx, cy)
ctx.beginPath()
ctx.arc(0, 0, base * 0.72, 0, Math.PI * 2)
const core = ctx.createRadialGradient(0, 0, 0, 0, 0, base * 0.75)
core.addColorStop(0, 'rgba(255,255,255,.96)')
core.addColorStop(.25, 'rgba(110,231,255,.9)')
core.addColorStop(.6, 'rgba(115,103,245,.42)')
core.addColorStop(1, 'rgba(115,103,245,0)')
ctx.fillStyle = core
ctx.shadowColor = '#6ee7ff'
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()
}
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()
}
ctx.restore()
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()
}
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)
}
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()
}
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()
}
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.fillStyle = core
ctx.shadowColor = '#6ee7ff'
ctx.shadowBlur = 28
ctx.beginPath()
ctx.arc(0, 0, base * 0.8, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
function drawWarp(t, p, cx, cy) {
const rays = 144
const maxR = Math.hypot(width, height) * 0.58
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)
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.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)')
core.addColorStop(1, 'rgba(124,108,255,0)')
ctx.fillStyle = core
ctx.beginPath()
ctx.arc(0, 0, Math.min(width, height) * 0.18, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
onMounted(() => {
reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches || false
resize()
window.addEventListener('resize', resize)
draw()
})
onUnmounted(() => {
cancelAnimationFrame(frame)
window.removeEventListener('resize', resize)
})
watch(() => props.progress, () => {
if (reduceMotion) draw()
})
</script>
<template>
<canvas ref="canvas" class="analysis-canvas" aria-hidden="true" />
</template>

View File

@@ -0,0 +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>

View File

@@ -0,0 +1,6 @@
<script setup>
import { onBeforeUnmount,onMounted,ref,watch } from 'vue';import * as echarts from 'echarts'
const props=defineProps({option:{type:Object,required:true}}),emit=defineEmits(['click','datazoom']),el=ref(), chart=ref();let ro
onMounted(()=>{chart.value=echarts.init(el.value);chart.value.setOption(props.option);chart.value.on('click',e=>emit('click',e));chart.value.on('datazoom',e=>emit('datazoom',e));ro=new ResizeObserver(()=>chart.value?.resize());ro.observe(el.value)})
watch(()=>props.option,v=>chart.value?.setOption(v,true),{deep:true});onBeforeUnmount(()=>{ro?.disconnect();chart.value?.dispose()})
</script><template><div ref="el" class="chart"/></template>

View File

@@ -0,0 +1,4 @@
<script setup>
import{Copy,X,FileCode2}from'lucide-vue-next';import{useI18n}from'vue-i18n'
defineProps({detail:Object,loading:Boolean});const emit=defineEmits(['close']),{t}=useI18n();async function copy(v){await navigator.clipboard.writeText(v)}
</script><template><div class="drawer-mask" @click.self="emit('close')"><aside class="commit-drawer"><header><div><small>{{t('commitDetail')}}</small><h2>{{detail?.message||'...'}}</h2></div><button @click="emit('close')"><X/></button></header><div v-if="loading" class="empty">Loading...</div><template v-else-if="detail"><div class="commit-meta"><code>{{detail.hash}}</code><button :title="t('copyHash')" @click="copy(detail.hash)"><Copy/></button><span>{{detail.author}} · {{detail.email}}</span><time>{{detail.date}}</time><b class="positive">+{{detail.added}}</b><b class="negative">-{{detail.deleted}}</b></div><h3>{{t('filesChanged')}} ({{detail.files?.length||0}})</h3><div class="change-file" v-for="f in detail.files" :key="f.path"><FileCode2/><span>{{f.path}}</span><small>{{f.status}}</small><b class="positive">+{{f.added}}</b><b class="negative">-{{f.deleted}}</b></div></template></aside></div></template>

View File

@@ -0,0 +1,10 @@
<script setup>
import{computed,ref}from'vue';import{Database,FolderOpen,RefreshCw,ShieldCheck,TriangleAlert}from'lucide-vue-next';import{call}from'../api';import{useAppStore}from'../store'
const props=defineProps({status:{type:Object,required:true}}),store=useAppStore(),path=ref(props.status.databasePath||props.status.defaultPath||''),busy=ref(false),error=ref('')
const recovery=computed(()=>props.status.state==='recovery_required')
const messages={BOOTSTRAP_INVALID:'数据库位置配置已损坏,请重新选择保存位置。',DB_OPEN_FAILED:'无法打开数据库,请检查文件权限或重新选择位置。',DB_FILE_MISSING:'原数据库文件不存在,请选择新位置创建数据库。',DB_FILE_UNREADABLE:'数据库文件不可读取,请重新选择位置。',DB_DIRECTORY_UNWRITABLE:'该目录不可写,请选择其他位置。',DB_INTEGRITY_FAILED:'数据库文件已损坏,请选择新位置创建数据库。'}
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>
<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

@@ -0,0 +1,64 @@
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({ days: { type: Array, default: () => [] }, selected: String })
const emit = defineEmits(['select'])
const { locale } = useI18n()
const hover = ref(null)
const weekMs = 7 * 24 * 60 * 60 * 1000
const matrix = computed(() => {
const end = new Date()
end.setHours(0, 0, 0, 0)
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 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 labels = []
let lastMonth = -1
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 7)) {
const m = d.getMonth()
if (m !== lastMonth) {
labels.push({ week: Math.floor((d - start) / weekMs), text: d.toLocaleString(locale.value === 'zh-CN' ? 'zh-CN' : 'en', { month: 'short' }) })
lastMonth = m
}
}
return { cells, labels, weeks: Math.max(53, ...cells.map(x => x.week + 1)) }
})
function pick(cell) {
if (!cell.date) return
emit('select', cell.date === props.selected ? '' : cell.date)
}
</script>
<template>
<div class="heat-grid-wrap" :style="{ '--weeks': matrix.weeks }">
<div class="heat-months">
<span v-for="m in matrix.labels" :key="m.week + m.text" :style="{ gridColumnStart: m.week + 1 }">{{ m.text }}</span>
</div>
<div class="heat-body">
<div class="heat-weekdays"><span>S</span><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span></div>
<div class="heat-grid">
<button
v-for="cell in matrix.cells"
:key="cell.date"
class="heat-cell"
:class="['l' + cell.level, { selected: cell.date === props.selected }]"
:style="{ gridColumnStart: cell.week + 1, gridRowStart: cell.dow + 1 }"
@mouseenter="hover = cell"
@mouseleave="hover = null"
@click="pick(cell)"
/>
</div>
<div v-if="hover" class="heat-tooltip">{{ hover.date }} · {{ hover.count }} {{ locale === 'zh-CN' ? '次提交' : 'commits' }}</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,7 @@
<script setup>
import{computed,ref}from'vue';import{useI18n}from'vue-i18n';import ChartView from './ChartView.vue'
const props=defineProps({commits:{type:Array,default:()=>[]}}),emit=defineEmits(['select']),mode=ref('day'),{t}=useI18n()
function bucket(date){const d=new Date(date);if(mode.value==='month')return date.slice(0,7);if(mode.value==='week'){const x=new Date(d);x.setDate(d.getDate()-((d.getDay()+6)%7));return x.toISOString().slice(0,10)};return date.slice(0,10)}
const option=computed(()=>{const map={};props.commits.forEach(c=>{const k=bucket(c.date);map[k]=(map[k]||0)+1});const rows=Object.entries(map).sort();return{tooltip:{trigger:'axis'},grid:{left:34,right:18,top:18,bottom:48},dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:5}],xAxis:{type:'category',data:rows.map(x=>x[0]),axisLabel:{color:'#929bac'}},yAxis:{type:'value',axisLabel:{color:'#929bac'},splitLine:{lineStyle:{color:'rgba(146,155,172,.15)'}}},series:[{type:'line',smooth:true,data:rows.map(x=>x[1]),areaStyle:{color:'rgba(123,115,255,.15)'},lineStyle:{color:'#7b73ff'},symbolSize:7}]}})
</script><template><div class="git-trend"><div class="segments"><button v-for="m in ['day','week','month']" :key="m" :class="{active:mode===m}" @click="mode=m">{{t(m)}}</button></div><ChartView :option="option" @click="e=>emit('select',e.name)"/></div></template>

View File

@@ -0,0 +1,71 @@
<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

@@ -0,0 +1,10 @@
<script setup>
defineProps({ label: String, value: [String, Number], icon: Object, tone: { type: String, default: 'violet' } })
</script>
<template>
<section class="stat-card shine-card">
<span class="stat-icon" :class="tone"><component :is="icon" /></span>
<div class="stat-copy"><strong class="stat-value">{{ value ?? 0 }}</strong><small>{{ label }}</small></div>
</section>
</template>

View File

@@ -0,0 +1 @@
.db-title{display:flex;align-items:center;justify-content:space-between}.db-title h2{display:flex;align-items:center;gap:9px}.db-title h2 svg{width:20px;color:#9188ff}.db-connected{display:flex;align-items:center;gap:6px;color:var(--green);font-size:13px}.db-connected svg{width:16px}.preview-notice{display:flex;gap:12px;margin-top:18px;padding:14px;border:1px solid rgba(79,157,245,.3);background:rgba(79,157,245,.09);border-radius:7px;color:var(--blue)}.preview-notice>svg{width:20px;flex:none}.preview-notice b,.preview-notice small{display:block}.preview-notice small{color:var(--muted);margin-top:4px}.database-panel .db-path{display:grid;grid-template-columns:90px minmax(0,1fr) 38px;align-items:center;gap:10px;border:1px solid var(--glass-border);border-radius:7px;background:var(--glass-soft);padding:12px 12px 12px 16px}.db-path>span{font-weight:bold}.db-path code{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#9188ff}.db-path button{height:34px;border:0;border-radius:6px;background:rgba(123,115,255,.14);color:#9188ff;display:grid;place-items:center;cursor:pointer}.db-path button:disabled{opacity:.4;cursor:not-allowed}.db-path button svg{width:16px}.db-message{color:var(--green);font-size:13px}.migrate:disabled{opacity:.45;cursor:not-allowed}.database-panel{animation:card-enter .45s both}

4
frontend/src/git.css Normal file
View File

@@ -0,0 +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}
.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}}

354
frontend/src/main.js Normal file
View File

@@ -0,0 +1,354 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHashHistory } from 'vue-router'
import { createI18n } from 'vue-i18n'
import App from './App.vue'
import Dashboard from './views/Dashboard.vue'
import ProjectDetail from './views/ProjectDetail.vue'
import Logs from './views/Logs.vue'
import Settings from './views/Settings.vue'
import './style.css'
import './motion.css'
import './database.css'
import './runtime.css'
import './git.css'
import './polish.css'
const zh = {
app: 'Code Count',
dashboard: '仪表盘',
logs: '运行日志',
settings: '设置',
projects: '我的项目',
addProject: '添加项目',
batch: '批量统计',
analyzingProject: '正在统计项目',
search: '搜索项目...',
totalProjects: '项目总数',
totalLines: '代码总行数',
commits: 'Git 提交数',
code: '代码统计',
git: 'Git 分析',
structure: '项目结构',
insights: '检查',
analyze: '重新分析',
analyzeGit: '重新分析 Git',
empty: '暂无数据',
back: '返回',
refreshProject: '更新此项目',
edit: '编辑',
delete: '删除',
add: '新增',
save: '保存',
cancel: '取消',
browse: '浏览',
selectWSL: '选择 WSL 目录',
noWSL: '未发现可用的 WSL 发行版',
dashboardSubtitle: '管理和分析你的代码项目',
projectName: '项目名称',
projectPath: '项目路径',
description: '描述(可选)',
addProjectTitle: '添加项目',
editProjectTitle: '编辑项目',
projectGroup: '项目组',
allProjectGroups: '全部项目组',
myProjectGroup: '我的项目组',
addProjectGroup: '新增项目组',
editProjectGroup: '编辑项目组',
deleteProjectGroup: '删除项目组',
projectGroupName: '项目组名称',
saveProjectGroup: '保存项目组',
saveProject: '保存项目',
saving: '正在保存...',
pathRequired: '请选择项目目录',
unanalyzed: '尚未分析',
totalLineCount: '总行数',
codeLines: '代码行',
commentLines: '注释行',
blankLines: '空行',
fileCount: '文件数',
languageDistribution: '语言分布',
languageDetails: '语言详情',
files: '文件',
lines: '行',
workspaceBranch: '工作区分支',
viewRef: '统计视图',
switchView: '查看统计',
checkout: '切换工作区',
checkoutConfirm: '确认将工作区切换到分支 {ref}?未提交或未跟踪改动会导致操作被拒绝。',
dirty: '工作区存在未提交或未跟踪文件,已拒绝切换',
branchChanged: '工作区分支已切换',
commitDetail: '提交详情',
copyHash: '复制哈希',
filesChanged: '变更文件',
clearFilter: '清除筛选',
selectedDate: '已筛选 {date}',
day: '日',
week: '周',
month: '月',
commitCount: '总提交数',
addedLines: '新增行数',
deletedLines: '删除行数',
contributors: '贡献者数',
activityHeatmap: '活跃度热力图',
branches: '分支信息',
recentCommits: '最近提交',
commitTrend: '提交趋势',
contributorRanking: '贡献者排行',
commitsUnit: '提交',
gitUnavailable: 'Git 数据不可用',
gitUnavailableHint: '当前项目的 Git 信息读取失败,代码统计不会受影响。',
gitDetail: '诊断详情',
totalFiles: '总文件数',
folderCount: '文件夹数',
totalSize: '总大小',
largeFiles: '大文件数',
directoryStructure: '目录结构',
folderSize: '文件夹大小',
largeFileDetection: '大文件检测(≥ 1 MB',
noLargeFiles: '未发现大文件',
deepInsights: '深度检查',
deepInsightsHint: '基于代码结构、源码标记和 Git 活跃度识别维护风险。',
healthScore: '健康分',
highRisk: '高风险',
mediumRisk: '中风险',
lowRisk: '低风险',
issueList: '问题列表',
allSeverity: '全部级别',
allTypes: '全部类型',
noIssues: '暂无风险项',
insightDone: '深度检查完成',
severity: { high: '高风险', medium: '中风险', low: '低风险' },
quickSettings: '快捷设置',
language: '语言',
theme: '主题',
themeDark: '暗色',
themeLight: '浅色',
themeSystem: '跟随系统',
glassOpacity: '卡片透明度',
loadingStyle: '统计 Loading 样式',
openSettings: '打开设置页',
logSubtitle: '查看应用运行状态和错误信息',
autoRefresh: '自动刷新',
refresh: '刷新',
clearLogs: '清空日志',
allLogs: '全部日志',
runLogs: '运行日志',
errorLogs: '错误日志',
totalLogs: '总日志',
info: '信息',
warning: '警告',
error: '错误',
noLogs: '暂无日志记录',
errors: {
PROJECT_PATH_NOT_FOUND: '项目目录不存在',
PROJECT_PATH_UNREADABLE: '项目目录不可读取',
PROJECT_PATH_NOT_DIRECTORY: '选择的路径不是目录',
PROJECT_PATH_DUPLICATE: '该项目目录已经添加',
PROJECT_GROUP_NOT_FOUND: '项目组不存在',
PROJECT_GROUP_NAME_REQUIRED: '请输入项目组名称',
PROJECT_GROUP_DEFAULT_READONLY: '默认项目组不能修改或删除',
PROJECT_GROUP_DUPLICATE: '项目组名称已存在',
DATABASE_NOT_READY: '数据库尚未初始化',
PROJECT_SAVE_FAILED: '项目保存失败',
NOT_GIT_REPOSITORY: '该目录不是 Git 仓库',
GIT_NOT_INSTALLED: '系统未安装 Git',
WSL_NOT_INSTALLED: '系统未安装 WSL',
WSL_DISTRO_NOT_FOUND: '未找到对应 WSL 发行版',
WSL_GIT_NOT_INSTALLED: 'WSL 发行版内未安装 Git',
WSL_PATH_UNREADABLE: 'WSL 路径不可访问',
GIT_PERMISSION_DENIED: 'Git 没有权限读取该仓库',
GIT_SAFE_DIRECTORY: 'Git 拒绝读取该仓库,请检查 safe.directory 配置',
GIT_REF_NOT_FOUND: '未找到该分支或引用',
GIT_COMMAND_FAILED: 'Git 命令执行失败'
},
task: {
start: '开始分析 {project}',
scan: '正在扫描文件',
count: '正在统计代码',
save: '正在保存统计结果',
git: '正在读取 Git 历史',
completed: '分析完成',
failed: '分析失败',
cancelled: '分析已取消'
}
}
const en = {
app: 'Code Count',
dashboard: 'Dashboard',
logs: 'Activity Log',
settings: 'Settings',
projects: 'Projects',
addProject: 'Add project',
batch: 'Analyze all',
analyzingProject: 'Analyzing project',
search: 'Search projects...',
totalProjects: 'Projects',
totalLines: 'Total lines',
commits: 'Git commits',
code: 'Code stats',
git: 'Git analysis',
structure: 'Structure',
insights: 'Check',
analyze: 'Analyze again',
analyzeGit: 'Analyze Git again',
empty: 'No data',
back: 'Back',
refreshProject: 'Update this project',
edit: 'Edit',
delete: 'Delete',
add: 'Add',
save: 'Save',
cancel: 'Cancel',
browse: 'Browse',
selectWSL: 'Select WSL directory',
noWSL: 'No WSL distribution found',
dashboardSubtitle: 'Manage and analyze your code projects',
projectName: 'Project name',
projectPath: 'Project path',
description: 'Description (optional)',
addProjectTitle: 'Add project',
editProjectTitle: 'Edit project',
projectGroup: 'Project group',
allProjectGroups: 'All project groups',
myProjectGroup: 'My project group',
addProjectGroup: 'Add project group',
editProjectGroup: 'Edit project group',
deleteProjectGroup: 'Delete project group',
projectGroupName: 'Project group name',
saveProjectGroup: 'Save project group',
saveProject: 'Save project',
saving: 'Saving...',
pathRequired: 'Please select a project directory',
unanalyzed: 'Not analyzed yet',
totalLineCount: 'Total lines',
codeLines: 'Code lines',
commentLines: 'Comment lines',
blankLines: 'Blank lines',
fileCount: 'Files',
languageDistribution: 'Language distribution',
languageDetails: 'Language details',
files: 'files',
lines: 'lines',
workspaceBranch: 'Workspace branch',
viewRef: 'Statistics view',
switchView: 'View statistics',
checkout: 'Switch workspace',
checkoutConfirm: 'Switch the workspace to {ref}? Uncommitted or untracked changes will be rejected.',
dirty: 'The worktree has uncommitted or untracked files',
branchChanged: 'Workspace branch changed',
commitDetail: 'Commit details',
copyHash: 'Copy hash',
filesChanged: 'Changed files',
clearFilter: 'Clear filter',
selectedDate: 'Filtered by {date}',
day: 'Day',
week: 'Week',
month: 'Month',
commitCount: 'Commits',
addedLines: 'Added lines',
deletedLines: 'Deleted lines',
contributors: 'Contributors',
activityHeatmap: 'Activity heatmap',
branches: 'Branches',
recentCommits: 'Recent commits',
commitTrend: 'Commit trend',
contributorRanking: 'Contributor ranking',
commitsUnit: 'commits',
gitUnavailable: 'Git data unavailable',
gitUnavailableHint: 'Git history could not be read for this project. Code statistics are unaffected.',
gitDetail: 'Diagnostic detail',
totalFiles: 'Total files',
folderCount: 'Folders',
totalSize: 'Total size',
largeFiles: 'Large files',
directoryStructure: 'Directory structure',
folderSize: 'Folder size',
largeFileDetection: 'Large files (≥ 1 MB)',
noLargeFiles: 'No large files found',
deepInsights: 'Deep inspection',
deepInsightsHint: 'Find maintainability risks from structure, source markers, and Git activity.',
healthScore: 'Health',
highRisk: 'high',
mediumRisk: 'medium',
lowRisk: 'low',
issueList: 'Issues',
allSeverity: 'All severity',
allTypes: 'All types',
noIssues: 'No issues found',
insightDone: 'Inspection completed',
severity: { high: 'High', medium: 'Medium', low: 'Low' },
quickSettings: 'Quick settings',
language: 'Language',
theme: 'Theme',
themeDark: 'Dark',
themeLight: 'Light',
themeSystem: 'System',
glassOpacity: 'Card opacity',
loadingStyle: 'Analysis loading style',
openSettings: 'Open settings',
logSubtitle: 'View application status and errors',
autoRefresh: 'Auto refresh',
refresh: 'Refresh',
clearLogs: 'Clear logs',
allLogs: 'All logs',
runLogs: 'Run logs',
errorLogs: 'Error logs',
totalLogs: 'Total logs',
info: 'Info',
warning: 'Warning',
error: 'Error',
noLogs: 'No logs yet',
errors: {
PROJECT_PATH_NOT_FOUND: 'Project directory does not exist',
PROJECT_PATH_UNREADABLE: 'Project directory is not readable',
PROJECT_PATH_NOT_DIRECTORY: 'Selected path is not a directory',
PROJECT_PATH_DUPLICATE: 'This project path already exists',
PROJECT_GROUP_NOT_FOUND: 'Project group does not exist',
PROJECT_GROUP_NAME_REQUIRED: 'Enter a project group name',
PROJECT_GROUP_DEFAULT_READONLY: 'The default project group cannot be changed or deleted',
PROJECT_GROUP_DUPLICATE: 'Project group name already exists',
DATABASE_NOT_READY: 'Database is not initialized',
PROJECT_SAVE_FAILED: 'Failed to save project',
NOT_GIT_REPOSITORY: 'This directory is not a Git repository',
GIT_NOT_INSTALLED: 'Git is not installed',
WSL_NOT_INSTALLED: 'WSL is not installed',
WSL_DISTRO_NOT_FOUND: 'WSL distribution was not found',
WSL_GIT_NOT_INSTALLED: 'Git is not installed inside the WSL distribution',
WSL_PATH_UNREADABLE: 'WSL path is not accessible',
GIT_PERMISSION_DENIED: 'Git does not have permission to read this repository',
GIT_SAFE_DIRECTORY: 'Git refused this repository; check safe.directory',
GIT_REF_NOT_FOUND: 'Branch or ref was not found',
GIT_COMMAND_FAILED: 'Git command failed'
},
task: {
start: 'Analyzing {project}',
scan: 'Scanning files',
count: 'Counting code',
save: 'Saving statistics',
git: 'Reading Git history',
completed: 'Analysis completed',
failed: 'Analysis failed',
cancelled: 'Analysis cancelled'
}
}
const saved = JSON.parse(localStorage.getItem('cc-settings') || '{}')
let theme = new URLSearchParams(location.search).get('theme') || saved.theme || 'dark'
if (theme === 'system') theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
document.documentElement.dataset.theme = theme
document.documentElement.style.setProperty('--glass-user-opacity', String((saved.glassOpacity || 55) / 100))
const i18n = createI18n({ legacy: false, locale: saved.locale || 'zh-CN', fallbackLocale: 'en', messages: { 'zh-CN': zh, en } })
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', component: Dashboard },
{ path: '/project/:id', component: ProjectDetail },
{ path: '/logs', component: Logs },
{ path: '/settings', component: Settings }
]
})
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app')

9
frontend/src/motion.css Normal file
View File

@@ -0,0 +1,9 @@
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}
.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}
@keyframes page-enter{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}@keyframes fade-rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}@keyframes card-enter{from{opacity:0;transform:translateY(18px)}to{opacity:1;transform:none}}@keyframes number-arrive{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:none}}@keyframes overlay-in{from{opacity:0}to{opacity:1}}@keyframes modal-in{from{opacity:0;transform:translateY(14px) scale(.97)}to{opacity:1;transform:none}}@keyframes toast-in{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:none}}@keyframes task-in{from{opacity:0;transform:translate(-50%,18px)}to{opacity:1;transform:translate(-50%,0)}}@keyframes progress-sweep{from{transform:translateX(-100%)}to{transform:translateX(100%)}}@keyframes heat-in{from{opacity:0;transform:scale(.35)}to{opacity:1;transform:none}}@keyframes setup-in{from{opacity:0;transform:translateY(22px) scale(.98)}to{opacity:1;transform:none}}@keyframes spin{to{transform:rotate(360deg)}}
@media(prefers-reduced-motion:reduce){.page,.page-head,.project-head,.stats-grid>*,.project-grid>*,.modal,.overlay,.toast,.taskbar,.setup-card,.stat-card strong,.heatmap i{animation:none!important}.stat-card:hover,.panel:hover,.project-card:hover,.add-card:hover{transform:none}.taskbar .progress i:after{display:none}}

183
frontend/src/polish.css Normal file
View File

@@ -0,0 +1,183 @@
main{position:relative;overflow:visible}
main::before{content:"";position:fixed;inset:0 0 0 232px;pointer-events:none;background:linear-gradient(90deg,rgba(255,255,255,.035) 1px,transparent 1px),linear-gradient(180deg,rgba(255,255,255,.03) 1px,transparent 1px);background-size:80px 80px;mask-image:linear-gradient(90deg,transparent,black 14%,black 86%,transparent);animation:gridDrift 18s linear infinite;opacity:.55}
main::after{content:"";position:fixed;inset:0 0 0 232px;pointer-events:none;background:linear-gradient(115deg,transparent 0 35%,rgba(124,115,255,.1) 45%,transparent 56%);transform:translateX(-35%);animation:ambientSweep 12s ease-in-out infinite}
.page{position:relative;z-index:1}
.sticky-head{position:sticky;top:0;z-index:8;margin:-12px -10px 24px;padding:18px 20px;border:1px solid rgba(255,255,255,.08);border-radius:8px;background:color-mix(in srgb,var(--surface) calc(var(--glass-user-opacity, .55) * 100%),transparent);backdrop-filter:blur(24px) saturate(140%);box-shadow:0 16px 38px rgba(0,0,0,.22)}
.project-head.sticky-head{margin:-12px -10px 16px}
.animated-logo{background:linear-gradient(135deg,rgba(115,103,245,.95),rgba(52,211,153,.82));box-shadow:0 10px 28px rgba(115,103,245,.34)}
.animated-logo svg{width:40px;height:40px;overflow:visible}
.logo-frame{fill:rgba(255,255,255,.08);stroke:url(#logoGlow);stroke-width:1.5}
.logo-track{fill:none;stroke:#fff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:68;animation:logoTrace 3.2s ease-in-out infinite}
.logo-spark{fill:none;stroke:url(#logoGlow);stroke-width:2.5;stroke-linecap:round;stroke-dasharray:8 12;animation:logoSpark 2.2s linear infinite}
.sidebar-bottom{position:relative;margin-top:auto;border-top:1px solid var(--border);padding:18px 8px 0;display:flex;align-items:center;gap:8px}
.sidebar-bottom .version{margin:0;border:0;padding:0;flex:1}
.quick-settings-btn{width:34px;height:34px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--muted);display:grid;place-items:center;cursor:pointer;transition:color .2s,border-color .2s,background .2s}
.quick-settings-btn:hover{color:var(--text);border-color:var(--primary)}
.quick-settings-btn svg{width:17px}
.quick-settings{position:absolute;left:0;bottom:48px;width:260px;padding:14px;border:1px solid var(--border);border-radius:8px;background:color-mix(in srgb,var(--surface-2) 82%,transparent);box-shadow:0 18px 42px rgba(0,0,0,.38);backdrop-filter:blur(24px) saturate(150%);display:grid;gap:12px;animation:popoverIn .18s ease-out}
.quick-settings header{display:flex;justify-content:space-between;align-items:center}
.quick-settings header button{border:0;background:transparent;color:var(--muted);cursor:pointer}
.quick-settings header svg{width:16px}
.quick-settings label{display:grid;gap:7px;color:var(--muted);font-size:12px}
.quick-settings select,.quick-settings input[type=range]{width:100%;accent-color:var(--primary)}
.quick-settings select{height:34px;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--text);padding:0 10px}
.quick-settings .full{width:100%;justify-content:center}
.shine-card{position:relative;overflow:hidden;isolation:isolate;transition:transform .22s ease,border-color .22s ease,box-shadow .22s ease,background .22s ease}
.shine-card>*{position:relative;z-index:1}
.shine-card::after{content:"";position:absolute;inset:-45%;z-index:0;pointer-events:none;background:linear-gradient(115deg,transparent 35%,rgba(255,255,255,.24) 49%,rgba(126,115,255,.18) 52%,transparent 64%);transform:translateX(-65%) rotate(8deg);opacity:0;transition:opacity .2s ease}
.shine-card:hover{transform:translateY(-3px);border-color:color-mix(in srgb,var(--primary) 55%,var(--border));box-shadow:0 20px 44px rgba(0,0,0,.28),0 0 0 1px rgba(123,115,255,.14)}
.shine-card:hover::after{opacity:1;animation:shineSweep .82s ease-out}
.stat-card{min-width:0;padding:20px;gap:14px}
.stat-copy{min-width:0;display:grid;gap:4px}
.stat-card strong,.stat-value{font-size:clamp(22px,2.2vw,29px);line-height:1.05;word-break:keep-all;white-space:normal}
.stat-card small{line-height:1.25}
.metric-row{gap:12px}
.metric-row>div{min-width:0}
.metric-row b{font-size:clamp(19px,2vw,25px);line-height:1.05;white-space:nowrap}
.metric-row span{display:block;white-space:nowrap}
.project-metrics b{font-variant-numeric:tabular-nums;font-weight:900;letter-spacing:.2px;text-shadow:0 0 18px currentColor}
.project-metrics .metric-total b{color:#72b7ff}
.project-metrics .metric-code b{color:#55d6a2}
.project-metrics .metric-files b{color:#b39cff}
.project-metrics>div{padding:8px 6px;border-radius:8px;background:linear-gradient(180deg,rgba(255,255,255,.045),transparent)}
.project-tools{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}
.group-filter{height:38px;display:flex;align-items:center;gap:8px}
.group-filter select{height:38px;min-width:180px;border:1px solid var(--border);border-radius:7px;background:var(--surface-2);color:var(--text);padding:0 11px;outline:none}
.group-filter button{width:34px;height:34px;border:1px solid var(--border);border-radius:7px;background:var(--surface-2);color:var(--muted);display:grid;place-items:center;cursor:pointer;transition:color .2s,border-color .2s,background .2s}
.group-filter button:hover{color:var(--text);border-color:var(--primary)}
.group-filter svg{width:15px}
.icon-actions{gap:6px}
.icon-actions button{width:30px;height:30px;display:grid;place-items:center;border-radius:7px;transition:background .2s,color .2s}
.icon-actions button:hover{background:var(--surface-3);color:var(--text)}
.group-chip{display:inline-flex;align-items:center;width:max-content;max-width:180px;height:22px;margin:0 0 7px;padding:0 8px;border-radius:999px;background:rgba(115,103,245,.14);color:#a9a2ff;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.compact-modal{width:420px}
.modal{display:flex;flex-direction:column;overflow:auto}
.modal header{align-items:center;gap:18px;padding:24px 26px 20px}
.modal header h2{font-size:22px;line-height:1.2}
.modal header button{width:36px;height:36px;display:grid;place-items:center;border-radius:8px;line-height:1;flex:none;transition:background .2s,color .2s}
.modal header button:hover{background:var(--surface-2);color:var(--text)}
.modal>label{margin:0;padding:18px 26px 0;font-weight:800;color:var(--text)}
.modal>label:first-of-type{padding-top:24px}
.modal input,.modal textarea,.modal>label select{height:44px;margin-top:10px;border-color:rgba(145,136,255,.26);background:rgba(32,40,56,.92);box-shadow:inset 0 1px 0 rgba(255,255,255,.035);transition:border-color .2s,box-shadow .2s,background .2s}
.modal input:focus,.modal textarea:focus,.modal>label select:focus{border-color:rgba(145,136,255,.72);box-shadow:0 0 0 3px rgba(115,103,245,.18),inset 0 1px 0 rgba(255,255,255,.05)}
.modal textarea{min-height:92px;height:92px}
.modal footer{gap:12px;padding:22px 26px;align-items:center}
.modal footer .btn{min-width:88px;justify-content:center}
.browse{gap:12px;align-items:flex-start}
.browse .btn{min-width:96px;justify-content:center}
.hidden-wsl-picker{display:none!important}
.modal>label select{width:100%;border-radius:7px;color:var(--text);padding:0 12px;outline:none}
.wsl-picker select{height:40px;min-width:180px;background:var(--surface-2);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:0 10px;outline:none}
.overlay{position:fixed;inset:0;width:100vw;min-height:100vh;min-height:100dvh;display:grid;place-items:center;padding:32px;z-index:80;overflow:auto}
.modal{max-width:calc(100vw - 64px);max-height:calc(100vh - 64px);max-height:calc(100dvh - 64px)}
.analysis-loading{position:fixed;inset:0;z-index:70;display:grid;place-items:center;padding:24px;background-color:#03070c;background-image:radial-gradient(circle at 50% 42%,rgba(83,214,162,.14),transparent 30%),linear-gradient(135deg,rgba(6,9,18,.96),rgba(3,7,12,.94));backdrop-filter:blur(16px) saturate(145%);-webkit-backdrop-filter:blur(16px) saturate(145%);overflow:hidden;isolation:isolate;transition:background-color .3s ease,background-image .3s ease}
.analysis-loading.holding section{opacity:.88;transform:translateY(0)}
.analysis-loading.fullscreen-grid{background-image:radial-gradient(circle at 50% 48%,rgba(110,231,255,.16),transparent 32%),radial-gradient(circle at 22% 22%,rgba(83,214,162,.1),transparent 28%),linear-gradient(135deg,rgba(7,12,24,.97),rgba(3,15,16,.95))}
.analysis-loading.fullscreen-warp{background-image:radial-gradient(circle at 50% 50%,rgba(124,108,255,.24),transparent 34%),radial-gradient(circle at 72% 34%,rgba(110,231,255,.12),transparent 26%),linear-gradient(135deg,rgba(5,6,16,.98),rgba(3,7,12,.95))}
.analysis-canvas{position:absolute;inset:0;width:100%;height:100%;opacity:.98}
.analysis-loading section{position:relative;z-index:1;width:min(420px,calc(100vw - 48px));padding:0;display:grid;gap:13px;text-align:center;justify-items:center;overflow:visible;background:transparent;border:0;box-shadow:none;backdrop-filter:none;-webkit-backdrop-filter:none;transition:opacity .28s ease,transform .28s ease}
.analysis-loading section::before{content:"";position:absolute;left:50%;top:50%;width:360px;height:240px;transform:translate(-50%,-50%);border-radius:50%;background:radial-gradient(circle,rgba(8,14,24,.58) 0 24%,rgba(8,14,24,.34) 45%,transparent 72%);filter:blur(12px);pointer-events:none;z-index:-1}
.analysis-loading b{font-size:21px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-shadow:0 0 18px rgba(110,231,255,.38),0 2px 14px rgba(0,0,0,.72)}
.analysis-loading span{color:#b4bdcf;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-shadow:0 2px 12px rgba(0,0,0,.8)}
.analysis-loading strong{color:var(--text);text-shadow:0 0 14px rgba(255,255,255,.28),0 2px 12px rgba(0,0,0,.74)}
.analysis-loading .progress{position:relative;width:min(310px,100%);height:7px;border-radius:999px;background:linear-gradient(180deg,rgba(4,9,18,.78),rgba(18,26,42,.66));overflow:hidden;margin-top:8px;box-shadow:0 0 0 1px rgba(255,255,255,.06),0 0 28px rgba(115,103,245,.24),inset 0 1px 5px rgba(0,0,0,.5)}
.analysis-loading .progress::before{content:"";position:absolute;inset:1px;border-radius:inherit;background:linear-gradient(90deg,rgba(110,231,255,.06),rgba(255,255,255,.16),rgba(83,214,162,.06));transform:translateX(-62%);animation:loadingTrackSweep 1.8s ease-in-out infinite}
.analysis-loading .progress i{position:relative;display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#6ee7ff,#7c6cff 42%,#53d6a2 72%,#6ee7ff);background-size:240% 100%;box-shadow:0 0 20px rgba(110,231,255,.76),0 0 34px rgba(83,214,162,.36);transition:width .48s cubic-bezier(.22,1,.36,1);animation:loadingBarFlow 1.2s linear infinite}
.analysis-loading .progress i::before{content:"";position:absolute;right:-8px;top:50%;width:18px;height:18px;border-radius:50%;background:radial-gradient(circle,#fff 0 18%,#6ee7ff 34%,rgba(110,231,255,0) 72%);transform:translateY(-50%);filter:blur(.2px);box-shadow:0 0 18px rgba(110,231,255,.86)}
.analysis-loading .progress i::after{content:"";position:absolute;inset:-3px;width:58px;background:linear-gradient(90deg,transparent,rgba(255,255,255,.86),transparent);filter:blur(1px);animation:loadingBarSweep 1.05s ease-in-out infinite}
.loading-style-setting{display:grid;gap:12px;margin-top:4px;color:var(--text);font-weight:800}
.loading-style-setting>span{display:flex;align-items:center;gap:8px}
.loading-style-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.loading-style-card{position:relative;min-height:118px;border:1px solid rgba(145,136,255,.2);border-radius:8px;background:linear-gradient(180deg,rgba(255,255,255,.055),rgba(255,255,255,.018)),var(--surface-2);color:var(--text);padding:14px 14px 13px;display:grid;grid-template-columns:46px 1fr;grid-template-rows:auto 1fr;column-gap:12px;row-gap:5px;text-align:left;cursor:pointer;outline:none;overflow:hidden;transition:border-color .2s ease,background .2s ease,box-shadow .2s ease,transform .2s ease}
.loading-style-card:hover{border-color:rgba(110,231,255,.5);box-shadow:0 14px 30px rgba(0,0,0,.22)}
.loading-style-card:focus-visible{box-shadow:0 0 0 3px rgba(110,231,255,.18),0 14px 30px rgba(0,0,0,.22)}
.loading-style-card.active{border-color:rgba(110,231,255,.72);background:linear-gradient(180deg,rgba(110,231,255,.11),rgba(83,214,162,.045)),var(--surface-2);box-shadow:0 0 0 1px rgba(110,231,255,.16),0 18px 36px rgba(0,0,0,.24),0 0 28px rgba(110,231,255,.12)}
.loading-style-card.active::after{content:"";position:absolute;right:12px;top:12px;width:9px;height:9px;border-radius:50%;background:#6ee7ff;box-shadow:0 0 14px rgba(110,231,255,.9)}
.loading-style-preview{grid-row:1/3;width:46px;height:46px;border-radius:8px;border:1px solid rgba(255,255,255,.08);background:rgba(4,9,18,.52);display:grid;place-items:center;overflow:hidden;box-shadow:inset 0 0 18px rgba(0,0,0,.26)}
.loading-style-preview i{position:relative;display:block;width:30px;height:30px;border-radius:50%}
.loading-style-card b{align-self:end;font-size:14px;line-height:1.25;letter-spacing:0}
.loading-style-card small{color:var(--muted);font-weight:600;line-height:1.35;word-break:break-word}
.loading-style-card.fullscreen-orbit .loading-style-preview i{border:2px solid rgba(110,231,255,.72);box-shadow:0 0 0 7px rgba(124,108,255,.14),0 0 18px rgba(110,231,255,.48)}
.loading-style-card.fullscreen-orbit .loading-style-preview i::before{content:"";position:absolute;inset:8px;border-radius:50%;background:#53d6a2;box-shadow:0 0 16px rgba(83,214,162,.78)}
.loading-style-card.fullscreen-orbit .loading-style-preview i::after{content:"";position:absolute;width:7px;height:7px;border-radius:50%;background:#fff;right:-2px;top:9px;box-shadow:0 0 12px rgba(255,255,255,.9)}
.loading-style-card.fullscreen-grid .loading-style-preview i{width:34px;height:34px;border-radius:4px;background:linear-gradient(90deg,rgba(110,231,255,.34) 1px,transparent 1px),linear-gradient(180deg,rgba(83,214,162,.3) 1px,transparent 1px);background-size:9px 9px}
.loading-style-card.fullscreen-grid .loading-style-preview i::before{content:"";position:absolute;left:4px;right:4px;top:15px;height:4px;background:rgba(110,231,255,.75);box-shadow:0 0 12px rgba(110,231,255,.75)}
.loading-style-card.fullscreen-grid .loading-style-preview i::after{content:"";position:absolute;width:7px;height:7px;right:6px;bottom:5px;border-radius:50%;background:#53d6a2;box-shadow:-17px -14px 0 rgba(124,108,255,.82),0 0 12px rgba(83,214,162,.8)}
.loading-style-card.fullscreen-warp .loading-style-preview i{width:36px;height:36px;background:radial-gradient(circle,#fff 0 8%,#6ee7ff 11%,rgba(124,108,255,.32) 34%,transparent 62%);box-shadow:0 0 20px rgba(124,108,255,.62)}
.loading-style-card.fullscreen-warp .loading-style-preview i::before,.loading-style-card.fullscreen-warp .loading-style-preview i::after{content:"";position:absolute;left:50%;top:50%;width:36px;height:2px;border-radius:999px;background:linear-gradient(90deg,transparent,#6ee7ff,transparent);transform:translate(-50%,-50%) rotate(32deg);box-shadow:0 0 10px rgba(110,231,255,.7)}
.loading-style-card.fullscreen-warp .loading-style-preview i::after{transform:translate(-50%,-50%) rotate(-28deg);background:linear-gradient(90deg,transparent,#53d6a2,transparent)}
.loading-style-card.bar .loading-style-preview i{width:34px;height:8px;border-radius:999px;background:rgba(255,255,255,.1);overflow:hidden}
.loading-style-card.bar .loading-style-preview i::before{content:"";position:absolute;inset:0 38% 0 0;border-radius:inherit;background:linear-gradient(90deg,#6ee7ff,#53d6a2);box-shadow:0 0 12px rgba(110,231,255,.65)}
.toast{transition:opacity .32s ease,transform .36s ease,filter .32s ease}
.toast.muted{opacity:.72;filter:saturate(.82)}
.toast.leaving{opacity:0;transform:translate(18px,-12px);pointer-events:none}
.detail-sticky-head{display:grid;gap:16px;align-items:stretch}
.detail-page{--detail-sticky-offset:16px;max-width:none;margin:0;padding:var(--detail-sticky-offset) 30px 60px}
.detail-page .detail-sticky-head{position:sticky;top:var(--detail-sticky-offset);width:100%;z-index:24;margin:0 0 24px;border-radius:8px;border-top:1px solid rgba(255,255,255,.08);background:color-mix(in srgb,var(--surface) 88%,transparent);box-shadow:0 18px 42px rgba(0,0,0,.34),0 1px 0 rgba(255,255,255,.06)}
.detail-head-row{display:flex;align-items:center;gap:28px;min-width:0}
.detail-head-row>div{min-width:0}
.detail-head-row p{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.detail-tabs{margin:0;background:rgba(255,255,255,.045)}
.heat-panel{height:auto;min-height:250px}
.heat-grid-wrap{--cell:13px;--gap:4px;position:relative;margin-top:12px;overflow-x:auto;padding-bottom:8px}
.heat-months{display:grid;grid-template-columns:repeat(var(--weeks),var(--cell));gap:var(--gap);margin-left:34px;margin-bottom:8px;min-width:calc(var(--weeks) * (var(--cell) + var(--gap)))}
.heat-months span{font-size:12px;color:var(--muted);white-space:nowrap}
.heat-body{position:relative;display:flex;gap:10px;align-items:flex-start}
.heat-weekdays{display:grid;grid-template-rows:repeat(7,var(--cell));gap:var(--gap);width:24px;color:var(--muted);font-size:12px;line-height:var(--cell);text-align:right}
.heat-grid{display:grid;grid-template-columns:repeat(var(--weeks),var(--cell));grid-template-rows:repeat(7,var(--cell));gap:var(--gap);min-width:calc(var(--weeks) * (var(--cell) + var(--gap)))}
.heat-cell{width:var(--cell);height:var(--cell);border:1px solid rgba(255,255,255,.035);border-radius:3px;background:#26303d;cursor:pointer;padding:0;transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease}
.heat-cell:hover,.heat-cell.selected{transform:translateY(-1px);border-color:rgba(255,255,255,.45);box-shadow:0 0 0 2px rgba(123,115,255,.22)}
.heat-cell.l1{background:#245140}.heat-cell.l2{background:#2e765b}.heat-cell.l3{background:#3cab7d}.heat-cell.l4{background:#55dfa1}
html[data-theme=light] .heat-cell{background:#e7ecf3;border-color:#d9e0ea}
html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .heat-cell.l2{background:#81dfb4}html[data-theme=light] .heat-cell.l3{background:#42c990}html[data-theme=light] .heat-cell.l4{background:#159c68}
.heat-tooltip{position:absolute;left:44px;bottom:-30px;z-index:3;border:1px solid var(--border);border-radius:7px;background:var(--surface-3);padding:6px 9px;color:var(--text);font-size:12px;box-shadow:0 10px 26px rgba(0,0,0,.28);pointer-events:none}
.insights-hero{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:22px}
.score-ring{width:116px;height:116px;border-radius:50%;display:grid;place-items:center;background:radial-gradient(circle at center,var(--surface) 58%,transparent 59%),conic-gradient(var(--green) calc(var(--score,75)*1%),rgba(255,255,255,.08) 0);border:1px solid var(--border)}
.score-ring strong{font-size:34px;line-height:1}
.score-ring span{font-size:12px;color:var(--muted);margin-top:-22px}
.insight-summary p{color:var(--muted);margin:8px 0 14px}
.insight-counts{display:flex;gap:10px;flex-wrap:wrap}
.insight-counts span,.issue-severity{border-radius:999px;padding:5px 9px;background:var(--surface-2);color:var(--muted);font-size:12px}
.insight-counts .high,.issue-card.high .issue-severity{color:#ff8a95;background:rgba(240,94,104,.12)}
.insight-counts .medium,.issue-card.medium .issue-severity{color:#ffd166;background:rgba(231,189,53,.12)}
.insight-counts .low,.issue-card.low .issue-severity{color:#72b7ff;background:rgba(79,157,245,.12)}
.insight-filter select{height:36px;border:1px solid var(--border);border-radius:7px;background:var(--surface-2);color:var(--text);padding:0 10px}
.issue-list{display:grid;gap:10px;margin-top:16px;max-height:620px;overflow:auto;padding-right:6px}
.issue-card{position:relative;display:grid;grid-template-columns:1fr;gap:10px;padding:16px 18px;border:1px solid var(--border);border-radius:8px;background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.015)),var(--surface-2);overflow:hidden}
.issue-card::before{display:none}
.issue-card.high{border-color:rgba(240,94,104,.34);background:linear-gradient(180deg,rgba(240,94,104,.055),rgba(255,255,255,.015)),var(--surface-2)}
.issue-card.medium{border-color:rgba(231,189,53,.32);background:linear-gradient(180deg,rgba(231,189,53,.05),rgba(255,255,255,.015)),var(--surface-2)}
.issue-card.low{border-color:rgba(79,157,245,.3);background:linear-gradient(180deg,rgba(79,157,245,.045),rgba(255,255,255,.015)),var(--surface-2)}
.issue-severity{display:inline-flex;align-items:center;justify-content:center;width:max-content;align-self:start;justify-self:start;line-height:1;font-weight:800;text-transform:none}
.issue-card h3{margin:0 0 6px;font-size:15px}
.issue-card p{margin:0;color:var(--muted)}
.issue-card small,.issue-card b,.issue-card code{display:block;margin-top:7px}
.issue-card small{color:#8fb2ff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.issue-card code{white-space:pre-wrap;word-break:break-word;background:rgba(0,0,0,.2);padding:8px;border-radius:6px;color:var(--muted);max-width:100%;overflow:hidden}
.issue-card b{color:var(--text);font-weight:700}
.git-error-panel{display:grid;grid-template-columns:42px 1fr auto;align-items:center;gap:14px;border-color:rgba(240,94,104,.42)}
.git-error-panel>svg{width:30px;color:var(--red)}
.git-error-panel h2{margin:0 0 6px}
.git-error-panel p{margin:0;color:var(--muted)}
.git-error-panel code{display:block;margin-top:8px;white-space:normal;word-break:break-word;color:#ffb4bc}
.structure-panel,.structure-scroll-panel{max-height:560px;overflow:auto}
.structure-panel .file-tree{height:auto;max-height:490px}
.file-tree>div span,.folder-size b,.large-file small{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file-tree>div{min-width:0}
.large-file-panel{max-height:360px;overflow:auto}
.large-file{border-radius:7px;gap:16px}
.large-file>div{min-width:0}
@keyframes shineSweep{from{transform:translateX(-65%) rotate(8deg)}to{transform:translateX(65%) rotate(8deg)}}
@keyframes logoTrace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:68}}
@keyframes logoSpark{to{stroke-dashoffset:-40}}
@keyframes popoverIn{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}
@keyframes gridDrift{to{background-position:80px 80px}}
@keyframes ambientSweep{0%,100%{transform:translateX(-40%);opacity:.18}50%{transform:translateX(22%);opacity:.34}}
@keyframes loadingBarFlow{to{background-position:220% 0}}
@keyframes loadingBarSweep{from{transform:translateX(-44px)}to{transform:translateX(290px)}}
@keyframes loadingTrackSweep{0%,100%{transform:translateX(-62%);opacity:.35}50%{transform:translateX(62%);opacity:.9}}
@media(max-width:1150px){main::before,main::after{inset-left:72px}.sidebar-bottom{padding-left:0;padding-right:0;justify-content:center}.quick-settings{left:0}.quick-settings-btn{width:40px;height:40px}.stats-grid.three,.stats-grid.four{grid-template-columns:repeat(2,minmax(0,1fr))}.project-card{padding:20px}}
@media(max-width:980px){body{min-width:0}.page{padding:24px 24px 56px}.stats-grid.three,.stats-grid.four,.stats-grid.five{grid-template-columns:repeat(2,minmax(0,1fr))}.project-grid{grid-template-columns:1fr}.section-head{align-items:flex-start;gap:12px;flex-direction:column}.project-tools{width:100%;justify-content:flex-start}.group-filter{width:100%;flex-wrap:wrap;height:auto}.group-filter select{flex:1;min-width:160px}.search{width:100%}.git-error-panel{grid-template-columns:36px 1fr}.git-error-panel .btn{grid-column:1/3;width:max-content}}
@media(max-width:720px){.loading-style-grid{grid-template-columns:1fr}.loading-style-card{min-height:104px}}
@media(max-width:980px){.detail-page{--detail-sticky-offset:12px;padding:var(--detail-sticky-offset) 20px 56px}.insights-hero{grid-template-columns:1fr}.detail-head-row{align-items:flex-start;flex-direction:column;gap:10px}.detail-tabs{width:100%;overflow-x:auto}}
@media(prefers-reduced-motion:reduce){main::before,main::after,.logo-track,.logo-spark,.shine-card:hover::after{animation:none!important}.shine-card:hover,.heat-cell:hover{transform:none}.toast{transition:opacity .2s ease}}

3
frontend/src/runtime.css Normal file
View File

@@ -0,0 +1,3 @@
.opacity-setting{align-items:center}.opacity-setting>span{display:flex;justify-content:space-between;gap:14px}.opacity-setting b{color:#9188ff}.opacity-setting input[type=range]{width:100%;accent-color:var(--primary);cursor:pointer}
.browser-blocked{min-height:100vh;display:grid;place-items:center;padding:32px;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);background-size:40px 40px}.blocked-card{width:min(590px,100%);padding:42px;border:1px solid var(--glass-border);border-radius:10px;background:var(--glass-strong);box-shadow:var(--glass-shadow);backdrop-filter:blur(24px);text-align:left}.blocked-card>span{width:58px;height:58px;display:grid;place-items:center;border-radius:9px;background:rgba(240,94,104,.12);color:var(--red)}.blocked-card>span svg{width:28px}.blocked-card>small{display:block;color:var(--red);font-weight:bold;margin-top:24px}.blocked-card h1{font-size:28px;margin:10px 0}.blocked-card p{color:var(--muted);line-height:1.7}.blocked-card code{color:#9188ff}.blocked-card>div{display:flex;gap:9px;align-items:center;padding:13px;background:var(--glass-soft);border-radius:7px;color:var(--muted)}.blocked-card>div svg{width:18px;color:var(--green)}
.btn:disabled{opacity:.55;cursor:not-allowed}

118
frontend/src/store.js Normal file
View File

@@ -0,0 +1,118 @@
import { defineStore } from 'pinia'
import { call, on } from './api'
export const useAppStore = defineStore('app', {
state: () => ({
bootstrap: { state: 'loading' },
projects: [],
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' },
tasks: {},
batchTaskIds: [],
toast: null,
toastTimer: null,
toastLeaveTimer: null,
loading: false
}),
actions: {
async boot() {
this.bootstrap = await call('GetBootstrapStatus')
if (this.bootstrap.state === 'ready') {
this.settings = await call('GetSettings')
this.applyAppearance(this.settings)
await this.refresh()
}
},
applyAppearance(settings) {
this.settings = { ...this.settings, ...settings }
let theme = this.settings.theme || 'dark'
if (theme === 'system') theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
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))
},
async saveSettings(patch) {
const next = { ...this.settings, ...patch }
await call('SaveSettings', next)
this.applyAppearance(next)
return next
},
showToast(payload) {
clearTimeout(this.toastTimer)
clearTimeout(this.toastLeaveTimer)
this.toast = { ...payload, leaving: false, muted: false, id: Date.now() }
this.toastTimer = setTimeout(() => {
if (this.toast) this.toast.muted = true
}, 3000)
this.toastLeaveTimer = setTimeout(() => {
if (this.toast) this.toast.leaving = true
setTimeout(() => {
this.toast = null
}, 360)
}, 5000)
},
closeToast() {
clearTimeout(this.toastTimer)
clearTimeout(this.toastLeaveTimer)
if (this.toast) this.toast.leaving = true
setTimeout(() => {
this.toast = null
}, 220)
},
async refresh() {
this.loading = true
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([
groupId ? call('ListProjectsByGroup', groupId) : call('ListProjects'),
groupId ? call('GetDashboardByGroup', groupId) : call('GetDashboard')
])
} finally {
this.loading = false
}
},
async changeProjectGroup(groupId) {
this.setProjectGroup(groupId)
await this.refresh()
},
setProjectGroup(groupId, persist = true) {
this.selectedProjectGroupId = Number(groupId) || 0
if (persist) localStorage.setItem('cc-project-group-id', String(this.selectedProjectGroupId))
},
listen() {
return on('analysis:progress', e => {
delete this.tasks.__batch_pending__
this.tasks[e.taskId] = e
if (['completed', 'error', 'cancelled'].includes(e.stage)) {
if (this.batchTaskIds.includes(e.taskId)) {
this.batchTaskIds = this.batchTaskIds.filter(id => id !== e.taskId)
if (this.batchTaskIds.length) {
this.tasks.__batch_pending__ = { taskId: '__batch_pending__', projectId: 0, stage: 'queued', progress: 100, messageKey: e.messageKey, params: e.params }
}
}
this.showToast({ type: e.stage === 'completed' ? 'success' : 'error', key: e.messageKey, params: e.params })
this.refresh()
}
})
},
async analyze(id, kind = 'all') {
const task = await call('StartAnalysis', id, kind)
this.tasks[task] = { taskId: task, projectId: id, stage: 'start', progress: 1, messageKey: 'task.start' }
return task
},
async batchAnalyze(groupId = 0) {
const ids = groupId ? await call('StartBatchAnalysisByGroup', groupId) : await call('StartBatchAnalysis')
this.batchTaskIds = ids || []
if (this.batchTaskIds.length && !Object.values(this.tasks).some(x => !['completed', 'error', 'cancelled'].includes(x.stage))) {
this.tasks.__batch_pending__ = { taskId: '__batch_pending__', projectId: 0, stage: 'queued', progress: 1, messageKey: 'task.start' }
}
return this.batchTaskIds
}
}
})

1
frontend/src/style.css Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,240 @@
<script setup>
import { computed, reactive, ref } 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 StatCard from '../components/StatCard.vue'
import { useAppStore } from '../store'
import { call } from '../api'
const store = useAppStore()
const router = useRouter()
const { t } = useI18n()
const search = ref('')
const modal = ref(false)
const groupModal = ref(false)
const editing = ref(0)
const groupEditing = ref(0)
const saving = ref(false)
const groupSaving = ref(false)
const error = ref('')
const groupError = ref('')
const wslDistros = ref([])
const wslDistro = ref('')
const form = reactive({ name: '', path: '', description: '', groupId: 1 })
const groupForm = reactive({ name: '' })
const palette = ['#53d6a2', '#5da8ff', '#f7cb4d', '#a78bfa', '#ef6f8f']
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return store.projects
return store.projects.filter(p => (p.name + ' ' + p.path + ' ' + (p.description || '')).toLowerCase().includes(q))
})
const filteredDashboard = computed(() => filtered.value.reduce((acc, p) => {
acc.projects += 1
acc.totalLines += Number(p.stats?.totalLines || 0)
acc.commits += Number(p.stats?.commitCount || 0)
return acc
}, { projects: 0, totalLines: 0, commits: 0 }))
const fmt = n => {
n = +n || 0
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
}
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)
const groupLabel = g => g?.id === 1 ? t('myProjectGroup') : (g?.name || t('projectGroup'))
const errorText = raw => {
const code = ['PROJECT_PATH_NOT_FOUND', 'PROJECT_PATH_UNREADABLE', 'PROJECT_PATH_NOT_DIRECTORY', 'PROJECT_PATH_DUPLICATE', 'PROJECT_GROUP_NOT_FOUND', 'PROJECT_GROUP_NAME_REQUIRED', 'PROJECT_GROUP_DEFAULT_READONLY', 'PROJECT_GROUP_DUPLICATE', 'DATABASE_NOT_READY', 'PROJECT_SAVE_FAILED'].find(x => String(raw).includes(x))
return code ? t(`errors.${code}`) : String(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 })
error.value = ''
modal.value = true
}
function openGroup(g) {
groupEditing.value = g?.id || 0
groupForm.name = g?.name || ''
groupError.value = ''
groupModal.value = true
}
async function saveGroup() {
if (groupSaving.value) return
groupSaving.value = true
groupError.value = ''
try {
const saved = await call('SaveProjectGroup', groupEditing.value, groupForm.name)
groupModal.value = false
await store.refresh()
if (!groupEditing.value) await store.changeProjectGroup(saved.id)
store.showToast({ type: 'success', text: t('saveProjectGroup') })
} catch (e) {
groupError.value = errorText(e)
store.showToast({ type: 'error', text: groupError.value })
} finally {
groupSaving.value = false
}
}
async function removeGroup(g) {
if (!g) return
if (g.id === 1) return
if (confirm(`${t('delete')} ${g.name}?`)) {
await call('DeleteProjectGroup', g.id)
if (store.selectedProjectGroupId === g.id) store.setProjectGroup(0)
await store.refresh()
}
}
async function changeGroup(value) {
await store.changeProjectGroup(Number(value))
}
async function browse() {
const p = await call('SelectDirectory')
if (p) {
form.path = p
if (!form.name) form.name = p.split(/[\\/]/).pop()
}
}
async function loadWSL() {
try {
wslDistros.value = await call('ListWSLDistros')
if (wslDistros.value.length) wslDistro.value = wslDistros.value[0]
else store.showToast({ type: 'error', key: 'noWSL' })
} catch {
store.showToast({ type: 'error', key: 'noWSL' })
}
}
async function browseWSL() {
if (!wslDistro.value) return
const p = await call('SelectWSLDirectory', wslDistro.value)
if (p) {
form.path = p
if (!form.name) form.name = p.split(/[\\/]/).pop()
}
}
async function save() {
if (saving.value) return
error.value = ''
form.path = form.path.trim()
if (!form.path) {
error.value = t('pathRequired')
return
}
saving.value = true
try {
await call('SaveProject', editing.value, { ...form, groupId: Number(form.groupId) || defaultGroupId.value })
await store.refresh()
modal.value = false
store.showToast({ type: 'success', text: t('saveProject') })
} catch (e) {
error.value = errorText(e)
store.showToast({ type: 'error', text: error.value })
try { await call('ReportClientError', 'frontend', '项目保存失败', String(e)) } catch {}
} finally {
saving.value = false
}
}
async function remove(p) {
if (confirm(`${t('delete')} ${p.name}?`)) {
await call('DeleteProject', p.id)
await store.refresh()
}
}
async function batch() {
await store.batchAnalyze(store.selectedProjectGroupId)
}
async function refreshProject(p) {
try {
await store.analyze(p.id, 'all')
} catch (e) {
store.showToast({ type: 'error', text: errorText(e) })
}
}
</script>
<template>
<div class="page dashboard-page">
<header class="page-head sticky-head">
<div><h1>{{ t('dashboard') }}</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')" />
</div>
<div class="section-head">
<h2>{{ t('projects') }}</h2>
<div class="project-tools">
<div class="group-filter">
<select :value="store.selectedProjectGroupId" @change="changeGroup($event.target.value)">
<option :value="0">{{ t('allProjectGroups') }}</option>
<option v-for="g in store.projectGroups" :key="g.id" :value="g.id">{{ groupLabel(g) }}</option>
</select>
<button :title="t('addProjectGroup')" @click="openGroup()"><Plus /></button>
<button v-if="store.selectedProjectGroupId && store.selectedProjectGroupId !== 1" :title="t('editProjectGroup')" @click="openGroup(store.projectGroups.find(g => g.id === store.selectedProjectGroupId))"><Pencil /></button>
<button v-if="store.selectedProjectGroupId && store.selectedProjectGroupId !== 1" :title="t('deleteProjectGroup')" @click="removeGroup(store.projectGroups.find(g => g.id === store.selectedProjectGroupId))"><Trash2 /></button>
</div>
<label class="search"><Search /><input v-model="search" :placeholder="t('search')" /></label>
</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)">
<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 :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>
</div>
</div>
<div class="metric-row project-metrics">
<div class="metric-total"><b>{{ fmt(p.stats.totalLines) }}</b><span>{{ t('totalLineCount') }}</span></div>
<div class="metric-code"><b>{{ fmt(p.stats.codeLines) }}</b><span>{{ t('codeLines') }}</span></div>
<div class="metric-files"><b>{{ fmt(p.stats.fileCount) }}</b><span>{{ t('fileCount') }}</span></div>
</div>
<div class="language-bar"><i v-for="(x, i) in p.languages.slice(0, 5)" :key="x.name" :style="{ background: palette[i], width: (x.code / Math.max(1, total(p)) * 100) + '%' }" /></div>
<div class="legend">
<span v-for="(x, i) in p.languages.slice(0, 4)" :key="x.name"><i :style="{ background: palette[i] }" />{{ x.name }} {{ Math.round(x.code / Math.max(1, total(p)) * 100) }}%</span>
<span v-if="!p.languages?.length">{{ t('unanalyzed') }}</span>
</div>
<footer><span> {{ p.stats.commitCount }} {{ t('commitsUnit') }}</span><b class="positive">+{{ fmt(p.stats.addedLines) }}</b><b class="negative">-{{ fmt(p.stats.deletedLines) }}</b></footer>
</article>
<button class="add-card shine-card" @click="open()"><span><Plus /></span>{{ t('addProject') }}</button>
</div>
</div>
<Teleport to="body">
<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>
<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>
<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>
</form>
</div>
<div v-if="groupModal" class="overlay" @click.self="!groupSaving && (groupModal = false)">
<form class="modal compact-modal" @submit.prevent="saveGroup">
<header><h2>{{ groupEditing ? t('editProjectGroup') : t('addProjectGroup') }}</h2><button type="button" :disabled="groupSaving" @click="groupModal = false">&times;</button></header>
<label>{{ t('projectGroupName') }}<input v-model="groupForm.name" :disabled="groupSaving" required /></label>
<p v-if="groupError" class="form-error">{{ groupError }}</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>
</Teleport>
</template>

View File

@@ -0,0 +1,47 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { RefreshCw, Trash2, ScrollText, Info, TriangleAlert, CircleX } from 'lucide-vue-next'
import { call } from '../api'
import StatCard from '../components/StatCard.vue'
const { t } = useI18n()
const logs = ref([])
const filter = ref('all')
const auto = ref(true)
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') }
async function clear() {
if (confirm(t('clearLogs') + '?')) {
await call('ClearLogs')
await load()
}
}
onMounted(async () => {
await load()
timer = setInterval(() => auto.value && load(), 5000)
})
onUnmounted(() => clearInterval(timer))
</script>
<template>
<div class="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>
</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>
<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>
</section>
</div>
</template>

View File

@@ -0,0 +1,210 @@
<script setup>
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 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 { call } from '../api'
import { useAppStore } from '../store'
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const tab = ref('code')
const p = ref({ stats: {}, languages: [] })
const git = ref({ commits: [], refs: [], contributors: [], heatmap: [] })
const diag = ref(null)
const structure = ref({ files: [], folders: [], largeFiles: [], extensions: [] })
const insights = ref({ healthScore: 0, summary: {}, issues: [] })
const loading = ref(true)
const gitLoading = ref(false)
const insightLoading = ref(false)
const selectedDate = ref('')
const issueSeverity = ref('all')
const issueType = ref('all')
const detail = ref(null)
const detailLoading = ref(false)
const colors = ['#7b73ff', '#4fd1a1', '#4da5ff', '#f4c84a', '#ef6683', '#23b5d3']
const fmt = n => {
n = +n || 0
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
}
const bytes = n => n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : (n || 0) + ' B'
const pie = computed(() => ({
backgroundColor: 'transparent',
tooltip: { trigger: 'item' },
series: [{ type: 'pie', radius: ['52%', '76%'], label: { show: false }, data: p.value.languages.map((x, i) => ({ name: x.name, value: x.code, itemStyle: { color: colors[i % colors.length] } })) }]
}))
const visibleCommits = computed(() => selectedDate.value ? git.value.commits.filter(c => c.date?.slice(0, 10) === selectedDate.value) : git.value.commits)
const gitErrorCode = computed(() => git.value.error || diag.value?.errorCode || '')
const gitErrorText = computed(() => gitErrorCode.value ? t(`errors.${gitErrorCode.value}`) : '')
const issueTypes = computed(() => [...new Set((insights.value.issues || []).map(x => x.type))])
const filteredIssues = computed(() => (insights.value.issues || []).filter(x => (issueSeverity.value === 'all' || x.severity === issueSeverity.value) && (issueType.value === 'all' || x.type === issueType.value)))
async function load() {
loading.value = true
;[p.value, git.value, structure.value, insights.value] = await Promise.all([call('GetProject', +route.params.id), call('GetGitStats', +route.params.id), call('GetStructure', +route.params.id), call('GetProjectInsights', +route.params.id)])
try { diag.value = await call('GetGitDiagnostics', +route.params.id) } catch { diag.value = null }
loading.value = false
}
async function refreshInsights() {
insightLoading.value = true
try {
insights.value = await call('RefreshProjectInsights', +route.params.id)
store.showToast({ type: 'success', key: 'insightDone' })
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
insightLoading.value = false
}
}
async function analyze(kind = tab.value === 'git' ? 'git' : 'code') {
await store.analyze(+route.params.id, kind)
}
async function selectRef(r) {
gitLoading.value = true
try {
git.value = await call('GetGitStatsForRef', +route.params.id, r.name)
diag.value = await call('GetGitDiagnostics', +route.params.id)
selectedDate.value = ''
} catch (e) {
const code = String(e)
store.showToast({ type: 'error', key: code ? `errors.${code}` : null, text: code })
git.value = { ...git.value, available: false, error: code }
} finally {
gitLoading.value = false
}
}
async function checkout(r) {
if (!confirm(t('checkoutConfirm', { ref: r.name }))) return
gitLoading.value = true
try {
await call('CheckoutBranch', +route.params.id, r.name)
store.showToast({ type: 'success', key: 'branchChanged' })
git.value = await call('GetGitStatsForRef', +route.params.id, '')
await store.analyze(+route.params.id, 'git')
} catch (e) {
const raw = String(e)
store.showToast({ type: 'error', key: raw.includes('GIT_WORKTREE_DIRTY') ? 'dirty' : `errors.${raw}`, text: raw })
} finally {
gitLoading.value = false
}
}
async function showCommit(c) {
detailLoading.value = true
detail.value = { ...c, files: [] }
try { detail.value = await call('GetCommitDetails', +route.params.id, c.hash) } finally { detailLoading.value = false }
}
onMounted(load)
</script>
<template>
<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>
<div><h1>{{ p.name }}</h1><p>{{ p.path }}</p></div>
</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>
</div>
</header>
<template v-if="tab === 'code'">
<div class="stats-grid five">
<StatCard :icon="Rows3" :value="fmt(p.stats.totalLines)" :label="t('totalLineCount')" />
<StatCard :icon="Code2" :value="fmt(p.stats.codeLines)" :label="t('codeLines')" />
<StatCard :icon="MessageSquareText" :value="fmt(p.stats.commentLines)" :label="t('commentLines')" />
<StatCard :icon="Rows3" :value="fmt(p.stats.blankLines)" :label="t('blankLines')" />
<StatCard :icon="Files" :value="fmt(p.stats.fileCount)" :label="t('fileCount')" />
</div>
<div class="split">
<section class="panel shine-card"><h2>{{ t('languageDistribution') }}</h2><ChartView v-if="p.languages.length" :option="pie" /><div v-else class="empty">{{ t('empty') }}</div></section>
<section class="panel shine-card"><h2>{{ t('languageDetails') }}</h2><div class="language-list"><div v-for="(x, i) in p.languages" :key="x.name"><b><i :style="{ background: colors[i % colors.length] }" />{{ x.name }}</b><span>{{ x.files }} {{ t('files') }}</span><strong>{{ fmt(x.code) }} {{ t('lines') }}</strong></div></div></section>
</div>
</template>
<template v-else-if="tab === 'git'">
<div class="git-context">
<span><GitBranch />{{ t('workspaceBranch') }}: <b>{{ git.workspaceBranch || git.currentBranch || diag?.workspaceBranch || '-' }}</b></span>
<span>{{ t('viewRef') }}: <b>{{ git.viewRef || git.currentBranch || diag?.viewRef || '-' }}</b></span>
<RefreshCw v-if="gitLoading" class="spin" />
</div>
<section v-if="gitErrorCode" class="panel git-error-panel">
<TriangleAlert />
<div><h2>{{ t('gitUnavailable') }}</h2><p>{{ gitErrorText || t('gitUnavailableHint') }}</p><code v-if="diag?.detail">{{ t('gitDetail') }}: {{ diag.detail }}</code></div>
<button class="btn secondary" @click="analyze('git')"><RefreshCw />{{ t('analyzeGit') }}</button>
</section>
<div class="stats-grid four">
<StatCard :icon="GitCommitHorizontal" :value="git.commitCount" :label="t('commitCount')" />
<StatCard :icon="Plus" tone="green" :value="'+' + fmt(git.added)" :label="t('addedLines')" />
<StatCard :icon="Minus" tone="red" :value="'-' + fmt(git.deleted)" :label="t('deletedLines')" />
<StatCard :icon="Users" tone="blue" :value="git.contributorCount" :label="t('contributors')" />
</div>
<section class="panel heat-panel shine-card"><div class="section-head"><h2>{{ t('activityHeatmap') }}</h2><button v-if="selectedDate" class="btn secondary" @click="selectedDate = ''">{{ t('selectedDate', { date: selectedDate }) }} · {{ t('clearFilter') }}</button></div><GitHeatmap :days="git.heatmap" :selected="selectedDate" @select="selectedDate = $event" /></section>
<div class="split git-split">
<section class="panel structure-scroll-panel"><h2>{{ t('branches') }}</h2><div class="branch interactive" v-for="r in git.refs" :key="r.name" :class="{ selected: r.name === git.viewRef }" @click="selectRef(r)"><span :class="{ current: r.current }"><GitCommitHorizontal />{{ r.name }}</span><code>{{ r.hash }}</code><small>{{ r.kind }}</small><button class="checkout-btn" :title="t('checkout')" @click.stop="checkout(r)"><ExternalLink /></button></div><div v-if="!git.refs?.length" class="empty">{{ t('empty') }}</div></section>
<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 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>
<template v-else-if="tab === 'structure'">
<div class="stats-grid four">
<StatCard :icon="Files" :value="structure.totalFiles" :label="t('totalFiles')" />
<StatCard :icon="Folder" :value="structure.totalDirs" :label="t('folderCount')" />
<StatCard :icon="HardDrive" :value="bytes(structure.totalSize || 0)" :label="t('totalSize')" />
<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('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>
</template>
<template v-else>
<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">
<h2>{{ t('deepInsights') }}</h2>
<p>{{ t('deepInsightsHint') }}</p>
<div class="insight-counts">
<span class="high">{{ insights.summary?.high || 0 }} {{ t('highRisk') }}</span>
<span class="medium">{{ insights.summary?.medium || 0 }} {{ t('mediumRisk') }}</span>
<span class="low">{{ insights.summary?.low || 0 }} {{ t('lowRisk') }}</span>
<span>{{ insights.summary?.todoCount || 0 }} TODO</span>
</div>
</div>
<button class="btn secondary" :disabled="insightLoading" @click="refreshInsights"><RefreshCw :class="{ spin: insightLoading }" />{{ t('refresh') }}</button>
</section>
<section class="panel">
<div class="section-head insight-filter">
<h2>{{ t('issueList') }}</h2>
<div class="actions">
<select v-model="issueSeverity"><option value="all">{{ t('allSeverity') }}</option><option value="high">{{ t('highRisk') }}</option><option value="medium">{{ t('mediumRisk') }}</option><option value="low">{{ t('lowRisk') }}</option></select>
<select v-model="issueType"><option value="all">{{ t('allTypes') }}</option><option v-for="type in issueTypes" :key="type" :value="type">{{ type }}</option></select>
</div>
</div>
<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>
</article>
<div v-if="!filteredIssues.length" class="empty"><ClipboardCheck />{{ t('noIssues') }}</div>
</div>
</section>
</template>
<div class="center-action"><button class="btn secondary" @click="analyze()"><RefreshCw />{{ t('analyze') }}</button></div>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { FileSliders, Database, Plus, Trash2, Upload, BarChart3, Folder, Languages, SunMoon, CheckCircle2, Info, Copy } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
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 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:'保留当前页面,只显示底部进度'}
]
async function load(){
rules.value=await call('GetRules')
const [saved,bootstrap]=await Promise.all([call('GetSettings'),call('GetBootstrapStatus')])
Object.assign(settings,saved)
settings.databasePath=bootstrap.databasePath||saved.databasePath||bootstrap.defaultPath||''
}
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))}
function apply(){locale.value=settings.locale;let theme=settings.theme;if(theme==='system')theme=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';document.documentElement.dataset.theme=theme;document.documentElement.style.setProperty('--glass-user-opacity',String((settings.glassOpacity||55)/100))}
async function migrate(){
dbMessage.value=''
try{
const p=await call('SelectInitialDatabaseFile',settings.databasePath)
if(!p)return
await call('MigrateDatabase',p)
const status=await call('GetBootstrapStatus')
settings.databasePath=status.databasePath
dbMessage.value='数据库已迁移并切换到新位置'
store.showToast({type:'success',text:dbMessage.value})
}catch(e){dbMessage.value=String(e);store.showToast({type:'error',text:'数据库迁移失败'})}
}
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()})
</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>
</div></template>