initial project

This commit is contained in:
2026-06-25 22:39:06 +08:00
commit 5a5ac2fcde
110 changed files with 8693 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
*.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

101
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

2
android/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

54
android/app/build.gradle Normal file
View File

@@ -0,0 +1,54 @@
apply plugin: 'com.android.application'
android {
namespace = "org.finbuddy"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "org.finbuddy"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View File

@@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-browser')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

21
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -0,0 +1,5 @@
package org.finbuddy;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">FinBuddy</string>
<string name="title_activity_main">FinBuddy</string>
<string name="package_name">org.finbuddy</string>
<string name="custom_url_scheme">org.finbuddy</string>
</resources>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View File

@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

29
android/build.gradle Normal file
View File

@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@@ -0,0 +1,6 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-browser'
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')

22
android/gradle.properties Normal file
View File

@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/gradlew vendored Normal file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

5
android/settings.gradle Normal file
View File

@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

16
android/variables.gradle Normal file
View File

@@ -0,0 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}

9
capacitor.config.json Normal file
View File

@@ -0,0 +1,9 @@
{
"appId": "org.finbuddy",
"appName": "FinBuddy",
"webDir": "dist",
"server": {
"url": "http://192.168.0.102:5173",
"cleartext": true
}
}

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>finance-frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

3210
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "finance-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@capacitor/android": "^8.4.1",
"@capacitor/browser": "^8.0.3",
"@capacitor/cli": "^8.4.1",
"@capacitor/core": "^8.4.1",
"axios": "^1.16.0",
"chart.js": "^4.5.1",
"pinia": "^3.0.4",
"vue": "^3.5.34",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",
"@vitejs/plugin-vue": "^6.0.6",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.14",
"tailwindcss": "^4.3.0",
"vite": "^8.0.12"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {}, // 👈 注意這裡改成了 @tailwindcss/postcss
autoprefixer: {},
},
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

7
src/App.vue Normal file
View File

@@ -0,0 +1,7 @@
<script setup>
import { RouterView } from 'vue-router';
</script>
<template>
<RouterView />
</template>

View File

@@ -0,0 +1,146 @@
<template>
<div class="bg-white rounded-lg p-2" style="position: relative; width: 100%; height: 350px;">
<Line
v-if="chartData.labels && chartData.labels.length"
:data="chartData"
:options="chartOptions"
:plugins="[zeroLinePlugin]"
/>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
} from 'chart.js'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
)
const props = defineProps({
trendData: { type: Array, required: true }
})
const chartData = ref({ labels: [], datasets: [] })
const chartOptions = ref({})
const colorPalette = ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#EC4899', '#06B6D4']
// 🟢 核心加強:定義 0% 虛線外掛
const zeroLinePlugin = {
id: 'zeroLine',
beforeDraw: (chart) => {
const { ctx, chartArea: { left, right }, scales: { y } } = chart
const zeroY = y.getPixelForValue(0) // 💡 動態取得 0% 所在的 Y 軸像素高
// 防止 0% 超出目前畫面的顯示邊界
if (zeroY >= chart.chartArea.top && zeroY <= chart.chartArea.bottom) {
ctx.save()
ctx.beginPath()
ctx.lineWidth = 1.5
ctx.strokeStyle = '#9ca3af' // 灰色的成本線
ctx.setLineDash([5, 5]) // 畫出 [5像素實線, 5像素空白] 的虛線效果
ctx.moveTo(left, zeroY)
ctx.lineTo(right, zeroY)
ctx.stroke()
ctx.closePath()
ctx.restore()
}
}
}
const updateChart = () => {
if (!props.trendData || !props.trendData.length) return
const allDates = new Set()
props.trendData.forEach(holding => {
holding.data.forEach(item => allDates.add(item.date))
})
const xAxisDates = Array.from(allDates).sort()
const datasets = props.trendData.map((holding, index) => {
const rateMap = {}
holding.data.forEach(item => { rateMap[item.date] = item.rate })
const seriesData = xAxisDates.map(date => rateMap[date] !== undefined ? rateMap[date] : null)
const color = colorPalette[index % colorPalette.length]
return {
label: `${holding.symbol} ${holding.name.substring(0, 5)}`,
data: seriesData,
borderColor: color,
backgroundColor: color,
borderWidth: 2.5,
tension: 0.3,
pointRadius: 0,
pointHoverRadius: 5,
spanGaps: true
}
})
chartData.value = { labels: xAxisDates, datasets: datasets }
chartOptions.value = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { color: '#374151', boxWidth: 12, font: { size: 12 } }
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
titleColor: '#4b5563',
bodyColor: '#1f2937',
borderColor: '#e5e7eb',
borderWidth: 1,
padding: 10,
callbacks: {
label: function (context) {
let label = context.dataset.label || ''
if (label) label += ': '
if (context.parsed.y !== null) {
label += (context.parsed.y >= 0 ? '+' : '') + context.parsed.y.toFixed(2) + '%'
}
return label
}
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: '#6B7280', maxRotation: 45, minRotation: 0 }
},
y: {
grid: { color: '#f3f4f6' },
ticks: {
color: '#6B7280',
callback: function (value) { return value.toFixed(1) + '%' }
}
}
}
}
}
watch(() => props.trendData, () => { updateChart() }, { deep: true, immediate: true })
onMounted(() => { updateChart() })
</script>

View File

@@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg">
<path
d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z"
/>
</svg>
</template>

View File

@@ -0,0 +1,34 @@
<script setup>
import { computed } from 'vue';
const emit = defineEmits(['update:checked']);
const props = defineProps({
checked: {
type: [Array, Boolean],
required: true,
},
value: {
default: null,
},
});
const proxyChecked = computed({
get() {
return props.checked;
},
set(val) {
emit('update:checked', val);
},
});
</script>
<template>
<input
type="checkbox"
:value="value"
v-model="proxyChecked"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
/>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<button
class="inline-flex items-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 active:bg-red-700"
>
<slot />
</button>
</template>

View File

@@ -0,0 +1,84 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
const props = defineProps({
align: {
type: String,
default: 'right',
},
width: {
type: String,
default: '48',
},
contentClasses: {
type: String,
default: 'py-1 bg-white',
},
});
const closeOnEscape = (e) => {
if (open.value && e.key === 'Escape') {
open.value = false;
}
};
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
const widthClass = computed(() => {
return {
48: 'w-48',
}[props.width.toString()];
});
const alignmentClasses = computed(() => {
if (props.align === 'left') {
return 'ltr:origin-top-left rtl:origin-top-right start-0';
} else if (props.align === 'right') {
return 'ltr:origin-top-right rtl:origin-top-left end-0';
} else {
return 'origin-top';
}
});
const open = ref(false);
</script>
<template>
<div class="relative">
<div @click="open = !open">
<slot name="trigger" />
</div>
<!-- Full Screen Dropdown Overlay -->
<div
v-show="open"
class="fixed inset-0 z-40"
@click="open = false"
></div>
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-show="open"
class="absolute z-50 mt-2 rounded-md shadow-lg"
:class="[widthClass, alignmentClasses]"
style="display: none"
@click="open = false"
>
<div
class="rounded-md ring-1 ring-black ring-opacity-5"
:class="contentClasses"
>
<slot name="content" />
</div>
</div>
</Transition>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
import { RouterLink } from 'vue-router';
defineProps({
href: {
type: String,
required: true,
},
});
</script>
<template>
<RouterLink
:to="href"
class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 transition duration-150 ease-in-out hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
>
<slot />
</RouterLink>
</template>

View File

@@ -0,0 +1,80 @@
<script setup>
import { computed } from 'vue'
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
const props = defineProps({
history: Array, // [{ date, close }]
buyPoints: Array, // [{ date, price, note }]
symbol: String,
})
const chartData = computed(() => {
if (!props.history?.length) return null
const labels = props.history.map(h => h.date)
const prices = props.history.map(h => h.close)
const buyPointMap = {}
props.buyPoints?.forEach(bp => {
buyPointMap[bp.date] = bp
})
const pointRadius = labels.map(date => buyPointMap[date] ? 6 : 0)
const pointBackgroundColor = labels.map(date => buyPointMap[date] ? '#ef4444' : '#6366f1')
return {
labels,
datasets: [{
label: props.symbol,
data: prices,
borderColor: '#6366f1',
borderWidth: 1.5,
pointRadius,
pointBackgroundColor,
tension: 0.1,
fill: false,
}]
}
})
const chartOptions = {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
afterBody: (items) => {
const date = items[0].label
const bp = props.buyPoints?.find(b => b.date === date)
if (bp) return `買入價:${bp.price}${bp.note ? `${bp.note}` : ''}`
}
}
}
},
scales: {
x: {
ticks: { maxTicksLimit: 10, maxRotation: 0 }
}
}
}
</script>
<template>
<div v-if="chartData" class="mb-4">
<Line :data="chartData" :options="chartOptions" />
</div>
<div v-else class="text-xs text-gray-400 text-center py-4">
載入歷史資料中...
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
defineProps({
message: {
type: String,
},
});
</script>
<template>
<div v-show="message">
<p class="text-sm text-red-600">
{{ message }}
</p>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
defineProps({
value: {
type: String,
},
});
</script>
<template>
<label class="block text-sm font-medium text-gray-700">
<span v-if="value">{{ value }}</span>
<span v-else><slot /></span>
</label>
</template>

View File

