feat: 增加权限系统,目前没有历史形成等记录

refactor
Lxy 7 days ago
parent 27d65ea08f
commit b43fa24165

Binary file not shown.

Binary file not shown.

Binary file not shown.

558
package-lock.json generated

@ -8,11 +8,15 @@
"name": "trip-planner", "name": "trip-planner",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"cors": "^2.8.6", "cors": "^2.8.6",
"express": "^4.22.2", "express": "^4.22.2",
"jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"uuid": "^14.0.0",
"vue": "^3.5.34", "vue": "^3.5.34",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
@ -987,6 +991,58 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/better-sqlite3": {
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
"integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/birpc": { "node_modules/birpc": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
@ -996,6 +1052,17 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.5", "version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@ -1020,6 +1087,36 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -1058,6 +1155,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -1141,6 +1244,30 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -1160,6 +1287,15 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -1174,6 +1310,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -1189,6 +1334,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@ -1291,6 +1445,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": { "node_modules/express": {
"version": "4.22.2", "version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@ -1337,6 +1500,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@ -1373,6 +1542,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -1434,6 +1609,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -1508,12 +1689,38 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -1535,12 +1742,103 @@
"url": "https://github.com/sponsors/mesqueeb" "url": "https://github.com/sponsors/mesqueeb"
} }
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/leaflet": { "node_modules/leaflet": {
"version": "1.9.4", "version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -1619,12 +1917,39 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mitt": { "node_modules/mitt": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@ -1649,6 +1974,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -1658,6 +1989,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-abi": {
"version": "3.92.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
@ -1691,6 +2034,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -1767,6 +2119,33 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -1780,6 +2159,16 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.2", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@ -1819,6 +2208,35 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/rfdc": { "node_modules/rfdc": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
@ -1896,6 +2314,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": { "node_modules/send": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@ -2019,6 +2449,51 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/sortablejs": { "node_modules/sortablejs": {
"version": "1.15.7", "version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
@ -2052,6 +2527,24 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/superjson": { "node_modules/superjson": {
"version": "2.2.6", "version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
@ -2064,6 +2557,34 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@ -2073,6 +2594,18 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@ -2095,6 +2628,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@ -2104,6 +2643,19 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/uuid": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -2214,6 +2766,12 @@
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT" "license": "MIT"
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
} }
} }
} }

@ -9,11 +9,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"cors": "^2.8.6", "cors": "^2.8.6",
"express": "^4.22.2", "express": "^4.22.2",
"jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"uuid": "^14.0.0",
"vue": "^3.5.34", "vue": "^3.5.34",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },

