initial project
25
.gitignore
vendored
Normal 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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
README.md
Normal 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
@@ -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
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
54
android/app/build.gradle
Normal 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")
|
||||
}
|
||||
19
android/app/capacitor.build.gradle
Normal 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
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
41
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
5
android/app/src/main/java/org/finbuddy/MainActivity.java
Normal file
@@ -0,0 +1,5 @@
|
||||
package org.finbuddy;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
BIN
android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
android/app/src/main/res/drawable-land-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
android/app/src/main/res/drawable-land-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
android/app/src/main/res/drawable-port-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-port-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -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>
|
||||
170
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
BIN
android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
12
android/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
7
android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
22
android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
5
android/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
6
android/capacitor.settings.gradle
Normal 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
@@ -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
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
31
package.json
Normal 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
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}, // 👈 注意這裡改成了 @tailwindcss/postcss
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal 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
@@ -0,0 +1,7 @@
|
||||
<script setup>
|
||||
import { RouterView } from 'vue-router';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
146
src/Components/AccountTrendChart.vue
Normal 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>
|
||||
7
src/Components/ApplicationLogo.vue
Normal 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>
|
||||
34
src/Components/Checkbox.vue
Normal 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>
|
||||
7
src/Components/DangerButton.vue
Normal 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>
|
||||
84
src/Components/Dropdown.vue
Normal 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>
|
||||
19
src/Components/DropdownLink.vue
Normal 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>
|
||||
80
src/Components/FundChart.vue
Normal 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>
|
||||
15
src/Components/InputError.vue
Normal 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>
|
||||
14
src/Components/InputLabel.vue
Normal 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>
|
||||
136
src/Components/InvestmentChart.vue
Normal 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
@@ -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>
|
||||
26
src/Components/NavLink.vue
Normal 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>
|
||||
7
src/Components/PrimaryButton.vue
Normal 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>
|
||||
26
src/Components/ResponsiveNavLink.vue
Normal 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>
|
||||
17
src/Components/SecondaryButton.vue
Normal 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>
|
||||
26
src/Components/TextInput.vue
Normal 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>
|
||||
199
src/Layouts/AuthenticatedLayout.vue
Normal 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>
|
||||
22
src/Layouts/GuestLayout.vue
Normal 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>
|
||||
393
src/Pages/Accounts/Index.vue
Normal 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
@@ -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>
|
||||
28
src/Pages/Auth/AuthCallback.vue
Normal 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>
|
||||
55
src/Pages/Auth/ConfirmPassword.vue
Normal 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>
|
||||
68
src/Pages/Auth/ForgotPassword.vue
Normal 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
@@ -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
@@ -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>
|
||||
101
src/Pages/Auth/ResetPassword.vue
Normal 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>
|
||||
62
src/Pages/Auth/VerifyEmail.vue
Normal 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
@@ -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>
|
||||
94
src/Pages/Expenses/Form.vue
Normal 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>
|
||||
93
src/Pages/Expenses/Import.vue
Normal 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>
|
||||
764
src/Pages/Expenses/Index.vue
Normal 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>
|
||||
56
src/Pages/Profile/Edit.vue
Normal 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>
|
||||
108
src/Pages/Profile/Partials/DeleteUserForm.vue
Normal 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>
|
||||
122
src/Pages/Profile/Partials/UpdatePasswordForm.vue
Normal 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>
|
||||
112
src/Pages/Profile/Partials/UpdateProfileInformationForm.vue
Normal 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
|
After Width: | Height: | Size: 13 KiB |