@@ -0,0 +1,136 @@
<script setup>
import { computed } from "vue";
import { Line } from "vue-chartjs";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend,
} from "chart.js";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend,
);
const props = defineProps({
history: Array, // [{ date, close }]
markPoints: Array, // [{ date, price, note }]
symbol: String,
});
const cleanDate = (dStr) =>
dStr ? String(dStr).replace(/\//g, "-").substring(0, 10).trim() : "";
const chartData = computed(() => {
if (!props.history?.length) return null;
const labels = props.history.map((h) => h.date);
const prices = props.history.map((h) => h.close);
const markPointMap = {};
props.markPoints?.forEach((mp) => {
if (mp.date) {
const dateKey = cleanDate(mp.date);
if (!markPointMap[dateKey]) markPointMap[dateKey] = [];
markPointMap[dateKey].push(mp);
}
});
const pointRadius = labels.map((date) =>
markPointMap[cleanDate(date)] ? 6 : 0,
);
const pointBackgroundColor = labels.map((date) => {
const mps = markPointMap[cleanDate(date)];
if (mps && mps.length > 0) {
const hasSell = mps.some(
(m) =>
String(m.type).toLowerCase().includes("sell") ||
String(m.type).includes("賣"),
);
return !hasSell ? "#10b981" : "#ef4444";
}
return "#6366f1";
});
return {
labels,
datasets: [
{
label: props.symbol,
data: prices,
borderColor: "#6366f1",
borderWidth: 1.5,
pointRadius,
pointBackgroundColor,
pointBorderColor: pointBackgroundColor,
tension: 0.1,
fill: false,
},
],
};
});
const chartOptions = {
responsive: true,
interaction: {
mode: "index", // 只要 X 軸對齊就觸發
intersect: false, // false 代表滑鼠「不需要」精準指在折線或圓點上
},
plugins: {
legend: { display: false },
tooltip: {
mode: "index",
intersect: false,
callbacks: {
afterBody: (items) => {
// 🚨 現在這邊就能順利呼叫到最外面的 cleanDate 了!
const date = cleanDate(items[0].label);
const mps = props.markPoints?.filter(
(m) => cleanDate(m.date) === date,
);
if (mps && mps.length > 0) {
const lines = [];
mps.forEach((mp) => {
const typeStr = String(mp.type).toLowerCase();
const isBuy = typeStr.includes("buy") || typeStr.includes("買");
const action = isBuy ? "買入價" : "賣出價";
const note = mp.note ? `${mp.note}` : "";
lines.push(`${action}${mp.price}${note}`);
});
return lines;
}
},
},
},
},
scales: {
x: {
offset: true,
ticks: { maxTicksLimit: 10, maxRotation: 0 },
},
y: {
grace: '5%',
},
},
};
</script>
<template>
<div v-if="chartData" class="mb-4">
<Line :data="chartData" :options="chartOptions" />
</div>
<div v-else class="text-xs text-gray-400 text-center py-4">
載入歷史資料中...
</div>
</template>

123
src/Components/Modal.vue Normal file
View File

@@ -0,0 +1,123 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
maxWidth: {
type: String,
default: '2xl',
},
closeable: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['close']);
const dialog = ref();
const showSlot = ref(props.show);
watch(
() => props.show,
() => {
if (props.show) {
document.body.style.overflow = 'hidden';
showSlot.value = true;
dialog.value?.showModal();
} else {
document.body.style.overflow = '';
setTimeout(() => {
dialog.value?.close();
showSlot.value = false;
}, 200);
}
},
);
const close = () => {
if (props.closeable) {
emit('close');
}
};
const closeOnEscape = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
if (props.show) {
close();
}
}
};
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape);
document.body.style.overflow = '';
});
const maxWidthClass = computed(() => {
return {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
}[props.maxWidth];
});
</script>
<template>
<dialog
class="z-50 m-0 min-h-full min-w-full overflow-y-auto bg-transparent backdrop:bg-transparent"
ref="dialog"
>
<div
class="fixed inset-0 z-50 overflow-y-auto px-4 py-6 sm:px-0"
scroll-region
>
<Transition
enter-active-class="ease-out duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="ease-in duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-show="show"
class="fixed inset-0 transform transition-all"
@click="close"
>
<div
class="absolute inset-0 bg-gray-500 opacity-75"
/>
</div>
</Transition>
<Transition
enter-active-class="ease-out duration-300"
enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to-class="opacity-100 translate-y-0 sm:scale-100"
leave-active-class="ease-in duration-200"
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
v-show="show"
class="mb-6 transform overflow-hidden rounded-lg bg-white shadow-xl transition-all sm:mx-auto sm:w-full"
:class="maxWidthClass"
>
<slot v-if="showSlot" />
</div>
</Transition>
</div>
</dialog>
</template>

View File

@@ -0,0 +1,26 @@
<script setup>
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
const props = defineProps({
href: {
type: String,
required: true,
},
active: {
type: Boolean,
},
});
const classes = computed(() =>
props.active
? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 text-sm font-medium leading-5 text-gray-900 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out'
: 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out',
);
</script>
<template>
<RouterLink :to="href" :class="classes">
<slot />
</RouterLink>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<button
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900"
>
<slot />
</button>
</template>

View File

@@ -0,0 +1,26 @@
<script setup>
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
const props = defineProps({
href: {
type: String,
required: true,
},
active: {
type: Boolean,
},
});
const classes = computed(() =>
props.active
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 text-start text-base font-medium text-indigo-700 bg-indigo-50 focus:outline-none focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700 transition duration-150 ease-in-out'
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out',
);
</script>
<template>
<RouterLink :to="href" :class="classes">
<slot />
</RouterLink>
</template>

View File

@@ -0,0 +1,17 @@
<script setup>
defineProps({
type: {
type: String,
default: 'button',
},
});
</script>
<template>
<button
:type="type"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-widest text-gray-700 shadow-sm transition duration-150 ease-in-out hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25"
>
<slot />
</button>
</template>

View File

@@ -0,0 +1,26 @@
<script setup>
import { onMounted, ref } from 'vue';
const model = defineModel({
type: String,
required: true,
});
const input = ref(null);
onMounted(() => {
if (input.value.hasAttribute('autofocus')) {
input.value.focus();
}
});
defineExpose({ focus: () => input.value.focus() });
</script>
<template>
<input
class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
v-model="model"
ref="input"
/>
</template>

View File