@ -45,15 +45,25 @@ function saveConfig(config) {
import cors from 'cors' import cors from 'cors'
// 导入路由
import authRoutes from './src/routes/auth.js'
import plansRoutes from './src/routes/plans.js'
import statsRoutes from './src/routes/stats.js'
const app = express() const app = express()
app.use(cors({ app.use(cors({
origin: 'http://localhost:5173', origin: 'http://localhost:5173',
methods: ['GET', 'POST', 'OPTIONS'], methods: ['GET', 'POST', 'OPTIONS', 'DELETE'],
allowedHeaders: ['Content-Type'], allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['Content-Type'] exposedHeaders: ['Content-Type']
})) }))
app.use(express.json()) app.use(express.json())
// 注册路由
app.use('/api/auth', authRoutes)
app.use('/api/plans', plansRoutes)
app.use('/api/stats', statsRoutes)
// API: Get config // API: Get config
app.get('/api/config', (req, res) => { app.get('/api/config', (req, res) => {
const config = loadConfig() const config = loadConfig()

@ -1,9 +1,40 @@
<template> <template>
<RouterView /> <RouterView />
<AuthModal v-model:visible="showAuthModal" @success="handleAuthSuccess" />
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { useAuthStore } from './stores/auth'
import AuthModal from './components/AuthModal.vue'
const authStore = useAuthStore()
const showAuthModal = ref(false)
onMounted(async () => {
//
await authStore.init()
//
checkGuestDataExpiry()
})
function handleAuthSuccess() {
//
if (authStore.guestId) {
if (confirm('检测到您有游客期间的行程数据,是否迁移到当前账户?')) {
authStore.migrateGuestData()
}
}
}
function checkGuestDataExpiry() {
if (authStore.isGuestDataExpiring) {
alert('提示您的游客数据将在2小时内过期建议登录账户以保存数据。')
showAuthModal.value = true
}
}
</script> </script>
<style> <style>

@ -0,0 +1,294 @@
<template>
<Teleport to="body">
<div v-if="visible" class="auth-modal-overlay" @click.self="close">
<div class="auth-modal">
<button class="auth-close" @click="close"></button>
<div class="auth-header">
<h2>{{ isLogin ? '登录' : '注册' }}</h2>
<p>{{ isLogin ? '欢迎回来,继续你的行程规划' : '创建账户,保存你的行程' }}</p>
</div>
<form @submit.prevent="handleSubmit" class="auth-form">
<div class="form-group">
<label>用户名</label>
<input v-model="form.username" type="text" placeholder="请输入用户名" required :disabled="authStore.loading" />
</div>
<div v-if="!isLogin" class="form-group">
<label>邮箱可选</label>
<input v-model="form.email" type="email" placeholder="请输入邮箱" :disabled="authStore.loading" />
</div>
<div class="form-group">
<label>密码</label>
<input v-model="form.password" type="password" placeholder="至少6个字符" required :disabled="authStore.loading" minlength="6" />
</div>
<div v-if="authStore.error" class="form-error">{{ authStore.error }}</div>
<button type="submit" class="auth-submit" :disabled="authStore.loading">
{{ authStore.loading ? '处理中...' : (isLogin ? '登录' : '注册') }}
</button>
</form>
<div class="auth-switch">
{{ isLogin ? '还没有账户?' : '已有账户?' }}
<a href="#" @click.prevent="toggleMode">{{ isLogin ? '立即注册' : '立即登录' }}</a>
</div>
<div class="auth-guest">
<p>或者</p>
<button class="guest-btn" @click="continueAsGuest">
以游客身份继续
</button>
<p class="guest-note">游客数据将保留24小时</p>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useAuthStore } from '../stores/auth'
const props = defineProps({
visible: { type: Boolean, default: false }
})
const emit = defineEmits(['close', 'update:visible', 'success'])
function close() {
emit('update:visible', false)
emit('close')
authStore.error = null
form.username = ''
form.email = ''
form.password = ''
}
const authStore = useAuthStore()
const isLogin = ref(true)
const form = reactive({
username: '',
email: '',
password: ''
})
function toggleMode() {
isLogin.value = !isLogin.value
authStore.error = null
form.password = ''
}
async function handleSubmit() {
const result = isLogin.value
? await authStore.login(form.username, form.password)
: await authStore.register(form.username, form.email, form.password)
if (result?.success) {
emit('success')
close()
}
}
function continueAsGuest() {
authStore.initGuestSession()
close()
}
</script>
<style scoped>
.auth-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.auth-modal {
background: #fff;
border-radius: 16px;
padding: 32px;
width: 100%;
max-width: 400px;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.auth-close {
position: absolute;
top: 16px;
right: 16px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #636e72;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.auth-close:hover {
background: #f5f7fa;
color: #2d3436;
}
.auth-header {
text-align: center;
margin-bottom: 24px;
}
.auth-header h2 {
font-size: 24px;
color: #2d3436;
margin-bottom: 8px;
}
.auth-header p {
font-size: 14px;
color: #636e72;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 13px;
font-weight: 600;
color: #2d3436;
}
.form-group input {
padding: 12px 14px;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #6c5ce7;
}
.form-group input:disabled {
background: #f5f7fa;
cursor: not-allowed;
}
.form-error {
background: #ffe0e0;
color: #d63031;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
}
.auth-submit {
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
border: none;
padding: 14px;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.auth-submit:hover:not(:disabled) {
opacity: 0.9;
}
.auth-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-switch {
text-align: center;
margin-top: 16px;
font-size: 14px;
color: #636e72;
}
.auth-switch a {
color: #6c5ce7;
font-weight: 600;
text-decoration: none;
}
.auth-switch a:hover {
text-decoration: underline;
}
.auth-guest {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
text-align: center;
}
.auth-guest p {
font-size: 13px;
color: #b2bec3;
margin-bottom: 10px;
}
.guest-btn {
background: #f5f7fa;
border: 2px solid #e9ecef;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
color: #636e72;
cursor: pointer;
transition: all 0.2s;
}
.guest-btn:hover {
border-color: #6c5ce7;
color: #6c5ce7;
}
.guest-note {
font-size: 11px !important;
margin-top: 8px !important;
margin-bottom: 0 !important;
}
</style>

@ -0,0 +1,253 @@
<template>
<div class="history-panel">
<div class="history-header">
<h3>📋 历史行程</h3>
<button class="close-btn" @click="$emit('close')"></button>
</div>
<div v-if="loading" class="history-loading">...</div>
<div v-else-if="plans.length === 0" class="history-empty">
<p>暂无历史行程</p>
<p class="hint">完成行程规划后会自动保存</p>
</div>
<div v-else class="history-list">
<div v-for="plan in plans" :key="plan.id" class="history-item" @click="loadPlan(plan)">
<div class="history-item-header">
<div class="history-name">{{ plan.name }}</div>
<button class="delete-btn" @click.stop="deletePlan(plan.id)" title="删除">🗑</button>
</div>
<div v-if="plan.description" class="history-desc">{{ plan.description }}</div>
<div class="history-meta">
<span>{{ formatDate(plan.created_at) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useItineraryStore } from '../stores/itinerary'
import { trackPlanLoad } from '../services/statsService'
const emit = defineEmits(['close', 'load'])
const authStore = useAuthStore()
const itineraryStore = useItineraryStore()
const plans = ref([])
const loading = ref(false)
onMounted(async () => {
await loadPlans()
})
async function loadPlans() {
loading.value = true
try {
const API_BASE = 'http://localhost:3001/api'
let url, headers = {}
if (authStore.isAuthenticated) {
url = `${API_BASE}/plans`
headers = { 'Authorization': `Bearer ${authStore.token}` }
} else if (authStore.guestId) {
url = `${API_BASE}/plans/guest/${authStore.guestId}`
} else {
plans.value = []
return
}
const res = await fetch(url, { headers })
const data = await res.json()
plans.value = data.plans || []
} catch (err) {
console.error('加载历史行程失败:', err)
} finally {
loading.value = false
}
}
async function loadPlan(plan) {
loading.value = true
try {
const API_BASE = 'http://localhost:3001/api'
let url, headers = {}
if (authStore.isAuthenticated) {
url = `${API_BASE}/plans/${plan.id}`
headers = { 'Authorization': `Bearer ${authStore.token}` }
} else if (authStore.guestId) {
url = `${API_BASE}/plans/${plan.id}?guestId=${authStore.guestId}`
}
const res = await fetch(url, { headers })
const data = await res.json()
if (data.plan) {
// store
const planData = data.plan.data
if (planData.points) {
itineraryStore.loadFromAI({ points: planData.points })
trackPlanLoad(plan.id)
emit('load', plan)
}
}
} catch (err) {
console.error('加载行程失败:', err)
alert('加载行程失败')
} finally {
loading.value = false
}
}
async function deletePlan(planId) {
if (!confirm('确定要删除这个行程吗?')) return
try {
const API_BASE = 'http://localhost:3001/api'
const res = await fetch(`${API_BASE}/plans/${planId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${authStore.token}` }
})
if (res.ok) {
plans.value = plans.value.filter(p => p.id !== planId)
}
} catch (err) {
console.error('删除行程失败:', err)
}
}
function formatDate(dateStr) {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<style scoped>
.history-panel {
position: fixed;
top: 0;
right: 0;
width: 360px;
height: 100vh;
background: #fff;
box-shadow: -4px 0 20px rgba(0,0,0,0.1);
z-index: 9999;
display: flex;
flex-direction: column;
}
.history-header {
padding: 20px;
border-bottom: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
}
.history-header h3 {
font-size: 18px;
color: #2d3436;
}
.close-btn {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #636e72;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background: #f5f7fa;
}
.history-loading,
.history-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #b2bec3;
gap: 8px;
}
.history-empty .hint {
font-size: 12px;
}
.history-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.history-item {
padding: 16px;
border-radius: 8px;
background: #f5f7fa;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
}
.history-item:hover {
background: #e8e4ff;
}
.history-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.history-name {
font-weight: 600;
color: #2d3436;
font-size: 14px;
}
.delete-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
opacity: 0;
transition: opacity 0.2s;
}
.history-item:hover .delete-btn {
opacity: 1;
}
.history-desc {
font-size: 12px;
color: #636e72;
margin-bottom: 8px;
line-height: 1.4;
}
.history-meta {
font-size: 11px;
color: #b2bec3;
}
</style>

@ -0,0 +1,87 @@
// SQLite 数据库初始化和表创建
import Database from 'better-sqlite3'
import path from 'path'
import fs from 'fs'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DB_PATH = path.join(__dirname, '../../.data', 'trip-planner.db')
// 确保数据目录存在
const dbDir = path.dirname(DB_PATH)
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true })
}
const db = new Database(DB_PATH)
// 启用 WAL 模式提高并发性能
db.pragma('journal_mode = WAL')
// 创建表
db.exec(`
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 行程计划表
CREATE TABLE IF NOT EXISTS plans (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
guest_id TEXT,
name TEXT NOT NULL,
description TEXT,
data TEXT NOT NULL, -- JSON 格式的完整行程数据
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 游客临时数据表24小时过期
CREATE TABLE IF NOT EXISTS guest_plans (
id TEXT PRIMARY KEY,
guest_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
data TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 使用统计表
CREATE TABLE IF NOT EXISTS stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT,
guest_id TEXT,
event_type TEXT NOT NULL, -- 'page_view', 'plan_create', 'plan_load', 'plan_export', 'chat_start', etc.
event_data TEXT, -- JSON 格式的额外数据
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_plans_user_id ON plans(user_id);
CREATE INDEX IF NOT EXISTS idx_plans_guest_id ON plans(guest_id);
CREATE INDEX IF NOT EXISTS idx_guest_plans_guest_id ON guest_plans(guest_id);
CREATE INDEX IF NOT EXISTS idx_guest_plans_expires_at ON guest_plans(expires_at);
CREATE INDEX IF NOT EXISTS idx_stats_user_id ON stats(user_id);
CREATE INDEX IF NOT EXISTS idx_stats_guest_id ON stats(guest_id);
CREATE INDEX IF NOT EXISTS idx_stats_event_type ON stats(event_type);
CREATE INDEX IF NOT EXISTS idx_stats_created_at ON stats(created_at);
`)
// 清理过期游客数据的函数
function cleanExpiredGuestPlans() {
db.prepare('DELETE FROM guest_plans WHERE expires_at < ?').run(new Date().toISOString())
}
// 每次启动时清理过期数据
cleanExpiredGuestPlans()
export { db, cleanExpiredGuestPlans }

@ -1,10 +1,12 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import HomePage from './views/HomePage.vue' import HomePage from './views/HomePage.vue'
import SettingsPage from './views/SettingsPage.vue' import SettingsPage from './views/SettingsPage.vue'
import AdminPage from './views/AdminPage.vue'
const routes = [ const routes = [
{ path: '/', component: HomePage }, { path: '/', component: HomePage },
{ path: '/settings', component: SettingsPage } { path: '/settings', component: SettingsPage },
{ path: '/admin', component: AdminPage }
] ]
const router = createRouter({ const router = createRouter({

@ -0,0 +1,219 @@
// 用户认证路由
import express from 'express'
import jwt from 'jsonwebtoken'
import bcrypt from 'bcryptjs'
import { v4 as uuidv4 } from 'uuid'
import { db } from '../db/database.js'
const router = express.Router()
// JWT 密钥(生产环境应使用环境变量)
const JWT_SECRET = process.env.JWT_SECRET || 'trip-planner-secret-key-2024'
const JWT_EXPIRES_IN = '7d' // Token 7天过期
// 中间件:验证 JWT Token
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization']
const token = authHeader && authHeader.split(' ')[1] // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: '未提供认证令牌' })
}
try {
const user = jwt.verify(token, JWT_SECRET)
req.user = user
next()
} catch (err) {
return res.status(403).json({ error: '无效的认证令牌' })
}
}
// 中间件:可选认证(有 token 则验证,没有则继续)
function optionalAuth(req, res, next) {
const authHeader = req.headers['authorization']
const token = authHeader && authHeader.split(' ')[1]
if (token) {
try {
const user = jwt.verify(token, JWT_SECRET)
req.user = user
} catch (err) {
// Token 无效但继续,视为游客
}
}
next()
}
// 中间件:管理员权限检查
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: '需要管理员权限' })
}
next()
}
// 注册新用户
router.post('/register', async (req, res) => {
try {
const { username, email, password } = req.body
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码不能为空' })
}
if (password.length < 6) {
return res.status(400).json({ error: '密码至少需要6个字符' })
}
// 检查用户名是否已存在
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username)
if (existing) {
return res.status(409).json({ error: '用户名已被使用' })
}
// 检查是否是第一个注册用户(自动成为管理员)
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get()
const role = userCount.count === 0 ? 'admin' : 'user'
// 哈希密码
const passwordHash = await bcrypt.hash(password, 10)
// 创建用户
const userId = uuidv4()
db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)').run(
userId, username, email || null, passwordHash, role
)
// 生成 JWT
const token = jwt.sign(
{ id: userId, username, role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
)
// 记录统计
db.prepare('INSERT INTO stats (user_id, event_type, event_data) VALUES (?, ?, ?)').run(
userId, 'user_register', JSON.stringify({ username, role })
)
res.status(201).json({
message: '注册成功',
user: { id: userId, username, role },
token
})
} catch (err) {
console.error('注册失败:', err)
res.status(500).json({ error: '注册失败,请稍后重试' })
}
})
// 用户登录
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码不能为空' })
}
// 查找用户
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username)
if (!user) {
return res.status(401).json({ error: '用户名或密码错误' })
}
// 验证密码
const validPassword = await bcrypt.compare(password, user.password_hash)
if (!validPassword) {
return res.status(401).json({ error: '用户名或密码错误' })
}
// 生成 JWT
const token = jwt.sign(
{ id: user.id, username: user.username, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
)
// 记录统计
db.prepare('INSERT INTO stats (user_id, event_type) VALUES (?, ?)').run(
user.id, 'user_login'
)
res.json({
message: '登录成功',
user: { id: user.id, username: user.username, role: user.role },
token
})
} catch (err) {
console.error('登录失败:', err)
res.status(500).json({ error: '登录失败,请稍后重试' })
}
})
// 获取当前用户信息
router.get('/me', authenticateToken, (req, res) => {
const user = db.prepare('SELECT id, username, email, role, created_at FROM users WHERE id = ?').get(req.user.id)
if (!user) {
return res.status(404).json({ error: '用户不存在' })
}
res.json({ user })
})
// 生成游客 ID
router.post('/guest', (req, res) => {
const guestId = uuidv4()
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // 24小时后过期
res.json({
guestId,
expiresAt,
message: '游客会话已创建数据将保留24小时'
})
})
// 迁移游客数据到登录用户
router.post('/migrate-guest-data', authenticateToken, (req, res) => {
try {
const { guestId } = req.body
if (!guestId) {
return res.status(400).json({ error: '缺少游客ID' })
}
// 获取游客的所有计划
const guestPlans = db.prepare('SELECT * FROM plans WHERE guest_id = ?').all(guestId)
// 将游客计划转移到用户
let migrated = 0
const stmt = db.prepare('UPDATE plans SET user_id = ?, guest_id = NULL WHERE id = ?')
for (const plan of guestPlans) {
stmt.run(req.user.id, plan.id)
migrated++
}
// 同时迁移游客计划
const guestTempPlans = db.prepare('SELECT * FROM guest_plans WHERE guest_id = ?').all(guestId)
const insertPlan = db.prepare('INSERT INTO plans (id, user_id, name, description, data) VALUES (?, ?, ?, ?, ?)')
for (const gp of guestTempPlans) {
insertPlan.run(uuidv4(), req.user.id, gp.name, gp.description, gp.data)
migrated++
}
// 删除游客临时数据
db.prepare('DELETE FROM guest_plans WHERE guest_id = ?').run(guestId)
// 记录统计
db.prepare('INSERT INTO stats (user_id, event_type, event_data) VALUES (?, ?, ?)').run(
req.user.id, 'guest_data_migrated', JSON.stringify({ guestId, migrated })
)
res.json({ message: '数据迁移成功', migrated })
} catch (err) {
console.error('数据迁移失败:', err)
res.status(500).json({ error: '数据迁移失败' })
}
})
export default router
export { authenticateToken, optionalAuth, requireAdmin }

