diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 32b6069..5d63001 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -17,8 +17,6 @@ declare module 'vue' { ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] ElButton: typeof import('element-plus/es')['ElButton'] - ElCarousel: typeof import('element-plus/es')['ElCarousel'] - ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElDialog: typeof import('element-plus/es')['ElDialog'] @@ -35,7 +33,6 @@ declare module 'vue' { ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] ElPagination: typeof import('element-plus/es')['ElPagination'] - ElPopover: typeof import('element-plus/es')['ElPopover'] ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c7a5095..a15d1ee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "unplugin-vue-components": "^32.1.0", "vant": "^4.9.24", "vite": "^7.2.4", + "vitest": "^4.1.8", "vue-tsc": "^3.1.5" } }, @@ -1017,6 +1018,31 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1106,6 +1132,129 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.28", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", @@ -1397,6 +1546,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-validator": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", @@ -1452,6 +1611,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -1516,6 +1685,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", @@ -1660,6 +1836,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1748,6 +1931,16 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -2464,6 +2657,13 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2482,6 +2682,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2533,6 +2747,23 @@ "node": ">=16" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -2550,6 +2781,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2818,6 +3059,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -2903,6 +3234,23 @@ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "license": "ISC" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index fc6b2a0..d0c2177 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "unplugin-vue-components": "^32.1.0", "vant": "^4.9.24", "vite": "^7.2.4", + "vitest": "^4.1.8", "vue-tsc": "^3.1.5" } } diff --git a/frontend/src/features/admin/index.ts b/frontend/src/features/admin/index.ts index e91a82e..076927b 100644 --- a/frontend/src/features/admin/index.ts +++ b/frontend/src/features/admin/index.ts @@ -29,6 +29,6 @@ export { updateRole, deleteRole, fetchPermissions, - type AdminRole, + type Role, type Permission } from './api/adminRoles' diff --git a/frontend/src/features/listings/views/MobileHomeFilterSheet.vue b/frontend/src/features/listings/views/MobileHomeFilterSheet.vue new file mode 100644 index 0000000..f666e3d --- /dev/null +++ b/frontend/src/features/listings/views/MobileHomeFilterSheet.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/frontend/src/features/listings/views/MobileHomeView.css b/frontend/src/features/listings/views/MobileHomeView.css new file mode 100644 index 0000000..5675bf3 --- /dev/null +++ b/frontend/src/features/listings/views/MobileHomeView.css @@ -0,0 +1,389 @@ +.mobile-shell { + min-height: 100vh; + padding-bottom: 68px; + background: #f6f8fb; + color: #17233d; +} + +.mobile-hero { + padding: 12px 12px 10px; + background: #fff; +} + +.mobile-topbar { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.mobile-brand { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.mobile-logo { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 8px; + background: #2563eb; + color: #fff; + font-weight: 900; +} + +.mobile-brand strong, +.mobile-brand small { + display: block; + white-space: nowrap; +} + +.mobile-brand strong { + color: #0f172a; + font-size: 14px; +} + +.mobile-brand small { + color: #64748b; + font-size: 11px; +} + +.home-search { + padding: 0; +} + +.mobile-service { + height: 32px; + padding: 0 10px; + border: 1px solid #dbe3ee; + border-radius: 8px; + background: #fff; + color: #2563eb; + font-weight: 700; +} + +.fraud-tip { + display: flex; + align-items: center; + gap: 8px; + height: 34px; + margin-top: 10px; + padding: 0 10px; + border: 1px solid #f5e6a3; + border-radius: 8px; + background: #fffdf0; + color: #8a6d1b; + font-size: 12px; +} + +.fraud-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: #f59e0b; + flex: 0 0 auto; +} + +.announcement-swipe { + flex: 1; + height: 20px; + overflow: hidden; +} + +.announcement-swipe span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mobile-content { + padding: 12px; +} + +.banner-swipe { + margin-bottom: 12px; + border-radius: 8px; + overflow: hidden; +} + +.mobile-banner { + position: relative; + display: flex; + min-height: 116px; + align-items: flex-end; + padding: 16px; + overflow: hidden; + background: #0f172a; + color: #fff; +} + +.mobile-banner.tone-warm { + background: #7c2d12; +} + +.mobile-banner.tone-cool { + background: #1e3a8a; +} + +.banner-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.mobile-banner.has-image::after { + position: absolute; + inset: 0; + content: ""; + background: linear-gradient(180deg, rgba(15, 23, 42, 0.05), rgba(15, 23, 42, 0.72)); +} + +.mobile-banner > div:not(.banner-badge) { + position: relative; + z-index: 1; +} + +.mobile-banner p, +.mobile-banner h1 { + margin: 0; +} + +.mobile-banner p, +.mobile-banner span { + font-size: 12px; + opacity: 0.9; +} + +.mobile-banner h1 { + margin-top: 4px; + font-size: 20px; +} + +.banner-badge { + position: absolute; + top: 12px; + right: 12px; + z-index: 1; + padding: 4px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.88); + color: #0f172a; + font-size: 12px; + font-weight: 800; +} + +.list-toolbar { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.sort-entry, +.filter-entry { + display: flex; + height: 36px; + align-items: center; + justify-content: center; + gap: 5px; + border: 1px solid #dbe3ee; + border-radius: 8px; + background: #fff; + color: #334155; + font-weight: 700; +} + +.sort-entry { + flex: 1; +} + +.filter-entry { + min-width: 86px; + position: relative; +} + +.filter-entry em { + display: grid; + min-width: 18px; + height: 18px; + place-items: center; + border-radius: 999px; + background: #2563eb; + color: #fff; + font-size: 11px; + font-style: normal; +} + +.sort-panel { + display: grid; + gap: 4px; + margin-bottom: 10px; + padding: 8px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; +} + +.sort-panel button { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 34px; + border: none; + border-radius: 6px; + background: transparent; + color: #334155; + font-weight: 700; +} + +.sort-panel button.active { + background: #eff6ff; + color: #1d4ed8; +} + +.result-count { + margin-bottom: 10px; + color: #64748b; + font-size: 13px; +} + +.result-count strong { + color: #2563eb; +} + +.state-loading { + padding: 32px 0; +} + +.mobile-list { + display: grid; + gap: 12px; +} + +.mobile-card { + display: grid; + overflow: hidden; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + color: inherit; + text-decoration: none; +} + +.card-cover { + position: relative; + aspect-ratio: 16 / 9; + display: grid; + place-items: center; + overflow: hidden; + background: #e2e8f0; + color: #64748b; + font-weight: 800; +} + +.card-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.card-cover-labels { + position: absolute; + left: 8px; + top: 8px; + display: flex; + gap: 6px; +} + +.card-cover-labels em { + padding: 3px 7px; + border-radius: 999px; + background: rgba(37, 99, 235, 0.92); + color: #fff; + font-size: 11px; + font-style: normal; + font-weight: 800; +} + +.card-main { + padding: 12px; +} + +.card-title-row h2 { + margin: 0; + overflow: hidden; + color: #0f172a; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-subtitle { + margin: 6px 0 10px; + color: #64748b; + font-size: 13px; + line-height: 1.4; +} + +.card-badges-row, +.card-chip-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.trust-badge, +.server-badge, +.card-chip-row span { + padding: 3px 7px; + border-radius: 999px; + background: #f1f5f9; + color: #475569; + font-size: 11px; +} + +.trust-badge { + background: #ecfdf5; + color: #047857; +} + +.card-footer { + margin-top: 10px; +} + +.price-col strong { + color: #ef4444; + font-size: 20px; +} + +.rent-sub { + margin-left: 8px; + color: #64748b; + font-size: 12px; +} + +.card-chip-row { + padding: 0 12px 12px; +} + +.mobile-load-state { + padding: 18px 0 4px; + text-align: center; + color: #94a3b8; + font-size: 12px; +} + +@media (max-width: 360px) { + .mobile-brand small { + display: none; + } + + .mobile-topbar { + grid-template-columns: 34px minmax(0, 1fr) auto; + } +} diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 17e5c88..80f6aae 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -23,6 +23,9 @@ export interface Order { deposit_amount: number platform_fee?: number account_snapshot?: Record + listing_snapshot?: string + checkout_info?: string + counter_info?: string status: OrderStatus handoff_status: HandoffStatus settlement_status: SettlementStatus diff --git a/frontend/src/features/orders/composables/useOrderDetail.ts b/frontend/src/features/orders/composables/useOrderDetail.ts index ff50437..7ce68ba 100644 --- a/frontend/src/features/orders/composables/useOrderDetail.ts +++ b/frontend/src/features/orders/composables/useOrderDetail.ts @@ -131,11 +131,10 @@ export function useOrderDetail() { if (!order.value) return false disputing.value = true try { - await createDispute({ - order_id: order.value.id, + await createDispute(order.value.id, { type: disputeType.value, description: disputeDescription.value.trim(), - evidence: disputeEvidenceText.value.trim(), + evidence_urls: linesToList(disputeEvidenceText.value), }) await loadOrder() return true @@ -180,6 +179,13 @@ export function useOrderDetail() { return stepMap[status] ?? 0 } + function linesToList(value: string) { + return value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + } + onMounted(loadOrder) return { diff --git a/frontend/src/features/orders/composables/useOrderSnapshot.ts b/frontend/src/features/orders/composables/useOrderSnapshot.ts index 57c4e14..2f12e85 100644 --- a/frontend/src/features/orders/composables/useOrderSnapshot.ts +++ b/frontend/src/features/orders/composables/useOrderSnapshot.ts @@ -41,7 +41,7 @@ export function readSnapshotResources(order: Order | null): SnapshotResource[] { label: String(item.label || ''), quantity: readNumber(item.quantity), unitPrice: readNumber(item.price), - chargeMode: item.charge_mode === '收费' ? '收费' : '赠送', + chargeMode: (item.charge_mode === '收费' ? '收费' : '赠送') as SnapshotResource['chargeMode'], })) .filter((item) => item.key && item.label) } @@ -108,7 +108,7 @@ export function hydrateResourceUsageFromOrder( if (consumedResources) { resources.forEach((res) => { if (res.key in consumedResources) { - usage[res.key] = consumedResources[res.key] + usage[res.key] = consumedResources[res.key] ?? 0 } }) } diff --git a/frontend/src/features/orders/composables/useSettlement.ts b/frontend/src/features/orders/composables/useSettlement.ts index 87a0ec0..1fc5b3f 100644 --- a/frontend/src/features/orders/composables/useSettlement.ts +++ b/frontend/src/features/orders/composables/useSettlement.ts @@ -1,4 +1,4 @@ -import { ref, computed } from 'vue' +import { ref, type Ref } from 'vue' import { submitCheckout, acceptCheckout, counterCheckout, confirmCheckout } from '../api/orders' import type { Order } from '../api/orders' @@ -23,7 +23,7 @@ export interface CounterForm { * 订单结算 Composable * 负责处理订单结算流程:提交结算、接受结算、反驳结算、确认结算 */ -export function useSettlement(order: globalThis.Ref) { +export function useSettlement(order: Ref) { const returning = ref(false) const acceptingCheckout = ref(false) const countering = ref(false) @@ -61,7 +61,7 @@ export function useSettlement(order: globalThis.Ref) { consumable_amount: checkoutForm.value.consumable_amount, coin_consumed_m: checkoutForm.value.coin_consumed_m, other_amount: checkoutForm.value.other_amount, - evidence: checkoutForm.value.evidenceText.trim(), + evidence_urls: linesToList(checkoutForm.value.evidenceText), }) onSuccess?.() return true @@ -98,7 +98,7 @@ export function useSettlement(order: globalThis.Ref) { other_amount: counterForm.value.other_amount, deposit_deduct_amount: counterForm.value.deposit_deduct_amount, reason: counterForm.value.reason.trim(), - evidence: counterForm.value.evidenceText.trim(), + evidence_urls: linesToList(counterForm.value.evidenceText), }) onSuccess?.() return true @@ -120,7 +120,7 @@ export function useSettlement(order: globalThis.Ref) { other_amount: 0, deposit_deduct_amount: 0, reason: reason.trim(), - evidence: '', + evidence_urls: [], }) onSuccess?.() return true @@ -144,6 +144,13 @@ export function useSettlement(order: globalThis.Ref) { } } + function linesToList(value: string) { + return value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + } + return { // Loading states returning, diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.vue b/frontend/src/features/orders/views/MobileOrderDetailView.vue index 320e6a6..085c436 100644 --- a/frontend/src/features/orders/views/MobileOrderDetailView.vue +++ b/frontend/src/features/orders/views/MobileOrderDetailView.vue @@ -3,9 +3,9 @@ import { computed, onMounted, ref } from "vue"; import { useRoute, useRouter } from "vue-router"; import { showToast, showDialog } from "vant"; -import { fetchOrderChat } from "@/api/chats"; -import { createDispute } from "@/api/disputes"; -import { uploadFile } from "@/api/files"; +import { fetchOrderChat } from "@/features/chats/api/chats"; +import { createDispute } from "@/features/disputes/api/disputes"; +import { uploadFile } from "@/shared/api/files"; import { acceptCheckout, cancelOrder, @@ -20,7 +20,7 @@ import { type HandoffRecord, type Order, type PaymentOrder, -} from "@/api/orders"; +} from "@/features/orders/api/orders"; import { useSessionStore } from "@/stores/session"; import { handoffStatusLabel, orderStatusLabel } from "@/utils/statusLabels"; import { formatDateTime } from "@/utils/time"; diff --git a/frontend/src/features/orders/views/MobileOrdersView.vue b/frontend/src/features/orders/views/MobileOrdersView.vue index 2028868..ff2918f 100644 --- a/frontend/src/features/orders/views/MobileOrdersView.vue +++ b/frontend/src/features/orders/views/MobileOrdersView.vue @@ -4,7 +4,7 @@ import { useRouter, useRoute } from "vue-router"; import { showDialog, showToast } from "vant"; import MobileBottomNav from "@/components/MobileBottomNav.vue"; -import { fetchOrders, startOrderPayment, type Order, type PaymentOrder } from "@/api/orders"; +import { fetchOrders, startOrderPayment, type Order, type PaymentOrder } from "@/features/orders/api/orders"; import { useSessionStore } from "@/stores/session"; import { formatDateMinute } from "@/utils/time"; diff --git a/frontend/src/features/seller/composables/usePublishForm.ts b/frontend/src/features/seller/composables/usePublishForm.ts index b4c2204..468b3b4 100644 --- a/frontend/src/features/seller/composables/usePublishForm.ts +++ b/frontend/src/features/seller/composables/usePublishForm.ts @@ -13,7 +13,7 @@ import { type ScreenshotKey, } from '@/features/listings/api/listingOptions' import { createListing } from '@/features/listings/api/listings' -import { usePricingCalculator } from '@/composables/usePricingCalculator' +import { usePricingCalculator } from '@/shared/composables/usePricingCalculator' import { buildPublishDraft, clearRecord, @@ -21,7 +21,7 @@ import { readPublishDraft, removePublishDraft, writePublishDraft, -} from '@/composables/usePublishDraft' +} from '@/features/seller/composables/usePublishDraft' import type { PublishForm } from '@/types/publish' import { commonOnlineTimes, dailyLossOptions, formatNumber, roundRatio } from '@/utils/pricing' diff --git a/frontend/src/features/seller/views/SellerListingCreateView.css b/frontend/src/features/seller/views/SellerListingCreateView.css new file mode 100644 index 0000000..278a562 --- /dev/null +++ b/frontend/src/features/seller/views/SellerListingCreateView.css @@ -0,0 +1,391 @@ +.publish-page { + max-width: 1180px; + margin: 0 auto; + padding: 24px 20px 40px; +} + +.publish-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 20px; +} + +.form-column, +.summary-column { + min-width: 0; +} + +.summary-column { + align-self: start; + position: sticky; + top: 84px; +} + +.field-row, +.input-block { + display: grid; + gap: 8px; +} + +.field-row + .field-row, +.compact-grid + .field-row, +.level-panel, +.time-preset-panel, +.region-panel, +.ratio-panel, +.price-grid, +.remark { + margin-top: 16px; +} + +.field-row label, +.input-block > span, +.quantity-meta strong { + color: #0f172a; + font-size: 14px; + font-weight: 700; +} + +.field-row span, +.input-block b, +.quantity-meta span, +.upload-copy span { + color: #ef4444; +} + +.field-hint { + margin: 6px 0 0; + color: #64748b; + font-size: 13px; + line-height: 1.6; +} + +.compact-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.compact-grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.level-panel, +.time-preset-panel, +.region-panel, +.ratio-panel, +.summary-panel { + padding: 16px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; +} + +.level-row, +.time-preset-row, +.region-title, +.skin-title { + display: grid; + gap: 10px; +} + +.level-row + .level-row, +.time-preset-row + .time-preset-row, +.skin-group + .skin-group { + margin-top: 14px; +} + +.level-options, +.region-grid, +.mode-toggle, +.ratio-mode-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.level-btn, +.region-btn, +.mode-toggle button, +.ratio-mode-btn, +.recommend-button, +.upload-add { + border: 1px solid #dbe3ee; + border-radius: 8px; + background: #fff; + color: #334155; + cursor: pointer; +} + +.level-btn, +.region-btn, +.mode-toggle button { + min-height: 32px; + padding: 0 12px; + font-weight: 600; +} + +.level-btn.active, +.region-btn.active, +.mode-toggle button.active, +.ratio-mode-btn.active { + border-color: #2563eb; + background: #eff6ff; + color: #1d4ed8; +} + +.quantity-table { + display: grid; + gap: 10px; +} + +.quantity-header, +.quantity-item { + display: grid; + grid-template-columns: minmax(160px, 1fr) 100px 120px 116px 80px; + gap: 12px; + align-items: center; +} + +.quantity-header { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.quantity-item { + padding: 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; +} + +.quantity-item.disabled { + opacity: 0.65; +} + +.quantity-meta small, +.quantity-status, +.quantity-price { + color: #64748b; + font-size: 12px; +} + +.time-preset-content, +.deposit-control { + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + gap: 10px; + align-items: center; +} + +.upload-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.upload-item { + display: grid; + gap: 10px; + padding: 14px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; +} + +.upload-copy { + display: grid; + gap: 4px; +} + +.upload-copy strong { + color: #0f172a; +} + +.upload-copy small, +.deposit-breakdown { + color: #64748b; + font-size: 12px; +} + +.upload-preview { + position: relative; + aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: 8px; + background: #e2e8f0; +} + +.upload-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.upload-preview button { + position: absolute; + top: 8px; + right: 8px; +} + +.upload-add { + display: grid; + min-height: 112px; + place-items: center; + align-content: center; + gap: 6px; + color: #2563eb; +} + +.hidden-file { + display: none; +} + +.recommend-button { + height: 32px; + padding: 0 10px; + color: #2563eb; + font-weight: 700; +} + +.deposit-breakdown { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ratio-reference { + display: flex; + align-items: center; + justify-content: space-between; +} + +.ratio-reference span, +.price-cell span, +.summary-main span, +.summary-list span { + color: #64748b; + font-size: 13px; +} + +.ratio-reference strong { + color: #2563eb; + font-size: 22px; +} + +.ratio-panel p { + margin: 8px 0 14px; + color: #64748b; + font-size: 13px; +} + +.ratio-mode-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.ratio-mode-btn, +.ratio-custom-card { + display: grid; + gap: 6px; + padding: 12px; + border: 1px solid #dbe3ee; + border-radius: 8px; + background: #fff; + text-align: left; +} + +.ratio-mode-btn span { + color: #64748b; + font-size: 12px; +} + +.price-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.price-cell { + display: grid; + gap: 6px; + padding: 12px; + border-radius: 8px; + background: #f8fafc; +} + +.price-cell strong, +.summary-list strong { + color: #0f172a; +} + +.price-cell.accent strong, +.summary-main strong { + color: #2563eb; +} + +.summary-panel { + background: #fff; +} + +.summary-main { + display: grid; + gap: 6px; + padding-bottom: 14px; + border-bottom: 1px solid #e2e8f0; +} + +.summary-main strong { + font-size: 28px; +} + +.summary-list { + display: grid; + gap: 10px; + margin: 14px 0; +} + +.summary-list div { + display: flex; + justify-content: space-between; + gap: 10px; +} + +.summary-actions { + display: grid; + gap: 10px; +} + +.summary-actions .el-button { + width: 100%; + margin-left: 0; +} + +@media (max-width: 960px) { + .publish-layout { + grid-template-columns: 1fr; + } + + .summary-column { + position: static; + } + + .compact-grid, + .compact-grid.two, + .upload-grid, + .price-grid, + .ratio-mode-grid, + .time-preset-content, + .deposit-control { + grid-template-columns: 1fr; + } + + .quantity-header { + display: none; + } + + .quantity-item { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/features/seller/views/SellerListingCreateView.vue b/frontend/src/features/seller/views/SellerListingCreateView.vue index e2c7ed1..7ef2656 100644 --- a/frontend/src/features/seller/views/SellerListingCreateView.vue +++ b/frontend/src/features/seller/views/SellerListingCreateView.vue @@ -2,7 +2,7 @@ import { Delete, DocumentChecked, Picture, RefreshRight, UploadFilled } from '@element-plus/icons-vue' import { ElMessage, ElMessageBox } from 'element-plus' -import { usePublishForm } from '@/composables/usePublishForm' +import { usePublishForm } from '@/features/seller/composables/usePublishForm' import OptionChips from './components/OptionChips.vue' import PublishSection from './components/PublishSection.vue' diff --git a/frontend/src/features/seller/views/components/OptionChips.vue b/frontend/src/features/seller/views/components/OptionChips.vue new file mode 100644 index 0000000..8ac67b7 --- /dev/null +++ b/frontend/src/features/seller/views/components/OptionChips.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/frontend/src/features/seller/views/components/PublishSection.vue b/frontend/src/features/seller/views/components/PublishSection.vue new file mode 100644 index 0000000..d23611b --- /dev/null +++ b/frontend/src/features/seller/views/components/PublishSection.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index 942e6ca..5bd8f9f 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -21,7 +21,7 @@ import { VideoPlay, Wallet, } from "@element-plus/icons-vue"; -import { ensureSupportChat } from "@/api/chats"; +import { ensureSupportChat } from "@/features/chats/api/chats"; import { useSessionStore } from "@/stores/session"; const session = useSessionStore(); diff --git a/frontend/src/router/mobileRoutes.ts b/frontend/src/router/mobileRoutes.ts index 28ec738..63078c0 100644 --- a/frontend/src/router/mobileRoutes.ts +++ b/frontend/src/router/mobileRoutes.ts @@ -70,13 +70,13 @@ export const mobileRoutes: RouteRecordRaw[] = [ { path: '/m/seller/listings/create', name: 'mobile-seller-listing-create', - component: () => import('@/views/mobile/MobileSellerListingCreateView.vue'), + component: () => import('@/features/seller/views/SellerListingCreateView.vue'), meta: { layout: 'blank', requiresAuth: true, requiresRealname: true }, }, { path: '/m/seller/listings', name: 'mobile-seller-listings', - component: () => import('@/views/mobile/MobileSellerListingsView.vue'), + component: () => import('@/features/seller/views/SellerListingsView.vue'), meta: { layout: 'blank', requiresAuth: true }, }, ] diff --git a/frontend/src/shared/composables/useSmsCountdown.ts b/frontend/src/shared/composables/useSmsCountdown.ts index 0aaed28..3da6d08 100644 --- a/frontend/src/shared/composables/useSmsCountdown.ts +++ b/frontend/src/shared/composables/useSmsCountdown.ts @@ -1,6 +1,6 @@ import { onUnmounted, ref } from "vue"; import { showToast } from "vant"; -import { sendSmsCode } from "@/api/auth"; +import { sendSmsCode } from "@/features/auth/api/auth"; export function useSmsCountdown() { const countDown = ref(0); diff --git a/frontend/src/shared/utils/listingDisplay.ts b/frontend/src/shared/utils/listingDisplay.ts index 3403b49..2643e21 100644 --- a/frontend/src/shared/utils/listingDisplay.ts +++ b/frontend/src/shared/utils/listingDisplay.ts @@ -1,4 +1,4 @@ -import type { Listing } from "@/api/listings"; +import type { Listing } from "@/features/listings/api/listings"; export interface ListingDisplayChip { label: string; diff --git a/frontend/src/stores/adminSession.ts b/frontend/src/stores/adminSession.ts index 1b6d1cb..fd92e76 100644 --- a/frontend/src/stores/adminSession.ts +++ b/frontend/src/stores/adminSession.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' -import { fetchAdminMe, loginAdmin, type AdminRole, type AdminUser } from '@/features/admin' +import { fetchAdminMe, loginAdmin, type AdminRole, type AdminUser } from '@/features/admin/api/adminAuth' import { clearAuthStorage, getAccessToken, getRefreshToken, setAuthTokens } from '@/utils/authStorage' export const useAdminSessionStore = defineStore('adminSession', { diff --git a/frontend/src/stores/session.ts b/frontend/src/stores/session.ts index 5cbc373..9b791f2 100644 --- a/frontend/src/stores/session.ts +++ b/frontend/src/stores/session.ts @@ -1,6 +1,6 @@ import { defineStore } from "pinia"; -import { fetchMe, loginWithSms, updateMe, type AuthUser } from "@/api/auth"; +import { fetchMe, loginWithSms, updateMe, type AuthUser } from "@/features/auth/api/auth"; import { clearAuthStorage, getAccessToken, getRefreshToken, setAuthTokens } from "@/utils/authStorage"; export const useSessionStore = defineStore("session", {