@@ -0,0 +1,199 @@
<script setup>
import { ref } from "vue";
import ApplicationLogo from "@/Components/ApplicationLogo.vue";
import Dropdown from "@/Components/Dropdown.vue";
import DropdownLink from "@/Components/DropdownLink.vue";
import NavLink from "@/Components/NavLink.vue";
import ResponsiveNavLink from "@/Components/ResponsiveNavLink.vue";
import { RouterLink, useRouter, useRoute } from "vue-router"; // 引入 useRoute 來判斷當前頁面
import { useAuthStore } from "@/stores/auth";
const showingNavigationDropdown = ref(false);
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
// 模擬登出功能
const logout = () => {
authStore.logout(); // 呼叫 auth store 的 logout 方法
router.push({ name: "login" });
};
</script>
<template>
<div>
<div class="min-h-screen bg-gray-100">
<nav class="border-b border-gray-100 bg-white">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex">
<!-- Logo -->
<div class="flex shrink-0 items-center">
<RouterLink to="/">
<ApplicationLogo
class="block h-9 w-auto fill-current text-gray-800"
/>
</RouterLink>
</div>
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink to="/" :active="route.path === '/'">
Dashboard
</NavLink>
<NavLink
to="/accounts"
:active="route.path.startsWith('/accounts')"
>
資產
</NavLink>
<NavLink
to="/expenses"
:active="route.path.startsWith('/expenses')"
>
記帳
</NavLink>
</div>
</div>
<div class="hidden sm:ms-6 sm:flex sm:items-center">
<div class="relative ms-3">
<Dropdown align="right" width="48">
<template #trigger>
<span class="inline-flex rounded-md">
<button
type="button"
class="inline-flex items-center rounded-md border border-transparent bg-white px-3 py-2 text-sm font-medium leading-4 text-gray-500 transition duration-150 ease-in-out hover:text-gray-700 focus:outline-none"
>
<!-- 修改移除 $page.props -->
{{ authStore.name }}
<svg
class="-me-0.5 ms-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</span>
</template>
<template #content>
<!-- 修改所有 route() 改為字串路徑 -->
<DropdownLink to="/accounts">資產</DropdownLink>
<DropdownLink to="/profile">檢視個人資料</DropdownLink>
<DropdownLink to="/expenses">記帳</DropdownLink>
<!-- 修改登出暫時改為按鈕觸發 -->
<button
@click="logout"
class="block w-full px-4 py-2 text-left text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none transition duration-150 ease-in-out"
>
Log Out
</button>
</template>
</Dropdown>
</div>
</div>
<!-- Hamburger (手機版選單) -->
<div class="-me-2 flex items-center sm:hidden">
<button
@click="showingNavigationDropdown = !showingNavigationDropdown"
class="inline-flex items-center justify-center rounded-md p-2 text-gray-400 transition duration-150 ease-in-out hover:bg-gray-100 hover:text-gray-500 focus:bg-gray-100 focus:text-gray-500 focus:outline-none"
>
<svg
class="h-6 w-6"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
>
<path
:class="{
hidden: showingNavigationDropdown,
'inline-flex': !showingNavigationDropdown,
}"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
<path
:class="{
hidden: !showingNavigationDropdown,
'inline-flex': showingNavigationDropdown,
}"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- Responsive Navigation Menu (手機版收合內容) -->
<div
:class="{
block: showingNavigationDropdown,
hidden: !showingNavigationDropdown,
}"
class="sm:hidden"
>
<div class="space-y-1 pb-3 pt-2">
<ResponsiveNavLink to="/" :active="route.path === '/'">
Dashboard
</ResponsiveNavLink>
</div>
<div class="border-t border-gray-200 pb-1 pt-4">
<div class="px-4">
<div class="text-base font-medium text-gray-800">
{{ authStore.name }}
</div>
</div>
<div class="mt-3 space-y-1">
<ResponsiveNavLink
to="/profile"
:active="route.path === '/profile'"
>檢視個人資料</ResponsiveNavLink
>
<ResponsiveNavLink
to="/accounts"
:active="route.path === '/accounts'"
>資產</ResponsiveNavLink
>
<ResponsiveNavLink
to="/expenses"
:active="route.path === '/expenses'"
>記帳</ResponsiveNavLink
>
<button
@click="logout"
class="block w-full pl-3 pr-4 py-2 border-l-4 border-transparent text-left text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 transition duration-150 ease-in-out"
>
Log Out
</button>
</div>
</div>
</div>
</nav>
<header class="bg-white shadow" v-if="$slots.header">
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<slot name="header" />
</div>
</header>
<main>
<slot />
</main>
</div>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
import { RouterLink } from 'vue-router';
</script>
<template>
<div
class="flex min-h-screen flex-col items-center bg-gray-100 pt-6 sm:justify-center sm:pt-0"
>
<div>
<RouterLink :to="{ name: 'login' }">
<ApplicationLogo class="h-20 w-20 fill-current text-gray-600" />
</RouterLink>
</div>
<div
class="mt-6 w-full overflow-hidden bg-white px-6 py-4 shadow-md sm:max-w-md sm:rounded-lg"
>
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,393 @@
<script setup>
import { ref, onMounted, computed } from "vue";
import { useRouter } from "vue-router";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import api from "@/axios.js";
const router = useRouter();
const accounts = ref([]);
const showModal = ref(false);
const isEditing = ref(false);
const form = ref({
id: null,
name: "",
type: "cash",
currency: "TWD",
balance: 0,
note: "",
});
const typeLabel = {
cash: "現金",
bank: "銀行",
stock: "股票",
fund: "基金",
wallet: "儲值卡或儲值帳戶",
credit_card: "信用卡",
};
const fetchAccounts = async () => {
const res = await api.get("/v1/accounts");
accounts.value = res.data;
console.log("Fetched accounts:", accounts.value);
};
const openCreate = () => {
isEditing.value = false;
form.value = {
id: null,
name: "",
type: "cash",
currency: "TWD",
balance: 0,
note: "",
};
showModal.value = true;
};
const openEdit = (account) => {
isEditing.value = true;
form.value = { ...account };
showModal.value = true;
};
const submit = async () => {
if (isEditing.value) {
await api.put(`/v1/accounts/${form.value.id}`, form.value);
} else {
await api.post("/v1/accounts", form.value);
}
showModal.value = false;
fetchAccounts();
};
const deleteAccount = async (id) => {
if (!confirm("確定要刪除這個帳戶嗎?")) return;
await api.delete(`/v1/accounts/${id}`);
fetchAccounts();
};
// 新增過濾功能
const filterType = ref("all");
const filteredAccounts = computed(() => {
if (filterType.value === "all") return accounts.value;
return accounts.value.filter((a) => a.type === filterType.value);
});
onMounted(fetchAccounts);
</script>
<template>
<AuthenticatedLayout>
<template #header>資產帳戶</template>
<div class="py-8 mx-auto max-w-4xl px-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-semibold text-gray-700">所有帳戶</h2>
<div class="flex gap-2 mb-4 flex-wrap">
<button
v-for="f in [
{ value: 'all', label: '全部' },
{ value: 'cash', label: '現金' },
{ value: 'bank', label: '銀行' },
{ value: 'wallet', label: '儲值' },
{ value: 'credit_card', label: '信用卡' },
{ value: 'stock', label: '股票' },
{ value: 'fund', label: '基金' },
]"
:key="f.value"
@click="filterType = f.value"
:class="
filterType === f.value
? 'bg-indigo-600 text-white'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
"
class="px-3 py-1 rounded-full text-sm transition"
>
{{ f.label }}
</button>
</div>
<button
@click="openCreate"
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 text-sm"
>
+ 新增帳戶
</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div
v-for="account in filteredAccounts"
:key="account.id"
class="bg-white rounded-lg shadow p-4 flex justify-between items-start"
>
<div class="text-left">
<div class="text-xs text-gray-400 mb-1">
{{ typeLabel[account.type] }}
</div>
<div class="font-semibold text-gray-800">
{{ account.name }}
</div>
<div class="text-sm text-gray-500 mt-1">
<template v-if="account.type === 'credit_card'">
本期未繳 {{ Number(account.unpaid_amount).toLocaleString() }}
{{ account.currency }}
</template>
<template
v-else-if="account.type === 'stock' || account.type === 'fund'"
>
<div
class="account-details"
style="
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
text-align: left;
margin-top: 8px;
"
>
<div
style="
display: flex;
justify-content: space-between;
font-size: 0.95rem;
color: #555;
"
>
<span>持倉成本</span>
<span style="font-weight: 500"
>{{ Math.round(account.total_cost).toLocaleString() }}
{{ account.currency }}</span
>
</div>
<div
style="
display: flex;
justify-content: space-between;
font-size: 0.95rem;
color: #555;
"
>
<span>目前市值</span>
<span style="font-weight: 500"
>{{ Math.round(account.total_value).toLocaleString() }}
{{ account.currency }}</span
>
</div>
<hr
style="
border: 0;
border-top: 1px dashed #eee;
margin: 4px 0;
"
/>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
"
>
<span style="font-size: 0.95rem; color: #555"
>未實現損益</span
>
<div
:style="{
color:
account.total_profit >= 0 ? '#52c41a' : '#ff4d4f',
fontWeight: 'bold',
fontSize: '1.05rem',
}"
>
<span
>{{ account.total_profit >= 0 ? "+" : ""
}}{{
Math.round(account.total_profit).toLocaleString()
}}</span
>
<span
style="
font-size: 0.85rem;
margin-left: 6px;
padding: 2px 6px;
background-color: rgba(82, 196, 26, 0.1);
border-radius: 4px;
"
v-if="account.total_profit >= 0"
>
{{
(Math.round(account.total_rate * 100) / 100).toFixed(
2,
)
}}%
</span>
<span
style="
font-size: 0.85rem;
margin-left: 6px;
padding: 2px 6px;
background-color: rgba(255, 77, 79, 0.1);
border-radius: 4px;
"
v-else
>
{{
(Math.round(account.total_rate * 100) / 100).toFixed(
2,
)
}}%
</span>
</div>
</div>
</div>
</template>
<template v-else>
{{ account.type !== "wallet" ? account.currency : "點數" }}
{{ Number(account.balance).toLocaleString() }}
</template>
</div>
<div v-if="account.note" class="text-xs text-gray-400 mt-1">
{{ account.note }}
</div>
</div>
<div class="flex flex-col gap-2 items-end">
<button
@click="openEdit(account)"
class="text-xs text-indigo-600 hover:underline"
>
編輯
</button>
<button
@click="deleteAccount(account.id)"
class="text-xs text-red-500 hover:underline"
>
刪除
</button>
<button
v-if="account.type === 'stock' || account.type === 'fund'"
@click="
router.push({
name: 'accounts.show',
params: { id: account.id },
})
"
class="text-xs text-gray-500 hover:underline"
>
持倉 →
</button>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div
v-if="showModal"
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg p-6 w-full max-w-md">
<h3 class="text-lg font-semibold mb-4">
{{ isEditing ? "編輯帳戶" : "新增帳戶" }}
</h3>
<div class="space-y-3">
<div>
<label class="text-sm text-gray-600">名稱</label>
<input
v-model="form.name"
type="text"
class="mt-1 block w-full border rounded-md px-3 py-2 text-sm"
/>
</div>
<div>
<label class="text-sm text-gray-600">類型</label>
<select
v-model="form.type"
class="mt-1 block w-full border rounded-md px-3 py-2 text-sm"
>
<option value="cash">現金</option>
<option value="bank">銀行</option>
<option value="stock">股票</option>
<option value="fund">基金</option>
<option value="wallet">儲值卡或儲值帳戶</option>
<option value="credit_card">信用卡</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600">幣別</label>
<input
v-model="form.currency"
type="text"
class="mt-1 block w-full border rounded-md px-3 py-2 text-sm"
/>
</div>
<div
v-if="
form.type === 'cash' ||
form.type === 'bank' ||
form.type === 'wallet'
"
>
<label class="text-sm text-gray-600">餘額</label>
<input
v-model="form.balance"
type="number"
class="mt-1 block w-full border rounded-md px-3 py-2 text-sm"
/>
</div>
<div>
<label class="text-sm text-gray-600">備註</label>
<input
v-model="form.note"
type="text"
class="mt-1 block w-full border rounded-md px-3 py-2 text-sm"
/>
</div>
</div>
<template v-if="form.type === 'credit_card'">
<div>
<label class="text-sm text-gray-600">結帳日(每月幾號)</label>
<input
v-model="form.billing_day"
type="number"
min="1"
max="31"
class="mt-1 block w-full border rounded-md px-3 py-2 text-sm"
/>
</div>
<div>
<label class="text-sm text-gray-600">繳款日(每月幾號)</label>
<input
v-model="form.payment_day"
type="number"
min="1"
max="31"
class="mt-1 block w-full border rounded-md px-3 py-2 text-sm"
/>
</div>
</template>
<div class="flex justify-end gap-2 mt-6">
<button
@click="showModal = false"
class="px-4 py-2 text-sm text-gray-600 hover:underline"
>
取消
</button>
<button
@click="submit"
class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-700"
>
儲存
</button>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

566
src/Pages/Accounts/Show.vue Normal file
View File