@ -0,0 +1,142 @@
// 行程计划路由
import express from 'express'
import { v4 as uuidv4 } from 'uuid'
import { db } from '../db/database.js'
import { authenticateToken, optionalAuth } from './auth.js'
const router = express.Router()
// 保存行程计划需要认证或游客ID
router.post('/', optionalAuth, (req, res) => {
try {
const { name, description, data } = req.body
if (!name || !data) {
return res.status(400).json({ error: '名称和行程数据不能为空' })
}
const planId = uuidv4()
if (req.user) {
// 已登录用户
db.prepare('INSERT INTO plans (id, user_id, name, description, data) VALUES (?, ?, ?, ?, ?)').run(
planId, req.user.id, name, description || null, JSON.stringify(data)
)
// 记录统计
db.prepare('INSERT INTO stats (user_id, event_type, event_data) VALUES (?, ?, ?)').run(
req.user.id, 'plan_create', JSON.stringify({ planId, name })
)
} else {
// 游客
const { guestId } = req.body
if (!guestId) {
return res.status(400).json({ error: '游客需要提供guestId' })
}
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
db.prepare('INSERT INTO guest_plans (id, guest_id, name, description, data, expires_at) VALUES (?, ?, ?, ?, ?, ?)').run(
planId, guestId, name, description || null, JSON.stringify(data), expiresAt
)
// 记录统计
db.prepare('INSERT INTO stats (guest_id, event_type, event_data) VALUES (?, ?, ?)').run(
guestId, 'plan_create', JSON.stringify({ planId, name })
)
}
res.status(201).json({ id: planId, message: '行程保存成功' })
} catch (err) {
console.error('保存行程失败:', err)
res.status(500).json({ error: '保存行程失败' })
}
})
// 获取用户的所有行程计划
router.get('/', authenticateToken, (req, res) => {
try {
const plans = db.prepare('SELECT id, name, description, created_at, updated_at FROM plans WHERE user_id = ? ORDER BY created_at DESC').all(req.user.id)
// 记录统计
db.prepare('INSERT INTO stats (user_id, event_type) VALUES (?, ?)').run(req.user.id, 'plans_list')
res.json({ plans })
} catch (err) {
console.error('获取行程列表失败:', err)
res.status(500).json({ error: '获取行程列表失败' })
}
})
// 获取游客的行程计划
router.get('/guest/:guestId', (req, res) => {
try {
const { guestId } = req.params
// 清理过期数据
db.prepare('DELETE FROM guest_plans WHERE expires_at < ?').run(new Date().toISOString())
const plans = db.prepare('SELECT id, name, description, created_at, expires_at FROM guest_plans WHERE guest_id = ? ORDER BY created_at DESC').all(guestId)
res.json({ plans })
} catch (err) {
console.error('获取游客行程失败:', err)
res.status(500).json({ error: '获取行程失败' })
}
})
// 获取单个行程详情
router.get('/:id', optionalAuth, (req, res) => {
try {
const { id } = req.params
let plan
if (req.user) {
plan = db.prepare('SELECT * FROM plans WHERE id = ? AND user_id = ?').get(id, req.user.id)
} else {
const { guestId } = req.query
plan = db.prepare('SELECT * FROM guest_plans WHERE id = ? AND guest_id = ?').get(id, guestId)
}
if (!plan) {
return res.status(404).json({ error: '行程不存在' })
}
// 解析 JSON 数据
plan.data = JSON.parse(plan.data)
// 记录统计
if (req.user) {
db.prepare('INSERT INTO stats (user_id, event_type, event_data) VALUES (?, ?, ?)').run(
req.user.id, 'plan_load', JSON.stringify({ planId: id })
)
}
res.json({ plan })
} catch (err) {
console.error('获取行程详情失败:', err)
res.status(500).json({ error: '获取行程详情失败' })
}
})
// 删除行程
router.delete('/:id', authenticateToken, (req, res) => {
try {
const { id } = req.params
const result = db.prepare('DELETE FROM plans WHERE id = ? AND user_id = ?').run(id, req.user.id)
if (result.changes === 0) {
return res.status(404).json({ error: '行程不存在' })
}
db.prepare('INSERT INTO stats (user_id, event_type, event_data) VALUES (?, ?, ?)').run(
req.user.id, 'plan_delete', JSON.stringify({ planId: id })
)
res.json({ message: '行程删除成功' })
} catch (err) {
console.error('删除行程失败:', err)
res.status(500).json({ error: '删除行程失败' })
}
})
export default router