@@ -0,0 +1,566 @@
<script setup>
import { ref, onMounted, watch } from "vue";
import { useRoute } from "vue-router";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import api from "@/axios.js";
import Modal from "../../Components/Modal.vue";
import InvestmentChart from "../../Components/InvestmentChart.vue";
import AccountTrendChart from "../../Components/AccountTrendChart.vue";
const route = useRoute();
const accountId = route.params.id;
const account = ref(null);
const holdings = ref([]);
const showHoldingModal = ref(false);
const expandedHoldingId = ref(null);
const transactions = ref({});
const currentPrices = ref({});
const showTransactionForm = ref(null);
const investmentHistory = ref({});
const securityOptions = ref([]);
const showOptions = ref(false);
let searchTimer = null;
const holdingForm = ref({
type: "stock",
symbol: "",
name: "",
});
const transactionForm = ref({
action: "buy",
shares: "",
price: "",
amount: "",
nav: "",
fee: "",
tax: "",
traded_at: "",
note: "",
});
const accountTrendData = ref([]);
const fetchAccountTrend = async () => {
try {
const res = await api.get(`/api/accounts/${accountId}/holdings-trend`);
accountTrendData.value = res.data;
} catch (e) {
console.error("無法撈取帳戶多線趨勢圖", e);
}
};
const fetchAccount = async () => {
const res = await api.get(`/api/accounts/${accountId}`);
account.value = res.data;
};
const fetchHoldings = async () => {
const res = await api.get(`/api/accounts/${accountId}/holdings`);
holdings.value = res.data;
// 🟢 修正:直接從大列表的內建數據裡,把最新價格和現值綁定給前端變數
res.data.forEach((holding) => {
currentPrices.value[holding.id] = Number(holding.latest_price);
});
};
const loadTransactions = async (holding) => {
const res = await api.get(`/api/holdings/${holding.id}/transactions`);
transactions.value[holding.id] = res.data;
};
const toggleHolding = async (holding) => {
if (expandedHoldingId.value === holding.id) {
expandedHoldingId.value = null;
return;
}
expandedHoldingId.value = holding.id;
await loadTransactions(holding);
if (holding.type === "stock" || holding.type === "fund") {
loadHistory(holding); // 非同步,不用 await
}
};
const loadHistory = async (holding) => {
try {
const res = await api.get(`/api/holdings/${holding.id}/history`);
investmentHistory.value[holding.id] = res.data;
} catch (e) {
console.error("取得歷史資料失敗", e);
}
};
const submitHolding = async () => {
await api.post(`/api/accounts/${accountId}/holdings`, holdingForm.value);
showHoldingModal.value = false;
holdingForm.value = { type: "stock", symbol: "", name: "" };
await fetchHoldings();
};
const deleteHolding = async (id) => {
if (!confirm("確定要刪除這筆持倉嗎?")) return;
await api.delete(`/api/holdings/delete/${accountId}/${id}`);
fetchHoldings();
};
const submitTransaction = async (holding) => {
await api.post(
`/api/holdings/${holding.id}/transactions`,
transactionForm.value,
);
transactionForm.value = {
action: "buy",
shares: "",
price: "",
amount: "",
nav: "",
fee: "",
tax: "",
traded_at: "",
note: "",
};
showTransactionForm.value = null; // 加這行
await fetchHoldings();
await loadTransactions(holding);
};
const deleteTransaction = async (holding, txId) => {
if (!confirm("確定要刪除這筆交易嗎?")) return;
await api.delete(`/api/holdings/${holding.id}/transactions/${txId}`);
await fetchHoldings();
await loadTransactions(holding);
};
const calcAmount = (tx, holding) => {
if (holding.type === "fund") {
if (tx.action === "buy") {
return Math.round(Number(tx.amount) + Number(tx.fee) + Number(tx.tax));
} else {
return Math.round(Number(tx.amount) - Number(tx.fee) - Number(tx.tax));
}
} else {
if (tx.action === "buy") {
return Math.floor(tx.shares * tx.price + Number(tx.fee) + Number(tx.tax));
} else {
return Math.floor(tx.shares * tx.price - Number(tx.fee) - Number(tx.tax));
}
}
};
const fetchPrice = async (holding) => {
try {
const res = await api.get(`/api/holdings/${holding.id}/realtime`);
currentPrices.value[holding.id] = res.data.price;
} catch (e) {
currentPrices.value[holding.id] = null;
}
};
const fetchAllPrices = async () => {
for (const holding of holdings.value) {
await fetchPrice(holding);
}
};
const currentValue = (holding) => {
const price = currentPrices.value[holding.id];
if (!price) return null;
return Math.round(holding.shares * price);
};
const profitLoss = (holding) => {
const value = currentValue(holding);
if (!value) return null;
const cost =
holding.type === "stock"
? Math.floor(holding.shares * holding.avg_cost)
: Math.round(holding.shares * holding.avg_cost);
return value - cost;
};
const searchSecurities = async (keyword) => {
if (!keyword) {
securityOptions.value = [];
return;
}
const { data } = await api.get("/api/securities/search", {
params: { type: holdingForm.value.type, keyword },
});
securityOptions.value = data;
showOptions.value = true;
};
watch(
() => holdingForm.value.symbol,
(val) => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => searchSecurities(val), 300);
},
);
watch(
() => holdingForm.value.type,
() => {
// 切換類型時代號跟名稱可能對不上,清空避免誤送
holdingForm.value.symbol = "";
holdingForm.value.name = "";
securityOptions.value = [];
},
);
const selectSecurity = (item) => {
holdingForm.value.symbol = item.code;
holdingForm.value.name = item.name;
securityOptions.value = [];
showOptions.value = false;
};
const hideOptionsDelayed = () => {
// 延遲一下,讓點擊清單項目的 click 事件能先觸發
setTimeout(() => {
showOptions.value = false;
}, 150);
};
onMounted(async () => {
await fetchAccount();
await fetchHoldings();
await fetchAccountTrend();
});
</script>
<template>
<AuthenticatedLayout>
<template #header>{{ account?.name ?? "帳戶詳細" }}</template>
<div class="py-8 mx-auto max-w-4xl px-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-semibold text-gray-700">持倉列表</h2>
<button
@click="showHoldingModal = true"
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 text-sm"
>
+ 新增持倉
</button>
</div>
<div
v-if="accountTrendData.length"
class="bg-white rounded-lg shadow p-4 mb-6"
>
<h3 class="text-sm font-semibold text-gray-700 mb-3">
📈 各標的歷史報酬率變化趨勢 (%)
</h3>
<AccountTrendChart :trend-data="accountTrendData" />
</div>
<div
v-for="holding in holdings"
:key="holding.id"
class="bg-white rounded-lg shadow mb-4"
>
<!-- 持倉標題列點擊展開/收合 -->
<div
class="p-4 flex justify-between items-center cursor-pointer"
@click="toggleHolding(holding)"
>
<div class="flex flex-col items-start">
<div class="text-xs text-gray-400">
{{ holding.type === "stock" ? "股票" : "基金" }}
</div>
<div class="font-semibold text-gray-800">
{{ holding.symbol }} {{ holding.name }}
</div>
<div class="text-sm text-gray-500 mt-1">
持有 {{ holding.shares }} 單位 平均成本
{{ Number(holding.avg_cost).toLocaleString() }}
</div>
<div class="text-sm mt-1" v-if="currentPrices[holding.id]">
估計現值 {{ currentValue(holding)?.toLocaleString() }}
<span
:class="
profitLoss(holding) >= 0 ? 'text-green-600' : 'text-red-500'
"
>
{{ profitLoss(holding) >= 0 ? "+" : ""
}}{{ profitLoss(holding)?.toLocaleString() }}
</span>
報酬率:
<span
:class="
profitLoss(holding) >= 0 ? 'text-green-600' : 'text-red-500'
"
>
{{
(
(profitLoss(holding) /
(holding.shares * holding.avg_cost)) *
100
).toFixed(2)
}}%
</span>
</div>
<div v-else class="text-xs text-gray-400 mt-1">載入現值中...</div>
</div>
<div class="flex gap-3 items-center">
<button
@click.stop="deleteHolding(holding.id)"
class="text-xs text-red-500 hover:underline"
>
刪除
</button>
<span class="text-gray-400 text-sm">{{
expandedHoldingId === holding.id ? "▲" : "▼"
}}</span>
</div>
</div>
<!-- 展開的交易明細 -->
<div v-if="expandedHoldingId === holding.id" class="border-t px-4 pb-4">
<div class="flex justify-end my-3">
<button
@click="
showTransactionForm =
showTransactionForm === holding.id ? null : holding.id
"
class="px-3 py-1.5 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-700"
>
{{ showTransactionForm === holding.id ? "取消" : "+ 新增交易" }}
</button>
</div>
<!-- 新增交易表單 -->
<div
v-if="showTransactionForm === holding.id"
class="bg-gray-50 rounded-lg p-4 my-4"
>
<h4 class="text-sm font-semibold text-gray-600 mb-3">新增交易</h4>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">買賣</label>
<select
v-model="transactionForm.action"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
>
<option value="buy">買入</option>
<option value="sell">賣出</option>
</select>
</div>
<div>
<label class="text-xs text-gray-500">交易日期</label>
<input
v-model="transactionForm.traded_at"
type="date"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
<div v-if="holding.type === 'stock'">
<label class="text-xs text-gray-500">股數</label>
<input
v-model="transactionForm.shares"
type="number"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
<div v-else>
<label class="text-xs text-gray-500">投入金額</label>
<input
v-model="transactionForm.amount"
type="number"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
<div v-if="holding.type === 'stock'">
<label class="text-xs text-gray-500">成交價</label>
<input
v-model="transactionForm.price"
type="number"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
<div v-else>
<label class="text-xs text-gray-500">淨值</label>
<input
v-model="transactionForm.nav"
type="number"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
<div>
<label class="text-xs text-gray-500">手續費</label>
<input
v-model="transactionForm.fee"
type="number"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
<div>
<label class="text-xs text-gray-500">交易稅</label>
<input
v-model="transactionForm.tax"
type="number"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
<div class="col-span-2">
<label class="text-xs text-gray-500">備註</label>
<input
v-model="transactionForm.note"
type="text"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
</div>
<div class="flex justify-end mt-3">
<button
@click="submitTransaction(holding)"
class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-700"
>
新增
</button>
</div>
</div>
<InvestmentChart
v-if="holding.type === 'stock' || holding.type === 'fund'"
:history="investmentHistory[holding.id]?.history"
:mark-points="investmentHistory[holding.id]?.buy_points"
:symbol="holding.symbol"
/>
<!-- 交易紀錄列表 -->
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 border-b">
<th class="pb-2 text-left">日期</th>
<th class="pb-2 text-left">買賣</th>
<th class="pb-2 text-right">
{{ holding.type === "stock" ? "股數" : "金額" }}
</th>
<th class="pb-2 text-right">
{{ holding.type === "stock" ? "成交價" : "淨值" }}
</th>
<th class="pb-2 text-right">手續費</th>
<th class="pb-2 text-right"></th>
<th class="pb-2 text-right">買入/贖回金額</th>
<th class="pb-2"></th>
</tr>
</thead>
<tbody>
<tr
v-for="tx in transactions[holding.id] || []"
:key="tx.id"
class="border-b last:border-0"
>
<td class="py-2 text-left">
{{ tx.traded_at.substring(0, 10) }}
</td>
<td class="py-2 text-left">
<span
:class="
tx.action === 'buy' ? 'text-green-600' : 'text-red-500'
"
>
{{ tx.action === "buy" ? "買入" : "賣出" }}
</span>
</td>
<td class="py-2 text-right">
{{
holding.type === "stock"
? tx.shares
: Number(tx.amount).toLocaleString()
}}
</td>
<td class="py-2 text-right">
{{
holding.type === "stock"
? Number(tx.price).toLocaleString()
: tx.nav
}}
</td>
<td class="py-2 text-right">{{ tx.fee }}</td>
<td class="py-2 text-right">{{ tx.tax }}</td>
<td class="py-2 text-right">
{{ calcAmount(tx, holding).toLocaleString() }}
</td>
<td class="py-2 text-right">
<button
@click="deleteTransaction(holding, tx.id)"
class="text-xs text-red-400 hover:underline"
>
刪除
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div
v-if="showHoldingModal"
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg p-6 w-full max-w-md">
<h3 class="text-lg font-semibold mb-4">新增持倉</h3>
<div class="space-y-3">
<div>
<label class="text-sm text-gray-600">類型</label>
<select
v-model="holdingForm.type"
class="mt-1 block w-full border rounded-md px-3 py-2 text-sm"
>
<option value="stock">股票</option>
<option value="fund">基金</option>
</select>
</div>
<div class="relative">
<label class="text-sm text-gray-600">代號</label>
<input
v-model="holdingForm.symbol"
type="text"
autocomplete="off"
@focus="showOptions = securityOptions.length > 0"
@blur="hideOptionsDelayed"
class="mt-1 block w-full border rounded-md px-3 py-2 text-sm"
/>
<ul
v-if="showOptions && securityOptions.length"
class="absolute z-10 bg-white border rounded-md mt-1 w-full max-h-48 overflow-auto shadow-lg"
>
<li
v-for="item in securityOptions"
:key="item.code"
@click="selectSecurity(item)"
class="px-3 py-2 text-sm hover:bg-gray-100 cursor-pointer"
>
<span class="font-medium">{{ item.code }}</span>
<span class="text-gray-500 ml-2">{{ item.name }}</span>
</li>
</ul>
</div>
<div>
<label class="text-sm text-gray-600">名稱</label>
<input
v-model="holdingForm.name"
type="text"
class="mt-1 block w-full border rounded-md px-3 py-2 text-sm"
/>
</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button
@click="showHoldingModal = false"
class="px-4 py-2 text-sm text-gray-600 hover:underline"
>
取消
</button>
<button
@click="submitHolding"
class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-700"
>
儲存
</button>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

View File

@@ -0,0 +1,28 @@
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
onMounted(() => {
// Hash 模式要這樣抓
const hash = window.location.hash // => #/auth/callback?token=xxx
const queryString = hash.includes('?') ? hash.split('?')[1] : ''
const params = new URLSearchParams(queryString)
const token = params.get('token')
const name = params.get('name')
if (token) {
authStore.login(token, name)
router.push({ name: 'dashboard' })
} else {
router.push({ name: 'login' })
}
})
</script>
<template>
<div>登入中...</div>
</template>

View File

@@ -0,0 +1,55 @@
<script setup>
import GuestLayout from '@/Layouts/GuestLayout.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { Head, useForm } from '@inertiajs/vue3';
const form = useForm({
password: '',
});
const submit = () => {
form.post(route('password.confirm'), {
onFinish: () => form.reset(),
});
};
</script>
<template>
<GuestLayout>
<Head title="Confirm Password" />
<div class="mb-4 text-sm text-gray-600">
This is a secure area of the application. Please confirm your
password before continuing.
</div>
<form @submit.prevent="submit">
<div>
<InputLabel for="password" value="Password" />
<TextInput
id="password"
type="password"
class="mt-1 block w-full"
v-model="form.password"
required
autocomplete="current-password"
autofocus
/>
<InputError class="mt-2" :message="form.errors.password" />
</div>
<div class="mt-4 flex justify-end">
<PrimaryButton
class="ms-4"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Confirm
</PrimaryButton>
</div>
</form>
</GuestLayout>
</template>

View File

@@ -0,0 +1,68 @@
<script setup>
import GuestLayout from '@/Layouts/GuestLayout.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { Head, useForm } from '@inertiajs/vue3';
defineProps({
status: {
type: String,
},
});
const form = useForm({
email: '',
});
const submit = () => {
form.post(route('password.email'));
};
</script>
<template>
<GuestLayout>
<Head title="Forgot Password" />
<div class="mb-4 text-sm text-gray-600">
Forgot your password? No problem. Just let us know your email
address and we will email you a password reset link that will allow
you to choose a new one.
</div>
<div
v-if="status"
class="mb-4 text-sm font-medium text-green-600"
>
{{ status }}
</div>
<form @submit.prevent="submit">
<div>
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autofocus
autocomplete="username"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div class="mt-4 flex items-center justify-end">
<PrimaryButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Email Password Reset Link
</PrimaryButton>
</div>
</form>
</GuestLayout>
</template>

34
src/Pages/Auth/Login.vue Normal file
View File

@@ -0,0 +1,34 @@
<script setup>
import GuestLayout from "@/Layouts/GuestLayout.vue";
import { Capacitor } from '@capacitor/core';
import { Browser } from '@capacitor/browser';
const handleGoogleLogin = async () => {
console.log("【測試】Google 登入按鈕確實被點擊了!");
const targetUrl = `${import.meta.env.VITE_BACKEND_URL}/auth/google`;
// 📱 如果是在手機 App 內運行
if (Capacitor.isNativePlatform()) {
// 使用原生系統瀏覽器打開,這樣登入完才能利用 Deep Link 彈回 App
await Browser.open({ url: targetUrl });
} else {
// 💻 如果是一般電腦網頁,就走原本的跳轉
window.location.href = targetUrl;
}
}
</script>
<template>
<GuestLayout>
<div class="flex flex-col items-center gap-4">
<h1 class="text-xl font-semibold text-gray-700">登入</h1>
<button
@click="handleGoogleLogin"
class="flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow hover:bg-gray-50 border border-gray-300"
>
使用 Google 登入
</button>
</div>
</GuestLayout>
</template>

114
src/Pages/Auth/Register.vue Normal file
View File