@ -0,0 +1,162 @@
// 使用统计和管理员路由
import express from 'express'
import { db } from '../db/database.js'
import { requireAdmin } from './auth.js'
const router = express.Router()
// 记录事件(前端调用)
router.post('/event', (req, res) => {
try {
const { userId, guestId, eventType, eventData } = req.body
if (!eventType) {
return res.status(400).json({ error: '事件类型不能为空' })
}
db.prepare('INSERT INTO stats (user_id, guest_id, event_type, event_data) VALUES (?, ?, ?, ?)').run(
userId || null,
guestId || null,
eventType,
eventData ? JSON.stringify(eventData) : null
)
res.json({ message: '事件记录成功' })
} catch (err) {
console.error('记录事件失败:', err)
res.status(500).json({ error: '记录事件失败' })
}
})
// 管理员:获取总体统计数据
router.get('/admin/overview', requireAdmin, (req, res) => {
try {
// 用户总数
const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get()
// 管理员数量
const adminCount = db.prepare('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']).get()
// 总计划数
const totalPlans = db.prepare('SELECT COUNT(*) as count FROM plans').get()
// 游客计划数(未过期)
const guestPlans = db.prepare('SELECT COUNT(*) as count FROM guest_plans WHERE expires_at > ?', [new Date().toISOString()]).get()
// 总事件数
const totalEvents = db.prepare('SELECT COUNT(*) as count FROM stats').get()
// 今日事件数
const todayEvents = db.prepare(`SELECT COUNT(*) as count FROM stats WHERE date(created_at) = date('now')`).get()
// 今日活跃用户
const todayActiveUsers = db.prepare(`SELECT COUNT(DISTINCT user_id) as count FROM stats WHERE date(created_at) = date('now') AND user_id IS NOT NULL`).get()
// 今日活跃游客
const todayActiveGuests = db.prepare(`SELECT COUNT(DISTINCT guest_id) as count FROM stats WHERE date(created_at) = date('now') AND guest_id IS NOT NULL`).get()
res.json({
overview: {
totalUsers: totalUsers.count,
adminCount: adminCount.count,
totalPlans: totalPlans.count,
activeGuestPlans: guestPlans.count,
totalEvents: totalEvents.count,
todayEvents: todayEvents.count,
todayActiveUsers: todayActiveUsers.count,
todayActiveGuests: todayActiveGuests.count
}
})
} catch (err) {
console.error('获取统计数据失败:', err)
res.status(500).json({ error: '获取统计数据失败' })
}
})
// 管理员:按类型统计事件
router.get('/admin/events-by-type', requireAdmin, (req, res) => {
try {
const { days = 30 } = req.query
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
const events = db.prepare(`
SELECT event_type, COUNT(*) as count
FROM stats
WHERE created_at > ?
GROUP BY event_type
ORDER BY count DESC
`).all(since)
res.json({ events, days })
} catch (err) {
console.error('获取事件统计失败:', err)
res.status(500).json({ error: '获取事件统计失败' })
}
})
// 管理员:每日活跃趋势
router.get('/admin/daily-active', requireAdmin, (req, res) => {
try {
const { days = 30 } = req.query
const dailyUsers = db.prepare(`
SELECT date(created_at) as date, COUNT(DISTINCT user_id) as users, COUNT(DISTINCT guest_id) as guests
FROM stats
WHERE created_at > datetime('now', ?)
GROUP BY date(created_at)
ORDER BY date ASC
`).all(`-${days} days`)
res.json({ daily: dailyUsers, days })
} catch (err) {
console.error('获取活跃趋势失败:', err)
res.status(500).json({ error: '获取活跃趋势失败' })
}
})
// 管理员:用户列表
router.get('/admin/users', requireAdmin, (req, res) => {
try {
const users = db.prepare(`
SELECT u.id, u.username, u.email, u.role, u.created_at,
COUNT(DISTINCT p.id) as plan_count,
COUNT(DISTINCT s.id) as event_count,
MAX(s.created_at) as last_active
FROM users u
LEFT JOIN plans p ON u.id = p.user_id
LEFT JOIN stats s ON u.id = s.user_id
GROUP BY u.id
ORDER BY u.created_at DESC
`).all()
res.json({ users })
} catch (err) {
console.error('获取用户列表失败:', err)
res.status(500).json({ error: '获取用户列表失败' })
}
})
// 管理员:热门行程
router.get('/admin/popular-plans', requireAdmin, (req, res) => {
try {
const { limit = 10 } = req.query
const plans = db.prepare(`
SELECT p.id, p.name, p.description, p.created_at, u.username,
COUNT(s.id) as view_count
FROM plans p
LEFT JOIN users u ON p.user_id = u.id
LEFT JOIN stats s ON s.event_data LIKE '%' || p.id || '%' AND s.event_type = 'plan_load'
GROUP BY p.id
ORDER BY view_count DESC
LIMIT ?
`).all(limit)
res.json({ plans })
} catch (err) {
console.error('获取热门行程失败:', err)
res.status(500).json({ error: '获取热门行程失败' })
}
})
export default router