@@ -0,0 +1,114 @@
<script setup>
import GuestLayout from '@/Layouts/GuestLayout.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { RouterLink, useRoute } from 'vue-router';
import { reactive } from 'vue';
const form = reactive({
name: '',
email: '',
password: '',
password_confirmation: '',
});
const submit = () => {
form.post(route('register'), {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<GuestLayout>
<div title="Register"></div>
<form @submit.prevent="submit">
<div>
<InputLabel for="name" value="Name" />
<TextInput
id="name"
type="text"
class="mt-1 block w-full"
v-model="form.name"
required
autofocus
autocomplete="name"
/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
<div class="mt-4">
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autocomplete="username"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div class="mt-4">
<InputLabel for="password" value="Password" />
<TextInput
id="password"
type="password"
class="mt-1 block w-full"
v-model="form.password"
required
autocomplete="new-password"
/>
<InputError class="mt-2" :message="form.errors.password" />
</div>
<div class="mt-4">
<InputLabel
for="password_confirmation"
value="Confirm Password"
/>
<TextInput
id="password_confirmation"
type="password"
class="mt-1 block w-full"
v-model="form.password_confirmation"
required
autocomplete="new-password"
/>
<InputError
class="mt-2"
:message="form.errors.password_confirmation"
/>
</div>
<div class="mt-4 flex items-center justify-end">
<RouterLink
:to="{ name: 'login' }"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Already registered?
</RouterLink>
<PrimaryButton
class="ms-4"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Register
</PrimaryButton>
</div>
</form>
</GuestLayout>
</template>

View File

@@ -0,0 +1,101 @@
<script setup>
import GuestLayout from '@/Layouts/GuestLayout.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { reactive } from 'vue';
const props = defineProps({
email: {
type: String,
required: true,
},
token: {
type: String,
required: true,
},
});
const form = reactive({
token: props.token,
email: props.email,
password: '',
password_confirmation: '',
});
const submit = () => {
form.post(route('password.store'), {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<GuestLayout>
<div title="Reset Password"></div>
<form @submit.prevent="submit">
<div>
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autofocus
autocomplete="username"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div class="mt-4">
<InputLabel for="password" value="Password" />
<TextInput
id="password"
type="password"
class="mt-1 block w-full"
v-model="form.password"
required
autocomplete="new-password"
/>
<InputError class="mt-2" :message="form.errors.password" />
</div>
<div class="mt-4">
<InputLabel
for="password_confirmation"
value="Confirm Password"
/>
<TextInput
id="password_confirmation"
type="password"
class="mt-1 block w-full"
v-model="form.password_confirmation"
required
autocomplete="new-password"
/>
<InputError
class="mt-2"
:message="form.errors.password_confirmation"
/>
</div>
<div class="mt-4 flex items-center justify-end">
<PrimaryButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Reset Password
</PrimaryButton>
</div>
</form>
</GuestLayout>
</template>

View File

@@ -0,0 +1,62 @@
<script setup>
import { computed } from 'vue';
import GuestLayout from '@/Layouts/GuestLayout.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import { RouterLink, useRoute } from 'vue-router';
import { reactive, computed } from 'vue';
const props = defineProps({
status: {
type: String,
},
});
const form = reactive({});
const submit = () => {
form.post(route('verification.send'));
};
const verificationLinkSent = computed(
() => props.status === 'verification-link-sent',
);
</script>
<template>
<GuestLayout>
<div title="Email Verification"></div>
<div class="mb-4 text-sm text-gray-600">
Thanks for signing up! Before getting started, could you verify your
email address by clicking on the link we just emailed to you? If you
didn't receive the email, we will gladly send you another.
</div>
<div
class="mb-4 text-sm font-medium text-green-600"
v-if="verificationLinkSent"
>
A new verification link has been sent to the email address you
provided during registration.
</div>
<form @submit.prevent="submit">
<div class="mt-4 flex items-center justify-between">
<PrimaryButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Resend Verification Email
</PrimaryButton>
<RouterLink
:to="{ name: 'logout' }"
method="post"
as="button"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>Log Out</RouterLink
>
</div>
</form>
</GuestLayout>
</template>

143
src/Pages/Dashboard.vue Normal file
View File

@@ -0,0 +1,143 @@
<script setup>
import { ref, onMounted, computed } from "vue"; // 💡 補上 ref, onMounted
import axios from "axios"; // 💡 引入你的 axios 或全域 API 工具
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Pie, Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend,
CategoryScale,
LinearScale,
BarElement,
} from 'chart.js'
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
// 🟢 響應式狀態,初始為預設值,防止載入中畫面崩潰
const monthlyTotal = ref(0)
const monthlyCount = ref(0)
const categoryStats = ref([])
const monthlyTrend = ref([])
const loading = ref(true)
// 圓餅圖資料
const pieData = computed(() => ({
labels: categoryStats.value.map(c => c.name),
datasets: [{
data: categoryStats.value.map(c => c.total),
backgroundColor: categoryStats.value.map(c => c.color),
}],
}))
const pieOptions = {
responsive: true,
plugins: {
legend: { position: 'bottom' },
},
}
// 長條圖資料
const barData = computed(() => ({
labels: monthlyTrend.value.map(m => m.month),
datasets: [{
label: '每月花費',
data: monthlyTrend.value.map(m => m.total),
backgroundColor: '#3B82F6',
borderRadius: 4,
}],
}))
const barOptions = {
responsive: true,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (val) => '$' + val.toLocaleString(),
},
},
},
}
// 🟢 組件掛載時,主動去向後端 API 撈取統計數據
onMounted(async () => {
try {
// 🟢 修正:精準指定後端 Laragon 的完整 API 網址
const response = await axios.get('http://127.0.0.1:8000/api/dashboard/stats', {
// 如果你是用 localStorage 存 token 的話,要在這邊帶上 Bearer Token
headers: {
Authorization: `Bearer ${localStorage.getItem('auth_token')}` // 💡 依據你儲存 Token 的 key 調整
},
withCredentials: true // 如果是用 Cookie/Sanctum 驗證則需要這行
})
monthlyTotal.value = response.data.monthlyTotal
monthlyCount.value = response.data.monthlyCount
categoryStats.value = response.data.categoryStats
monthlyTrend.value = response.data.monthlyTrend
} catch (error) {
console.error("無法加載總覽數據:", error)
} finally {
loading.value = false
}
})
</script>
<template>
<AuthenticatedLayout>
<template #header>
<h2 class="text-xl font-semibold">總覽</h2>
</template>
<div class="py-8 max-w-5xl mx-auto px-4 space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm text-gray-500">本月花費</p>
<p class="text-3xl font-bold text-blue-500 mt-1">
${{ monthlyTotal.toLocaleString() }}
</p>
</div>
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm text-gray-500">本月記帳筆數</p>
<p class="text-3xl font-bold text-gray-700 mt-1">
{{ monthlyCount }} <span class="text-lg font-normal text-gray-500"></span>
</p>
</div>
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm text-gray-500">最高花費分類</p>
<p class="text-3xl font-bold text-gray-700 mt-1">
{{ categoryStats.length > 0
? categoryStats.reduce((a, b) => a.total > b.total ? a : b).name
: '—' }}
</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium mb-4">本月分類佔比</h3>
<div v-if="categoryStats.length > 0" class="max-w-xs mx-auto">
<Pie :data="pieData" :options="pieOptions" />
</div>
<div v-else class="py-12 text-center text-gray-400">
本月還沒有記錄
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium mb-4">近6個月趨勢</h3>
<Bar :data="barData" :options="barOptions" />
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

View File

@@ -0,0 +1,94 @@
<script setup>
import { ref, watch } from "vue";
import { useForm } from "@inertiajs/vue3"; // 如果你使用 Inertia Form或是直接用 ref/axios
// 假設這是你原本的表單物件,補上這三個欄位
const form = useForm({
category_id: "",
amount: "",
item_name: "",
date: new Date().toISOString().split("T")[0], // 預設今天(實際付款日)
note: "",
// 🟢 新增分攤相關欄位
is_amortized: false,
period_start: "",
period_end: "",
});
// 💡 貼心小防呆:如果使用者關閉分攤開關,自動清空起訖日期
watch(
() => form.is_amortized,
(newVal) => {
if (!newVal) {
form.period_start = "";
form.period_end = "";
} else {
// 如果開啟,預設歸屬月份為實際付款日當月
if (!form.period_start) form.period_start = form.date;
if (!form.period_end) form.period_end = form.date;
}
},
);
</script>
<template>
<div class="bg-gray-50 p-4 rounded-lg border border-gray-200 space-y-4">
<div class="flex items-center justify-between">
<div>
<label class="block text-sm font-medium text-gray-700"
>智慧費用分攤</label
>
<p class="text-xs text-gray-500">
適用於房租水電半年繳或年繳大筆支出自動平攤至各月圖表
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
v-model="form.is_amortized"
class="sr-only peer"
/>
<div
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"
></div>
</label>
</div>
<div
v-if="form.is_amortized"
class="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2 border-t border-gray-200 text-sm animate-fade-in"
>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1"
>費用歸屬開始日期</label
>
<input
type="date"
v-model="form.period_start"
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
required
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1"
>費用歸屬結束日期</label
>
<input
type="date"
v-model="form.period_end"
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
required
/>
</div>
<div
class="sm:col-span-2 text-xs bg-blue-50 text-blue-700 p-2 rounded flex items-center gap-1"
>
<span>💡</span>
<span
>系統將自動計算起訖月份區間並在 Dashboard
趨勢圖中平均分配這筆金額</span
>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,93 @@
<script setup>
import { ref, reactive } from "vue";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import api from "@/axios";
const form = reactive({
file: null,
});
const flashMessage = ref("");
const errorMessage = ref("");
const processing = ref(false);
const submit = async () => {
if (!form.file) return;
processing.value = true;
flashMessage.value = "";
errorMessage.value = "";
// 💡 因為有二進位檔案,必須使用標準 FormData 封裝
const formData = new FormData();
formData.append('file', form.file);
try {
const response = await api.post("/api/expenses/import", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
console.log("上傳成功", response.data);
flashMessage.value = "🎉 財政部發票資料同步成功!已排重並寫入帳本。";
form.file = null; // 清空選取
} catch (error) {
console.error("上傳失敗", error.response?.data);
errorMessage.value = error.response?.data?.message || "匯入失敗,請確認 CSV 格式是否正確。";
} finally {
processing.value = false;
}
};
</script>
<template>
<AuthenticatedLayout>
<template #header>
<h2 class="text-xl font-semibold text-gray-800">匯入發票</h2>
</template>
<div class="py-8 max-w-xl mx-auto px-4">
<div class="bg-white rounded-lg shadow p-6">
<div v-if="flashMessage" class="mb-4 p-4 bg-green-50 text-green-700 rounded border border-green-200 text-sm">
{{ flashMessage }}
</div>
<div v-if="errorMessage" class="mb-4 p-4 bg-red-50 text-red-700 rounded border border-red-200 text-sm">
{{ errorMessage }}
</div>
<h3 class="text-lg font-medium text-gray-700 mb-2">📥 上傳財政部發票明細 CSV</h3>
<p class="text-sm text-gray-500 mb-6 leading-relaxed">
請上傳從財政部電子發票整合服務平台載具下載的消費明細 CSV 檔案
系統會自動比對發票號碼**重複的發票會自動略過**免擔心重複記帳
</p>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">選擇 CSV 檔案</label>
<input
type="file"
accept=".csv"
@change="form.file = $event.target.files[0]"
class="w-full border rounded px-3 py-2 text-sm bg-gray-50 file:mr-4 file:py-1 file:px-3 file:rounded file:border-0 file:text-xs file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"
/>
</div>
<div class="flex gap-3 pt-2">
<router-link to="/expenses" class="flex-1 text-center py-2 border rounded-md text-sm text-gray-600 hover:bg-gray-50">
返回帳本
</router-link>
<button
type="submit"
:disabled="!form.file || processing"
class="flex-[2] bg-indigo-600 text-white py-2 rounded-md text-sm font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm transition-all"
>
{{ processing ? "⚙️ 正在解析並套用自動分類規則..." : "確認開始匯入" }}
</button>
</div>
</form>
</div>
</div>
</AuthenticatedLayout>
</template>

View File

@@ -0,0 +1,764 @@
<script setup>
import { ref, reactive, onMounted, watch } from "vue"; // 🟢 1. 補上 watch 匯入,防止網頁當場崩潰
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import api from "@/axios";
// --- 1. 資料狀態管理 ---
const expenses = ref([]);
const categories = ref([]);
const rules = ref([]);
const loading = ref(false);
const flash = ref("");
const fetchAllData = async () => {
loading.value = true;
try {
const [expRes, catRes, ruleRes] = await Promise.all([
api.get("/api/expenses"),
api.get("/api/categories"),
api.get("/api/category-rules"),
]);
expenses.value = expRes.data;
categories.value = catRes.data;
rules.value = ruleRes.data;
} catch (error) {
console.error("載入失敗", error);
flash.value = "無法取得資料,請檢查網路連線";
} finally {
loading.value = false;
}
};
onMounted(fetchAllData);
// 日期格式化小工具
const formatDate = (dateStr) => {
if (!dateStr) return "";
return dateStr.substring(0, 10);
};
// --- 2. 支出管理 (Expenses) ---
const form = reactive({
amount: "",
category_id: "",
item_name: "",
date: new Date().toISOString().split("T")[0],
seller_name: "",
is_amortized: false,
period_start: "",
period_end: "",
});
watch(
() => form.is_amortized,
(newVal) => {
if (!newVal) {
form.period_start = "";
form.period_end = "";
} else {
if (!form.period_start) form.period_start = form.date;
if (!form.period_end) form.period_end = form.date;
}
},
);
const submit = async () => {
try {
const response = await api.post("/api/expenses", form);
expenses.value.unshift(response.data);
// 🟢 重置表單:一併歸零分攤欄位
Object.assign(form, {
amount: "",
category_id: "",
item_name: "",
date: new Date().toISOString().split("T")[0],
seller_name: "",
is_amortized: false,
period_start: "",
period_end: "",
});
flash.value = "新增成功!";
setTimeout(() => (flash.value = ""), 3000);
} catch (error) {
alert("新增失敗");
}
};
const editing = ref(null);
const editForm = reactive({
amount: "",
category_id: "",
item_name: "",
date: "",
seller_name: "",
is_amortized: false,
period_start: "",
period_end: "",
});
// 💡 編輯表單防呆
watch(
() => editForm.is_amortized,
(newVal) => {
if (!newVal) {
editForm.period_start = "";
editForm.period_end = "";
} else {
if (!editForm.period_start) editForm.period_start = editForm.date;
if (!editForm.period_end) editForm.period_end = editForm.date;
}
},
);
const startEdit = (expense) => {
editing.value = expense.id;
// 🟢 點擊編輯時,把資料庫現有的分攤資料同步倒進編輯表單
Object.assign(editForm, {
amount: expense.amount,
category_id: expense.category_id ?? "",
item_name: expense.item_name ?? "",
date: expense.date ? expense.date.split("T")[0] : "",
seller_name: expense.seller_name ?? "",
is_amortized: !!expense.is_amortized, // 💡 轉成 boolean
period_start: expense.period_start
? expense.period_start.split("T")[0]
: "",
period_end: expense.period_end ? expense.period_end.split("T")[0] : "",
});
};
const submitEdit = async (id) => {
try {
const response = await api.put(`/api/expenses/${id}`, editForm);
const index = expenses.value.findIndex((ex) => ex.id === id);
expenses.value[index] = response.data;
editing.value = null;
} catch (error) {
alert("修改失敗");
}
};
const destroy = async (id) => {
if (!confirm("確定刪除?")) return;
try {
await api.delete(`/api/expenses/${id}`);
expenses.value = expenses.value.filter((ex) => ex.id !== id);
} catch (error) {
alert("刪除失敗");
}
};
// --- 3. 分類管理 (Categories) ---
const showCategories = ref(false);
const categoryForm = reactive({ name: "", color: "#3B82F6" });
const editingCategory = ref(null);
const editCategoryForm = reactive({ name: "", color: "" });
const submitCategory = async () => {
try {
const response = await api.post("/api/categories", categoryForm);
categories.value.push(response.data);
Object.assign(categoryForm, { name: "", color: "#3B82F6" });
} catch (error) {
alert("分類新增失敗");
}
};
const startEditCategory = (cat) => {
editingCategory.value = cat.id;
Object.assign(editCategoryForm, { name: cat.name, color: cat.color });
};
const submitEditCategory = async (id) => {
try {
const response = await api.put(`/api/categories/${id}`, editCategoryForm);
const index = categories.value.findIndex((cat) => cat.id === id);
categories.value[index] = response.data;
editingCategory.value = null;
} catch (error) {
alert("分類修改失敗");
}
};
const destroyCategory = async (id) => {
if (!confirm("刪除分類不會刪除明細,但明細會變未分類。確定刪除?")) return;
try {
await api.delete(`/api/categories/${id}`);
categories.value = categories.value.filter((cat) => cat.id !== id);
} catch (error) {
alert("分類刪除失敗");
}
};
// --- 4. 規則管理 (Rules) ---
const showRules = ref(false);
const ruleForm = reactive({ keyword: "", category_id: "" });
const submitRule = async () => {
try {
const response = await api.post("/api/category-rules", ruleForm);
rules.value.push(response.data);
Object.assign(ruleForm, { keyword: "", category_id: "" });
} catch (error) {
alert("規則新增失敗");
}
};
// --- 5. 批量操作 ---
const selected = ref([]);
const toggleAll = (e) => {
selected.value = e.target.checked ? expenses.value.map((ex) => ex.id) : [];
};
const deleteSelected = async () => {
if (selected.value.length === 0) return;
if (!confirm(`確定刪除 ${selected.value.length} 筆?`)) return;
try {
await api.post("/api/expenses/destroy-batch", { ids: selected.value });
expenses.value = expenses.value.filter(
(ex) => !selected.value.includes(ex.id),
);
selected.value = [];
} catch (error) {
alert("批量刪除失敗");
}
};
</script>
<template>
<AuthenticatedLayout>
<template #header>
<div class="flex justify-between items-center">
<h2 class="text-xl font-semibold text-gray-800">記帳帳本</h2>
</div>
</template>
<div class="py-8 max-w-4xl mx-auto px-4 space-y-6">
<div
v-if="flash"
class="bg-green-50 text-green-700 p-4 rounded shadow-sm border border-green-200"
>
{{ flash }}
</div>
<div class="bg-white rounded-lg shadow p-6">
<div
class="flex justify-between items-center cursor-pointer select-none"
@click="showCategories = !showCategories"
>
<h3 class="text-lg font-medium text-gray-700">🏷 分類管理</h3>
<span class="text-gray-400 text-sm">{{
showCategories ? "▲ 收起" : "▼ 展開"
}}</span>
</div>
<div v-if="showCategories" class="mt-4 space-y-4">
<form
@submit.prevent="submitCategory"
class="flex gap-3 items-end bg-gray-50 p-4 rounded-lg"
>
<div class="flex-1">
<label class="block text-xs text-gray-500 mb-1">分類名稱</label>
<input
v-model="categoryForm.name"
type="text"
class="w-full border rounded px-3 py-1.5 text-sm"
placeholder="如:餐飲食品"
required
/>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">標籤顏色</label>
<input
v-model="categoryForm.color"
type="color"
class="h-9 w-16 border rounded cursor-pointer p-0.5"
/>
</div>
<button
type="submit"
class="bg-indigo-600 text-white px-4 py-1.5 rounded text-sm hover:bg-indigo-700"
>
新增
</button>
</form>
<div class="divide-y border rounded-lg overflow-hidden">
<div
v-for="cat in categories"
:key="cat.id"
class="px-4 py-2.5 bg-white"
>
<div
v-if="editingCategory !== cat.id"
class="flex items-center justify-between"
>
<div class="flex items-center gap-3">
<span
class="w-4 h-4 rounded-full"
:style="{ background: cat.color }"
></span>
<span class="text-sm text-gray-700">{{ cat.name }}</span>
</div>
<div class="space-x-3">
<button
@click="startEditCategory(cat)"
class="text-indigo-500 hover:text-indigo-700 text-xs"
>
編輯
</button>
<button
@click="destroyCategory(cat.id)"
class="text-red-400 hover:text-red-600 text-xs"
>
刪除
</button>
</div>
</div>
<div v-else class="flex items-center gap-3">
<input
v-model="editCategoryForm.color"
type="color"
class="h-7 w-12 border rounded cursor-pointer p-0.5"
/>
<input
v-model="editCategoryForm.name"
type="text"
class="flex-1 border rounded px-2 py-1 text-xs"
/>
<button
@click="submitEditCategory(cat.id)"
class="text-green-500 hover:text-green-700 text-xs"
>
儲存
</button>
<button
@click="editingCategory = null"
class="text-gray-400 hover:text-gray-600 text-xs"
>
取消
</button>
</div>
</div>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div
class="flex justify-between items-center cursor-pointer select-none"
@click="showRules = !showRules"
>
<h3 class="text-lg font-medium text-gray-700">
🤖 Line Bot 自動分類規則
</h3>
<span class="text-gray-400 text-sm">{{
showRules ? "▲ 收起" : "▼ 展開"
}}</span>
</div>
<div v-if="showRules" class="mt-4 space-y-4">
<form
@submit.prevent="submitRule"
class="flex gap-3 items-end bg-gray-50 p-4 rounded-lg"
>
<div class="flex-1">
<label class="block text-xs text-gray-500 mb-1"
>發票品名關鍵字</label
>
<input
v-model="ruleForm.keyword"
type="text"
class="w-full border rounded px-3 py-1.5 text-sm"
placeholder="例如:全家、大潤發"
required
/>
</div>
<div class="flex-1">
<label class="block text-xs text-gray-500 mb-1">對應歸類至</label>
<select
v-model="ruleForm.category_id"
class="w-full border rounded px-3 py-1.5 text-sm"
required
>
<option value="">選擇分類</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</option>
</select>
</div>
<button
type="submit"
class="bg-indigo-600 text-white px-4 py-1.5 rounded text-sm hover:bg-indigo-700"
>
新增規則
</button>
</form>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-700"> 手動記帳</h3>
<router-link
to="/expenses/import"
class="bg-emerald-600 text-white px-4 py-2 rounded text-sm hover:bg-emerald-700 shadow-sm"
>
📥 匯入電子發票 CSV
</router-link>
</div>
<form @submit.prevent="submit" class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs text-gray-500 mb-1">金額</label>
<input
v-model="form.amount"
type="number"
class="w-full border rounded px-3 py-2 text-sm"
placeholder="0"
required
/>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">日期</label>
<input
v-model="form.date"
type="date"
class="w-full border rounded px-3 py-2 text-sm"
required
/>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">主觀分類</label>
<select
v-model="form.category_id"
class="w-full border rounded px-3 py-2 text-sm"
>
<option value="">未分類</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">品名</label>
<input
v-model="form.item_name"
type="text"
class="w-full border rounded px-3 py-2 text-sm"
placeholder="如:麥當勞大麥克"
/>
</div>
<div
class="col-span-2 bg-gray-50 p-3 rounded border border-gray-200 space-y-3"
>
<div class="flex items-center justify-between">
<div>
<label class="text-xs font-semibold text-gray-700 block"
>開啟智慧費用分攤</label
>
<span class="text-[10px] text-gray-400 block"
>適用於房租水電半年繳等大筆支出</span
>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
v-model="form.is_amortized"
class="sr-only peer"
/>
<div
class="w-9 h-5 bg-gray-200 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"
></div>
</label>
</div>
<div
v-if="form.is_amortized"
class="grid grid-cols-2 gap-3 pt-2 border-t border-dashed border-gray-200 text-xs animate-fade-in"
>
<div>
<label class="block text-gray-500 mb-1">費用歸屬開始</label>
<input
type="date"
v-model="form.period_start"
class="w-full border rounded px-2 py-1 text-xs"
required
/>
</div>
<div>
<label class="block text-gray-500 mb-1">費用歸屬結束</label>
<input
type="date"
v-model="form.period_end"
class="w-full border rounded px-2 py-1 text-xs"
required
/>
</div>
</div>
</div>
<div class="col-span-2">
<button
type="submit"
class="w-full bg-indigo-600 text-white py-2 rounded font-medium text-sm hover:bg-indigo-700 shadow-sm"
>
確認記入帳本
</button>
</div>
</form>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<div
class="p-4 bg-gray-50 border-b flex justify-between items-center"
v-if="selected.length > 0"
>
<span class="text-sm text-gray-600"
>已選取 {{ selected.length }} 筆消費明細</span
>
<button
@click="deleteSelected"
class="px-3 py-1 bg-red-500 text-white rounded text-xs hover:bg-red-600"
>
批量刪除
</button>
</div>
<table class="w-full table-fixed divide-y divide-gray-200">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
<th class="p-3 text-center w-[5%]"></th>
<th class="p-3 text-left w-[17%]">消費日期</th>
<th class="p-3 text-left w-[15%]">分類</th>
<th class="p-3 text-left w-[43%]">品名明細/備註</th>
<th class="p-3 text-right w-[10%]">金額</th>
<th class="p-3 text-right w-[10%]">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-sm">
<tr v-if="loading" class="text-center text-gray-400">
<td colspan="6" class="py-8">資料加載中...</td>
</tr>
<tr
v-else-if="expenses.length === 0"
class="text-center text-gray-400"
>
<td colspan="6" class="py-8">本月尚無任何記帳明細</td>
</tr>
<template v-else v-for="expense in expenses" :key="expense.id">
<tr
v-if="editing !== expense.id"
class="hover:bg-gray-50 border-b border-gray-100"
>
<td class="p-3 text-center">
<input
type="checkbox"
:value="expense.id"
v-model="selected"
/>
</td>
<td
class="p-3 text-gray-600 text-left text-sm whitespace-nowrap"
>
{{ formatDate(expense.date) }}
</td>
<td class="p-3 text-left max-w-[120px] md:max-w-[150px]">
<span
v-if="expense.category"
class="px-2 py-0.5 rounded text-xs text-white font-medium block truncate text-center"
:style="{ background: expense.category.color }"
:title="expense.category.name"
>
{{ expense.category.name }}
</span>
<span
v-else
class="text-gray-400 text-xs bg-gray-100 px-2 py-0.5 rounded block text-center"
>未分類</span
>
</td>
<td class="p-3 text-sm max-w-xs md:max-w-md lg:max-w-xl">
<div class="flex items-center gap-1.5">
<div
v-if="expense.item_name"
class="text-gray-800 font-medium truncate text-left"
:title="expense.item_name"
>
{{ expense.item_name }}
</div>
<span
v-if="expense.is_amortized"
class="bg-indigo-100 text-indigo-700 border border-indigo-200 text-[10px] px-1 rounded shrink-0 font-medium scale-90"
>
🔄 已分攤
</span>
</div>
<div
v-if="expense.is_amortized"
class="text-[11px] text-indigo-500 mt-1 font-medium text-left flex items-center gap-1"
>
<span
class="bg-indigo-50 px-1 py-0.5 rounded text-indigo-600 border border-indigo-100"
>
歸屬{{ formatDate(expense.period_start) }}
{{ formatDate(expense.period_end) }}
</span>
</div>
<div
v-if="
expense.seller_name &&
expense.seller_name !== expense.item_name
"
class="text-xs text-gray-400 mt-0.5 truncate flex items-center gap-1"
:title="expense.seller_name"
>
<span
class="bg-gray-100 text-gray-500 px-1 py-0.5 rounded text-[10px] shrink-0"
>商戶</span
>
<span class="truncate">{{ expense.seller_name }}</span>
</div>
</td>
<td
class="p-3 text-right font-semibold text-gray-900 whitespace-nowrap"
>
${{ Number(expense.amount).toLocaleString() }}
</td>
<td class="p-3 text-right space-x-2 whitespace-nowrap">
<button
@click="startEdit(expense)"
class="text-indigo-500 hover:underline text-xs"
>
編輯
</button>
<button
@click="destroy(expense.id)"
class="text-red-400 hover:underline text-xs"
>
刪除
</button>
</td>
</tr>
<tr
v-else
class="bg-indigo-50/40 border-b border-indigo-100 animate-fadeIn"
>
<td class="p-2"></td>
<td class="p-2">
<input
v-model="editForm.date"
type="date"
class="w-full border rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</td>
<td class="p-2">
<select
v-model="editForm.category_id"
class="w-full border rounded px-1 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
<option :value="null">未分類</option>
<option
v-for="cat in categories"
:key="cat.id"
:value="cat.id"
>
{{ cat.name }}
</option>
</select>
</td>
<td class="p-2">
<div class="flex flex-col gap-1.5 w-full">
<input
v-model="editForm.item_name"
type="text"
placeholder="品名明細"
class="w-full border rounded px-2 py-1 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<input
v-model="editForm.seller_name"
type="text"
placeholder="商戶 / 備註"
class="w-full border rounded px-2 py-0.5 text-[11px] text-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<div
class="bg-white p-2 rounded border border-indigo-100 space-y-1.5 mt-1"
>
<div
class="flex items-center justify-between text-[11px]"
>
<span class="text-gray-500 font-medium"
>🔁 此筆改為智慧分攤</span
>
<input
type="checkbox"
v-model="editForm.is_amortized"
class="rounded text-indigo-600 focus:ring-indigo-500 w-3 h-3 cursor-pointer"
/>
</div>
<div
v-if="editForm.is_amortized"
class="grid grid-cols-2 gap-2 text-[10px]"
>
<div>
<span class="text-gray-400 block mb-0.5"
>歸屬開始</span
>
<input
v-model="editForm.period_start"
type="date"
class="w-full border rounded px-1 py-0.5 scale-95"
required
/>
</div>
<div>
<span class="text-gray-400 block mb-0.5"
>歸屬結束</span
>
<input
v-model="editForm.period_end"
type="date"
class="w-full border rounded px-1 py-0.5 scale-95"
required
/>
</div>
</div>
</div>
</div>
</td>
<td class="p-2">
<input
v-model="editForm.amount"
type="number"
class="w-full border rounded px-2 py-1 text-xs text-right font-medium focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</td>
<td class="p-2 text-right space-x-2 whitespace-nowrap">
<button
@click="submitEdit(expense.id)"
class="text-green-600 font-semibold text-xs hover:underline"
>
儲存
</button>
<button
@click="editing = null"
class="text-gray-400 text-xs hover:underline"
>
取消
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</AuthenticatedLayout>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import DeleteUserForm from './Partials/DeleteUserForm.vue';
import UpdatePasswordForm from './Partials/UpdatePasswordForm.vue';
import UpdateProfileInformationForm from './Partials/UpdateProfileInformationForm.vue';
import { Head } from '@inertiajs/vue3';
defineProps({
mustVerifyEmail: {
type: Boolean,
},
status: {
type: String,
},
});
</script>
<template>
<Head title="Profile" />
<AuthenticatedLayout>
<template #header>
<h2
class="text-xl font-semibold leading-tight text-gray-800"
>
Profile
</h2>
</template>
<div class="py-12">
<div class="mx-auto max-w-7xl space-y-6 sm:px-6 lg:px-8">
<div
class="bg-white p-4 shadow sm:rounded-lg sm:p-8"
>
<UpdateProfileInformationForm
:must-verify-email="mustVerifyEmail"
:status="status"
class="max-w-xl"
/>
</div>
<div
class="bg-white p-4 shadow sm:rounded-lg sm:p-8"
>
<UpdatePasswordForm class="max-w-xl" />
</div>
<div
class="bg-white p-4 shadow sm:rounded-lg sm:p-8"
>
<DeleteUserForm class="max-w-xl" />
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

View File

@@ -0,0 +1,108 @@
<script setup>
import DangerButton from '@/Components/DangerButton.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import Modal from '@/Components/Modal.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { useForm } from '@inertiajs/vue3';
import { nextTick, ref } from 'vue';
const confirmingUserDeletion = ref(false);
const passwordInput = ref(null);
const form = useForm({
password: '',
});
const confirmUserDeletion = () => {
confirmingUserDeletion.value = true;
nextTick(() => passwordInput.value.focus());
};
const deleteUser = () => {
form.delete(route('profile.destroy'), {
preserveScroll: true,
onSuccess: () => closeModal(),
onError: () => passwordInput.value.focus(),
onFinish: () => form.reset(),
});
};
const closeModal = () => {
confirmingUserDeletion.value = false;
form.clearErrors();
form.reset();
};
</script>
<template>
<section class="space-y-6">
<header>
<h2 class="text-lg font-medium text-gray-900">
Delete Account
</h2>
<p class="mt-1 text-sm text-gray-600">
Once your account is deleted, all of its resources and data will
be permanently deleted. Before deleting your account, please
download any data or information that you wish to retain.
</p>
</header>
<DangerButton @click="confirmUserDeletion">Delete Account</DangerButton>
<Modal :show="confirmingUserDeletion" @close="closeModal">
<div class="p-6">
<h2
class="text-lg font-medium text-gray-900"
>
Are you sure you want to delete your account?
</h2>
<p class="mt-1 text-sm text-gray-600">
Once your account is deleted, all of its resources and data
will be permanently deleted. Please enter your password to
confirm you would like to permanently delete your account.
</p>
<div class="mt-6">
<InputLabel
for="password"
value="Password"
class="sr-only"
/>
<TextInput
id="password"
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-3/4"
placeholder="Password"
@keyup.enter="deleteUser"
/>
<InputError :message="form.errors.password" class="mt-2" />
</div>
<div class="mt-6 flex justify-end">
<SecondaryButton @click="closeModal">
Cancel
</SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
@click="deleteUser"
>
Delete Account
</DangerButton>
</div>
</div>
</Modal>
</section>
</template>

View File

@@ -0,0 +1,122 @@
<script setup>
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
const passwordInput = ref(null);
const currentPasswordInput = ref(null);
const form = useForm({
current_password: '',
password: '',
password_confirmation: '',
});
const updatePassword = () => {
form.put(route('password.update'), {
preserveScroll: true,
onSuccess: () => form.reset(),
onError: () => {
if (form.errors.password) {
form.reset('password', 'password_confirmation');
passwordInput.value.focus();
}
if (form.errors.current_password) {
form.reset('current_password');
currentPasswordInput.value.focus();
}
},
});
};
</script>
<template>
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
Update Password
</h2>
<p class="mt-1 text-sm text-gray-600">
Ensure your account is using a long, random password to stay
secure.
</p>
</header>
<form @submit.prevent="updatePassword" class="mt-6 space-y-6">
<div>
<InputLabel for="current_password" value="Current Password" />
<TextInput
id="current_password"
ref="currentPasswordInput"
v-model="form.current_password"
type="password"
class="mt-1 block w-full"
autocomplete="current-password"
/>
<InputError
:message="form.errors.current_password"
class="mt-2"
/>
</div>
<div>
<InputLabel for="password" value="New Password" />
<TextInput
id="password"
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
/>
<InputError :message="form.errors.password" class="mt-2" />
</div>
<div>
<InputLabel
for="password_confirmation"
value="Confirm Password"
/>
<TextInput
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
/>
<InputError
:message="form.errors.password_confirmation"
class="mt-2"
/>
</div>
<div class="flex items-center gap-4">
<PrimaryButton :disabled="form.processing">Save</PrimaryButton>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-if="form.recentlySuccessful"
class="text-sm text-gray-600"
>
Saved.
</p>
</Transition>
</div>
</form>
</section>
</template>

View File

@@ -0,0 +1,112 @@
<script setup>
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { Link, useForm, usePage } from '@inertiajs/vue3';
defineProps({
mustVerifyEmail: {
type: Boolean,
},
status: {
type: String,
},
});
const user = usePage().props.auth.user;
const form = useForm({
name: user.name,
email: user.email,
});
</script>
<template>
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
Profile Information
</h2>
<p class="mt-1 text-sm text-gray-600">
Update your account's profile information and email address.
</p>
</header>
<form
@submit.prevent="form.patch(route('profile.update'))"
class="mt-6 space-y-6"
>
<div>
<InputLabel for="name" value="Name" />
<TextInput
id="name"
type="text"
class="mt-1 block w-full"
v-model="form.name"
required
autofocus
autocomplete="name"
/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
<div>
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autocomplete="username"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div v-if="mustVerifyEmail && user.email_verified_at === null">
<p class="mt-2 text-sm text-gray-800">
Your email address is unverified.
<Link
:href="route('verification.send')"
method="post"
as="button"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Click here to re-send the verification email.
</Link>
</p>
<div
v-show="status === 'verification-link-sent'"
class="mt-2 text-sm font-medium text-green-600"
>
A new verification link has been sent to your email address.
</div>
</div>
<div class="flex items-center gap-4">
<PrimaryButton :disabled="form.processing">Save</PrimaryButton>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-if="form.recentlySuccessful"
class="text-sm text-gray-600"
>
Saved.
</p>
</Transition>
</div>
</form>
</section>
</template>

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Some files were not shown because too many files have changed in this diff Show More