@ -0,0 +1,52 @@
// 使用统计服务
import { useAuthStore } from '../stores/auth'
const API_BASE = 'http://localhost:3001/api'
export function trackEvent(eventType, eventData = {}) {
const authStore = useAuthStore()
const payload = {
userId: authStore.user?.id || null,
guestId: authStore.guestId || null,
eventType,
eventData
}
// 异步发送,不阻塞主流程
fetch(`${API_BASE}/stats/event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).catch(err => console.error('统计记录失败:', err))
}
// 页面访问统计
export function trackPageView(pageName, extra = {}) {
trackEvent('page_view', { page: pageName, ...extra })
}
// 规划创建统计
export function trackPlanCreate(planName, mode) {
trackEvent('plan_create', { planName, mode })
}
// 规划加载统计
export function trackPlanLoad(planId) {
trackEvent('plan_load', { planId })
}
// 规划导出统计
export function trackPlanExport(planId, format) {
trackEvent('plan_export', { planId, format })
}
// 聊天开始统计
export function trackChatStart(mode) {
trackEvent('chat_start', { mode })
}
// 方案选择统计
export function trackSchemeSelect(schemeName, schemeIndex) {
trackEvent('scheme_select', { schemeName, schemeIndex })
}

@ -0,0 +1,218 @@
// 用户认证状态管理
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
const API_BASE = 'http://localhost:3001/api'
export const useAuthStore = defineStore('auth', () => {
// 状态
const user = ref(null)
const token = ref(localStorage.getItem('auth_token') || null)
const guestId = ref(localStorage.getItem('guest_id') || null)
const guestExpiresAt = ref(localStorage.getItem('guest_expires_at') || null)
const loading = ref(false)
const error = ref(null)
// 计算属性
const isAuthenticated = computed(() => !!user.value && !!token.value)
const isGuest = computed(() => !!guestId.value && !user.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const isGuestDataExpiring = computed(() => {
if (!guestExpiresAt.value) return false
const expires = new Date(guestExpiresAt.value)
const now = new Date()
const hoursLeft = (expires - now) / (1000 * 60 * 60)
return hoursLeft < 2 // 少于2小时提醒
})
const isGuestDataExpired = computed(() => {
if (!guestExpiresAt.value) return false
return new Date(guestExpiresAt.value) < new Date()
})
// 方法
function setAuthHeader() {
if (token.value) {
return { 'Authorization': `Bearer ${token.value}` }
}
return {}
}
async function login(username, password) {
loading.value = true
error.value = null
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || '登录失败')
user.value = data.user
token.value = data.token
localStorage.setItem('auth_token', data.token)
// 如果有游客数据,提示迁移
return { success: true, user: data.user }
} catch (err) {
error.value = err.message
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
async function register(username, email, password) {
loading.value = true
error.value = null
try {
const res = await fetch(`${API_BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password })
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || '注册失败')
user.value = data.user
token.value = data.token
localStorage.setItem('auth_token', data.token)
return { success: true, user: data.user }
} catch (err) {
error.value = err.message
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
function logout() {
user.value = null
token.value = null
localStorage.removeItem('auth_token')
}
async function initGuestSession() {
// 检查现有游客会话是否过期
if (guestId.value && guestExpiresAt.value) {
const expires = new Date(guestExpiresAt.value)
if (expires > new Date()) {
return guestId.value // 会话仍有效
}
// 过期了,清除
clearGuestSession()
}
// 创建新游客会话
loading.value = true
try {
const res = await fetch(`${API_BASE}/auth/guest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || '创建游客会话失败')
guestId.value = data.guestId
guestExpiresAt.value = data.expiresAt
localStorage.setItem('guest_id', data.guestId)
localStorage.setItem('guest_expires_at', data.expiresAt)
return data.guestId
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
function clearGuestSession() {
guestId.value = null
guestExpiresAt.value = null
localStorage.removeItem('guest_id')
localStorage.removeItem('guest_expires_at')
}
async function migrateGuestData() {
if (!token.value || !guestId.value) return
loading.value = true
try {
const res = await fetch(`${API_BASE}/auth/migrate-guest-data`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...setAuthHeader()
},
body: JSON.stringify({ guestId: guestId.value })
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || '数据迁移失败')
clearGuestSession()
return data
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
async function loadCurrentUser() {
if (!token.value) return
loading.value = true
try {
const res = await fetch(`${API_BASE}/auth/me`, {
headers: setAuthHeader()
})
if (res.ok) {
const data = await res.json()
user.value = data.user
} else {
// Token 无效,清除
logout()
}
} catch (err) {
console.error('加载用户信息失败:', err)
} finally {
loading.value = false
}
}
// 初始化时加载用户信息
async function init() {
if (token.value) {
await loadCurrentUser()
} else {
await initGuestSession()
}
}
return {
user,
token,
guestId,
guestExpiresAt,
loading,
error,
isAuthenticated,
isGuest,
isAdmin,
isGuestDataExpiring,
isGuestDataExpired,
login,
register,
logout,
initGuestSession,
clearGuestSession,
migrateGuestData,
loadCurrentUser,
init,
setAuthHeader
}
})

@ -0,0 +1,313 @@
<template>
<div class="admin-page">
<div class="admin-header">
<h1>📊 管理后台</h1>
<button class="back-btn" @click="$router.push('/')"></button>
</div>
<div v-if="loading" class="admin-loading">...</div>
<template v-else>
<!-- 概览卡片 -->
<div class="overview-grid">
<div class="stat-card">
<div class="stat-value">{{ overview.totalUsers }}</div>
<div class="stat-label">总用户数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.totalPlans }}</div>
<div class="stat-label">总行程数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.todayActiveUsers }}</div>
<div class="stat-label">今日活跃用户</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.todayActiveGuests }}</div>
<div class="stat-label">今日活跃游客</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.todayEvents }}</div>
<div class="stat-label">今日事件数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.totalEvents }}</div>
<div class="stat-label">总事件数</div>
</div>
</div>
<div class="admin-content">
<!-- 事件类型统计 -->
<div class="admin-section">
<h2>事件类型统计近30天</h2>
<table class="data-table">
<thead>
<tr>
<th>事件类型</th>
<th>次数</th>
<th>占比</th>
</tr>
</thead>
<tbody>
<tr v-for="event in events" :key="event.event_type">
<td>{{ getEventLabel(event.event_type) }}</td>
<td>{{ event.count }}</td>
<td>{{ getPercentage(event.count) }}%</td>
</tr>
</tbody>
</table>
</div>
<!-- 用户列表 -->
<div class="admin-section">
<h2>用户列表</h2>
<table class="data-table">
<thead>
<tr>
<th>用户名</th>
<th>角色</th>
<th>行程数</th>
<th>活跃次数</th>
<th>最后登录</th>
<th>注册时间</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.username }}</td>
<td>
<span class="role-badge" :class="user.role">{{ user.role === 'admin' ? '管理员' : '用户' }}</span>
</td>
<td>{{ user.plan_count }}</td>
<td>{{ user.event_count }}</td>
<td>{{ user.last_active ? formatDate(user.last_active) : '未活跃' }}</td>
<td>{{ formatDate(user.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(true)
const overview = ref({})
const events = ref([])
const users = ref([])
const totalEventsCount = ref(0)
onMounted(async () => {
//
if (!authStore.isAdmin) {
alert('需要管理员权限')
router.push('/')
return
}
await loadData()
})
async function loadData() {
loading.value = true
try {
const API_BASE = 'http://localhost:3001/api'
const headers = { 'Authorization': `Bearer ${authStore.token}` }
//
const overviewRes = await fetch(`${API_BASE}/stats/admin/overview`, { headers })
const overviewData = await overviewRes.json()
overview.value = overviewData.overview
//
const eventsRes = await fetch(`${API_BASE}/stats/admin/events-by-type?days=30`, { headers })
const eventsData = await eventsRes.json()
events.value = eventsData.events
totalEventsCount.value = eventsData.events.reduce((sum, e) => sum + e.count, 0)
//
const usersRes = await fetch(`${API_BASE}/stats/admin/users`, { headers })
const usersData = await usersRes.json()
users.value = usersData.users
} catch (err) {
console.error('加载数据失败:', err)
alert('加载数据失败')
} finally {
loading.value = false
}
}
function getEventLabel(type) {
const labels = {
'page_view': '页面访问',
'plan_create': '创建行程',
'plan_load': '加载行程',
'plan_export': '导出行程',
'chat_start': '开始聊天',
'scheme_select': '选择方案',
'user_register': '用户注册',
'user_login': '用户登录',
'guest_data_migrated': '游客数据迁移'
}
return labels[type] || type
}
function getPercentage(count) {
if (totalEventsCount.value === 0) return 0
return ((count / totalEventsCount.value) * 100).toFixed(1)
}
function formatDate(dateStr) {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<style scoped>
.admin-page {
min-height: 100vh;
background: #f5f7fa;
padding: 20px;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.admin-header h1 {
font-size: 28px;
color: #2d3436;
}
.back-btn {
background: #6c5ce7;
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.2s;
}
.back-btn:hover {
opacity: 0.9;
}
.admin-loading {
text-align: center;
padding: 40px;
color: #636e72;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 24px;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.stat-value {
font-size: 36px;
font-weight: 700;
color: #6c5ce7;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #636e72;
}
.admin-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.admin-section {
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.admin-section h2 {
font-size: 18px;
color: #2d3436;
margin-bottom: 16px;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e9ecef;
}
.data-table th {
font-size: 13px;
font-weight: 600;
color: #636e72;
background: #f5f7fa;
}
.data-table td {
font-size: 14px;
color: #2d3436;
}
.data-table tr:hover {
background: #f8f9ff;
}
.role-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.role-badge.admin {
background: #ffe0e0;
color: #d63031;
}
.role-badge.user {
background: #e8e4ff;
color: #6c5ce7;
}
</style>

@ -3,7 +3,20 @@
<!-- Top Navigation Bar --> <!-- Top Navigation Bar -->
<nav class="top-bar"> <nav class="top-bar">
<div class="brand"><span class="icon"></span> 智能行程规划</div> <div class="brand"><span class="icon"></span> 智能行程规划</div>
<div class="top-bar-right">
<button v-if="authStore.isAdmin" class="admin-btn" @click="$router.push('/admin')"></button>
<button v-if="authStore.isAuthenticated" class="history-btn" @click="showHistory = true">📋 </button>
<div v-if="authStore.isAuthenticated" class="user-info">
<span class="username">{{ authStore.user?.username }}</span>
<button class="logout-btn" @click="handleLogout">退</button>
</div>
<div v-else-if="authStore.isGuest" class="guest-info">
<span class="guest-badge">👤 游客</span>
<button class="login-btn" @click="showAuthModal = true">登录/注册</button>
</div>
<button v-else class="login-btn" @click="showAuthModal = true">登录/注册</button>
<button v-if="!showModeSelection" class="home-btn" @click="goHome"></button> <button v-if="!showModeSelection" class="home-btn" @click="goHome"></button>
</div>
</nav> </nav>
<!-- Main Content --> <!-- Main Content -->
@ -43,26 +56,55 @@
<ComparisonView v-else-if="store.phase === 'comparison'" /> <ComparisonView v-else-if="store.phase === 'comparison'" />
<Workbench v-else /> <Workbench v-else />
</div> </div>
<!-- 历史记录面板 -->
<HistoryPanel v-if="showHistory" @close="showHistory = false" @load="showHistory = false" />
<!-- 登录/注册模态框 -->
<AuthModal v-model:visible="showAuthModal" @success="handleAuthSuccess" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useItineraryStore } from '../stores/itinerary' import { useItineraryStore } from '../stores/itinerary'
import { useAuthStore } from '../stores/auth'
import ChatInterface from '../components/ChatInterface.vue' import ChatInterface from '../components/ChatInterface.vue'
import ComparisonView from '../components/ComparisonView.vue' import ComparisonView from '../components/ComparisonView.vue'
import Workbench from '../components/Workbench.vue' import Workbench from '../components/Workbench.vue'
import QuickPlanPanel from '../components/QuickPlanPanel.vue' import QuickPlanPanel from '../components/QuickPlanPanel.vue'
import CustomPlanPanel from '../components/CustomPlanPanel.vue' import CustomPlanPanel from '../components/CustomPlanPanel.vue'
import HistoryPanel from '../components/HistoryPanel.vue'
import AuthModal from '../components/AuthModal.vue'
const store = useItineraryStore() const store = useItineraryStore()
const authStore = useAuthStore()
const router = useRouter() const router = useRouter()
const showHistory = ref(false)
const showAuthModal = ref(false)
const showModeSelection = computed(() => { const showModeSelection = computed(() => {
return store.phase === 'chat' && !store.planningMode return store.phase === 'chat' && !store.planningMode
}) })
function handleAuthSuccess() {
if (authStore.guestId) {
if (confirm('检测到您有游客期间的行程数据,是否迁移到当前账户?')) {
authStore.migrateGuestData()
}
}
}
function handleLogout() {
if (confirm('确定要退出登录吗?')) {
authStore.logout()
//
authStore.initGuestSession()
router.push('/')
}
}
const selectMode = (mode) => { const selectMode = (mode) => {
store.setPlanningMode(mode) store.setPlanningMode(mode)
} }
@ -111,6 +153,106 @@ const goHome = () => {
background-size: contain; background-size: contain;
} }
.top-bar-right {
display: flex;
align-items: center;
gap: 12px;
}
.login-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
border: 1.5px solid #6c5ce7;
background: #fff;
color: #6c5ce7;
transition: all 0.2s;
font-weight: 500;
}
.login-btn:hover {
background: #6c5ce7;
color: #fff;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
}
.guest-info {
display: flex;
align-items: center;
gap: 8px;
}
.guest-badge {
font-size: 12px;
color: #636e72;
background: #f5f7fa;
padding: 4px 10px;
border-radius: 20px;
font-weight: 500;
}
.username {
font-size: 13px;
color: #2d3436;
font-weight: 600;
}
.logout-btn {
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
border: 1.5px solid #d63031;
background: #fff;
color: #d63031;
transition: all 0.2s;
}
.logout-btn:hover {
background: #d63031;
color: #fff;
}
.history-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
border: 1.5px solid #00b894;
background: #fff;
color: #00b894;
transition: all 0.2s;
font-weight: 500;
}
.history-btn:hover {
background: #00b894;
color: #fff;
}
.admin-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
border: 1.5px solid #f39c12;
background: #fff;
color: #f39c12;
transition: all 0.2s;
font-weight: 500;
}
.admin-btn:hover {
background: #f39c12;
color: #fff;
}
.home-btn { .home-btn {
padding: 8px 16px; padding: 8px 16px;
border-radius: 6px; border-radius: 6px;

Loading…
Cancel
Save