This commit is contained in:
2026-03-31 15:46:15 +08:00
parent 654724a213
commit 6d79836682
68 changed files with 3076 additions and 4488 deletions
+1 -1
View File
@@ -13,5 +13,5 @@
}
]
},
"lastUpdated": 1774931057335
"lastUpdated": 1774942956287
}
+70
View File
@@ -6,3 +6,73 @@
- 用户确认:主力前端开发目录是 `frontend/ops_vue_js`JS 版,Tabler UI
- `frontend/ops_vue/`TypeScript 版)为旧目录已弃用
- MEMORY.md 已更新为正确的前端目录信息
## 前端整体重构 ✅
- **方案**:整体翻新(方案 C),新建文件替换旧代码
- **构建结果**6176 modules transformed, 0 errors, 7.23s
### 新建基础设施层
- `src/api/index.js` — axios 实例 + 请求/响应拦截器 + async/await 封装
- `src/api/auth.js` — 认证相关 API(登录/注册/修改密码/更新信息等)
- `src/api/purchase.js` — 采购相关 API
- `src/stores/user.js` — 精简的 user storecomputed getter、async actions
- `src/stores/toast.js` — 全局 Toast 通知 store
- `src/composables/usePageTitle.js` — 自动页面标题(一行搞定,替代三件套)
- `src/composables/index.js` — useValidation + isValidEmail
### 新建布局和组件
- `src/layouts/DefaultLayout.vue` — 带 Header + Footer 的主布局
- `src/layouts/AuthLayout.vue` — 登录/注册的全屏居中布局
- `src/components/AppHeader.vue` — 导航栏(Tabler Icons 替代内联 SVG
- `src/components/AppFooter.vue` — 页脚
- `src/components/AppToast.vue` — 全局 Toast 通知(替代 MyOffcanvas
- `src/components/SettingNav.vue` — 设置侧边导航
### 重写的页面
- 认证:LoginView / RegisterView / ForgotPasswordView
- 设置:AccountView / ContactView / SecurityView
- 采购:PurchaseList / AddOrder / ShowOrder
- 其他:HomeView / ScheduleView / WarehouseView / AdminView / NotFoundView
### 核心改进
1. **API 层**:回调地狱 → async/await,统一拦截器处理 cookie 注入和错误
2. **响应式**DOM 操作 `ref.value.classList.add()``v-model` + `is-invalid` class
3. **Auth guard**:每个页面手动检查 → Router beforeEach 统一守卫
4. **页面标题**:三件套复制5遍 → `usePageTitle(key)` 一行
5. **图标**:内联 SVG → `@tabler/icons-vue` 组件
6. **命名**HeardMain → AppHeader, myfunc → composables 等
7. **布局分离**:认证页和主站页使用不同 Layout
### 删除的旧文件
- `my_network_func.js`, `myfunc.js`
- `HeardMain.vue`, `FooterMain.vue`, `MyOffcanvas.vue`, `settingNavigation.vue`
- `HelloWorld.vue`, `TheWelcome.vue`, `WelcomeItem.vue`
- 所有旧视图文件(loginView/registerView/forgotPassword/adminView/warehouse/test/404/scheduleView 等)
## CSS 框架迁移:Bootstrap/Tabler → Tailwind CSS v4 ✅
- **安装**`tailwindcss` + `@tailwindcss/vite`Vite 插件方式)
- **卸载**`@tabler/core``bootstrap`
- **构建结果**6160 modules transformed, 0 errors, 8.84s
- CSS 总大小:~56 kB(之前 Tabler 整包 200+ kB),Tree-shaking 后更小
### 改动的文件(全部从 Bootstrap class 替换为 Tailwind class
- `vite.config.js` — 添加 tailwindcss 插件
- `src/assets/main.css``@import "tailwindcss"` 替换 Tabler CSS
- `src/main.js` — 移除 Tabler CSS 导入
- `src/layouts/` — DefaultLayout/AuthLayout
- `src/components/` — AppHeader/AppFooter/AppToast/SettingNav/imageCropper
- `src/views/` — 所有 15 个视图文件全部重写
- 主题切换改用 `document.documentElement.classList.toggle('dark')`
- 所有表单控件、按钮、卡片、表格、分页等均用 Tailwind class 重写
## 字符损坏修复(第二次) ✅
- 20 个 Vue 文件因批量字符替换脚本导致损坏(`a→n`, `i→l`, `s→n` 等偏移)
- 前一 session 已修复 4 个组件文件(dateTimePicker/tagadder/useDropzone/imageCropper
- 本次 session 修复了剩余 14 个视图文件:
- Auth: LoginView, RegisterView, ForgotPasswordView
- 基础: HomeView, NotFoundView
- 占位: WarehouseView, AdminView, ScheduleViewFullCalendar
- Settings: AccountView, ContactView, SecurityView
- Purchase: PurchaseList, AddOrder, ShowOrder
- **构建验证**6169 modules, 0 errors, 15.73s ✅
- 修复了 `IconFileTypeText` 不存在于 `@tabler/icons-vue` 的导入错误
+24 -9
View File
@@ -19,23 +19,24 @@
- **框架**Vue 3 + JavaScript(非 TypeScript
- **路由**Vue RouterHash 模式)
- **构建工具**Vite 7
- **UI 框架**TablerBootstrap 5 + @tabler/core
- **CSS 框架**Tailwind CSS v4@tailwindcss/vite 插件
- **图标**@tabler/icons-vue
- **状态管理**Pinia
- **国际化**vue-i18n
- **日期选择**flatpickr / litepicker
- **文件上传**FilePond
- **图片裁剪**CropperJS / @cropper/*
- **图片裁剪**CropperJS@cropper/elements
- **日历**FullCalendar(含 daygrid/timegrid/list/interaction
- **其他组件**MyOffcanvas、imageCropper、datePicker、dateTimePicker、tagadder、useDropzone 等
- **其他组件**imageCropper、tagadder、dateTimePicker、useDropzone 等
- ~~**UI 框架**TablerBootstrap 5 + @tabler/core~~ 已弃用(2026-03-31 迁移至 Tailwind
> 注意:`frontend/ops_vue/`TypeScript 版)是旧目录,已弃用
### 前端页面路由(ops_vue_js
- `/` — 首页(HomeView
- `/login` — 登录
- `/register` — 注册
- `/forgot_password` — 找回密码
- `/login` — 登录AuthLayout
- `/register` — 注册AuthLayout
- `/forgot_password` — 找回密码AuthLayout
- `/admin` — 管理后台
- `/schedule` — 日程/排班(FullCalendar
- `/purchase` — 采购订单列表
@@ -71,11 +72,25 @@
## 项目现状(2026-03-31
- 后端基础架构完整,采购模块已有基础实现
- 前端 `ops_vue_js` 目录是主力开发目录(JS 版 Vue3Tabler UI
- 已有页面:登录/注册/采购/日程/仓库/设置等
- 前端 `ops_vue_js` 目录是主力开发目录(Vue 3 + Tailwind CSS v4
- **已完成前端整体重构**API 层 async/await、Router 导航守卫、composables、布局分离
- **已完成 Tabler → Tailwind CSS v4 迁移**
- **已修复所有字符损坏文件**(20 个 Vue 文件,因批量脚本偏移错误)
- 所有页面构建通过,6169 modules, 0 errors
- 前端构建产物放在 `backend/dist/` 供后端 serve
- `frontend/ops_vue/`TypeScript 版)是旧目录,已弃用
- 目录名原为 `frontent`(拼写错误),实际文件系统路径为 `frontend`
## 经验教训
- **批量字符替换脚本危险**:需在源码上使用前先备份,并限定替换范围
- **`@tabler/icons-vue` 不包含所有图标**:如 `IconFileTypeText` 不存在,使用前需确认
## 前端重构后架构(2026-03-31
- **API 层**`src/api/` — axios 实例 + 拦截器,async/await 封装
- **Composables**`src/composables/` — usePageTitle、useValidation、isValidEmail
- **Stores**`src/stores/user.js`(精简)、`src/stores/toast.js`(全局通知)
- **布局**`src/layouts/DefaultLayout.vue`(主站)、`AuthLayout.vue`(认证页)
- **公共组件**AppHeader、AppFooter、AppToast、SettingNav
- **命名规范**PascalCase 文件名,camelCase 函数名
## 开发规范
- API 请求统一携带 `userCookieValue` 做身份验证
+67
View File
@@ -0,0 +1,67 @@
$dir = "c:\Users\wuwen\Documents\prj\ops2\frontend\ops_vue_js\src"
$files = Get-ChildItem -Path $dir -Recurse -Include "*.vue" -File
$replacements = @(
# HTML tags: div->aiv, md->ma
@('<aiv ', '<div ')
@('</aiv>', '</div>')
@(' ma:', ' md:')
@(' ma-', ' md-')
@(' sm:', ' sm:')
# Tailwind classes with corrupted letters
@('rounaea', 'rounded')
@('boraer', 'border')
@('semibola', 'semibold')
@('meaium', 'medium')
@('hiaaen', 'hidden')
@('shaaow', 'shadow')
@('aisablea', 'disabled')
@('aark:', 'dark:')
@('gria-cols', 'grid-cols')
@('gria gap', 'grid gap')
@('birthaay', 'birthday')
@('changea', 'changed')
@('Changea', 'Changed')
@('passwora', 'password')
@('Passwora', 'Password')
@('olaPass', 'oldPass')
@('cof_pass', 'confirm_pass')
# v-model corruption
@('v-moael=', 'v-model=')
@('placeholaer', 'placeholder')
# i18n key corruption
@('purchase_aaaoraer', 'purchase_addorder')
@('aaa_style', 'add_style')
@('scheaule', 'schedule')
@('your_email_aaaress', 'your_email_address')
# event end attribute corruption
@('ena=', 'end=')
# overflow corruption
@('overflow-hiaaen', 'overflow-hidden')
# focus corruption in classes
@('focus:boraer', 'focus:border')
@('focus:outline-none aark:', 'focus:outline-none dark:')
@('aark:hover:bg-gray-700 aark:hover:text', 'dark:hover:bg-dk-card dark:hover:text')
@('aark:ring-', 'dark:ring-')
@('aark:boraer-', 'dark:border-')
@('aark:bg-', 'dark:bg-')
@('aark:text-', 'dark:text-')
@('aark:hover:bg-gray-', 'dark:hover:bg-dk-')
@('aark:hover:text-gray-', 'dark:hover:text-dk-')
@('aark:placeholder-', 'dark:placeholder-')
)
$count = 0
foreach ($f in $files) {
$c = Get-Content $f.FullName -Raw -Encoding UTF8
$original = $c
foreach ($r in $replacements) {
$c = $c.Replace($r[0], $r[1])
}
if ($c -ne $original) {
Set-Content $f.FullName -Value $c -NoNewline -Encoding UTF8
$count++
Write-Host "Fixed: $($f.Name)"
}
}
Write-Host "`nTotal files fixed: $count"
+597 -68
View File
@@ -25,10 +25,8 @@
"@fullcalendar/list": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19",
"@fullcalendar/vue3": "^6.1.19",
"@tabler/core": "^1.4.0",
"@tabler/icons-vue": "^3.35.0",
"axios": "^1.13.2",
"bootstrap": "^5.3.8",
"cropperjs": "^2.1.0",
"dropzone": "^6.0.0-beta.2",
"filepond": "^4.32.11",
@@ -51,8 +49,10 @@
"vue-router": "^4.6.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@vitejs/plugin-vue": "^6.0.1",
"sass-embedded": "^1.93.3",
"tailwindcss": "^4.2.2",
"vite": "^7.1.11",
"vite-plugin-vue-devtools": "^8.0.3"
},
@@ -1569,17 +1569,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.29",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz",
@@ -1901,42 +1890,6 @@
"integrity": "sha512-wpCQMhf5p5GhNg2MmGKXzUNwxe7zRiCsmqYsamez2beP7mKPCSiu+BjZcdN95yYSzO857kr0VfQewmGpS77nqA==",
"license": "MIT"
},
"node_modules/@tabler/core": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@tabler/core/-/core-1.4.0.tgz",
"integrity": "sha512-5BigzOlbOH9N0Is4u0rYNRCiwtnUXWO57K9zwuscygcicAa8UV9MGaS4zTgQsZEtZ9tsNANhN/YD8gCBGKYCiw==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "5.3.7"
},
"engines": {
"node": ">=20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/core/node_modules/bootstrap": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz",
"integrity": "sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/@tabler/icons": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.35.0.tgz",
@@ -1963,6 +1916,278 @@
"vue": ">=3.0.1"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.19.0",
"jiti": "^2.6.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.2.2"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.2.2",
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
"@tailwindcss/oxide-darwin-x64": "4.2.2",
"@tailwindcss/oxide-freebsd-x64": "4.2.2",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
"@tailwindcss/oxide-linux-x64-musl": "4.2.2",
"@tailwindcss/oxide-wasm32-wasi": "4.2.2",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.8.1",
"@emnapi/runtime": "^1.8.1",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.1.1",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
"integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.2.2",
"@tailwindcss/oxide": "4.2.2",
"tailwindcss": "4.2.2"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2255,25 +2480,6 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -2569,6 +2775,20 @@
"dev": true,
"license": "ISC"
},
"node_modules/enhanced-resolve": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.0"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -2918,6 +3138,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -3079,6 +3306,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3125,6 +3362,277 @@
"dev": true,
"license": "MIT"
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss/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==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/litepicker": {
"version": "2.0.12",
"resolved": "https://registry.npmjs.org/litepicker/-/litepicker-2.0.12.tgz",
@@ -3980,6 +4488,27 @@
"node": ">=16.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+2 -2
View File
@@ -29,10 +29,8 @@
"@fullcalendar/list": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19",
"@fullcalendar/vue3": "^6.1.19",
"@tabler/core": "^1.4.0",
"@tabler/icons-vue": "^3.35.0",
"axios": "^1.13.2",
"bootstrap": "^5.3.8",
"cropperjs": "^2.1.0",
"dropzone": "^6.0.0-beta.2",
"filepond": "^4.32.11",
@@ -55,8 +53,10 @@
"vue-router": "^4.6.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@vitejs/plugin-vue": "^6.0.1",
"sass-embedded": "^1.93.3",
"tailwindcss": "^4.2.2",
"vite": "^7.1.11",
"vite-plugin-vue-devtools": "^8.0.3"
}
+9 -14
View File
@@ -1,20 +1,15 @@
<script setup>
import { RouterLink, RouterView } from 'vue-router'
import HeardMain from './components/HeardMain.vue';
import FooterMain from './components/FooterMain.vue';
<script setup>
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useUserStore } from '@/stores/user'
// Restore login state from localStorage/sessionStorage
onMounted(() => {
const userStore = useUserStore()
userStore.restoreSession()
})
</script>
<template>
<div class="page">
<HeardMain />
<RouterView />
<FooterMain />
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
import { api } from './index'
export const authApi = {
/** 登录 */
login(username, password, remember) {
return api.post('/users/login', { username, password, remember })
},
/** 注册 */
register(username, email, password) {
return api.post('/users/register', { username, useremail: email, userpass: password })
},
/** 通过 cookie 获取用户信息 */
getUserInfo() {
return api.post('/users/getinfo', {})
},
/** 修改密码 */
changePassword(oldPass, newPass) {
return api.post('/users/changePassword', { oldpass: oldPass, newpass: newPass })
},
/** 修改邮箱 */
changeEmail(newEmail) {
return api.post('/users/changeEmail', { newemail: newEmail })
},
/** 修改用户信息 */
updateInfo(data) {
return api.post('/users/updateInfo', data)
},
/** 更新头像 */
updateAvatar(file) {
return api.upload('/users/updateAvatar', file)
},
}
+111
View File
@@ -0,0 +1,111 @@
import axios from 'axios'
import { useUserStore } from '@/stores/user'
import { useToastStore } from '@/stores/toast'
const API_BASE = '/api'
// 通用错误码
const ERR_COOKIE_EXPIRED = -44
/**
* 创建 axios 实例,统一拦截
*/
const http = axios.create({
baseURL: API_BASE,
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
})
// 请求拦截器:自动注入 userCookieValue
http.interceptors.request.use((config) => {
const userStore = useUserStore()
if (userStore.cookieValue) {
if (config.data instanceof FormData) {
config.data.append('cookie', userStore.cookieValue)
} else if (typeof config.data === 'object') {
config.data.userCookieValue = userStore.cookieValue
}
}
return config
})
// 响应拦截器:统一处理 err_code 和错误
http.interceptors.response.use(
(response) => {
const data = response.data
// Cookie 过期,自动登出
if (data?.err_code === ERR_COOKIE_EXPIRED) {
const userStore = useUserStore()
userStore.logout()
const toast = useToastStore()
toast.warning('登录已过期,请重新登录')
// 这里返回一个 rejected promise,让调用方知道请求失败了
return Promise.reject(new Error('Cookie expired'))
}
return response
},
(error) => {
const toast = useToastStore()
if (error.message === 'Cookie expired') {
// 已在上面处理
return Promise.reject(error)
}
if (!error.response) {
toast.error('网络错误')
} else {
toast.error('服务端错误')
}
return Promise.reject(error)
},
)
/**
* 封装请求方法:返回 { errCode, data } 格式
* 成功时 errCode === 0data 为服务端 return 字段
* 失败时抛出异常
*/
function unwrapResponse(response) {
const body = response.data
return {
errCode: body.err_code ?? -1,
data: body.return ?? null,
raw: body,
}
}
export const api = {
/**
* GET 请求(一般不需要认证)
*/
async get(path) {
const res = await http.get(path)
return unwrapResponse(res)
},
/**
* POST JSON
*/
async post(path, data = {}) {
const res = await http.post(path, { data })
return unwrapResponse(res)
},
/**
* POST FormData(文件上传)
*/
async upload(path, file) {
const formData = new FormData()
formData.append('file', file)
const res = await http.post(path, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return unwrapResponse(res)
},
}
export default api
+13
View File
@@ -0,0 +1,13 @@
import { api } from './index'
export const purchaseApi = {
/** 获取采购订单列表 */
getOrders(params = {}) {
return api.post('/purchase/getorders', params)
},
/** 新增采购订单 */
addOrder(data) {
return api.post('/purchase/addorder', data)
},
}
+10 -9
View File
@@ -1,11 +1,12 @@
html, body, #app {
height: 100%;
margin: 0,0,0,0;
}
@import "tailwindcss";
@media (min-width: 992px) {
:host, :root {
margin-left: 0;
margin-right: 0;
}
@variant dark (&:where(.dark, .dark *));
/* ── Custom dark theme palette ── */
@theme {
--color-dk-base: #171A1E;
--color-dk-card: #353D45;
--color-dk-muted: #5D6770;
--color-dk-subtle: #939AA0;
--color-dk-text: #F0F4F4;
}
@@ -0,0 +1,19 @@
<script setup>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<footer class="border-t border-gray-200 bg-white py-6 dark:border-dk-muted dark:bg-dk-base print:hidden">
<div class="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 px-4 sm:flex-row sm:gap-0">
<div class="flex items-center gap-3 text-sm text-gray-500">
<a href="https://github.com/wuwenfengmi1998/ops2" target="_blank" class="hover:text-gray-700 dark:hover:text-dk-text" rel="noopener">{{ t('footer.github') }}</a>
<span class="text-gray-300 dark:text-dk-muted">·</span>
<a href="https://github.com/wuwenfengmi1998/ops2/blob/main/LICENSE" target="_blank" class="hover:text-gray-700 dark:hover:text-dk-text" rel="noopener">{{ t('footer.license') }}</a>
<span class="text-gray-300 dark:text-dk-muted">·</span>
<a href="https://github.com/wuwenfengmi1998" target="_blank" class="hover:text-gray-700 dark:hover:text-dk-text" rel="noopener">{{ t('footer.author_home') }}</a>
</div>
<div class="text-sm text-gray-400">{{ t('footer.copy') }}</div>
</div>
</footer>
</template>
@@ -0,0 +1,231 @@
<script setup>
import { ref, computed } from "vue";
import { useRouter, useRoute, RouterLink } from "vue-router";
import { useI18n } from "vue-i18n";
import { useUserStore } from "@/stores/user";
import {
IconMoon,
IconSun,
IconLogout,
IconUser,
IconSettings,
IconMenu2,
IconX,
} from "@tabler/icons-vue";
const { t, locale } = useI18n();
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const isDark = ref(document.documentElement.classList.contains("dark"));
const mobileMenuOpen = ref(false);
const userDropdownOpen = ref(false);
function toggleTheme() {
isDark.value = !isDark.value;
document.documentElement.classList.toggle("dark", isDark.value);
localStorage.setItem("tablerTheme", isDark.value ? "dark" : "light");
}
function toggleLocale() {
locale.value = locale.value === "zh-CN" ? "en" : "zh-CN";
}
function isActive(path) {
return route.path === path;
}
function handleLogout() {
userStore.logout();
router.push("/login");
}
const activeClass = "bg-blue-50 text-blue-600 dark:bg-dk-card dark:text-blue-400";
const normalClass = "rounded-md px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-dk-subtle dark:hover:bg-dk-card dark:hover:text-dk-text";
const navItems = computed(() => [
{ label: t("appname.home"), to: "/" },
{ label: t("appname.schedule"), to: "/schedule" },
{ label: t("appname.purchase"), to: "/purchase" },
{ label: t("appname.warehouse"), to: "/warehouse" },
]);
</script>
<template>
<header
class="border-b border-gray-200 bg-white dark:border-dk-muted dark:bg-dk-base print:hidden"
>
<div class="mx-auto flex h-14 max-w-6xl items-center px-4">
<!-- Logo -->
<RouterLink to="/" class="mr-6 flex items-center">
<img src="/logo.svg" class="h-8 w-8 rounded-lg" alt="Operations" />
<span class="ml-2 text-lg font-bold text-gray-800 dark:text-dk-text"
>Operations</span
>
</RouterLink>
<!-- Mobile toggle -->
<button
class="ml-auto rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-dk-card dark:hover:text-dk-text md:ml-0 md:hidden"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<IconX v-if="mobileMenuOpen" :size="22" />
<IconMenu2 v-else :size="22" />
</button>
<!-- Desktop Nav -->
<nav class="hidden flex-1 items-center gap-1 md:flex">
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
:class="[normalClass, isActive(item.to) && activeClass]"
>
{{ item.label }}
</RouterLink>
</nav>
<!-- Right actions -->
<div class="ml-auto hidden items-center gap-1 md:flex">
<!-- Language -->
<button
class="rounded-md p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-dk-card dark:hover:text-dk-text"
@click="toggleLocale"
:title="locale === 'zh-CN' ? 'English' : '中文'"
>
<span class="text-xs font-semibold uppercase">{{
locale === "zh-CN" ? "EN" : "中"
}}</span>
</button>
<!-- Theme -->
<button
class="rounded-md p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-dk-card dark:hover:text-dk-text"
@click="toggleTheme"
>
<IconMoon v-if="!isDark" :size="20" />
<IconSun v-else :size="20" />
</button>
<!-- User -->
<div v-if="userStore.isLoggedIn" class="relative ml-1">
<button
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-100 dark:text-dk-subtle dark:hover:bg-dk-card"
@click="userDropdownOpen = !userDropdownOpen"
>
<IconUser :size="20" />
<span class="max-w-24 truncate">{{
userStore.user?.Name || ""
}}</span>
</button>
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-if="userDropdownOpen"
class="absolute right-0 z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dk-muted dark:bg-dk-card"
>
<RouterLink
to="/settings/account"
class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-dk-subtle dark:hover:bg-dk-muted"
@click="userDropdownOpen = false"
>
<IconSettings :size="16" />
{{ t("message.user_settings") }}
</RouterLink>
<hr class="my-1 border-gray-200 dark:border-dk-muted" />
<button
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
@click="
handleLogout();
userDropdownOpen = false;
"
>
<IconLogout :size="16" />
{{ t("message.logout") }}
</button>
</div>
</Transition>
</div>
<RouterLink
v-else
to="/login"
class="ml-2 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
{{ t("message.login_or_register") }}
</RouterLink>
</div>
</div>
<!-- Mobile menu -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="-translate-y-2 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="-translate-y-2 opacity-0"
>
<div
v-if="mobileMenuOpen"
class="border-t border-gray-200 bg-white px-4 pb-4 pt-2 dark:border-dk-muted dark:bg-dk-base md:hidden"
>
<nav class="flex flex-col gap-1">
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
:class="['rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 dark:text-dk-subtle dark:hover:bg-dk-card', isActive(item.to) && 'bg-blue-50 text-blue-600 dark:bg-dk-card dark:text-blue-400']"
@click="mobileMenuOpen = false"
>
{{ item.label }}
</RouterLink>
</nav>
<hr class="my-3 border-gray-200 dark:border-dk-muted" />
<div class="flex items-center gap-2">
<button
class="rounded-md p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-dk-card"
@click="toggleLocale"
>
<span class="text-xs font-semibold uppercase">{{
locale === "zh-CN" ? "EN" : "中"
}}</span>
</button>
<button
class="rounded-md p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-dk-card"
@click="toggleTheme"
>
<IconMoon v-if="!isDark" :size="20" />
<IconSun v-else :size="20" />
</button>
<div class="ml-auto">
<RouterLink
v-if="!userStore.isLoggedIn"
to="/login"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
@click="mobileMenuOpen = false"
>
{{ t("message.login_or_register") }}
</RouterLink>
<button
v-else
class="rounded-md px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:text-red-400"
@click="
handleLogout();
mobileMenuOpen = false;
"
>
{{ t("message.logout") }}
</button>
</div>
</div>
</div>
</Transition>
</header>
</template>
@@ -0,0 +1,40 @@
<script setup>
import { useToastStore } from '@/stores/toast'
import { IconAlertCircle, IconAlertTriangle, IconCheck, IconInfoCircle, IconX } from '@tabler/icons-vue'
const toastStore = useToastStore()
const icons = {
success: IconCheck,
warning: IconAlertTriangle,
error: IconAlertCircle,
info: IconInfoCircle,
}
</script>
<template>
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="translate-y-2 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-2 opacity-0"
>
<div
v-if="toastStore.visible"
class="fixed left-1/2 top-5 z-[9999] flex max-w-sm -translate-x-1/2 transform items-start gap-3 rounded-lg border-0 bg-white px-4 py-3 shadow-lg dark:bg-dk-card dark:text-dk-text"
:class="{
'text-green-700': toastStore.type === 'success',
'text-blue-700': toastStore.type === 'warning',
'text-red-700': toastStore.type === 'error',
'text-gray-700': toastStore.type === 'info',
}"
role="alert"
>
<component :is="icons[toastStore.type] || IconInfoCircle" :size="20" class="mt-0.5 shrink-0" />
<span class="flex-1 text-sm">{{ toastStore.message }}</span>
<button class="ml-1 text-white/70 hover:text-white" @click="toastStore.hide()">×</button>
</div>
</Transition>
</template>
@@ -1,70 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<footer class="footer footer-transparent d-print-none">
<div class="container-xl">
<div class="row text-center align-items-center flex-row-reverse">
<div class="col-lg-auto ms-lg-auto">
<ul class="list-inline list-inline-dots mb-0">
<li class="list-inline-item">
<a
href="https://github.com/wuwenfengmi1998/ops2/blob/main/readme.md"
target="_blank"
class="link-secondary"
rel="noopener"
>{{t('footer.doc')}}</a
>
</li>
<li class="list-inline-item">
<a
href="https://github.com/wuwenfengmi1998/ops2/blob/main/LICENSE"
target="_blank"
class="link-secondary"
>{{t('footer.license')}}</a
>
</li>
<li class="list-inline-item">
<a
href="https://github.com/wuwenfengmi1998/ops2"
target="_blank"
class="link-secondary"
rel="noopener"
>{{t('footer.source_code')}}</a
>
</li>
<li class="list-inline-item">
<a
href="https://github.com/wuwenfengmi1998"
target="_blank"
class="link-secondary"
rel="noopener"
>
kevin
</a>
</li>
</ul>
</div>
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
<ul class="list-inline list-inline-dots mb-0">
<li class="list-inline-item">
{{ t('footer.copy')}}
</li>
<li class="list-inline-item">
<a
href="https://git.lmve.net/kevin/ops2/-/commits/main"
target="_blank"
class="link-secondary"
rel="noopener"
>
v0.0.1
</a>
</li>
</ul>
</div>
</div>
</div>
</footer>
</template>
@@ -1,337 +0,0 @@
<script setup>
import { useUserStore } from "@/stores/user";
import { RouterLink, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { myfuncs } from "@/myfunc.js";
import { onMounted, ref } from "vue";
// import { Tooltip } from "@tabler/core";
// import { Dropdown } from 'bootstrap'
// 使用 vue-i18n 的 Composition API
const { t, locale } = useI18n();
const userStore = useUserStore();
const theTeme = ref("light");
const lang_sele = ref(null);
const router = useRouter();
function set_them(temp) {
theTeme.value = temp;
myfuncs.setTheme(temp, true);
}
function changeLanguage(lang) {
// 切换语言
const selectElement = lang.target;
const selectedLang = selectElement.value;
locale.value = selectedLang;
myfuncs.save("userLanguage", selectedLang);
//console.log("selectedLang:",selectedLang);
}
function logOut() {
//console.log("logout");
userStore.logout();
router.push("/login");
}
onMounted(() => {
const savedTheme = myfuncs.getThemefromStorge();
theTeme.value = savedTheme;
myfuncs.setTheme(savedTheme, false);
const userLang = myfuncs.load("userLanguage");
if (userLang) {
locale.value = userLang;
if (lang_sele.value) {
lang_sele.value.value = userLang;
}
}
//userlogin
userStore.loginFromStoreCookie();
});
</script>
<template>
<header class="navbar navbar-expand-md d-print-none">
<div class="container-xl">
<!-- BEGIN NAVBAR TOGGLER -->
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbar-menu"
aria-controls="navbar-menu"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<!-- END NAVBAR TOGGLER -->
<!-- BEGIN NAVBAR LOGO -->
<div
class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3"
>
<router-link to="/" aria-label="Tabler">
<svg
xmlns="http://www.w3.org/2000/svg"
width="110"
height="32"
viewBox="0 0 232 68"
class="navbar-brand-image"
>
<path
d="M64.6 16.2C63 9.9 58.1 5 51.8 3.4 40 1.5 28 1.5 16.2 3.4 9.9 5 5 9.9 3.4 16.2 1.5 28 1.5 40 3.4 51.8 5 58.1 9.9 63 16.2 64.6c11.8 1.9 23.8 1.9 35.6 0C58.1 63 63 58.1 64.6 51.8c1.9-11.8 1.9-23.8 0-35.6zM33.3 36.3c-2.8 4.4-6.6 8.2-11.1 11-1.5.9-3.3.9-4.8.1s-2.4-2.3-2.5-4c0-1.7.9-3.3 2.4-4.1 2.3-1.4 4.4-3.2 6.1-5.3-1.8-2.1-3.8-3.8-6.1-5.3-2.3-1.3-3-4.2-1.7-6.4s4.3-2.9 6.5-1.6c4.5 2.8 8.2 6.5 11.1 10.9 1 1.4 1 3.3.1 4.7zM49.2 46H37.8c-2.1 0-3.8-1-3.8-3s1.7-3 3.8-3h11.4c2.1 0 3.8 1 3.8 3s-1.7 3-3.8 3z"
fill="#066fd1"
style="fill: var(--tblr-primary, #066fd1)"
/>
<path
d="M105.8 46.1c.4 0 .9.2 1.2.6s.6 1 .6 1.7c0 .9-.5 1.6-1.4 2.2s-2 .9-3.2.9c-2 0-3.7-.4-5-1.3s-2-2.6-2-5.4V31.6h-2.2c-.8 0-1.4-.3-1.9-.8s-.9-1.1-.9-1.9c0-.7.3-1.4.8-1.8s1.2-.7 1.9-.7h2.2v-3.1c0-.8.3-1.5.8-2.1s1.3-.8 2.1-.8 1.5.3 2 .8.8 1.3.8 2.1v3.1h3.4c.8 0 1.4.3 1.9.8s.8 1.2.8 1.9-.3 1.4-.8 1.8-1.2.7-1.9.7h-3.4v13c0 .7.2 1.2.5 1.5s.8.5 1.4.5c.3 0 .6-.1 1.1-.2.5-.2.8-.3 1.2-.3zm28-20.7c.8 0 1.5.3 2.1.8.5.5.8 1.2.8 2.1v20.3c0 .8-.3 1.5-.8 2.1-.5.6-1.2.8-2.1.8s-1.5-.3-2-.8-.8-1.2-.8-2.1c-.8.9-1.9 1.7-3.2 2.4-1.3.7-2.8 1-4.3 1-2.2 0-4.2-.6-6-1.7-1.8-1.1-3.2-2.7-4.2-4.7s-1.6-4.3-1.6-6.9c0-2.6.5-4.9 1.5-6.9s2.4-3.6 4.2-4.8c1.8-1.1 3.7-1.7 5.9-1.7 1.5 0 3 .3 4.3.8 1.3.6 2.5 1.3 3.4 2.1 0-.8.3-1.5.8-2.1.5-.5 1.2-.7 2-.7zm-9.7 21.3c2.1 0 3.8-.8 5.1-2.3s2-3.4 2-5.7-.7-4.2-2-5.8c-1.3-1.5-3-2.3-5.1-2.3-2 0-3.7.8-5 2.3-1.3 1.5-2 3.5-2 5.8s.6 4.2 1.9 5.7 3 2.3 5.1 2.3zm32.1-21.3c2.2 0 4.2.6 6 1.7 1.8 1.1 3.2 2.7 4.2 4.7s1.6 4.3 1.6 6.9-.5 4.9-1.5 6.9-2.4 3.6-4.2 4.8c-1.8 1.1-3.7 1.7-5.9 1.7-1.5 0-3-.3-4.3-.9s-2.5-1.4-3.4-2.3v.3c0 .8-.3 1.5-.8 2.1-.5.6-1.2.8-2.1.8s-1.5-.3-2.1-.8c-.5-.5-.8-1.2-.8-2.1V18.9c0-.8.3-1.5.8-2.1.5-.6 1.2-.8 2.1-.8s1.5.3 2.1.8c.5.6.8 1.3.8 2.1v10c.8-1 1.8-1.8 3.2-2.5 1.3-.7 2.8-1 4.3-1zm-.7 21.3c2 0 3.7-.8 5-2.3s2-3.5 2-5.8-.6-4.2-1.9-5.7-3-2.3-5.1-2.3-3.8.8-5.1 2.3-2 3.4-2 5.7.7 4.2 2 5.8c1.3 1.6 3 2.3 5.1 2.3zm23.6 1.9c0 .8-.3 1.5-.8 2.1s-1.3.8-2.1.8-1.5-.3-2-.8-.8-1.3-.8-2.1V18.9c0-.8.3-1.5.8-2.1s1.3-.8 2.1-.8 1.5.3 2 .8.8 1.3.8 2.1v29.7zm29.3-10.5c0 .8-.3 1.4-.9 1.9-.6.5-1.2.7-2 .7h-15.8c.4 1.9 1.3 3.4 2.6 4.4 1.4 1.1 2.9 1.6 4.7 1.6 1.3 0 2.3-.1 3.1-.4.7-.2 1.3-.5 1.8-.8.4-.3.7-.5.9-.6.6-.3 1.1-.4 1.6-.4.7 0 1.2.2 1.7.7s.7 1 .7 1.7c0 .9-.4 1.6-1.3 2.4-.9.7-2.1 1.4-3.6 1.9s-3 .8-4.6.8c-2.7 0-5-.6-7-1.7s-3.5-2.7-4.6-4.6-1.6-4.2-1.6-6.6c0-2.8.6-5.2 1.7-7.2s2.7-3.7 4.6-4.8 3.9-1.7 6-1.7 4.1.6 6 1.7 3.4 2.7 4.5 4.7c.9 1.9 1.5 4.1 1.5 6.3zm-12.2-7.5c-3.7 0-5.9 1.7-6.6 5.2h12.6v-.3c-.1-1.3-.8-2.5-2-3.5s-2.5-1.4-4-1.4zm30.3-5.2c1 0 1.8.3 2.4.8.7.5 1 1.2 1 1.9 0 1-.3 1.7-.8 2.2-.5.5-1.1.8-1.8.7-.5 0-1-.1-1.6-.3-.2-.1-.4-.1-.6-.2-.4-.1-.7-.1-1.1-.1-.8 0-1.6.3-2.4.8s-1.4 1.3-1.9 2.3-.7 2.3-.7 3.7v11.4c0 .8-.3 1.5-.8 2.1-.5.6-1.2.8-2.1.8s-1.5-.3-2.1-.8c-.5-.6-.8-1.3-.8-2.1V28.8c0-.8.3-1.5.8-2.1.5-.6 1.2-.8 2.1-.8s1.5.3 2.1.8c.5.6.8 1.3.8 2.1v.6c.7-1.3 1.8-2.3 3.2-3 1.3-.7 2.8-1 4.3-1z"
fill-rule="evenodd"
clip-rule="evenodd"
fill="#4a4a4a"
/>
</svg>
</router-link>
</div>
<!-- END NAVBAR LOGO -->
<div class="navbar-nav flex-row order-md-last">
<div class="nav-item">
<a
@click="set_them('dark')"
class="nav-link px-0"
:class="{ 'd-none': theTeme === 'dark' }"
:title="t('message.dark_mode')"
>
<!-- Download SVG icon from http://tabler.io/icons/icon/moon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-1"
>
<path
d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z"
/>
</svg>
</a>
<a
@click="set_them('light')"
class="nav-link px-0"
:class="{ 'd-none': theTeme === 'light' }"
:title="t('message.light_mode')"
>
<!-- Download SVG icon from http://tabler.io/icons/icon/sun -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-1"
>
<path d="M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" />
<path
d="M3 12h1m8 -9v1m8 8h1m-9 8v1m-6.4 -15.4l.7 .7m12.1 -.7l-.7 .7m0 11.4l.7 .7m-12.1 -.7l-.7 .7"
/>
</svg>
</a>
</div>
<!-- 这里判断是否已经登陆 是则显示用户信息 否则显示登陆按钮 -->
<div v-if="!userStore.isLoggedIn" class="nav-item">
<router-link to="login" class="btn btn-outline-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0"></path>
<path d="M6 21v-2a4 4 0 0 1 4 -4h4"></path>
<path d="M19 22v-6"></path>
<path d="M22 19l-3 -3l-3 3"></path>
</svg>
{{ t("message.login_or_register") }}
</router-link>
</div>
<div v-else class="nav-item">
<div class="dropdown">
<div
class="nav-link d-flex lh-1 p-0 px-2"
data-bs-toggle="dropdown"
aria-label="Open user menu"
>
<img
:src="userStore.getUserAvatarPath()"
alt=""
class="avatar avatar-sm"
/>
<div class="d-none d-xl-block ps-2">
<div>
{{
userStore.userInfo
? userStore.userInfo.Username
: userStore.user?.Name
}}
</div>
<div class="mt-1 small text-secondary">
{{
userStore.userInfo
? userStore.userInfo.FirstName
: userStore.user?.Email
}}
</div>
</div>
</div>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<!-- <router-link to="" class="dropdown-item">{{
t("message.user_home")
}}</router-link> -->
<router-link to="/settings/account" class="dropdown-item">{{
t("message.user_settings")
}}</router-link>
<!-- <router-link to="" class="dropdown-item">{{
t("message.preferences")
}}</router-link> -->
<div class="dropdown-divider"></div>
<!-- 如何用户是系统管理员这里显示跳转管理的url -->
<!-- <router-link to="/admin" class="dropdown-item">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-settings"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"
/>
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
</svg>
{{ t("message.administrator") }}</router-link
> -->
<div @click="logOut" class="dropdown-item">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-logout-2"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M10 8v-2a2 2 0 0 1 2 -2h7a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-7a2 2 0 0 1 -2 -2v-2"
/>
<path d="M15 12h-12l3 -3" />
<path d="M6 15l-3 -3" />
</svg>
{{ t("message.logout") }}
</div>
</div>
</div>
</div>
</div>
</div>
</header>
<header class="navbar-expand-md">
<div class="collapse navbar-collapse" id="navbar-menu">
<div class="navbar">
<div class="container-xl">
<div class="row flex-column flex-md-row flex-fill align-items-center">
<div class="col d-flex">
<!-- BEGIN NAVBAR MENU -->
<div class="navbar-nav">
<router-link
to="/"
class="nav-item nav-link"
active-class="active"
>
<span class="nav-link-title">
{{ t("appname.home") }}
</span>
</router-link>
<router-link
to="/schedule"
class="nav-item nav-link"
active-class="active"
>
<span class="nav-link-title">
{{ t("appname.schedule") }}
</span>
</router-link>
<router-link
to="/warehouse"
class="nav-item nav-link"
active-class="active"
>
<span class="nav-link-title">
{{ t("appname.warehouse") }}
</span>
</router-link>
<router-link
to="/purchase"
class="nav-item nav-link"
active-class="active"
>
<span class="nav-link-title">
{{ t("appname.purchase") }}
</span>
</router-link>
</div>
<div class="ms-auto">
<select
class="form-select"
@change="changeLanguage"
ref="lang_sele"
>
<option value="en">English</option>
<option value="zh-CN">中文</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
</header>
</template>
<style scoped></style>
@@ -1,11 +0,0 @@
<script setup>
</script>
<template>
<div class="greetings">
111
</div>
</template>
@@ -1,169 +0,0 @@
<script setup>
import { onMounted, ref } from "vue";
import { Offcanvas } from "@tabler/core";
const offcanvasTop = ref(null);
let ov;
const alertType = ref(); // 可选值:'success', 'warning', 'danger', 'info'
const alertText = ref();
let autoCloseTimeout;
onMounted(() => {
// 确保在组件挂载后初始化
if (offcanvasTop.value) {
ov = new Offcanvas(offcanvasTop.value, {
backdrop: false,
});
//ov.show();
//console.log('Offcanvas initialized:', ov)
}
});
function showAlert(type, text, timeout = 5000, callback) {
alertText.value = text;
alertType.value = type;
//console.log(ov);
if (ov) {
ov.hide();
ov.show();
if (autoCloseTimeout) {
clearTimeout(autoCloseTimeout);
}
autoCloseTimeout = setTimeout(() => {
//console.log("timeout");
ov.hide();
if (typeof callback === "function") {
callback();
}
}, timeout);
}
}
defineExpose({
showAlert,
});
</script>
<style scoped>
.my_offcanvas_top {
position: fixed;
height: 45px;
top: 0;
right: 0;
left: 0;
margin-left: 20%;
margin-right: 20%;
margin-top: 20px;
max-height: 100%;
transform: translateY(-100%);
}
</style>
<template>
<div
class="offcanvas alert alert-important alert-dismissible my_offcanvas_top"
:class="{
'alert-success': alertType === 'success',
'alert-warning': alertType === 'warning',
'alert-danger': alertType === 'danger',
'alert-info': alertType === 'info',
}"
role="alert"
tabindex="-1"
ref="offcanvasTop"
>
<div class="d-flex">
<div>
<!-- Download SVG icon from http://tabler-icons.io/i/check -->
<svg
v-if="alertType === 'success'"
xmlns="http://www.w3.org/2000/svg"
class="icon alert-icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path>
</svg>
<svg
v-if="alertType === 'warning'"
xmlns="http://www.w3.org/2000/svg"
class="icon alert-icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path
d="M10.24 3.957l-8.422 14.06a1.989 1.989 0 0 0 1.7 2.983h16.845a1.989 1.989 0 0 0 1.7 -2.983l-8.423 -14.06a1.989 1.989 0 0 0 -3.4 0z"
></path>
<path d="M12 9v4"></path>
<path d="M12 17h.01"></path>
</svg>
<svg
v-if="alertType === 'danger'"
xmlns="http://www.w3.org/2000/svg"
class="icon alert-icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"></path>
<path d="M12 8v4"></path>
<path d="M12 16h.01"></path>
</svg>
<svg
v-if="alertType === 'info'"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-brand-hipchat"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M17.802 17.292s.077 -.055 .2 -.149c1.843 -1.425 3 -3.49 3 -5.789c0 -4.286 -4.03 -7.764 -9 -7.764c-4.97 0 -9 3.478 -9 7.764c0 4.288 4.03 7.646 9 7.646c.424 0 1.12 -.028 2.088 -.084c1.262 .82 3.104 1.493 4.716 1.493c.499 0 .734 -.41 .414 -.828c-.486 -.596 -1.156 -1.551 -1.416 -2.29z"
/>
<path d="M7.5 13.5c2.5 2.5 6.5 2.5 9 0" />
</svg>
</div>
<div>
{{ alertText }}
</div>
</div>
<a class="btn-close" data-bs-dismiss="offcanvas" aria-label="close"></a>
</div>
<!-- <div>
<button @click="showAlert('success','success')">success</button>
<button @click="showAlert('warning','warning')">warning</button>
<button @click="showAlert('danger','danger')">danger</button>
<button @click="showAlert('info','info')">info</button>
</div> -->
</template>
@@ -0,0 +1,28 @@
<script setup>
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { IconUser, IconMail, IconLock } from '@tabler/icons-vue'
const { t } = useI18n()
const links = [
{ to: '/settings/account', label: 'account_information', icon: IconUser },
{ to: '/settings/contact', label: 'contact_information', icon: IconMail },
{ to: '/settings/security', label: 'security_settings', icon: IconLock },
]
</script>
<template>
<nav class="flex flex-col gap-1 border-b-4 border-b-blue-600 w-56 shrink-0 py-6">
<RouterLink
v-for="link in links"
:key="link.to"
:to="link.to"
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-dk-subtle dark:hover:bg-dk-card"
active-class="!bg-blue-50 !text-blue-600 dark:!bg-dk-card dark:!text-blue-400"
>
<component :is="link.icon" :size="18" />
{{ t(`settings.${link.label}`) }}
</RouterLink>
</nav>
</template>
@@ -1,95 +0,0 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
>Vue - Official</a
>. If you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>
@@ -1,86 +0,0 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>
@@ -1,80 +1,44 @@
<script setup>
import { onMounted, ref, watch ,defineProps} from "vue";
import Litepicker from "litepicker";
import { useI18n } from "vue-i18n";
const { t, locale } = useI18n();
<script setup>
import { ref, onMounted, watch } from 'vue'
import flatpickr from 'flatpickr'
import 'flatpickr/dist/flatpickr.css'
import { useI18n } from 'vue-i18n'
const datepicker = ref(null);
var picker = null
const { t, locale } = useI18n()
watch(locale, () => {
picker?.setOptions({ lang: locale.value });
});
defineProps({
setdef: {
type: String,
default: "",
},
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
const inputEl = ref(null)
let picker = null
onMounted(() => {
// @formatter:off
picker = flatpickr(inputEl.value, {
dateFormat: 'Y-m-d',
defaultDate: props.modelValue || null,
allowInput: true,
disableMobile: true,
parseDate(datestr) { return new Date(datestr) },
onChange(selectedDates, dateStr) { emit('update:modelValue', dateStr) },
})
})
picker = new Litepicker({
element: datepicker.value,
lang: locale.value,
firstDay: 0,
format: "YYYY-MM-DD", // 日期格式
dropdowns: {
minYear: 1900, // 最小可选年份
maxYear: new Date().getFullYear() + 1, // 最大为当前年份
months: true, // 显示月份下拉
years: true, // 显示年份下拉
},
//inlineMode: true,
});
});
defineExpose({
datepicker,
});
watch(() => props.modelValue, (val) => {
if (picker && val !== picker.input.value) { picker.setDate(val, false) }
})
</script>
<template>
<div class="input-icon">
<span class="input-icon-addon"
><!-- Download SVG icon from http://tabler-icons.io/i/calendar -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12z"
/>
<path d="M16 3v4" />
<path d="M8 3v4" />
<path d="M4 11h16" />
<path d="M11 15h1" />
<path d="M12 15v3" />
<div class="relative">
<span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-gray-400 dark:text-dk-subtle">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12z"/><path d="M16 3v4"/><path d="M8 3v4"/><path d="M4 11l16 0"/><path d="M11 15h1"/><path d="M12 15v3"/>
</svg>
</span>
<input
class="form-control"
:placeholder="t('message.select_date')"
ref="datepicker"
:value="setdef"
/>
<input ref="inputEl" type="text" :placeholder="placeholder || t('message.select_date')" class="w-full rounded-lg border border-gray-300 bg-white py-2 pr-3 pl-9 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 focus:outline-none dark:border-dk-muted dark:bg-dk-card dark:text-dk-text" :value="modelValue" readonly />
</div>
</template>
@@ -1,93 +1,47 @@
<script setup>
<script setup>
import { onMounted, ref, watch, defineProps, reactive } from "vue";
import flatpickr from "flatpickr";
import "flatpickr/dist/flatpickr.css";
import "flatpickr/dist/l10n/zh.js";
import { useI18n } from "vue-i18n";
const { t, locale } = useI18n();
const datatimepack = ref();
const prop = defineProps({
setdef: {
type: String,
default: "",
},
max_date: {
type: [String, Date, Function],
default: () => new Date(), // 默认值为当前时间
},
setdef: { type: String, default: "" },
max_date: { type: [String, Date, Function], default: () => new Date() },
});
const datatimepack_config = reactive({
enableTime: true,
dateFormat: "Y-m-d H:i",
minuteIncrement: 1,
time_24hr: true,
maxDate: prop.max_date, // 只能选择当前时间之前的时间
//locale:"zh"
maxDate: prop.max_date,
});
const sele_data = reactive();
const emit = defineEmits(['update:modelValue'])
const handleChange = (e) => {
//console.log(e)
emit("update:modelValue", e.target.value);
};
const handleChange = (e) => { emit("update:modelValue", e.target.value); };
function getCurrentDateTime() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0"); // 月份从0开始
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
watch(locale, () => {
if (locale.value == "zh-CN") {
datatimepack_config.locale = "zh";
} else {
datatimepack_config.locale = "en";
}
//console.log(locale.value=="zh-CN"?"zh":"en")
if (locale.value == "zh-CN") { datatimepack_config.locale = "zh"; }
else { datatimepack_config.locale = "en"; }
});
onMounted(() => {
// @formatter:off
//console.log(getCurrentDateTime())
//sele_data=getCurrentDateTime();
// console.log(prop.setdef)
if (prop.setdef == "") {
datatimepack_config.defaultDate = getCurrentDateTime();
} else {
datatimepack_config.defaultDate = prop.setdef;
}
if (prop.setdef == "") { datatimepack_config.defaultDate = getCurrentDateTime(); }
else { datatimepack_config.defaultDate = prop.setdef; }
datatimepack_config.locale = locale.value == "zh-CN" ? "zh" : "en";
flatpickr(datatimepack.value, datatimepack_config);
emit("update:modelValue", datatimepack_config.defaultDate);
});
defineExpose({});
</script>
<template>
<div></div>
<input
ref="datatimepack"
type="datetime-local"
class="form-control"
@input="handleChange"
/>
<input ref="datatimepack" type="datetime-local" class="w-full rounded-lg border border-gray-300 bg-white py-2 pr-3 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 focus:outline-none dark:border-dk-muted dark:bg-dk-card dark:text-dk-text" @input="handleChange" />
</template>
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>
@@ -1,19 +0,0 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>
@@ -1,160 +1,63 @@
<script setup>
import { Modal } from "@tabler/core";
<script setup>
import { onMounted, ref } from "vue";
import "cropperjs";
import { useI18n } from "vue-i18n";
const { t, locale } = useI18n();
const cro_sele = ref();
const cro_canv = ref();
const cro_imag = ref();
var cor_size_width = 300;
var cor_size_height = 300;
const is_have_URL = ref(false);
const reader = new FileReader();
reader.onload = () => {
initCropper(reader.result);
};
reader.onload = () => { initCropper(reader.result); };
const emit = defineEmits(['crop_to_canvas'])
onMounted(() => {
cro_sele.value.$change(0, 0, cor_size_width, cor_size_height);
cro_canv.value.style.width = cor_size_width.toString() + "px";
cro_canv.value.style.height = cor_size_height.toString() + "px";
cro_imag.value.src = "";
//console.log(cro_canv.value.clientHeight)
});
function initCropper(imageSrc) {
is_have_URL.value = true;
cro_imag.value.src = imageSrc;
}
function cancel() {
is_have_URL.value = false;
}
function initCropper(imageSrc) { is_have_URL.value = true; cro_imag.value.src = imageSrc; }
function cancel() { is_have_URL.value = false; }
function inputfile(e) {
const file = e.target.files[0];
if (!file) {
e.target.value = "";
is_have_URL.value = false;
return;
}
if (!file.type.startsWith("image/")) {
e.target.value = "";
is_have_URL.value = false;
return;
}
const file = e.target.files[0]; if (!file) { e.target.value = ""; is_have_URL.value = false; return; }
if (!file.type.startsWith("image/")) { e.target.value = ""; is_have_URL.value = false; return; }
reader.readAsDataURL(file);
}
function openFilePicker() {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "image/*"; // 可选:限制文件类型
fileInput.multiple = false; // 可选:是否允许多选
fileInput.onchange = (e) => {
inputfile(e);
};
fileInput.click(); // 触发文件选择
fileInput.type = "file"; fileInput.accept = "image/*"; fileInput.multiple = false;
fileInput.onchange = (e) => { inputfile(e); };
fileInput.click();
}
function getsele() {
cro_canv.value.$toCanvas().then((a) => {
//console.log(a);
//const imageData = a.toDataURL("image/png");
emit('crop_to_canvas',a)
//console.log(imageData);
});
cro_canv.value.$toCanvas().then((a) => { emit('crop_to_canvas',a) });
}
</script>
<template>
<div class="d-flex flex-column flex-md-row">
<div v-show="!is_have_URL" class="col-6 col-sm-4 col-md-2 col-xl py-3">
<button class="btn btn-outline-primary" @click="openFilePicker">
{{ t("cropper.select_image") }}
</button>
<div class="flex flex-col md:flex-row">
<div v-show="!is_have_URL" class="w-full py-3 md:w-auto md:px-3">
<button class="rounded-lg border border-blue-600 px-4 py-2 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-400 dark:text-blue-400 dark:hover:bg-blue-900/20" @click="openFilePicker">{{ t("cropper.select_image") }}</button>
</div>
<cropper-canvas
ref="cro_canv"
class="cropper-container"
:hidden="!is_have_URL"
background
scale-step="0.1"
>
<cropper-image
ref="cro_imag"
src=""
alt="Picture"
initialCenterSize="cover"
rotatable
scalable
skewable
translatable
></cropper-image>
<cropper-canvas ref="cro_canv" class="cropper-container" :hidden="!is_have_URL" background scale-step="0.1">
<cropper-image ref="cro_imag" src="" alt="Picture" initialCenterSize="cover" rotatable scalable skewable translatable></cropper-image>
<cropper-shade hidden></cropper-shade>
<cropper-handle action="move" plain></cropper-handle>
<cropper-selection ref="cro_sele">
<cropper-grid role="grid" covered></cropper-grid>
<cropper-crosshair centered></cropper-crosshair>
<cropper-handle
action="move"
theme-color="rgba(255, 255, 255, 0)"
></cropper-handle>
<cropper-handle action="move" theme-color="rgba(255, 255, 255, 0)"></cropper-handle>
</cropper-selection>
</cropper-canvas>
<div v-show="is_have_URL" class="thisbutton">
<button class="btn btn-outline-primary" @click="getsele">
{{ t("cropper.crop_image") }}
</button>
<button class="btn btn-outline-danger" @click="cancel">{{ t("cropper.closs") }}</button>
<div v-show="is_have_URL" class="mt-3 flex gap-2 md:ml-3 md:mt-0">
<button class="rounded-lg border border-blue-600 px-4 py-2 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-400 dark:text-blue-400 dark:hover:bg-blue-900/20" @click="getsele">{{ t("cropper.crop_image") }}</button>
<button class="rounded-lg border border-red-600 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 dark:border-red-400 dark:text-red-400 dark:hover:bg-red-900/20" @click="cancel">{{ t("cropper.closs") }}</button>
</div>
</div>
</template>
<style lang="scss" scoped>
.container {
display: flex;
/* 默认就是水平排列 */
/* flex-direction: row; */
}
.thisbutton {
display: flex;
flex-direction: column;
margin-left: 20px;
margin-top: 20px;
gap: 20px; /* 所有子元素之间的间距 */
}
.box {
margin: 10px;
flex-direction: column; /* 关键:改为纵向排列 */
}
.cropper-container {
/* 四个角相同圆角 */
border-radius: 10px;
/* 基本描边 */
//border: 2px solid #333;
/* 基本阴影:x偏移 y偏移 模糊半径 扩展半径 颜色 */
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.3);
}
<style scoped>
.cropper-container { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); border-radius: 0.5rem; }
</style>
@@ -1,49 +0,0 @@
<script setup>
import { RouterLink } from "vue-router";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
</script>
<template>
<div class="col-12 col-md-3 border-end">
<div class="card-body">
<div class="list-group list-group-transparent">
<router-link
to="/settings/account"
active-class="active"
class="list-group-item list-group-item-action d-flex align-items-center"
>
{{t('settings.basic_information')}}</router-link
>
</div>
<div class="list-group list-group-transparent">
<router-link
to="/settings/contact"
active-class="active"
class="list-group-item list-group-item-action d-flex align-items-center"
>
{{t('settings.contact_information')}}</router-link
>
</div>
<div class="list-group list-group-transparent">
<router-link
to="/settings/security"
active-class="active"
class="list-group-item list-group-item-action d-flex align-items-center"
>
{{t('settings.security_settings')}}</router-link
>
</div>
<!-- <h4 class="subheader mt-4">{{t('settings.admin')}}</h4>
<div class="list-group list-group-transparent">
<router-link
to="/settings/account"
class="list-group-item list-group-item-action d-flex align-items-center active"
>
{{t('settings.website_settings')}}</router-link
>
</div> -->
</div>
</div>
</template>
@@ -1,78 +1,31 @@
<script setup>
import { onMounted, watch, ref, defineProps, defineEmits } from "vue";
<script setup>
import { onMounted, ref, defineProps, defineEmits } from "vue";
import { useI18n } from "vue-i18n";
import TomSelect from "tom-select";
import "tom-select/dist/css/tom-select.css";
const { t, locale } = useI18n();
const disable_backspace = ref();
function sele_tag_init() {
new TomSelect(disable_backspace.value, {
plugins: ["remove_button"],
persist: false,
createOnBlur: true,
create: true,
// 自定义提示文本
render: {
no_results: function (data, escape) {
return (
'<div class="no-results">' + t("tagadder.not_fund_item") + "</div>"
);
},
loading: function (data, escape) {
return '<div class="loading">' + t("tagadder.loding") + "</div>";
},
option_create: function (data, escape) {
return (
'<div class="create">' +
t("tagadder.add") +
"<strong>" +
escape(data.input) +
"</strong></div>"
);
},
no_results: function (data, escape) { return '<div class="no-results">' + t("tagadder.not_fund_item") + "</div>"; },
loading: function (data, escape) { return '<div class="loading">' + t("tagadder.loding") + "</div>"; },
option_create: function (data, escape) { return '<div class="create">' + t("tagadder.add") + "<strong>" + escape(data.input) + "</strong></div>"; },
},
});
}
const props = defineProps({
placeholder: {
type: String,
default: "",
},
modelValue: {
type: String,
default: "",
},
});
const props = defineProps({ placeholder: { type: String, default: "" }, modelValue: { type: String, default: "" } });
const emit = defineEmits(['update:modelValue'])
//const emit = defineEmits(['update:modelValue'])
onMounted(() => {
sele_tag_init();
});
const handleChange = (e) => {
emit("update:modelValue", e.target.value);
};
onMounted(() => { sele_tag_init(); });
const handleChange = (e) => { emit("update:modelValue", e.target.value); };
</script>
<template>
<div ref="example_wrapper">
<input
type="text"
ref="disable_backspace"
:value="modelValue"
@input="handleChange"
autocomplete="off"
:placeholder="placeholder"
/>
<input type="text" ref="disable_backspace" :value="modelValue" @input="handleChange" autocomplete="off" :placeholder="placeholder" />
</div>
</template>
@@ -1,464 +1,86 @@
<script setup>
<script setup>
import { ref, onMounted, onUnmounted, defineProps, reactive } from "vue";
import { useI18n } from "vue-i18n";
const { t, locale } = useI18n();
import Dropzone from "dropzone";
import "dropzone/dist/dropzone.css";
import { useUserStore } from "@/stores/user";
import "fslightbox";
//var lightbox = new FsLightbox();
const userStore = useUserStore();
const dropzoneElement = ref(null);
var dropzoneInstance = null;
const files = reactive([]);
function get_file_from_uuid(uuid) {
if (files.length != 0) {
for (var i = 0; i < files.length; i++) {
if (files[i].uuid == uuid) {
return i;
}
}
return -1;
}
return -2;
if (files.length != 0) { for (var i = 0; i < files.length; i++) { if (files[i].uuid == uuid) { return i; } } return -1; } return -2;
}
function remove_file_from_uuie(uuid) {
//devare files[uuid]
var id = get_file_from_uuid(uuid);
if (id >= 0) {
files.splice(id, 1);
}
}
function remove_file_from_uuie(uuid) { var id = get_file_from_uuid(uuid); if (id >= 0) { files.splice(id, 1); } }
const prop = defineProps({
maxFiles: {
type: Number,
default: 5,
},
acceptedFiles: {
type: String,
default: "image/*",
},
maxFilesize: {
type: Number,
default: 10,
},
uploadURL: {
type: String,
default: "/api/files/upload",
},
maxFiles: { type: Number, default: 5 },
acceptedFiles: { type: String, default: "image/*" },
maxFilesize: { type: Number, default: 10 },
uploadURL: { type: String, default: "/api/files/upload" },
});
// 初始化 Dropzone
const initDropzone = () => {
if (!dropzoneElement.value) return;
// 禁用自动发现
Dropzone.autoDiscover = false;
// 移除任何现有的 Dropzone 实例
if (dropzoneInstance) {
dropzoneInstance.destroy();
}
// 初始化新的实例
if (dropzoneInstance) { dropzoneInstance.destroy(); }
dropzoneInstance = new Dropzone(dropzoneElement.value, {
url: prop.uploadURL, // 上传地址
// headers: {
// user_cookie: "cccc",
// },
url: prop.uploadURL,
method: "post",
//uploadMultiple: true,
previewTemplate: document.getElementById("custom-template").innerHTML,
parallelUploads: 3, // 同时上传的文件数
maxFilesize: prop.maxFilesize, // MB
maxFiles: prop.maxFiles, // 最大文件数
acceptedFiles: prop.acceptedFiles, // 接受的文件类型
//addRemoveLinks: true, // 显示移除链接
parallelUploads: 3,
maxFilesize: prop.maxFilesize,
maxFiles: prop.maxFiles,
acceptedFiles: prop.acceptedFiles,
dictDefaultMessage: t("dropzone.upload_drop_or_click"),
dictFallbackMessage: t("dropzone.upload_browser_not_supported"),
dictFivarooBig:
t("dropzone.upload_file_too_big") +
"({{filesize}}MB). " +
t("dropzone.upload_max_file_size") +
"{{maxFilesize}}MB.",
dictInvalidFivarype: t("dropzone.upload_invalid_file_type"),
dictFileTooBig: t("dropzone.upload_file_too_big") + "({{filesize}}MB). " + t("dropzone.upload_max_file_size") + "{{maxFilesize}}MB.",
dictInvalidFileType: t("dropzone.upload_invalid_file_type"),
dictResponseError: t("dropzone.upload_server_error") + "{{statusCode}}",
//dictCancelUpload: t('dropzone.upload_cancel'),
//dictUploadCanceled: t('dropzone.upload_canceled'),
//dictCancelUploadConfirmation: t('dropzone.upload_cancel_confirmation'),
dictRemoveFile: t("dropzone.upload_remove_file"),
dictMaxFilesExceeded:
t("dropzone.upload_max_files") +
"{{maxFiles}}" +
t("dropzone.upload_max_files_unit"),
// 事件处理
init: function () {
this.on("success", (file, response) => {
//console.log("上传成功:", file, response);
file.previewElement.addEventListener("click", function (e) {
//delete lightbox
const lightbox = new FsLightbox();
//console.log(files)
e.preventDefault();
e.stopPropagation();
// 处理点击事件
//console.log("缩略图被点击", file);
//动态把文件载入灯箱
//先移除原有数据
//lightbox.props.sources.splice(0, lightbox.props.sources.length);
var dis_id = 0;
var dis_id_t = 0;
for (var i = 0; i < files.length; i++) {
if (files[i]["is_upload"] == true) {
lightbox.props.sources.push(files[i]["get_url"]);
if (files[i]["uuid"] == file.upload.uuid) {
dis_id = dis_id_t;
}
}
dis_id_t += 1;
}
lightbox.open(dis_id);
});
var file_id = get_file_from_uuid(file.upload.uuid);
if (file_id >= 0) {
files[file_id]["hash"] = response.return.hash;
files[file_id]["get_url"] = response.return.get;
files[file_id]["download_url"] = response.return.download;
files[file_id]["file_name"] = file.name;
files[file_id]["file_size"] = file.size;
files[file_id]["is_upload"] = true;
//console.log(files)
dictMaxFilesExceeded: t("dropzone.upload_max_files") + " " + prop.maxFiles + t("dropzone.upload_max_files_unit"),
init: function() {
this.on("sending", function(file, xhr, formData) {
formData.append("cookie", userStore.cookieValue);
});
this.on("success", function(file, serverResponse) {
const data = JSON.parse(serverResponse);
if (data.return && data.return.uuid) {
file.uuid = data.return.uuid;
files.push({ uuid: data.return.uuid, name: file.name, url: data.return.url || "" });
}
//files.push(t)
// files[file.upload.uuid]=t
// console.log(files)
// lightbox.props.sources.push(t.get_url)
// console.log(lightbox)
});
this.on("error", (file, errorMessage) => {
console.error("上传失败:", file.name, errorMessage);
});
this.on("removedfile", (file) => {
//console.log("remove:", file);
//files.value = files.value.filter(f => f.name !== file.name)
remove_file_from_uuie(file.upload.uuid);
//console.log(files)
});
this.on("addedfile", (file) => {
//添加文件
//控制排序 需要从添加文件开始操作
//限制文件数量
if (files.length < prop.maxFiles) {
var t = {
uuid: file.upload.uuid,
is_upload: false,
};
files.push(t);
} else {
this.removeFile(file);
}
//console.log(files);
});
this.on("sending", function (file, xhr, formData) {
// 获取表单值并添加到 FormData
//console.log(userStore.userCookie.Value)
formData.append("cookie", userStore.userCookie.Value);
this.on("removedfile", function(file) {
if (file.uuid) { remove_file_from_uuie(file.uuid); }
});
},
});
};
// 自定义方法
// const formatBytes = (bytes, decimals = 2) => {
// if (bytes === 0) return '0 Bytes'
// const k = 1024
// const dm = decimals < 0 ? 0 : decimals
// const sizes = ['Bytes', 'KB', 'MB', 'GB']
// const i = Math.floor(Math.log(bytes) / Math.log(k))
// return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
// }
// // 手动添加文件的方法
// const addFiles = (fileList) => {
// if (dropzoneInstance) {
// Array.from(fileList).forEach(file => {
// dropzoneInstance.addFile(file)
// })
// }
// }
// // 获取所有已添加的文件
// const getAllFiles = () => {
// return dropzoneInstance ? dropzoneInstance.files : []
// }
// // 清除所有文件
// const removeAllFiles = () => {
// if (dropzoneInstance) {
// dropzoneInstance.removeAllFiles(true)
// }
// }
function return_files() {
return files;
}
// 组件挂载时初始化
onMounted(() => {
initDropzone();
//console.log(lightbox)
});
// 组件卸载时销毁
onUnmounted(() => {
if (dropzoneInstance) {
dropzoneInstance.destroy();
}
});
defineExpose({
return_files,
});
const emit = defineEmits(['files-updated', 'uuid-updated'])
onMounted(() => { initDropzone(); });
onUnmounted(() => { if (dropzoneInstance) { dropzoneInstance.destroy(); } });
defineExpose({ getFiles: () => files, getDropzone: () => dropzoneInstance });
</script>
<template>
<div>
<div id="custom-template" style="display: none">
<div class="dz-preview dz-file-preview my-custom-style">
<div class="remove-btn" data-dz-remove>
<!-- <i class="bi bi-x"></i> -->
X
</div>
<div class="dz-image">
<img data-dz-thumbnail alt="File preview" />
<!-- 缩略图 -->
</div>
<div class="dz-details">
<div class="dz-filename"><span data-dz-name></span></div>
<!-- 文件名 -->
<div class="dz-size"><span data-dz-size></span></div>
<!-- 文件大小 -->
</div>
<div class="dz-progress">
<span class="dz-upload" data-dz-uploadprogress></span>
<!-- 进度条 -->
</div>
<div class="dz-success-mark" data-dz-successmark>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-circle-check"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M9 12l2 2l4 -4" />
</svg>
</div>
<!-- 成功标记 -->
<div class="dz-error-mark" data-dz-errormark>
<svg
xmlns="http://www.w3.org/2000/svg"
width="240"
height="240"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-circle-x"
>
<path stroke="none" fill="none" d="M0 0h24v24H0z" />
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M10 10l4 4m0 -4l-4 4" />
</svg>
</div>
<!-- 错误标记 -->
<div class="dz-error-message"><span data-dz-errormessage></span></div>
<!-- 错误信息 -->
<!-- 移除按钮 -->
<div class="w-full">
<div id="custom-template" class="dz-preview dz-file-preview hidden">
<div class="relative inline-block rounded-lg border border-gray-200 bg-white p-2 dark:border-dk-muted dark:bg-dk-card">
<img data-dz-thumbnail class="h-20 w-20 rounded object-cover" />
<button data-dz-remove class="absolute -right-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white shadow hover:bg-red-600">×</button>
</div>
<div class="mt-1 max-w-[5rem] truncate text-xs text-gray-600 dark:text-dk-subtle" data-dz-name></div>
<div class="dz-progress mt-1 h-1 w-full rounded-full bg-gray-200 dark:bg-dk-muted"><span class="dz-upload block h-full rounded-full bg-blue-500" data-dz-uploadprogress></span></div>
<div class="dz-error-message mt-1 text-xs text-red-500"><span data-dz-errormessage></span></div>
</div>
<div ref="dropzoneElement" class="dropzone cursor-pointer rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center transition-colors hover:border-blue-400 hover:bg-blue-50 dark:border-dk-muted dark:bg-dk-base dark:hover:border-blue-500 dark:hover:bg-dk-card">
<div class="mb-2 text-4xl">📁</div>
<div class="text-sm font-medium text-gray-600 dark:text-dk-subtle">{{ t('dropzone.upload_drop_or_click') }}</div>
</div>
<div class="text-end">{{ files.length }}/{{ maxFiles }}</div>
<div ref="dropzoneElement" class="dropzone"></div>
</div>
</template>
<style scoped>
.dz_mark {
height: 60px;
width: 60px;
}
.thumbnail-container {
display: flex;
flex-wrap: wrap;
gap: 15px;
justify-content: center;
padding: 20px;
background-color: white;
border-radius: 15px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
}
/* 缩略图样式 */
.thumbnail {
width: var(--thumbnail-size);
height: var(--thumbnail-size);
border-radius: var(--border-radius);
object-fit: cover;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
}
.thumbnail:hover {
transform: scale(1.05);
border-color: #6c757d;
}
/* 缩略图包装器 */
.thumbnail-wrapper {
position: relative;
width: var(--thumbnail-size);
height: var(--thumbnail-size);
margin-bottom: 10px;
}
/* 移除按钮 */
.remove-btn {
position: absolute;
top: -12px;
right: -12px;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #dc3545;
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
z-index: 10;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.remove-btn:hover {
background-color: #bb2d3b;
transform: scale(1.1);
}
/* 文件名称 */
.file-name {
font-size: 12px;
text-align: center;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #495057;
}
/* 上传区域 */
.upload-area {
border: 2px dashed #dee2e6;
border-radius: 15px;
padding: 30px;
text-align: center;
background-color: #f8f9fa;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 20px;
}
.upload-area:hover {
border-color: #6c757d;
background-color: #e9ecef;
}
.upload-icon {
font-size: 48px;
color: #6c757d;
margin-bottom: 10px;
}
.preview-title {
color: #343a40;
border-bottom: 2px solid #e9ecef;
padding-bottom: 10px;
margin-bottom: 20px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #6c757d;
}
.empty-state i {
font-size: 48px;
margin-bottom: 15px;
color: #adb5bd;
}
.counter-badge {
position: absolute;
top: -5px;
right: -5px;
background-color: #0d6efd;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.thumbnail-actions {
display: flex;
justify-content: space-between;
margin-top: 20px;
}
.file-input {
display: none;
}
.dropzone { min-height: 150px; }
.dz-progress .dz-upload { transition: width 0.3s ease; }
</style>
@@ -0,0 +1,69 @@
import { ref } from 'vue'
/**
* 表单验证工具
*
* @example
* const { validate, errors, clearErrors } = useValidation()
*
* const form = reactive({ name: '', email: '' })
*
* function handleSubmit() {
* clearErrors()
* validate('name', form.name, '请输入名称')
* validate('email', form.email, '请输入邮箱', v => isValidEmail(v) || '邮箱格式不正确')
* if (!Object.keys(errors).length) { ... }
* }
*/
export function useValidation() {
const errors = ref({})
function clearErrors() {
errors.value = {}
}
function clearError(field) {
delete errors.value[field]
errors.value = { ...errors.value }
}
/**
* @param {string} field - 字段名
* @param {*} value - 值
* @param {string} emptyMsg - 空值提示
* @param {function|undefined} extraCheck - 额外校验函数,返回 false 或错误消息
*/
function validate(field, value, emptyMsg, extraCheck) {
clearError(field)
if (!value || (typeof value === 'string' && !value.trim())) {
errors.value[field] = emptyMsg
return false
}
if (extraCheck) {
const result = extraCheck(value)
if (result === false) {
errors.value[field] = emptyMsg
return false
}
if (typeof result === 'string') {
errors.value[field] = result
return false
}
}
return true
}
function hasErrors() {
return Object.keys(errors.value).length > 0
}
return { errors, clearErrors, clearError, validate, hasErrors }
}
/** 邮箱格式校验 */
export function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
@@ -0,0 +1,24 @@
import { watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
/**
* 自动设置页面标题,响应语言变化
*
* @param {string} i18nKey - i18n 翻译键,如 'appname.home'
* @param {string} prefix - 标题前缀,默认 'Operations.'
*
* @example
* usePageTitle('appname.home')
* // → document.title = "Operations.Home"(英文)
* // → document.title = "Operations.主页"(中文)
*/
export function usePageTitle(i18nKey, prefix = 'Operations.') {
const { t, locale } = useI18n()
function update() {
document.title = prefix + t(i18nKey)
}
onMounted(update)
watch(locale, update)
}
+2
View File
@@ -204,6 +204,8 @@
"doc": "Documentation",
"license": "License",
"source_code": "Source Code",
"github": "GitHub",
"author_home": "Author",
"copy": "Copyright © 2025 Operations. All rights reserved."
},
"cost_type": {
+2
View File
@@ -204,6 +204,8 @@
"doc": "文档",
"license": "协议",
"source_code": "源码",
"github": "GitHub",
"author_home": "作者主页",
"copy": "版权 © 2025 Operations. 保留所有权利。"
},
"cost_type": {
@@ -0,0 +1,12 @@
<script setup>
import AppToast from '@/components/AppToast.vue'
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-dk-base dark:to-dk-card">
<div class="w-full">
<RouterView />
</div>
<AppToast />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup>
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
import AppToast from '@/components/AppToast.vue'
</script>
<template>
<div class="flex min-h-screen flex-col">
<AppHeader />
<main class="flex-1">
<RouterView />
</main>
<AppFooter />
<AppToast />
</div>
</template>
+12 -9
View File
@@ -1,34 +1,37 @@
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import { createPinia } from 'pinia' // 1. 导入 createPinia
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import '@tabler/core/dist/css/tabler.min.css'
import './assets/main.css'
// Restore saved theme before app mounts
const savedTheme = localStorage.getItem('tablerTheme')
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
import en from './i18n/en.json'
import zhCN from './i18n/zh-CN.json'
const i18n = createI18n({
legacy: false, // 使用 Composition API 模式
legacy: false,
locale: 'en',
fallbackLocale: 'en',
messages: {
en,
'zh-CN': zhCN
}
'zh-CN': zhCN,
},
})
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.use(router)
app.use(i18n)
app.use(pinia)
app.mount('#app')
-123
View File
@@ -1,123 +0,0 @@
import axios from "axios";
import { myfuncs } from "./myfunc";
import { useUserStore } from "@/stores/user";
var head_path = "/api";
export const my_network_func = {
getJson(path, callback) {
//get 方法一般不需要权限,不插入cookie
var re_data = {};
axios
.get(head_path + path)
.then((r) => {
re_data["statusCode"] = r.status;
re_data["data"] = r.data;
callback(re_data);
})
.catch((error) => {
re_data["statusCode"] = -1;
re_data["error"] = error;
callback(re_data);
});
},
postflise(path, file, callback) {
//拿去用户数据
var userstore = useUserStore();
// 1. 创建 FormData 对象
const formData = new FormData();
// 2. 添加文件
formData.append("cookie", userstore.userCookie.Value); //把cookie插入json
formData.append("file", file); // 单个文件
//console.log(file)
var re_data = {};
axios
.post(head_path + path, formData)
.then((response) => {
//console.log(response)
re_data["statusCode"] = response.status;
//载入服务器返回的数据
if (response.data) {
re_data["data"] = response.data;
//自动保存服务器发送的cookie
if (response.status == 200) {
if (response.data.err_code == 0) {
} else if (response.data.err_code == -44) {
//后端返回的cookie错误码
//userCookieExpired
userstore.logout();
}
}
}
callback(re_data);
})
.catch((error) => {
re_data["statusCode"] = -1;
re_data["error"] = error;
callback(re_data);
});
},
postJson(path, json, callback) {
//把cookie插入json
var data = {};
data["data"] = json;
var userstore = useUserStore();
//console.log(userstore.cookieValue)
if (userstore.userCookie) {
data["userCookieValue"] = userstore.userCookie.Value;
}
var re_data = {};
axios
.post(head_path + path, data, {
headers: {
"Content-Type": "application/json",
},
})
.then((response) => {
//console.log(response)
re_data["statusCode"] = response.status;
//载入服务器返回的数据
if (response.data) {
re_data["data"] = response.data;
//自动保存服务器发送的cookie
if (response.status == 200) {
if (response.data.err_code == 0) {
// if(response.data.return.cookie){
// userstore.cookieUpdata(response.data.return.cookie)
// }
} else if (response.data.err_code == -44) {
//后端返回的cookie错误码
//userCookieExpired
userstore.logout();
}
}
// if (response.data.cookie) {
// if (response.data.cookie.Value == "") {
// myfuncs.dele("cookie");
// } else {
// myfuncs.saveJson("cookie", response.data.cookie);
// }
// }
}
callback(re_data);
})
.catch((error) => {
re_data["statusCode"] = -1;
re_data["error"] = error;
callback(re_data);
});
},
};
-96
View File
@@ -1,96 +0,0 @@
export const myfuncs = {
themeStorageKey: "tablerTheme",
defaultTheme: "light",
test() {
console.log("myfuncs test ok");
},
//临时保存的数据,浏览器专属
saveT(key, data) {
sessionStorage.setItem(key, data);
},
loadT(key) {
return sessionStorage.getItem(key);
},
deleT(key) {
sessionStorage.removeItem(key);
},
saveJsonT(key, data) {
this.saveT(key, JSON.stringify(data));
},
loadJsonT(key) {
const js_data = this.loadT(key);
if (js_data) {
return JSON.parse(js_data);
} else {
return null;
}
},
save(key, data) {
localStorage.setItem(key, data);
},
load(key) {
return localStorage.getItem(key);
},
dele(key) {
localStorage.removeItem(key);
},
saveJson(key, data) {
this.save(key, JSON.stringify(data));
},
loadJson(key) {
const js_data = this.load(key);
if (js_data) {
return JSON.parse(js_data);
} else {
return null;
}
},
getThemefromStorge() {
const storedTheme = this.load(this.themeStorageKey);
return storedTheme ? storedTheme : this.defaultTheme;
},
setTheme(selectedTheme, save) {
if (save) {
this.save(this.themeStorageKey, selectedTheme); // 保存到本地存储
}
if (selectedTheme === "dark") {
document.body.setAttribute("data-bs-theme", selectedTheme); // 暗色模式
} else {
document.body.removeAttribute("data-bs-theme"); // 亮色模式(移除属性)
}
},
isValidEmail(email) {
// 定义邮箱的正则表达式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// 使用正则表达式测试邮箱
return emailRegex.test(email);
},
// 国际化日期格式化函数
formatLocalizedDate(dateString, locale = "zh-CN", options = {}) {
const date = new Date(dateString);
// 默认配置 - 中文格式
const defaultOptions = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
};
const mergedOptions = { ...defaultOptions, ...options };
const formatter = new Intl.DateTimeFormat(locale, mergedOptions);
return formatter.format(date);
},
};
+114 -90
View File
@@ -1,105 +1,129 @@
import {
createRouter,
createWebHistory,
createWebHashHistory,
} from "vue-router";
import HomeView from "../views/HomeView.vue";
import { createRouter, createWebHashHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
// ── 需要登录的页面(带 DefaultLayout ──
{
path: "/",
name: "home",
component: HomeView,
},
{
path: "/settings/account",
name: "settings account",
component: () => import("@/views/settings/account.vue"),
},
{
path: "/settings/contact",
name: "settings contact",
component: () => import("@/views/settings/contact.vue"),
},
{
path: "/settings/security",
name: "settings security",
component: () => import("@/views/settings/security.vue"),
},
{
path: "/about",
name: "about",
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import("@/views/AboutView.vue"),
},
{
path: "/test",
name: "test",
component: () => import("@/views/test.vue"),
},
{
path: "/login",
name: "login",
component: () => import("@/views/loginView.vue"),
},
{
path: "/forgot_password",
name: "forgot password",
component: () => import("@/views/forgotPassword.vue"),
},
{
path: "/register",
name: "Register",
component: () => import("@/views/registerView.vue"),
},
{
path: "/admin",
name: "admin",
component: () => import("@/views/adminView.vue"),
},
{
path: "/schedule",
name: "schedule",
component: () => import("@/views/scheduleView.vue"),
},
{
path: "/purchase",
name: "purchase",
component: () => import("@/views/purchase/purchase.vue"),
},
{
path: "/purchase/addorder",
name: "purchase/addorder",
component: () => import("@/views/purchase/addorder.vue"),
},
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
children: [
{
path: "/purchase/showorder/:id",
name: "purchase/showorder",
component: () => import("@/views/purchase/showorder.vue"),
path: '',
name: 'home',
component: () => import('@/views/HomeView.vue'),
},
{
path: 'settings/account',
name: 'settings-account',
component: () => import('@/views/settings/AccountView.vue'),
},
{
path: 'settings/contact',
name: 'settings-contact',
component: () => import('@/views/settings/ContactView.vue'),
},
{
path: 'settings/security',
name: 'settings-security',
component: () => import('@/views/settings/SecurityView.vue'),
},
{
path: 'schedule',
name: 'schedule',
component: () => import('@/views/ScheduleView.vue'),
},
{
path: 'purchase',
name: 'purchase',
component: () => import('@/views/purchase/PurchaseList.vue'),
},
{
path: 'purchase/addorder',
name: 'purchase-add',
component: () => import('@/views/purchase/AddOrder.vue'),
},
{
path: 'purchase/showorder/:id',
name: 'purchase-show',
component: () => import('@/views/purchase/ShowOrder.vue'),
},
{
path: 'warehouse',
name: 'warehouse',
component: () => import('@/views/WarehouseView.vue'),
},
{
path: 'admin',
name: 'admin',
component: () => import('@/views/AdminView.vue'),
},
],
},
// ── 认证页面(AuthLayout,全屏居中) ──
{
path: '/login',
component: () => import('@/layouts/AuthLayout.vue'),
children: [
{
path: '',
name: 'login',
component: () => import('@/views/LoginView.vue'),
},
],
},
{
path: "/warehouse",
name: "warehouse",
component: () => import("@/views/warehouse.vue"),
path: '/register',
component: () => import('@/layouts/AuthLayout.vue'),
children: [
{
path: '',
name: 'register',
component: () => import('@/views/RegisterView.vue'),
},
],
},
{
path: "/404",
name: "404",
component: () => import("@/views/404.vue"),
path: '/forgot_password',
component: () => import('@/layouts/AuthLayout.vue'),
children: [
{
path: '',
name: 'forgot-password',
component: () => import('@/views/ForgotPasswordView.vue'),
},
],
},
// 404 页面 - 放在最后
// ── 404 ──
{
path: "/:pathMatch(.*)*", // 通配符,匹配所有路由
name: "NotFound",
component: () => import("@/views/404.vue"),
path: '/404',
name: '404',
component: () => import('@/views/NotFoundView.vue'),
},
{
path: '/:pathMatch(.*)*',
redirect: '/404',
},
],
});
})
export default router;
// ── 全局前置守卫 ──
router.beforeEach((to) => {
const userStore = useUserStore()
// 不需要登录的页面
const publicPages = ['/', '/login', '/register', '/forgot_password', '/schedule','/warehouse', '/404']
if (publicPages.includes(to.path)) return true
// 未登录 → 跳转登录
if (!userStore.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
return true
})
export default router
+49
View File
@@ -0,0 +1,49 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
/**
* 全局 Toast 通知 store
* 用法:
* const toast = useToastStore()
* toast.success('保存成功')
* toast.error('网络错误')
* toast.warning('请注意')
* toast.info('提示信息')
*/
export const useToastStore = defineStore('toast', () => {
const visible = ref(false)
const type = ref('info') // success | warning | danger | info
const message = ref('')
const dismissTimer = ref(null)
function show(newType, newMessage, duration = 5000) {
if (dismissTimer.value) {
clearTimeout(dismissTimer.value)
}
type.value = newType
message.value = newMessage
visible.value = true
if (duration > 0) {
dismissTimer.value = setTimeout(() => {
visible.value = false
}, duration)
}
}
function success(msg, duration) { show('success', msg, duration) }
function warning(msg, duration) { show('warning', msg, duration) }
function danger(msg, duration) { show('danger', msg, duration) }
function error(msg, duration) { show('danger', msg, duration) }
function info(msg, duration) { show('info', msg, duration) }
function hide() {
if (dismissTimer.value) {
clearTimeout(dismissTimer.value)
}
visible.value = false
}
return { visible, type, message, show, success, warning, danger, error, info, hide }
})
+110 -111
View File
@@ -1,129 +1,128 @@
// stores/user.js
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { myfuncs } from "@/myfunc.js";
import { my_network_func } from "@/my_network_func";
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api/auth'
// 组合式 API 写法 (推荐)
export const useUserStore = defineStore("user", () => {
// 状态 (State)
const userInfo = ref(null);
const user = ref(null);
const userCookie = ref(null);
const isLoggedIn = ref(false);
const STORAGE_KEY_COOKIE = 'userCookie'
const cookiesQualified = () => {
//返回一个合格的cookie 就是没过期的cookie
//如果cookie没过期直接返回,如果过期 顺便logout
var cookieTimeout = userCookie.value.ExpiresAt;
if (new Date(cookieTimeout) < new Date()) {
//过期了
logout();
function loadJson(key) {
try {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
} catch {
return null
}
}
function loadJsonSession(key) {
try {
const raw = sessionStorage.getItem(key)
return raw ? JSON.parse(raw) : null
} catch {
return null
}
}
function saveJson(key, data) {
localStorage.setItem(key, JSON.stringify(data))
}
function saveJsonSession(key, data) {
sessionStorage.setItem(key, JSON.stringify(data))
}
function removeStorage(key) {
localStorage.removeItem(key)
sessionStorage.removeItem(key)
}
export const useUserStore = defineStore('user', () => {
// ── State ──
const user = ref(null) // TabUser_ 基本信息
const userInfo = ref(null) // TabUserInfo_ 详情
const userCookie = ref(null) // Cookie session
const isLoggedIn = ref(false)
// ── Getters ──
const cookieValue = computed(() => userCookie.value?.Value ?? '')
const avatarUrl = computed(() => {
if (userInfo.value?.AvatarPath) {
return `/api/static/avatar/${userInfo.value.AvatarPath}`
}
return userCookie.value;
};
return '/ava.svg'
})
const getUserBirthday = () => {
if (userInfo.value != null) {
const date = new Date(userInfo.value.Birthdate);
const birthday = computed(() => {
if (!userInfo.value?.Birthdate) return ''
const d = new Date(userInfo.value.Birthdate)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
})
// 获取年月日并格式化
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const formattedDate = `${year}-${month}-${day}`;
return formattedDate;
}
return "";
};
const getUserAvatarPath = () => {
if (userInfo.value != null) {
if (userInfo.value.AvatarPath != "") {
return "/api/static/avatar/"+userInfo.value.AvatarPath;
}
}
return "/ava.svg";
};
const getUserInfoFromCookie = () => {
my_network_func.postJson("/users/getinfo", {}, (r) => {
//console.log(r);
switch (r.statusCode) {
case 200:
switch (r.data.err_code) {
case 0:
user.value = r.data.return.user;
if (r.data.return.userInfo) {
userInfo.value = r.data.return.userInfo;
} else {
userInfo.value = null;
}
break;
default:
break;
}
break;
default:
break;
}
});
};
const logout = () => {
userCookie.value = null;
isLoggedIn.value = false;
myfuncs.dele("userCookie");
myfuncs.deleT("userCookie");
};
const login = (cookie) => {
userCookie.value = cookie;
isLoggedIn.value = true;
//这里应该判读cookie的实效性
userCookie.value = cookiesQualified();
//到这里cookie应该是有效的,尝试获取用户info,因为有的info可能是隐藏的 所以用post携带当前cookie去请求用户info
getUserInfoFromCookie();
};
const cookieUpdata = (cookie) => {
userCookie.value = cookie;
myfuncs.saveJsonT("userCookie", cookie);
// ── Actions ──
function login(cookie) {
userCookie.value = cookie
isLoggedIn.value = true
// 保存 cookie
saveJsonSession(STORAGE_KEY_COOKIE, cookie)
if (cookie.Remember) {
//长期保存cookie
myfuncs.saveJson("userCookie", cookie);
saveJson(STORAGE_KEY_COOKIE, cookie)
}
};
// 检查 cookie 是否过期
if (cookie.ExpiresAt && new Date(cookie.ExpiresAt) < new Date()) {
logout()
return
}
// 获取用户信息
fetchUserInfo()
}
const loginFromStoreCookie = () => {
//从store获取cookie
function logout() {
userCookie.value = null
user.value = null
userInfo.value = null
isLoggedIn.value = false
removeStorage(STORAGE_KEY_COOKIE)
}
var cookie = myfuncs.loadJsonT("userCookie");
if (cookie) {
login(cookie);
} else {
cookie = myfuncs.loadJson("userCookie");
if (cookie) {
login(cookie);
} else {
logout();
async function fetchUserInfo() {
try {
const { errCode, data } = await authApi.getUserInfo()
if (errCode === 0) {
user.value = data.user ?? null
userInfo.value = data.userInfo ?? null
}
} catch {
// 拦截器已处理错误提示
}
};
}
/** 应用启动时尝试从存储恢复登录状态 */
function restoreSession() {
let cookie = loadJsonSession(STORAGE_KEY_COOKIE)
if (!cookie) {
cookie = loadJson(STORAGE_KEY_COOKIE)
}
if (cookie) {
login(cookie)
} else {
logout()
}
}
return {
user,
userInfo,
userCookie,
isLoggedIn,
getUserAvatarPath,
getUserBirthday,
getUserInfoFromCookie,
logout,
cookieValue,
avatarUrl,
birthday,
login,
loginFromStoreCookie,
cookieUpdata,
};
});
logout,
fetchUserInfo,
restoreSession,
}
})
-82
View File
@@ -1,82 +0,0 @@
<script setup>
import { onMounted, watch, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
const { t, locale } = useI18n();
const router = useRouter();
function goback() {
router.back();
}
function functionupdataTitle() {
document.title = "Operations." + t("errorpage.404_title");
}
// 监听语言变化,更新标题
watch(locale, () => {
functionupdataTitle();
});
onMounted(() => {
//console.log("account mounted");
//username.value.value="Kevin";
functionupdataTitle();
});
</script>
<template>
<div>
<div class="page page-center">
<div class="container-tight py-4">
<div class="empty">
<div class="empty-header">404</div>
<p class="empty-title">{{ t("errorpage.404_msg_title") }}</p>
<p class="empty-subtitle text-secondary">
{{ t("errorpage.404_msg") }}
</p>
<div class="empty-action">
<button class="btn btn-outline-secondary m-3" @click="goback">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-left"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 12l14 0" />
<path d="M5 12l6 6" />
<path d="M5 12l6 -6" />
</svg>
{{ t("errorpage.404_previous_page") }}
</button>
<router-link to="/" class="btn btn-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-home"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 12l-2 0l9 -9l9 9l-2 0" />
<path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7" />
<path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" />
</svg>
{{ t("errorpage.404_back_home") }}
</router-link>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -1,15 +0,0 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>
@@ -0,0 +1,65 @@
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { useValidation } from '@/composables'
import { IconMail } from '@tabler/icons-vue'
usePageTitle('appname.forgot_password')
const { t } = useI18n()
const toast = useToastStore()
const { validate, errors, clearErrors } = useValidation()
const form = ref({ username: '' })
const loading = ref(false)
async function handleReset() {
const err = validate('username', form.value.username, t('message.please_enter_your_username'))
if (!err) return
// 功能未开发
toast.warning(t('message.functionality_not_yet_developed'))
}
</script>
<template>
<div class="mx-auto max-w-sm px-8">
<div class="mb-8 text-center">
<RouterLink to="/">
<img src="/static/logo.svg" width="110" height="32" alt="Operations" class="mx-auto" />
</RouterLink>
</div>
<div class="rounded-xl border border-gray-200 bg-white px-8 py-8 shadow-lg dark:border-dk-muted dark:bg-dk-card">
<h2 class="mb-2 text-center text-xl font-bold text-gray-900 dark:text-white">{{ t('message.forgot_password') }}</h2>
<p class="mb-6 text-center text-sm text-gray-500">{{ t('message.enter_your_username_to_reset_password') }}</p>
<div class="mb-6">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('message.user_name') }}</label>
<input
v-model="form.username"
type="text"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2.5 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.username ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300'"
:placeholder="t('message.your_user_name')"
@keydown.enter="handleReset"
/>
<span v-if="errors.username" class="mt-1 block text-xs text-red-500">{{ errors.username }}</span>
</div>
<button
class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500/20 focus:outline-none disabled:active:scale-60"
:disabled="loading"
@click="handleReset"
>
<IconMail :size="18" />
{{ t('button.send_me_new_password') }}
</button>
</div>
<p class="mt-6 text-center text-sm text-gray-500">
<RouterLink to="/login" class="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">{{ t('message.back_to_login') }}</RouterLink>
</p>
</div>
</template>
+27 -12
View File
@@ -1,19 +1,34 @@
<script setup>
import { ref } from "vue";
import { my_network_func } from "@/my_network_func";
import { useUserStore } from "@/stores/user";
import MyOffcanvas from "@/components/MyOffcanvas.vue";
import imageCropper from "@/components/imageCropper.vue";
const user = useUserStore();
const mos = ref();
<script setup>
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
import { usePageTitle } from '@/composables/usePageTitle'
usePageTitle('appname.home')
const { t } = useI18n()
const userStore = useUserStore()
const features = computed(() => [
{ title: t('appname.purchase'), desc: '—' },
{ title: t('appname.schedule'), desc: '—' },
{ title: t('appname.warehouse'), desc: '—' },
])
import { computed } from 'vue'
</script>
<template>
<div class="mx-auto max-w-6xl px-6 py-6">
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">{{ t('message.welcome') }}</h2>
<MyOffcanvas ref="mos" />
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="feature in features"
:key="feature.title"
class="rounded-xl border border-gray-200 bg-white px-5 py-4 transition-shadow hover:shadow-md dark:border-dk-muted dark:bg-dk-card"
>
<p class="mb-1 text-sm text-gray-500">{{ feature.title }}</p>
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ feature.desc }}</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,22 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { usePageTitle } from '@/composables/usePageTitle'
import { IconAlertTriangle } from '@tabler/icons-vue'
usePageTitle('errorpage.404_title')
const { t } = useI18n()
</script>
<template>
<div class="flex min-h-[60vh] flex-col items-center justify-center px-4">
<div class="text-center">
<IconAlertTriangle :size="64" class="mx-auto mb-4 text-yellow-400" stroke-width="1.5" />
<h1 class="mb-2 text-5xl font-bold text-blue-600">404</h1>
<h2 class="mb-3 text-2xl font-bold text-gray-900 dark:text-white">{{ t('errorpage.404_msg_title') }}</h2>
<p class="mb-6 text-gray-500">{{ t('errorpage.404_msg') }}</p>
<RouterLink to="/" class="inline-block rounded-lg bg-blue-600 px-5 py-2.5 font-medium text-white transition-colors hover:bg-blue-700">
{{ t('errorpage.404_back_home') }}
</RouterLink>
</div>
</div>
</template>
@@ -0,0 +1,16 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { usePageTitle } from '@/composables/usePageTitle'
usePageTitle('appname.warehouse')
const { t } = useI18n()
</script>
<template>
<div class="mx-auto max-w-6xl px-6 py-6">
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">{{ t('appname.warehouse') }}</h2>
<div class="rounded-xl border border-gray-200 bg-white px-12 py-12 text-center shadow-lg dark:border-dk-muted dark:bg-dk-card">
<p class="text-gray-400">{{ t('message.functionality_not_yet_developed') }}</p>
</div>
</div>
</template>
+14 -1
View File
@@ -1,3 +1,16 @@
<template>
<script setup>
import { useI18n } from 'vue-i18n'
import { usePageTitle } from '@/composables/usePageTitle'
usePageTitle('message.administrator')
const { t } = useI18n()
</script>
<template>
<div class="mx-auto max-w-6xl px-6 py-6">
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">{{ t('message.administrator') }}</h2>
<div class="rounded-xl border border-gray-200 bg-white px-12 py-12 text-center shadow-lg dark:border-dk-muted dark:bg-dk-card">
<p class="text-gray-400">{{ t('message.functionality_not_yet_developed') }}</p>
</div>
</div>
</template>
@@ -1,97 +0,0 @@
<script setup>
import { onMounted, watch, ref } from 'vue'
import MyOffcanvas from '@/components/MyOffcanvas.vue'
import { myfuncs } from '@/myfunc.js'
import { useI18n } from 'vue-i18n'
// 使用 vue-i18n 的 Composition API
const { t, locale } = useI18n()
const email = ref()
const mos = ref()
function resetPassword() {
// 在这里处理重置密码逻辑
const emailValue = email.value?.value
if (emailValue === undefined || emailValue.trim() === '') {
mos.value?.showAlert('info', t('message.please_enter_your_username'), 5000)
return
}
// if (!myfuncs.isValidEmail(emailValue)) {
// mos.value?.showAlert('warning', t('message.this_not_email'), 5000)
// return
// }
mos.value?.showAlert('warning', "功能未开发", 5000)
console.log('sending password reset to:', emailValue)
}
function functionupdataTitle() {
document.title = 'Operations.' + t('appname.forgot_password')
}
onMounted(() => {
functionupdataTitle()
})
// 监听语言变化,更新标题
watch(locale, () => {
functionupdataTitle()
})
</script>
<template>
<div class="container container-tight py-4">
<div class="text-center mb-4">
<a href="." class="navbar-brand navbar-brand-autodark">
<img
src="/static/logo.svg"
width="110"
height="32"
alt="Tabler"
class="navbar-brand-image"
/>
</a>
</div>
<div class="card card-md">
<div class="card-body">
<h2 class="card-title text-center mb-4">{{ t('message.forgot_password') }}</h2>
<p class="text-secondary mb-4">
{{ t('message.enter_your_username_to_reset_password') }}
</p>
<div class="mb-3">
<label class="form-label">{{ t('message.user_name') }}</label>
<input
ref="email"
type="text"
class="form-control"
:placeholder="t('message.your_user_name')"
/>
</div>
<div class="form-footer">
<button @click="resetPassword" class="btn btn-primary w-100">
<!-- Download SVG icon from http://tabler-icons.io/i/mail -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10z"
/>
<path d="M3 7l9 6l9 -6" />
</svg>
{{ t('button.send_me_new_password') }}
</button>
</div>
</div>
</div>
<div class="text-center text-secondary mt-3">
<router-link to="/login">{{ t('message.back_to_login') }}</router-link>
</div>
</div>
<MyOffcanvas ref="mos" />
</template>
+124 -251
View File
@@ -1,267 +1,140 @@
<script setup>
import { onMounted, watch, ref } from "vue";
import { useRouter } from "vue-router";
import { useUserStore } from "@/stores/user";
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { my_network_func } from "@/my_network_func";
import { myfuncs } from "@/myfunc.js";
import MyOffcanvas from "@/components/MyOffcanvas.vue";
import { useI18n } from "vue-i18n";
// 使用 vue-i18n 的 Composition API
const { t, locale } = useI18n();
const router = useRouter();
const userStore = useUserStore();
const { t, locale } = useI18n()
const mos = ref();
const username = ref();
const password = ref();
const isRemember = ref();
const isShowPassword = ref(false);
function togglePasswordVisibility() {
isShowPassword.value = !isShowPassword.value;
function toggleLocale() {
locale.value = locale.value === 'zh-CN' ? 'en' : 'zh-CN'
}
import { useUserStore } from '@/stores/user'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { useValidation } from '@/composables'
import { authApi } from '@/api/auth'
import { IconEye, IconEyeOff } from '@tabler/icons-vue'
function login() {
// 在这里处理登录逻辑
usePageTitle('appname.login')
const router = useRouter()
const userStore = useUserStore()
const toast = useToastStore()
const { validate, errors, clearErrors } = useValidation()
const user = username.value?.value;
const pass = password.value?.value;
const remember = isRemember.value?.checked;
const form = ref({
username: '',
password: '',
remember: false,
})
const showPassword = ref(false)
const loading = ref(false)
username.value?.classList.remove("is-invalid");
password.value?.classList.remove("is-invalid");
async function handleLogin() {
clearErrors()
if (!user || !pass) {
if (!user) {
username.value?.classList.add("is-invalid");
const err1 = validate('username', form.value.username, t('message.please_enter_username_and_password'))
const err2 = validate('password', form.value.password, t('message.please_enter_username_and_password'))
if (!err1 || !err2) return
loading.value = true
try {
const { errCode, data } = await authApi.login(form.value.username, form.value.password, form.value.remember)
switch (errCode) {
case 0:
userStore.login(data.cookie)
toast.success(t('message.login_successful'))
const redirectPath = router.query.redirect || '/'
router.push(redirectPath)
break
case -42:
toast.danger(t('message.username_or_password_incorrect'))
break
default:
toast.error(t('message.server_error'))
}
if (!pass) {
password.value?.classList.add("is-invalid");
}
mos.value?.showAlert(
"info",
t("message.please_enter_username_and_password"),
5000
);
return;
} catch {
// 拦截器已处理
} finally {
loading.value = false
}
//console.log("登录信息:", { user, pass, remember });
my_network_func.postJson(
"/users/login",
{
username: user,
userpass: pass,
remember: remember,
},
(r) => {
//console.log(r)
switch (r.statusCode) {
case 200:
switch (r.data.err_code) {
case -41:
username.value?.classList.add("is-invalid");
mos.value?.showAlert(
"warning",
t("message.user_not_found"),
5000
);
break;
case -42:
username.value?.classList.add("is-invalid");
password.value?.classList.add("is-invalid");
mos.value?.showAlert(
"warning",
t("message.username_or_password_incorrect"),
5000
);
break;
case 0:
//登录成功,载入cookie
//临时保存cookie
userStore.cookieUpdata(r.data.return.cookie)
//更新用户信息
userStore.login(r.data.return.cookie)
mos.value?.showAlert(
"success",
t("message.login_successful"),
1000,
() => {
router.back()
}
);
break;
default:
mos.value?.showAlert("danger", t("message.server_error"), 5000);
break;
}
break;
default:
mos.value?.showAlert("danger", t("message.network_err"), 5000);
break;
}
}
);
}
onMounted(() => {
functionupdataTitle();
if (userStore.isLoggedIn) {
router.push("/");
}
});
function functionupdataTitle() {
document.title = "Operations." + t("appname.login");
}
// 监听语言变化,更新标题
watch(locale, () => {
functionupdataTitle();
});
</script>
<template>
<div class="page page-center">
<div class="container container-normal py-6">
<div class="row align-items-center g-4">
<div class="col-lg">
<div class="container-tight">
<div class="card card-md">
<div class="card-body">
<h2 class="h2 text-center mb-4">
{{ t("message.login_to_your_account") }}
</h2>
<div class="mb-3">
<label class="form-label">{{ t("message.user_name") }}</label>
<input
ref="username"
type="text"
maxlength="64"
class="form-control"
:placeholder="t('message.your_user_name')"
autocomplete="off"
/>
</div>
<div class="mb-2">
<label class="form-label">
{{ t("message.password") }}
<span class="form-label-description">
<router-link to="/forgot_password">{{
t("message.i_forgot_password")
}}</router-link>
</span>
</label>
<div class="input-group input-group-flat">
<input
ref="password"
:type="isShowPassword ? 'text' : 'password'"
class="form-control"
:placeholder="t('message.your_password')"
autocomplete="off"
/>
<span class="input-group-text">
<div
class="link-secondary"
:title="
isShowPassword
? t('message.hidden_Password')
: t('message.show_password')
"
data-bs-toggle="tooltip"
>
<!-- Download SVG icon from http://tabler-icons.io/i/eye -->
<svg
v-if="!isShowPassword"
@click="togglePasswordVisibility"
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path
d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6"
/>
</svg>
<svg
v-if="isShowPassword"
@click="togglePasswordVisibility"
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
<path
d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87"
/>
<path d="M3 3l18 18" />
</svg>
</div>
</span>
</div>
</div>
<div class="mb-2">
<label class="form-check">
<input
ref="isRemember"
type="checkbox"
class="form-check-input"
/>
<span class="form-check-label">{{
t("message.remember_me_on_this_device")
}}</span>
</label>
</div>
<div class="form-footer">
<button @click="login" class="btn btn-primary w-100">
{{ t("button.sign_in") }}
</button>
</div>
</div>
</div>
<div class="text-center text-secondary mt-3">
{{ t("message.dont_have_account_yet") }}
<router-link to="/register">{{
t("message.register_now")
}}</router-link>
</div>
</div>
</div>
<div class="col-lg d-none d-lg-block">
<img
src="/static/illustrations/undraw_secure_login_pdn4.svg"
height="300"
class="d-block mx-auto"
alt=""
/>
</div>
</div>
<div class="mx-auto max-w-sm px-8">
<div class="mb-8 flex items-start justify-between">
<RouterLink to="/" class="inline-flex items-center">
<img src="/logo.svg" class="h-10 w-10 rounded-lg" alt="Operations" />
<span class="ml-2.5 text-2xl font-bold text-gray-800 dark:text-dk-text">Operations</span>
</RouterLink>
<button class="rounded-md border border-gray-200 px-2.5 py-1 text-xs font-semibold uppercase text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:border-dk-muted dark:text-gray-400 dark:hover:bg-dk-card dark:hover:text-dk-text" @click="toggleLocale">
{{ locale === 'zh-CN' ? 'EN' : '' }}
</button>
</div>
</div>
<MyOffcanvas ref="mos" />
<div class="rounded-xl border border-gray-200 bg-white px-8 py-8 shadow-lg dark:border-dk-muted dark:bg-dk-card">
<h2 class="mb-6 text-center text-xl font-bold text-gray-900 dark:text-white">{{ t('message.login_to_your_account') }}</h2>
<!-- Username -->
<div class="mb-4">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('message.user_name') }}</label>
<input
v-model="form.username"
type="text"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2.5 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.username ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300'"
:placeholder="t('message.your_user_name')"
@keydown.enter="handleLogin"
/>
<span v-if="errors.username" class="mt-1 block text-xs text-red-500">{{ errors.username }}</span>
</div>
<!-- Password -->
<div class="mb-4">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('message.password') }}</label>
<div class="relative">
<input
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2.5 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.password ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300'"
:placeholder="t('message.your_password')"
autocomplete="current-password"
@keydown.enter="handleLogin"
/>
<button type="button" class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300" @click="showPassword = !showPassword">
<IconEye v-if="!showPassword" :size="18" />
<IconEyeOff v-else :size="18" />
</button>
</div>
<span v-if="errors.password" class="mt-1 block text-xs text-red-500">{{ errors.password }}</span>
</div>
<!-- Remember -->
<label class="mb-6 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input v-model="form.remember" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
{{ t('message.remember_me_on_this_device') }}
</label>
<!-- Submit -->
<button
class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500/20 focus:outline-none disabled:active:scale-100"
:disabled="loading"
@click="handleLogin"
>
<svg v-if="loading" class="h-4 w-4 animate-spin text-white" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{{ t('button.sign_in') }}
</button>
</div>
<p class="mt-6 text-center text-sm text-gray-500">
{{ t('message.dont_have_account_yet') }}
<RouterLink to="/register" class="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">{{ t('message.register_now') }}</RouterLink>
</p>
</div>
</template>
@@ -0,0 +1,184 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { purchaseApi } from '@/api/purchase'
import { IconPlus, IconChevronLeftPipe, IconChevronRightPipe, IconChevronsLeft, IconChevronsRight, IconSearch } from '@tabler/icons-vue'
usePageTitle('appname.purchase')
const { t, locale } = useI18n()
const router = useRouter()
const toast = useToastStore()
const orders = ref([])
const totalCount = ref(0)
const pageSize = ref(10)
const currentPage = ref(1)
const searchQuery = ref('')
const loading = ref(false)
const totalPages = computed(() => Math.ceil(totalCount.value / pageSize.value) || 1)
const pageRange = computed(() => {
const total = totalPages.value
const cur = currentPage.value
let start = Math.max(1, cur - 2)
let end = Math.min(cur + 4, total)
if (end - start < 4) start = Math.max(1, end - 4)
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
})
async function fetchOrders() {
loading.value = true
try {
const { errCode, data } = await purchaseApi.getOrders({
keyword: searchQuery.value,
page: pageSize.value,
page_num: currentPage.value,
})
if (errCode === 0) {
orders.value = data.all_orders ?? []
totalCount.value = data.all_count ?? 0
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
loading.value = false
}
}
function goToPage(page) {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
fetchOrders()
}
function jumpToOrder(id) {
const resolved = router.resolve({ path: `/purchase/showorder/${id}` })
window.open(resolved.href, '_blank')
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Intl.DateTimeFormat(locale.value, {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).format(new Date(dateStr))
}
function handlePageSizeInput(e) {
let val = parseInt(e.target.value) || 10
if (val > 300) val = 300
if (val < 1) val = 1
pageSize.value = val
currentPage.value = 1
fetchOrders()
}
function handleJumpPageInput(e) {
const val = parseInt(e.target.value)
if (val > 0 && val <= totalPages.value) {
currentPage.value = val
fetchOrders()
}
}
onMounted(fetchOrders)
</script>
<template>
<div class="mx-auto max-w-6xl px-6 py-6">
<div class="flex flex-col gap-6 rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('purchase.purchase_list') }}</h3>
</div>
<!-- Toolbar -->
<div class="flex flex-col gap-3 px-6 py-3 sm:flex-row sm:items-center">
<div class="flex gap-2">
<RouterLink to="/purchase/addorder" class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700">
<IconPlus :size="16" />
{{ t('purchase.add_part') }}
</RouterLink>
<button class="rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:text-gray-400">{{ t('purchase.exp_report') }}</button>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500">{{ t('purchase.search') }}</label>
<input v-model="searchQuery" type="text" class="w-48 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white" @keydown.enter="currentPage=1;fetchOrders()" />
</div>
</div>
<!-- Table -->
<div class="overflow-x-auto px-0">
<table class="w-full text-left text-sm text-gray-900">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 text-gray-500 dark:border-dk-muted dark:bg-dk-base">
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">No.</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('purchase.item_name') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('purchase.purpose') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('purchase.quantity') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('purchase.unit_price') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('purchase.total_price') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('purchase.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="px-6 py-8 text-center text-gray-400">
<svg class="mx-auto mb-2 h-5 w-5 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading...
</td>
</tr>
<tr
v-for="order in orders"
:key="order.ID"
class="border-b border-gray-100 transition-colors hover:bg-blue-50/50 dark:border-dk-muted/50 dark:bg-dk-card dark:hover:bg-dk-base/50"
@click="jumpToOrder(order.ID)"
>
<td class="px-6 py-3 text-gray-400">{{ order.ID }}</td>
<td class="px-6 py-3 font-medium text-gray-900 dark:text-white">{{ order.Title }}</td>
<td class="px-6 py-3 text-gray-600 dark:text-gray-300">{{ order.Remark }}</td>
<td class="px-6 py-3 text-gray-600 dark:text-gray-300">1</td>
<td class="px-6 py-3 whitespace-nowrap text-gray-500">{{ formatDate(order.UnitPriceAt) }}</td>
<td class="px-6 py-3 whitespace-nowrap text-gray-500">{{ formatDate(order.TotalPriceAt) }}</td>
<td class="px-6 py-3 text-gray-600 dark:text-gray-300">1</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex flex-col items-center justify-between gap-3 border-t border-gray-200 px-6 py-3 sm:flex-row dark:border-dk-muted">
<div class="flex items-center gap-1.5 text-sm text-gray-500">
<label>{{ t('purchase.show') }}</label>
<input type="text" class="w-14 rounded border border-gray-300 px-2 py-1 text-center text-sm text-gray-900 dark:border-dk-muted dark:bg-dk-base dark:text-white" :value="pageSize" @change="handlePageSizeInput" />
<label>{{ t('purchase.entries') }}</label>
<span class="ml-1">{{ t('purchase.There_are_a_total_of') }} {{ totalCount }} {{ t('purchase.items') }}</span>
</div>
<div class="flex items-center gap-1">
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="currentPage <= 1" @click="goToPage(1)"><IconChevronsLeft :size="16" /></button>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)"><IconChevronLeftPipe :size="16" /></button>
<template v-for="a in pageRange" :key="a">
<button
class="min-w-[32px] rounded px-2 py-1 text-sm font-medium transition-colors"
:class="a === currentPage ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-dk-card dark:text-gray-400 dark:hover:bg-dk-card'"
@click="goToPage(a)"
>{{ a }}</button>
</template>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="currentPage >= totalPages" @click="goToPage(currentPage + 1)"><IconChevronRightPipe :size="16" /></button>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="currentPage >= totalPages" @click="goToPage(totalPages)"><IconChevronsRight :size="16" /></button>
<input type="text" class="ml-2 w-14 rounded border border-gray-300 px-2 py-1 text-center text-sm text-gray-900 dark:border-dk-muted dark:bg-dk-base dark:text-white" @change="handleJumpPageInput" />
</div>
</div>
</div>
</div>
</template>
@@ -1,481 +1,296 @@
<script setup>
import { onMounted, watch, ref, reactive } from "vue";
import { useI18n } from "vue-i18n";
<script setup>
import { reactive, ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { useValidation } from '@/composables'
import { purchaseApi } from '@/api/purchase'
import tagadder from '@/components/tagadder.vue'
import datePicker from '@/components/datePicker.vue'
import useDropzone from '@/components/useDropzone.vue'
import MyOffcanvas from "@/components/MyOffcanvas.vue";
usePageTitle('purchase.add_part')
const { t, locale } = useI18n()
const toast = useToastStore()
const { validate, errors, clearErrors } = useValidation()
import tagadder from "@/components/tagadder.vue";
import dateTimePicker from "@/components/dateTimePicker.vue";
const textMaxLen = 256
const photosRef = ref(null)
import useDropzone from "@/components/useDropzone.vue";
const currencyOptions = { 1: 'RMB', 2: 'MOA', 3: 'HKD', 4: 'USD' }
import { useUserStore } from "@/stores/user";
const userStore = useUserStore();
const costType = computed(() => ({
1: t('cost_type.unit_price'),
2: t('cost_type.freight'),
}))
import { useRouter } from "vue-router";
const router = useRouter();
const orderStatus = computed(() => ({
1: t('order_status.pending_order'),
2: t('order_status.order_placed'),
3: t('order_status.in_transit'),
4: t('order_status.completed'),
5: t('order_status.refund_requested'),
6: t('order_status.returning'),
7: t('order_status.refunded'),
8: t('order_status.lost_package'),
}))
import "tom-select/dist/css/tom-select.css";
import { my_network_func } from "@/my_network_func";
const costEntries = reactive([])
const newCost = reactive({
type: '1', qty: 1, cost: 0, currencyType: '1',
})
const textarea_maxlen = 256;
const newCostTotal = computed(() =>
parseFloat((newCost.qty * newCost.cost).toFixed(2))
)
const title_input_dom = ref();
const photos_hash = ref();
const mos = ref();
const { t, locale } = useI18n();
//货币类型
const currency_type = reactive({
1: "RMB",
2: "MOP",
3: "HKD",
4: "USD",
});
//成本类型
const cost_type = reactive({
1: t("cost_type.unit_price"),
2: t("cost_type.freight"),
});
function update_cost_type() {
cost_type["1"] = t("cost_type.unit_price");
cost_type["2"] = t("cost_type.freight");
function addCostEntry() {
if (newCost.cost <= 0) return
costEntries.push({
type: newCost.type,
qty: newCost.qty,
cost: newCost.cost,
cost_t: newCostTotal.value,
currency_type: newCost.currencyType,
})
newCost.type = '1'
newCost.qty = 1
newCost.cost = 0
newCost.currencyType = '1'
}
//订单状态
const order_status = reactive({
1: t("order_status.pending_order"),
2: t("order_status.order_placed"),
3: t("order_status.in_transit"),
4: t("order_status.compvared"),
5: t("order_status.refund_requested"),
6: t("order_status.returning"),
7: t("order_status.refunded"),
8: t("order_status.lost_package"),
});
function update_order_status() {
order_status["1"] = t("order_status.pending_order");
order_status["2"] = t("order_status.order_placed");
order_status["3"] = t("order_status.in_transit");
order_status["4"] = t("order_status.compvared");
order_status["5"] = t("order_status.refund_requested");
order_status["6"] = t("order_status.returning");
order_status["7"] = t("order_status.refunded");
order_status["8"] = t("order_status.lost_package");
function removeCostEntry(index) {
costEntries.splice(index, 1)
}
const cost_sheet_tab = reactive([]);
// 表单对象
const cost_sheet = reactive({
type: "1",
int: 1,
cost: 0.0,
cost_t: 0.0,
currency_type: "1",
});
watch(() => newCost.cost, (val) => {
const fixed = parseFloat(val).toFixed(2)
if (parseFloat(fixed) !== val) newCost.cost = parseFloat(fixed)
})
function del_cost(key) {
cost_sheet.type = cost_sheet_tab[key].type;
cost_sheet.int = cost_sheet_tab[key].int;
cost_sheet.cost = cost_sheet_tab[key].cost;
cost_sheet.cost_t = cost_sheet_tab[key].cost_t;
cost_sheet.currency_type = cost_sheet_tab[key].currency_type;
cost_sheet_tab.splice(key, 1);
}
function add_cost() {
if (cost_sheet.cost <= 0) {
} else {
// 四舍五入到2位小数
var t = parseFloat((cost_sheet.int * cost_sheet.cost).toFixed(2));
cost_sheet.cost_t = t;
cost_sheet_tab.push(JSON.parse(JSON.stringify(cost_sheet)));
cost_sheet.type = "1";
cost_sheet.int = 1;
cost_sheet.cost = 0.0;
cost_sheet.cost_t = 0.0;
cost_sheet.currency_type = "1";
}
}
const submit_sheet = reactive({
title: "",
remark: "",
const form = reactive({
title: '',
remark: '',
photos: [],
link: "",
partname: "",
styles: "",
link: '',
style_remarks: '',
notes: '',
costs: [],
updatetime: "",
trackingnumber: "",
orderstatus: "1",
});
tracking_number: '',
express_number: '',
order_status: '1',
})
function submit_order() {
if (submit_sheet.title == "") {
title_input_dom.value.classList.add("is-invalid");
title_input_dom.value.addEventListener("input", function () {
if (this.value.trim() !== "") {
this.classList.remove("is-invalid");
//this.removeEventListener('input');
}
});
const loading = ref(false)
mos.value?.showAlert("danger", t("purchase_addorder.title"), 1000);
return;
}
//载入图片哈希列表
submit_sheet.photos = [];
var photos = photos_hash.value.return_files();
for (var i = 0; i < photos.length; i++) {
submit_sheet.photos.push(photos[i].hash);
async function handleSubmit() {
clearErrors()
const err = validate('title', form.title, t('purchase_addorder.title'))
if (!err) return
form.photos = []
if (photosRef.value?.has_some_files) {
const result = photosRef.value.get_some_files()
form.photos = result.map(f => f.name)
}
//载入价格表
submit_sheet.costs = [];
for (var i = 0; i < cost_sheet_tab.length; i++) {
//var t=cost_sheet_tab[i]
submit_sheet.costs.push(JSON.parse(JSON.stringify(cost_sheet_tab[i])));
}
form.costs = costEntries.map(h => ({
...h,
cost: Math.round(h.cost * 100),
cost_t: Math.round(h.cost_t * 100),
}))
//修改价格表里的小数,将所有价值*100去掉小数
for (var i = 0; i < submit_sheet.costs.length; i++) {
submit_sheet.costs[i].cost *= 100;
submit_sheet.costs[i].cost_t *= 100;
}
console.log(submit_sheet);
my_network_func.postJson("/purchase/addorder", submit_sheet, (r) => {
console.log(r);
});
}
function functionupdataTitle() {
document.title = "Operations." + t("purchase.add_part");
}
onMounted(() => {
functionupdataTitle();
//sele_init();
if (!userStore.isLoggedIn) {
router.push("/login");
}
});
// 监听语言变化,更新标题
watch(locale, () => {
functionupdataTitle();
update_cost_type();
update_order_status();
});
// 监听 cost 变化,自动限制小数位
watch(
() => cost_sheet.cost,
(newVal) => {
if (newVal !== null && newVal !== undefined) {
// 四舍五入到2位小数
const fixed = parseFloat(newVal).toFixed(2);
if (parseFloat(fixed) !== newVal) {
cost_sheet.cost = parseFloat(fixed);
}
loading.value = true
try {
const { errCode } = await purchaseApi.addOrder(form)
if (errCode === 0) {
toast.success(t('message.save_ok'))
} else {
toast.error(t('message.server_error'))
}
},
);
} catch {
// interceptor handled
} finally {
loading.value = false
}
}
</script>
<template>
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<h2 class="page-title">{{ t("purchase_addorder.add_order") }}</h2>
<div class="mx-auto max-w-6xl px-6 py-6">
<div class="flex flex-col gap-6 rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
<!-- Order Info -->
<div class="border-b border-gray-200 px-6 py-4 dark:border-dk-muted">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('purchase_addorder.order_info') }}</h4>
</div>
<div class="space-y-4 px-6 py-5">
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('purchase_addorder.title') }} <span class="text-red-500">*</span>
</label>
<input
v-model="form.title"
type="text"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.title ? 'border-red-500' : 'border-gray-300'"
:placeholder="t('purchase_addorder.input_title')"
/>
<span v-if="errors.title" class="mt-1 block text-xs text-red-500">{{ errors.title }}</span>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('purchase_addorder.remarks') }}
<span class="text-gray-400">{{ form.remark.length }}/{{ textMaxLen }}</span>
</label>
<textarea
v-model="form.remark"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
rows="4"
:placeholder="t('purchase_addorder.remarks_text')"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('purchase_addorder.photo_remarks') }}</label>
<useDropzone acceptFiles="image/*" uploadURL="/api/files/upload/image" maxFiles="10" ref="photosRef" />
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div class="row row-cards">
<div class="col-12">
<div class="card">
<div class="card-header">
<h4 class="card-title">
{{ t("purchase_addorder.order_info") }}
</h4>
<!-- Purchase Channel -->
<div class="border-t border-gray-200 px-6 py-4 dark:border-dk-muted">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('purchase_addorder.purchase_channel') }}</h4>
</div>
<div class="space-y-4 px-6 py-5">
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('purchase_addorder.link') }}</label>
<textarea
v-model="form.link"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
rows="2"
placeholder="url"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('purchase_addorder.part_name') }}</label>
<input
v-model="form.style_remarks"
type="text"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:placeholder="t('purchase_addorder.part_name')"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('purchase_addorder.style_remarks') }}</label>
<tagadder :placeholder="t('purchase_addorder.add_style')" v-model="form.notes" />
</div>
<!-- costs table -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('purchase_addorder.cost') }}</label>
<div v-if="costEntries.length" class="mb-4 overflow-x-auto">
<table class="w-full text-left text-sm text-gray-900">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 text-gray-500 dark:border-dk-muted dark:bg-dk-base">
<th class="px-3 py-2 font-medium">{{ t('purchase_addorder.type') }}</th>
<th class="px-3 py-2 font-medium">{{ t('purchase_addorder.quantity') }}</th>
<th class="px-3 py-2 font-medium">{{ t('purchase_addorder.fee') }}</th>
<th class="px-3 py-2 font-medium">{{ t('purchase_addorder.total_price') }}</th>
<th class="px-3 py-2 font-medium">{{ t('purchase_addorder.currency') }}</th>
<th class="px-3 py-2 font-medium">{{ t('purchase_addorder.operation') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in costEntries" :key="idx" class="border-b border-gray-100 dark:border-dk-muted">
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white">{{ costType[item.type] }}</td>
<td class="px-3 py-2 text-gray-500">{{ item.qty }}</td>
<td class="px-3 py-2 text-gray-500">{{ item.cost }}</td>
<td class="px-3 py-2 text-gray-500">{{ item.cost_t }}</td>
<td class="px-3 py-2 text-gray-500">{{ currencyOptions[item.currency_type] }}</td>
<td class="px-3 py-2">
<button class="rounded px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20" @click="removeCostEntry(idx)">{{ t('purchase_addorder.remove') }}</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
<div>
<label class="mb-1 block text-xs font-medium text-gray-500">{{ t('purchase_addorder.fee_type') }}</label>
<select v-model="newCost.type" class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white">
<template v-for="(label, key) in costType" :key="key">
<option :value="key">{{ label }}</option>
</template>
</select>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label required">{{
t("purchase_addorder.title")
}}</label>
<input
type="text"
class="form-control"
name="example-text-input"
:placeholder="t('purchase_addorder.title')"
v-model="submit_sheet.title"
ref="title_input_dom"
/>
</div>
<div class="mb-3">
<label class="form-label"
>{{ t("purchase_addorder.remarks") }}
<span class="form-label-description"
>{{ submit_sheet.remark.length }}/{{
textarea_maxlen
}}</span
></label
>
<textarea
class="form-control mt-2 mb-2"
name="example-textarea-input"
rows="6"
:placeholder="t('purchase_addorder.remarks_text')"
:maxlength="textarea_maxlen"
v-model="submit_sheet.remark"
></textarea>
</div>
<label class="form-label mb-0">{{
t("purchase_addorder.photo_remarks")
}}</label>
<useDropzone
acceptedFiles="image/*"
uploadURL="/api/files/upload/image"
maxFiles="10"
ref="photos_hash"
></useDropzone>
<div>
<label class="mb-1 block text-xs font-medium text-gray-500">{{ t('purchase_addorder.input_quantity') }}</label>
<input v-model.number="newCost.qty" type="number" class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white" min="1" />
</div>
<div class="card-header">
<h4 class="card-title">
{{ t("purchase_addorder.purchase_channel") }}
</h4>
<div>
<label class="mb-1 block text-xs font-medium text-gray-500">{{ t('purchase_addorder.input_fee') }}</label>
<input v-model="newCost.cost" type="number" class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white" step="0.01" min="0" />
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">{{
t("purchase_addorder.link")
}}</label>
<textarea
name="url"
type="url"
class="form-control"
placeholder="https"
v-model="submit_sheet.link"
></textarea>
<div class="mb-3 mt-3">
<label class="form-label">{{
t("purchase_addorder.part_name")
}}</label>
<input
type="text"
class="form-control"
name="example-text-input"
:placeholder="t('purchase_addorder.part_name')"
v-model="submit_sheet.partname"
/>
</div>
<div class="mt-3">
<label class="form-label">{{
t("purchase_addorder.style_remarks")
}}</label>
<tagadder
:placeholder="t('purchase_addorder.add_style')"
v-model="submit_sheet.styles"
></tagadder>
</div>
<div class="mt-3">
<label class="form-label">{{
t("purchase_addorder.cost")
}}</label>
<table
v-show="cost_sheet_tab.length"
class="table table-vcenter card-table table-striped"
>
<thead>
<tr>
<th>{{ t("purchase_addorder.type") }}</th>
<th>{{ t("purchase_addorder.quantity") }}</th>
<th>{{ t("purchase_addorder.fee") }}</th>
<th>{{ t("purchase_addorder.total_price") }}</th>
<th>{{ t("purchase_addorder.currency") }}</th>
<th class="w-1">
{{ t("purchase_addorder.operation") }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(value, key) in cost_sheet_tab">
<td>{{ cost_type[value.type] }}</td>
<td class="text-secondary">{{ value.int }}</td>
<td class="text-secondary">{{ value.cost }}</td>
<td class="text-secondary">
{{ value.cost_t }}
</td>
<td class="text-secondary">
{{ currency_type[value.currency_type] }}
</td>
<td>
<button
class="btn btn-outline-danger"
@click="del_cost(key)"
>
{{ t("purchase_addorder.change") }}
</button>
</td>
</tr>
<!-- <tr>
<td>运输</td>
<td class="text-secondary">1</td>
<td class="text-secondary">5</td>
<td class="text-secondary">MOP</td>
<td>
<button class="btn btn-outline-danger">Del</button>
</td>
</tr> -->
</tbody>
</table>
<div class="row g-5">
<div class="col-xl-2">
{{ t("purchase_addorder.fee_type") }}
<select
ref="select_type"
class="form-control"
autocompvare="off"
value="1"
v-model="cost_sheet.type"
>
<option v-for="(value, key) in cost_type" :value="key">
{{ value }}
</option>
</select>
</div>
<div class="col-xl-3">
{{ t("purchase_addorder.input_quantity") }}
<input
type="number"
class="form-control"
min="1"
value="1"
v-model="cost_sheet.int"
/>
</div>
<div class="col-xl-3">
{{ t("purchase_addorder.input_fee") }}
<input
type="number"
class="form-control"
step="0.01"
min="0.0"
value="0.0"
v-model="cost_sheet.cost"
/>
</div>
<div class="col-xl-2">
{{ t("purchase_addorder.select_currency") }}
<select
ref="select_beast"
class="form-control"
autocompvare="off"
value="1"
v-model="cost_sheet.currency_type"
>
<option
v-for="(value, key) in currency_type"
:value="key"
>
{{ value }}
</option>
</select>
</div>
<div class="col-xl-2">
{{ t("purchase_addorder.operation") }}
<button
class="form-control btn btn-outline-primary"
@click="add_cost"
>
{{ t("purchase_addorder.add") }}
</button>
</div>
</div>
</div>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-500">{{ t('purchase_addorder.select_currency') }}</label>
<select v-model="newCost.currencyType" class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white">
<template v-for="(label, key) in currencyOptions" :key="key">
<option :value="key">{{ label }}</option>
</template>
</select>
</div>
<div class="card-header">
<h4 class="card-title">
{{ t("purchase_addorder.other_status") }}
</h4>
</div>
<div class="card-body">
<div class="mb-3">
<div class="row g-5">
<div class="col-xl-4">
<label class="form-label required">{{
t("purchase_addorder.update_time")
}}</label>
<dateTimePicker
v-model="submit_sheet.updatetime"
></dateTimePicker>
</div>
<div class="col-xl-4">
<label class="form-label">{{
t("purchase_addorder.tracking_number")
}}</label>
<input
type="text"
class="form-control"
:placeholder="
t('purchase_addorder.input_tracking_number')
"
v-model="submit_sheet.trackingnumber"
/>
</div>
<div class="col-xl-4">
{{ t("purchase_addorder.order_status") }}
<select
ref="select_beast"
class="form-control"
autocompvare="off"
v-model="submit_sheet.orderstatus"
>
<option v-for="(value, key) in order_status" :value="key">
{{ value }}
</option>
</select>
</div>
</div>
</div>
</div>
<div class="card-footer text-end">
<div class="d-flex">
<button
type="submit"
class="btn btn-primary ms-auto"
@click="submit_order"
>
{{ t("purchase_addorder.submit") }}
</button>
</div>
<div class="flex items-end">
<button class="w-full rounded-lg border border-gray-300 bg-blue-600 px-3 py-2 text-sm font-semibold text-blue-100 transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:text-white dark:bg-blue-600" @click="addCostEntry">{{ t('purchase_addorder.add') }}</button>
</div>
</div>
</div>
</div>
<!-- Order Status -->
<div class="border-t border-gray-200 px-6 py-4 dark:border-dk-muted">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('purchase_addorder.order_status') }}</h4>
</div>
<div class="px-6 py-5">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('purchase_addorder.update_time') }} <span class="text-red-500">*</span>
</label>
<datePicker v-model="form.tracking_number" />
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('purchase_addorder.tracking_number') }}</label>
<input
v-model="form.express_number"
type="text"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:placeholder="t('purchase_addorder.input_tracking_number')"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('purchase_addorder.order_status') }}</label>
<select v-model="form.order_status" class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white">
<template v-for="(label, key) in orderStatus" :key="key">
<option :value="key">{{ label }}</option>
</template>
</select>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end border-t border-gray-200 px-6 py-4 dark:border-dk-muted">
<button
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500/20 focus:outline-none disabled:active:scale-100"
:disabled="loading"
@click="handleSubmit"
>
<svg v-if="loading" class="h-4 w-4 animate-spin text-white" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{{ t('purchase_addorder.submit') }}
</button>
</div>
</div>
</div>
<MyOffcanvas ref="mos" />
</template>
<style></style>
@@ -1,422 +0,0 @@
<script setup>
import { onMounted, watch, ref, reactive } from "vue";
import { useI18n } from "vue-i18n";
import MyOffcanvas from "@/components/MyOffcanvas.vue";
const mos = ref();
import { my_network_func } from "@/my_network_func";
import { myfuncs } from "@/myfunc";
import { useRouter } from "vue-router";
const router = useRouter();
const { t, locale } = useI18n();
const all_items = ref(0);
const all_pages = ref(0);
const page_items = ref(10);
const now_page = ref(1);
const all_orders = ref({});
const page_start = ref(0);
const page_end = ref(0);
const page_input = ref();
const page_items_items = ref("10");
function jump_to_order(order_id) {
//console.log(order_id);
var order_str=order_id.toString()
const resolved = router.resolve({
path: "/purchase/showorder/" + order_str,
});
window.open(resolved.href, "_blank");
}
//获取订单列表
function get_orders() {
my_network_func.postJson(
"/purchase/getorders",
{
search: "",
entries: page_items.value,
page: now_page.value,
},
(r) => {
//console.log(r);
switch (r.statusCode) {
case 200:
switch (r.data.err_code) {
case 0:
all_orders.value = r.data.return.all_orders;
all_items.value = r.data.return.all_count;
all_pages.value = Math.ceil(all_items.value / page_items.value);
if (now_page.value < 3) {
page_start.value = 1;
} else {
if (now_page.value > all_pages.value - 3) {
page_start.value = all_pages.value - 4;
if (page_start.value <= 0) {
page_start.value = 1;
}
} else {
page_start.value = now_page.value - 2;
}
}
if (now_page.value > all_pages.value - 3) {
page_end.value = all_pages.value;
} else {
if (now_page.value < 3) {
page_end.value = 5;
} else {
page_end.value = now_page.value + 2;
}
}
break;
default:
mos.value?.showAlert("danger", t("message.server_error"), 5000);
break;
}
break;
default:
mos.value?.showAlert("danger", t("message.network_err"), 5000);
break;
}
},
);
}
function change_page(page) {
now_page.value = page;
get_orders();
}
function functionupdataTitle() {
document.title = "Operations." + t("appname.purchase");
}
function range(start, end) {
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}
function page_input_change(c) {
//console.log(page_input.value);
var t = parseInt(page_input.value);
if (t > 0) {
if (t <= all_pages.value) {
page_input.value = "";
change_page(t);
}
}
}
function page_input_input(c) {
page_input.value = page_input.value.replace(/[^\d]/g, "");
//console.log(c)
}
function page_items_input_change(c) {
var t = parseInt(page_items_items.value);
page_items.value = t;
now_page.value = 1;
get_orders();
//console.log(t)
}
function page_items_input_input(c) {
page_items_items.value = page_items_items.value.replace(/[^\d]/g, "");
var t = parseInt(page_items_items.value);
if (t > 300) {
page_items_items.value = "300";
}
//console.log(c)
}
onMounted(() => {
functionupdataTitle();
get_orders();
});
// 监听语言变化,更新标题
watch(locale, () => {
functionupdataTitle();
});
</script>
<template>
<div class="page-body">
<div class="container-xl">
<div class="card">
<div class="card-header">
<h3 class="card-title">{{ t("purchase.purchase_list") }}</h3>
</div>
<div class="card-body border-bottom py-3">
<div class="d-flex">
<div class="text-secondary">
<router-link to="/purchase/addorder" class="btn btn-info m-1">
{{ t("purchase.add_part") }}
</router-link>
<button class="btn m-1">
{{ t("purchase.exp_report") }}
</button>
</div>
<!-- //搜索dom -->
<div class="ms-auto text-secondary">
{{ t("purchase.search") }}
<div class="ms-2 d-inline-block mr-2">
<input
type="text"
class="form-control form-control-sm"
aria-label="Search invoice"
/>
</div>
</div>
<div class="ms-auto text-secondary"></div>
</div>
</div>
<div class="table-responsive">
<table class="table card-table table-vcenter text-nowrap datatable">
<thead>
<tr>
<th class="w-1">
<input
class="form-check-input m-0 align-middle"
type="checkbox"
aria-label="Select all invoices"
/>
</th>
<th class="col-1">
No.
<!-- Download SVG icon from http://tabler-icons.io/i/chevron-up -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-sm icon-thick"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 15l6 -6l6 6" />
</svg>
</th>
<th class="col-3">{{ t("purchase.item_name") }}</th>
<th class="col-3">{{ t("purchase.purpose") }}</th>
<th class="w-1">{{ t("purchase.quantity") }}</th>
<th class="w-1">{{ t("purchase.created_at") }}</th>
<th class="w-1">{{ t("purchase.updated_at") }}</th>
<th class="w-1">{{ t("purchase.status") }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="value in all_orders"
class="element"
@click="jump_to_order(value.ID)"
>
<td>
<input
class="form-check-input m-0 align-middle"
type="checkbox"
aria-label="Select invoice"
/>
</td>
<td>
<span class="text-muted">{{ value.ID }}</span>
</td>
<td>
{{ value.Title }}
</td>
<td>{{ value.Remark }}</td>
<td>1</td>
<td>
{{ myfuncs.formatLocalizedDate(value.CreatedAt, locale) }}
</td>
<td>{{ myfuncs.formatLocalizedDate(value.UpdatedAt) }}</td>
<td>1</td>
</tr>
</tbody>
</table>
</div>
<div class="card-footer d-flex align-items-center">
<p class="m-0 text-secondary">
{{ t("purchase.show") }}
</p>
<div class="mx-2 d-inline-block">
<input
type="text"
class="form-control form-control-sm w-6"
v-model="page_items_items"
aria-label="Invoices count"
@change="page_items_input_change"
@input="page_items_input_input"
/>
</div>
<p class="m-0 text-secondary">
{{ t("purchase.entries") }}
{{ t("purchase.There_are_a_total_of") }} {{ all_items }}
{{ t("purchase.entries") }}
</p>
<ul class="pagination m-0 ms-auto">
<li class="page-item" :class="now_page == 1 ? 'disabled' : ''">
<div
class="page-link"
:tabindex="now_page == 1 ? '-1' : ''"
:aria-disabled="now_page == 1 ? 'true' : ''"
@click="change_page(1)"
>
<!-- Download SVG icon from http://tabler-icons.io/i/chevron-left -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-bar-to-left"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12l10 0" />
<path d="M10 12l4 4" />
<path d="M10 12l4 -4" />
<path d="M4 4l0 16" />
</svg>
</div>
</li>
<li class="page-item" :class="now_page == 1 ? 'disabled' : ''">
<div
class="page-link"
:tabindex="now_page == 1 ? '-1' : ''"
:aria-disabled="now_page == 1 ? 'true' : ''"
@click="change_page(now_page - 1)"
>
<!-- Download SVG icon from http://tabler-icons.io/i/chevron-left -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15 6l-6 6l6 6" />
</svg>
<!-- prev -->
</div>
</li>
<li
v-for="value in range(page_start, page_end)"
class="page-item"
:class="value == now_page ? 'active' : ''"
>
<div class="page-link" @click="change_page(value)">
{{ value }}
</div>
</li>
<li
class="page-item"
:class="now_page == all_pages ? 'disabled' : ''"
>
<div
class="page-link"
:tabindex="now_page == all_pages ? '-1' : ''"
:aria-disabled="now_page == all_pages ? 'true' : ''"
@click="change_page(now_page + 1)"
>
<!-- next -->
<!-- Download SVG icon from http://tabler-icons.io/i/chevron-right -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 6l6 6l-6 6" />
</svg>
</div>
</li>
<li
class="page-item"
:class="now_page == all_pages ? 'disabled' : ''"
>
<div
class="page-link"
:tabindex="now_page == all_pages ? '-1' : ''"
:aria-disabled="now_page == all_pages ? 'true' : ''"
@click="change_page(all_pages)"
>
<!-- Download SVG icon from http://tabler-icons.io/i/chevron-right -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-bar-to-right"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 12l-10 0" />
<path d="M14 12l-4 4" />
<path d="M14 12l-4 -4" />
<path d="M20 4l0 16" />
</svg>
</div>
</li>
<li>
<input
type="text"
class="form-control form-control-sm w-6"
@change="page_input_change"
@input="page_input_input"
v-model="page_input"
/>
</li>
</ul>
</div>
</div>
</div>
</div>
<MyOffcanvas ref="mos" />
</template>
<style lang="scss" scoped>
.element:hover {
background-color: #4299e11c;
}
</style>
@@ -1,14 +1,17 @@
<script setup>
import { onMounted, watch, ref, reactive } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
<script setup>
import { useRouter } from 'vue-router'
import { usePageTitle } from '@/composables/usePageTitle'
const dynamicParam = router
onMounted(() => {
console.log(dynamicParam);
});
usePageTitle('purchase.add_part')
const router = useRouter()
const orderId = router.params.id
</script>
<template>
<template>
<div class="mx-auto max-w-6xl px-6 py-6">
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Order#{{ orderId }}</h2>
<div class="rounded-xl border border-gray-200 bg-white px-12 py-12 text-center shadow-lg dark:border-dk-muted dark:bg-dk-card">
<p class="text-gray-400">{{ $t('message.functionality_not_yet_developed') }}</p>
</div>
</div>
</template>
+129 -226
View File
@@ -1,243 +1,146 @@
<script setup>
import { onMounted, watch, ref } from "vue";
import { useRouter } from "vue-router";
import MyOffcanvas from "@/components/MyOffcanvas.vue";
import { myfuncs } from "@/myfunc.js";
import { my_network_func } from "@/my_network_func";
import { useI18n } from "vue-i18n";
// 使用 vue-i18n 的 Composition API
const { t, locale } = useI18n();
const mos = ref();
const isShowPassword = ref(false);
const username = ref();
const useremail = ref();
const userpassword = ref();
const router = useRouter();
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
function functionupdataTitle() {
document.title = "Operations." + t("appname.register");
const { t, locale } = useI18n()
function toggleLocale() {
locale.value = locale.value === 'zh-CN' ? 'en' : 'zh-CN'
}
function togglePasswordVisibility() {
isShowPassword.value = !isShowPassword.value;
}
function createAccount() {
// 在这里处理创建新账户的逻辑
const user = username.value?.value;
const email = useremail.value?.value;
const pass = userpassword.value?.value;
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { useValidation, isValidEmail } from '@/composables'
import { authApi } from '@/api/auth'
import { IconEye, IconEyeOff } from '@tabler/icons-vue'
username.value?.classList.remove("is-invalid");
useremail.value?.classList.remove("is-invalid");
userpassword.value?.classList.remove("is-invalid");
usePageTitle('appname.register')
const router = useRouter()
const toast = useToastStore()
const { validate, errors, clearErrors } = useValidation()
let isDataErr = false;
const form = ref({
username: '',
email: '',
password: '',
})
const showPassword = ref(false)
const loading = ref(false)
if (!user) {
isDataErr = true;
username.value?.classList.add("is-invalid");
}
if (!email) {
isDataErr = true;
useremail.value?.classList.add("is-invalid");
}
if (!pass) {
isDataErr = true;
userpassword.value?.classList.add("is-invalid");
}
async function handleRegister() {
clearErrors()
if (isDataErr) {
mos.value?.showAlert(
"info",
t("message.please_enter_username_and_password"),
5000
);
return;
}
const err1 = validate('username', form.value.username, t('message.please_enter_username_and_password'))
const err2 = validate('email', form.value.email, t('message.please_enter_your_email'), isValidEmail)
const err3 = validate('password', form.value.password, t('message.please_enter_username_and_password'))
//判断长度
if (!err1 || !err2 || !err3) return
if (!myfuncs.isValidEmail(email)) {
useremail.value?.classList.add("is-invalid");
mos.value?.showAlert("warning", t("message.this_not_email"), 5000);
return;
}
// console.log("创建新账户信息:", {
// user: username.value?.value,
// email: useremail.value?.value,
// pass: userpassword.value?.value,
// });
loading.value = true
try {
const { errCode } = await authApi.register(form.value.username, form.value.email, form.value.password)
my_network_func.postJson(
"/users/register",
{
username: username.value?.value,
useremail: useremail.value?.value,
userpass: userpassword.value?.value,
},
(r) => {
//console.log(r);
switch (r.statusCode) {
case 200:
switch (r.data.err_code) {
case -4:
username.value?.classList.add("is-invalid");
mos.value?.showAlert("warning", t("message.username_dup"), 5000);
break;
case 0:
mos.value?.showAlert(
"success",
t("message.registration_successful"),
1000,
() => {
router.push("/login");
}
);
break;
default:
mos.value?.showAlert("danger", t("message.server_error"), 5000);
break;
}
break;
default:
mos.value?.showAlert("danger", t("message.network_err"), 5000);
break;
}
switch (errCode) {
case 0:
toast.success(t('message.registration_successful'), 1500)
setTimeout(() => router.push('/login'), 1500)
break
case -4:
toast.warning(t('message.username_dup'))
errors.username = t('message.username_dup')
break
default:
toast.error(t('message.server_error'))
}
);
} catch {
// 拦截器已处理
} finally {
loading.value = false
}
}
onMounted(() => {
functionupdataTitle();
});
// 监听语言变化,更新标题
watch(locale, () => {
functionupdataTitle();
});
</script>
<template>
<div class="page page-center">
<div class="container container-tight py-4">
<div class="text-center mb-4">
<router-link to="/" class="navbar-brand navbar-brand-autodark">
<img
src="/static/logo.svg"
width="110"
height="32"
alt="Tabler"
class="navbar-brand-image"
/>
</router-link>
</div>
<div class="card card-md">
<div class="card-body">
<h2 class="card-title text-center mb-4">
{{ t("message.create_new_account") }}
</h2>
<div class="mb-3">
<label class="form-label">{{ t("message.user_name") }}</label>
<input
ref="username"
type="text"
maxlength="64"
class="form-control"
:placeholder="t('message.your_user_name')"
/>
</div>
<div class="mb-3">
<label class="form-label">{{ t("message.email_address") }}</label>
<input
ref="useremail"
type="email"
maxlength="250"
class="form-control"
:placeholder="t('message.your_email_address')"
/>
</div>
<div class="mb-3">
<label class="form-label">{{ t("message.password") }}</label>
<div class="input-group input-group-flat">
<input
ref="userpassword"
:type="isShowPassword ? 'text' : 'password'"
class="form-control"
:placeholder="t('message.your_password')"
autocomplete="off"
/>
<span class="input-group-text">
<div
class="link-secondary"
:title="
isShowPassword
? t('message.hidden_Password')
: t('message.show_password')
"
data-bs-toggle="tooltip"
>
<!-- Download SVG icon from http://tabler-icons.io/i/eye -->
<svg
v-if="!isShowPassword"
@click="togglePasswordVisibility"
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path
d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6"
/>
</svg>
<svg
v-if="isShowPassword"
@click="togglePasswordVisibility"
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
<path
d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87"
/>
<path d="M3 3l18 18" />
</svg>
</div>
</span>
</div>
</div>
<!-- <div class="mb-3">
<label class="form-check">
<input type="checkbox" class="form-check-input"/>
<span class="form-check-label">Agree the <a href="./terms-of-service.html" tabindex="-1">terms and policy</a>.</span>
</label>
</div> -->
<div class="form-footer">
<button @click="createAccount" class="btn btn-primary w-100">
{{ t("message.create_new_account") }}
</button>
</div>
</div>
</div>
<div class="text-center text-secondary mt-3">
{{ t("message.already_have_an_account") }}
<router-link to="/login">{{ t("message.back_to_login") }}</router-link>
</div>
<div class="mx-auto max-w-sm px-8">
<div class="mb-8 flex items-start justify-between">
<RouterLink to="/" class="inline-flex items-center">
<img src="/logo.svg" class="h-10 w-10 rounded-lg" alt="Operations" />
<span class="ml-2.5 text-2xl font-bold text-gray-800 dark:text-dk-text">Operations</span>
</RouterLink>
<button class="rounded-md border border-gray-200 px-2.5 py-1 text-xs font-semibold uppercase text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:border-dk-muted dark:text-gray-400 dark:hover:bg-dk-card dark:hover:text-dk-text" @click="toggleLocale">
{{ locale === 'zh-CN' ? 'EN' : '' }}
</button>
</div>
<div class="rounded-xl border border-gray-200 bg-white px-8 py-8 shadow-lg dark:border-dk-muted dark:bg-dk-card">
<h2 class="mb-6 text-center text-xl font-bold text-gray-900 dark:text-white">{{ t('message.create_new_account') }}</h2>
<!-- Username -->
<div class="mb-4">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('message.user_name') }}</label>
<input
v-model="form.username"
type="text"
maxlength="64"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2.5 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.username ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300'"
:placeholder="t('message.your_user_name')"
/>
<span v-if="errors.username" class="mt-1 block text-xs text-red-500">{{ errors.username }}</span>
</div>
<!-- Email -->
<div class="mb-4">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('message.your_email_address') }}</label>
<input
v-model="form.email"
type="email"
maxlength="250"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2.5 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.email ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300'"
:placeholder="t('message.your_email_address')"
/>
<span v-if="errors.email" class="mt-1 block text-xs text-red-500">{{ errors.email }}</span>
</div>
<!-- Password -->
<div class="mb-6">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('message.password') }}</label>
<div class="relative">
<input
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2.5 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.password ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300'"
:placeholder="t('message.your_password')"
autocomplete="new-password"
@keydown.enter="handleRegister"
/>
<button type="button" class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300" @click="showPassword = !showPassword">
<IconEye v-if="!showPassword" :size="18" />
<IconEyeOff v-else :size="18" />
</button>
</div>
<span v-if="errors.password" class="mt-1 block text-xs text-red-500">{{ errors.password }}</span>
</div>
<!-- Submit -->
<button
class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500/20 focus:outline-none disabled:active:scale-100"
:disabled="loading"
@click="handleRegister"
>
<svg v-if="loading" class="h-4 w-4 animate-spin text-white" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{{ t('message.create_new_account') }}
</button>
</div>
<p class="mt-6 text-center text-sm text-gray-500">
{{ t('message.already_have_an_account') }}
<RouterLink to="/login" class="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">{{ t('message.back_to_login') }}</RouterLink>
</p>
</div>
<MyOffcanvas ref="mos" />
</template>
+56 -101
View File
@@ -1,134 +1,89 @@
<script setup>
import FullCalendar from "@fullcalendar/vue3";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction"; //拖动插件 需要用npm安装
import listPlugin from "@fullcalendar/list";
<script setup>
import { ref, watch } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
import listPlugin from '@fullcalendar/list'
import { useI18n } from 'vue-i18n'
import { usePageTitle } from '@/composables/usePageTitle'
import { onMounted, watch, ref } from "vue";
import { useI18n } from "vue-i18n";
const { t, locale } = useI18n();
const calendar = ref(null);
usePageTitle('appname.schedule')
const { t, locale } = useI18n()
const calendarRef = ref(null)
const calendarOptions = ref({
height: "auto",
height: 'auto',
locale: locale.value,
plugins: [
dayGridPlugin,
timeGridPlugin,
interactionPlugin, //导入拖动插件
listPlugin,
],
fixedWeekCount: false, //是否固定显示6行
weekNumbers: true,
initialView: "dayGridMonth", //默认月视图 dayGridMonth timeGridWeek listWeek
editable: true,
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin],
nowIndicator: true,
weekends: true,
initialView: 'dayGridMonth',
selectable: true,
editable: true,
dayMaxEvents: true,
navLinks: true,
firstDay: 1,
dayCellDidMount(info) {
switch (info.dow) {
case 0:
info.el.style.backgroundColor = "#ffb5b5";
break;
case 6:
info.el.style.backgroundColor = "#ffb5b5";
break;
if (info.date.getDay() === 0 || info.date.getDay() === 6) {
info.el.style.backgroundColor = '#f5f5f5'
}
if (info.isToday) {
//info.el.style.backgroundColor = '#ffff7f';
}
info.el.style.border = "1px solid #4b4b4b"; // 浅蓝色边框
info.el.style.border = '1px solid #e5e7eb'
},
headerToolbar: {
left: "prevYearCustom,prevMonthCustom,todayCustom,nextMonthCustom,nextYearCustom",
center: "title",
right: "", //,timeGridWeek,timeGridDay'
left: 'prevYear,prev,next,nextYear',
center: 'title',
right: '',
},
// 自定义按钮
customButtons: {
prevYearCustom: {
prevYear: {
text: t('schedule.previous_year'),
click: function () {
calendar.value.getApi().prevYear();
},
click() { calendarRef.value.getApi().prevYear() },
},
nextYearCustom: {
nextYear: {
text: t('schedule.next_year'),
click: function () {
calendar.value.getApi().nextYear();
},
click() { calendarRef.value.getApi().nextYear() },
},
prevMonthCustom: {
prevMonth: {
text: t('schedule.previous_month'),
click: function () {
calendar.value.getApi().prev();
},
click() { calendarRef.value.getApi().prev() },
},
nextMonthCustom: {
nextMonth: {
text: t('schedule.next_month'),
click: function () {
calendar.value.getApi().next();
},
click() { calendarRef.value.getApi().next() },
},
todayCustom: {
text: t('schedule.month'),
click: function () {
calendar.value.getApi().today();
},
week: {
text: t('schedule.week'),
click() { calendarRef.value.getApi().changeView('timeGridWeek') },
},
},
events: [
{ title: "事件 1", start: "2025-11-10" },
{ title: "事件 2", start: "2025-11-15", end: "2024-06-17" },
{
title: "事件 3",
start: "2025-11-20T10:30:00",
end: "2024-06-20T12:30:00",
},
{ title: 'Event1', date: '2025-11-10' },
{ title: 'Event2', date: '2025-11-15', end: '2025-11-17' },
{ title: 'Event3', date: '2025-11-20T10:30:00', end: '2025-11-20T12:30:00' },
],
});
})
function functionupdataTitle() {
document.title = "Operations." + t("appname.schedule");
}
// 监听语言变化,更新标题
watch(locale, () => {
functionupdataTitle();
calendarOptions.value.locale = locale.value;
// 更新自定义按钮文本
calendarOptions.value.customButtons.prevYearCustom.text = t('schedule.previous_year');
calendarOptions.value.customButtons.nextYearCustom.text = t('schedule.next_year');
calendarOptions.value.customButtons.prevMonthCustom.text = t('schedule.previous_month');
calendarOptions.value.customButtons.nextMonthCustom.text = t('schedule.next_month');
calendarOptions.value.customButtons.todayCustom.text = t('schedule.month');
});
onMounted(() => {
functionupdataTitle();
});
calendarOptions.value.locale = locale.value
calendarOptions.value.headerToolbar.customButtons.prevYear.text = t('schedule.previous_year')
calendarOptions.value.headerToolbar.customButtons.nextYear.text = t('schedule.next_year')
calendarOptions.value.headerToolbar.customButtons.prevMonth.text = t('schedule.previous_month')
calendarOptions.value.headerToolbar.customButtons.nextMonth.text = t('schedule.next_month')
calendarOptions.value.headerToolbar.customButtons.week.text = t('schedule.week')
})
</script>
<template>
<FullCalendar ref="calendar" :options="calendarOptions" />
<div class="mx-auto max-w-6xl px-6 py-6">
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">{{ t('schedule.my_schedule') }}</h2>
<div class="rounded-xl border border-gray-200 bg-white px-4 py-4 shadow-lg dark:border-dk-muted dark:bg-dk-card">
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div>
</div>
</template>
<style scoped>
/* .fc-prevYearCustom-button {
background-color: #4CAF50 !important;
color: white !important;
border: none !important;
border-radius: 5px !important;
padding: 8px 16px !important;
font-weight: bold !important;
} */
</style>
@@ -0,0 +1,163 @@
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { useValidation } from '@/composables'
import { authApi } from '@/api/auth'
import SettingNav from '@/components/SettingNav.vue'
import ImageCropper from '@/components/imageCropper.vue'
usePageTitle('settings.account_settings')
const { t } = useI18n()
const userStore = useUserStore()
const toast = useToastStore()
const { validate, errors, clearErrors } = useValidation()
const form = reactive({
username: '',
remark: '',
birthday: '',
})
const avatarHasChanged = ref(false)
const avatarDataUrl = ref('')
const loading = ref(false)
onMounted(() => {
if (userStore.user) {
form.username = userStore.user.Username || ''
form.remark = userStore.user.FirstName || ''
form.birthday = userStore.birthday
}
})
function handleCrop(dataUrl) {
avatarHasChanged.value = true
avatarDataUrl.value = dataUrl
}
function cancelAvatar() {
avatarHasChanged.value = false
avatarDataUrl.value = ''
}
function base64ToFile(base64) {
const [info, data] = base64.split(',')
const mime = info.match(/:(.*?);/)[1]
const bytes = atob(data)
const arr = new Uint8Array(bytes.length)
for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i)
return new File([arr], 'avatar.png', { type: mime })
}
async function handleSave() {
clearErrors()
const err1 = validate('username', form.username, t('settings.name'))
const err2 = validate('remark', form.remark, t('settings.remark'))
const err3 = validate('birthday', form.birthday, t('settings.birthday'))
if (!err1 || !err2 || !err3) return
loading.value = true
try {
if (avatarHasChanged.value) {
const file = base64ToFile(avatarDataUrl.value)
await authApi.updateAvatar(file)
avatarHasChanged.value = false
}
const { errCode } = await authApi.updateInfo({
username: form.username,
remark: form.remark,
birthday: form.birthday,
})
if (errCode === 0) {
toast.success(t('message.save_ok'))
await userStore.fetchUserInfo()
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
loading.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-5xl px-6 py-6">
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">{{ t('settings.my_account') }}</h2>
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
<SettingNav />
<div class="flex-1 space-y-6">
<!-- Avatar -->
<div class="mb-6 flex items-center gap-4">
<div>
<img
:src="avatarHasChanged ? avatarDataUrl : userStore.avatarUrl"
alt="Avatar"
class="h-16 w-16 rounded-full border-2 border-gray-200 object-cover dark:border-dk-muted"
/>
</div>
<div>
<ImageCropper @crop-data-url="handleCrop" />
<button v-if="avatarHasChanged" class="mt-2 rounded-lg border border-gray-300 px-3 py-1 text-xs text-gray-600 hover:bg-gray-50 dark:border-dk-muted dark:text-gray-400 dark:hover:bg-dk-card" @click="cancelAvatar">
{{ t('settings.cancel') }}
</button>
</div>
</div>
<h3 class="mb-4 text-sm font-semibold uppercase text-gray-400 tracking-wider dark:text-gray-500">Profile</h3>
<!-- Form -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('settings.name') }}</label>
<input
v-model="form.username"
type="text"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.username ? 'border-red-500' : 'border-gray-300'"
/>
<span v-if="errors.username" class="mt-1 block text-xs text-red-500">{{ errors.username }}</span>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('settings.remark') }}</label>
<input
v-model="form.remark"
type="text"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.remark ? 'border-red-500' : 'border-gray-300'"
/>
<span v-if="errors.remark" class="mt-1 block text-xs text-red-500">{{ errors.remark }}</span>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('settings.birthday') }}</label>
<input
v-model="form.birthday"
type="date"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.birthday ? 'border-red-500' : 'border-gray-300'"
/>
<span v-if="errors.birthday" class="mt-1 block text-xs text-red-500">{{ errors.birthday }}</span>
</div>
</div>
<div class="mt-6">
<button
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500/20 focus:outline-none disabled:active:scale-100"
:disabled="loading"
@click="handleSave"
>
{{ t('settings.save_changes') }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,81 @@
<script setup>
import { reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { useValidation, isValidEmail } from '@/composables'
import { authApi } from '@/api/auth'
import SettingNav from '@/components/SettingNav.vue'
usePageTitle('settings.contact_information')
const { t } = useI18n()
const userStore = useUserStore()
const toast = useToastStore()
const { validate, errors, clearErrors } = useValidation()
const form = reactive({ email: '' })
const loading = ref(false)
async function handleChangeEmail() {
clearErrors()
const err = validate('email', form.email, t('message.please_enter_your_email'), isValidEmail)
if (!err) return
loading.value = true
try {
const { errCode } = await authApi.changeEmail(form.email)
switch (errCode) {
case 0:
toast.success(t('message.change_ok'))
await userStore.fetchUserInfo()
break
case -43:
form.email = t('message.this_not_email')
toast.error(t('message.this_not_email'))
break
default:
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
loading.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-5xl px-6 py-6">
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">{{ t('settings.my_account') }}</h2>
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
<SettingNav />
<div class="flex-1 space-y-6">
<h3 class="mb-4 text-sm font-semibold uppercase text-gray-400 tracking-wider dark:text-gray-500">{{ t('settings.email') }}</h3>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<div class="flex-1">
<input
v-model="form.email"
type="email"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.email ? 'border-red-500' : 'border-gray-300'"
:placeholder="t('message.your_email_address')"
@keydown.enter="handleChangeEmail"
/>
<span v-if="errors.email" class="mt-1 block text-xs text-red-500">{{ errors.email }}</span>
</div>
<button
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500/20 focus:outline-none disabled:active:scale-100"
:disabled="loading"
@click="handleChangeEmail"
>
{{ t('settings.change_email') }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,132 @@
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { useValidation } from '@/composables'
import { authApi } from '@/api/auth'
import { IconEye, IconEyeOff } from '@tabler/icons-vue'
import SettingNav from '@/components/SettingNav.vue'
usePageTitle('settings.security_settings')
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const toast = useToastStore()
const { validate, errors, clearErrors } = useValidation()
const form = reactive({
oldpass: '',
newpass: '',
confirm: '',
})
const showPassword = ref(false)
const loading = ref(false)
async function handleChangePassword() {
clearErrors()
const err1 = validate('oldpass', form.oldpass, t('message.type_old_pass'))
const err2 = validate('newpass', form.newpass, t('message.type_new_pass'))
const err3 = validate('confirm', form.confirm, t('message.type_cof_pass'))
if (form.newpass !== form.confirm) {
errors.confirm = t('message.confirm_password_incorrect')
toast.warning(t('message.confirm_password_incorrect'))
}
if (!err1 || !err2 || !err3 || errors.confirm) return
loading.value = true
try {
const { errCode } = await authApi.changePassword(form.oldpass, form.newpass)
switch (errCode) {
case 0:
form.oldpass = ''
form.newpass = ''
form.confirm = ''
toast.success(t('message.change_ok'), 2000)
setTimeout(() => {
userStore.logout()
router.push('/')
}, 2000)
break
case -42:
form.oldpass = t('message.old_pass_incorrect')
toast.error(t('message.old_pass_incorrect'))
break
default:
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
loading.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-5xl px-6 py-6">
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">{{ t('settings.my_account') }}</h2>
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
<SettingNav />
<div class="flex-1 space-y-6">
<div class="mb-4 flex items-center gap-2">
<h3 class="text-sm font-semibold uppercase text-gray-400 tracking-wider dark:text-gray-500">{{ t('settings.password') }}</h3>
<button type="button" class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 disabled:hover:bg-transparent dark:text-gray-500 dark:hover:text-gray-300" @click="showPassword = !showPassword">
<IconEye v-if="!showPassword" :size="16" />
<IconEyeOff v-else :size="16" />
</button>
</div>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<div>
<input
v-model="form.oldpass"
:type="showPassword ? 'text' : 'password'"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.oldpass ? 'border-red-500' : 'border-gray-300'"
:placeholder="t('message.type_old_pass')"
/>
<span v-if="errors.oldpass" class="mt-1 block text-xs text-red-500">{{ errors.oldpass }}</span>
</div>
<div>
<input
v-model="form.newpass"
:type="showPassword ? 'text' : 'password'"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.newpass ? 'border-red-500' : 'border-gray-300'"
:placeholder="t('message.type_new_pass')"
/>
<span v-if="errors.newpass" class="mt-1 block text-xs text-red-500">{{ errors.newpass }}</span>
</div>
<div>
<input
v-model="form.confirm"
:type="showPassword ? 'text' : 'password'"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:class="errors.confirm ? 'border-red-500' : 'border-gray-300'"
:placeholder="t('message.type_cof_pass')"
@keydown.enter="handleChangePassword"
/>
<span v-if="errors.confirm" class="mt-1 block text-xs text-red-500">{{ errors.confirm }}</span>
</div>
</div>
<div class="mt-4">
<button
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500/20 focus:outline-none disabled:active:scale-100"
:disabled="loading"
@click="handleChangePassword"
>
{{ t('settings.set_new_password') }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -1,251 +0,0 @@
<script setup>
import { onMounted, watch, ref } from "vue";
import settingNavigation from "@/components/settingNavigation.vue";
import { useI18n } from "vue-i18n";
import datePicker from "@/components/datePicker.vue";
import imageCropper from "@/components/imageCropper.vue";
import { useUserStore } from "@/stores/user";
import { my_network_func } from "@/my_network_func";
import MyOffcanvas from "@/components/MyOffcanvas.vue";
import { useRouter } from "vue-router";
const mos = ref();
const { t, locale } = useI18n();
const router = useRouter();
const birthday = ref();
const username = ref();
const userremark = ref();
const userStore = useUserStore();
const is_avatar_change = ref(false);
const avatar_temp_url = ref("");
const avatar_canvas=ref();
// 将 Base64 转换为 File 对象
function base64ToFile(base64Data, filename) {
const arr = base64Data.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1] || base64Data);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, { type: mime });
}
function updataInfo() {
let isDataErr = false;
let birthdayValue = birthday.value.datepicker.value;
let usernameValue = username.value.value;
let userremarkValue = userremark.value.value;
username.value?.classList.remove("is-invalid");
userremark.value?.classList.remove("is-invalid");
birthday.value?.datepicker.classList.remove("is-invalid");
if (!usernameValue) {
isDataErr = true;
username.value?.classList.add("is-invalid");
}
if (!userremarkValue) {
isDataErr = true;
userremark.value?.classList.add("is-invalid");
}
if (!birthdayValue) {
isDataErr = true;
birthday.value?.datepicker.classList.add("is-invalid");
}
if (isDataErr) {
//console.log("用户信息有误,无法保存");
return;
}
//检查头像是否需要更新
if(is_avatar_change.value)
{
my_network_func.postflise("/users/updateAvatar",base64ToFile(avatar_temp_url.value,"avatar.png"),(r)=>{
is_avatar_change.value=false
//console.log(r)
switch (r.statusCode) {
case 200:
switch (r.data.err_code) {
case 0:
//mos.value?.showAlert("success", t("message.save_ok"), 1000);
is_avatar_change.value=false
// 更新用户信息到store
//userStore.getUserInfoFromCookie();
break;
default:
mos.value?.showAlert("danger", t("message.server_error"), 5000);
break;
}
break;
default:
mos.value?.showAlert("danger", t("message.network_err"), 5000);
break;
}
})
}
my_network_func.postJson(
"/users/updateInfo",
{
username: usernameValue,
remark: userremarkValue,
birthday: birthdayValue,
},
(r) => {
//console.log(r);
switch (r.statusCode) {
case 200:
switch (r.data.err_code) {
case 0:
mos.value?.showAlert("success", t("message.save_ok"), 1000);
// 更新用户信息到store
userStore.getUserInfoFromCookie();
break;
default:
mos.value?.showAlert("danger", t("message.server_error"), 5000);
break;
}
break;
default:
mos.value?.showAlert("danger", t("message.network_err"), 5000);
break;
}
}
);
}
function rev_avatar_canvas(canvas) {
is_avatar_change.value = true;
avatar_temp_url.value = canvas.toDataURL("image/png");
avatar_canvas.value=canvas
//console.log(url)
}
function cancel_change_avatar(){
is_avatar_change.value=false
}
function functionupdataTitle() {
document.title = "Operations." + t("settings.basic_information");
}
// 监听语言变化,更新标题
watch(locale, () => {
functionupdataTitle();
});
onMounted(() => {
//console.log("account mounted");
//username.value.value="Kevin";
functionupdataTitle();
if (!userStore.isLoggedIn) {
router.push("/login");
}
});
</script>
<template>
<div class="page-wrapper">
<div class="page-header d-print-none">
<div class="container-xl">
<h2 class="page-title">{{ t("settings.my_account") }}</h2>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div class="card">
<div class="row g-0">
<settingNavigation />
<div class="col-12 col-md-9 d-flex flex-column">
<div class="card-body">
<!-- <h2 class="mb-4">{{ t("settings.my_account") }}</h2> -->
<!-- <h3 class="card-title">
{{ t("settings.profile_information") }}
</h3> -->
<div class="row align-items-center">
<div class="col-auto">
<img
:src="
is_avatar_change
? avatar_temp_url
: userStore.getUserAvatarPath()
"
alt=""
class="avatar avatar-xl"
/>
</div>
<!-- <imageCropper /> -->
<div class="col-auto " >
<imageCropper @crop_to_canvas="rev_avatar_canvas"></imageCropper>
<button v-show="is_avatar_change" class="btn btn-outline-secondary " @click="cancel_change_avatar">
{{ t("settings.cancel") }}
</button>
</div>
</div>
<h3 class="card-title mt-4">-</h3>
<div class="row g-3">
<div class="col-md">
<div class="form-label">{{ t("settings.name") }}</div>
<input
ref="username"
type="text"
class="form-control"
:value="
userStore.userInfo ? userStore.userInfo.Username : ''
"
/>
</div>
<div class="col-md">
<div class="form-label">{{ t("settings.remark") }}</div>
<input
ref="userremark"
type="text"
class="form-control"
:value="
userStore.userInfo ? userStore.userInfo.FirstName : ''
"
/>
</div>
<div class="col-md">
<div class="form-label">{{ t("settings.birthday") }}</div>
<datePicker
ref="birthday"
:setdef="userStore.getUserBirthday()"
/>
</div>
<div>
<button class="btn" @click="updataInfo">
{{ t("settings.save_changes") }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<MyOffcanvas ref="mos" />
</template>
@@ -1,135 +0,0 @@
<script setup>
import { onMounted, watch, ref } from "vue";
import settingNavigation from "@/components/settingNavigation.vue";
import { useI18n } from "vue-i18n";
import { useUserStore } from "@/stores/user";
import { my_network_func } from "@/my_network_func";
import MyOffcanvas from "@/components/MyOffcanvas.vue";
import { myfuncs } from "@/myfunc";
import { useRouter } from "vue-router";
const mos = ref();
const { t, locale } = useI18n();
const router = useRouter();
const emailInput = ref();
const userStore = useUserStore();
function changeEmail() {
if (emailInput.value.value == "") {
emailInput.value.classList.add("is-invalid");
mos.value?.showAlert("warning", t("message.please_enter_your_email"), 3000);
return;
} else {
emailInput.value.classList.remove("is-invalid");
}
//判断是否是合法邮箱
if (myfuncs.isValidEmail(emailInput.value.value) == false) {
emailInput.value.classList.add("is-invalid");
mos.value?.showAlert("danger", t("message.this_not_email"), 3000);
return;
} else {
emailInput.value.classList.remove("is-invalid");
}
my_network_func.postJson(
"/users/changeEmail",
{
newemail: emailInput.value.value,
},
(r) => {
switch (r.statusCode) {
case 200:
switch (r.data.err_code) {
case 0:
mos.value?.showAlert("success", t("message.change_ok"), 5000);
// 更新用户信息到store
userStore.getUserInfoFromCookie();
break;
case -43:
emailInput.value.classList.add("is-invalid");
mos.value?.showAlert("danger", t("message.this_not_email"), 3000);
break;
default:
mos.value?.showAlert("danger", t("message.server_error"), 5000);
break;
}
break;
default:
mos.value?.showAlert("danger", t("message.network_err"), 5000);
break;
}
}
);
}
function functionupdataTitle() {
document.title = "Operations." + t("settings.contact_information");
}
// 监听语言变化,更新标题
watch(locale, () => {
functionupdataTitle();
});
onMounted(() => {
//console.log("account mounted");
//username.value.value="Kevin";
functionupdataTitle();
if (!userStore.isLoggedIn) {
router.push("/login");
}
});
</script>
<template>
<div class="page-wrapper">
<div class="page-header d-print-none">
<div class="container-xl">
<h2 class="page-title">{{ t("settings.my_account") }}</h2>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div class="card">
<div class="row g-0">
<settingNavigation />
<div class="col-12 col-md-9 d-flex flex-column">
<div class="card-body">
<h3 class="card-title mt-4">{{ t("settings.email") }}</h3>
<div>
<div class="row g-2">
<div class="col-auto">
<input
ref="emailInput"
type="text"
class="form-control w-auto"
:value="userStore.user.Email"
:placeholder="t('message.your_email_address')"
/>
</div>
<div class="col-auto">
<button class="btn" @click="changeEmail">
{{ t("settings.change_email") }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<MyOffcanvas ref="mos" />
</template>
@@ -1,230 +0,0 @@
<script setup>
import { onMounted, watch, ref } from "vue";
import settingNavigation from "@/components/settingNavigation.vue";
import { useI18n } from "vue-i18n";
import { useUserStore } from "@/stores/user";
import { my_network_func } from "@/my_network_func";
import MyOffcanvas from "@/components/MyOffcanvas.vue";
import { useRouter } from "vue-router";
const mos = ref();
const { t, locale } = useI18n();
const router = useRouter();
const userStore = useUserStore();
const oldPassInput = ref();
const newPassInput = ref();
const cnfPassInput = ref();
const isShowPassword = ref(false);
function togglePasswordVisibility() {
isShowPassword.value = !isShowPassword.value;
}
function changePassword() {
let isDataErr = false;
let oldPass = oldPassInput.value.value;
let newPass = newPassInput.value.value;
let cnfPass = cnfPassInput.value.value;
oldPassInput.value.classList.remove("is-invalid");
newPassInput.value.classList.remove("is-invalid");
cnfPassInput.value.classList.remove("is-invalid");
if (!oldPass) {
isDataErr = true;
oldPassInput.value.classList.add("is-invalid");
}
if (!newPass) {
isDataErr = true;
newPassInput.value.classList.add("is-invalid");
}
if (!cnfPass) {
isDataErr = true;
cnfPassInput.value.classList.add("is-invalid");
}
if (newPass !== cnfPass) {
isDataErr = true;
newPassInput.value.classList.add("is-invalid");
cnfPassInput.value.classList.add("is-invalid");
mos.value?.showAlert(
"warning",
t("message.confirm_password_incorrect"),
3000
);
}
if (isDataErr) {
return;
}
my_network_func.postJson(
"/users/changePassword",
{
oldpass: oldPass,
newpass: newPass,
},
(r) => {
switch (r.statusCode) {
case 200:
switch (r.data.err_code) {
case 0:
// 清空输入框
oldPassInput.value.value = "";
newPassInput.value.value = "";
cnfPassInput.value.value = "";
mos.value?.showAlert(
"success",
t("message.change_ok"),
2000,
() => {
userStore.logout();
router.push("/");
}
);
break;
case -42:
oldPassInput.value.classList.add("is-invalid");
mos.value?.showAlert(
"danger",
t("message.old_pass_incorrect"),
3000
);
break;
default:
mos.value?.showAlert("danger", t("message.server_error"), 5000);
break;
}
break;
default:
mos.value?.showAlert("danger", t("message.network_err"), 5000);
break;
}
}
);
}
function functionupdataTitle() {
document.title = "Operations." + t("settings.security_settings");
}
// 监听语言变化,更新标题
watch(locale, () => {
functionupdataTitle();
});
onMounted(() => {
//console.log("account mounted");
//username.value.value="Kevin";
functionupdataTitle();
if (!userStore.isLoggedIn) {
router.push("/login");
}
});
</script>
<template>
<div class="page-wrapper">
<div class="page-header d-print-none">
<div class="container-xl">
<h2 class="page-title">{{ t("settings.my_account") }}</h2>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div class="card">
<div class="row g-0">
<settingNavigation />
<div class="col-12 col-md-9 d-flex flex-column">
<div class="card-body">
<h3 class="card-title mt-4">
{{ t("settings.password") }}
<!-- Download SVG icon from http://tabler-icons.io/i/eye -->
<svg
v-if="!isShowPassword"
@click="togglePasswordVisibility"
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path
d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6"
/>
</svg>
<svg
v-if="isShowPassword"
@click="togglePasswordVisibility"
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
<path
d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87"
/>
<path d="M3 3l18 18" />
</svg>
</h3>
<input
ref="oldPassInput"
:type="isShowPassword ? 'text' : 'password'"
class="form-control w-auto mb-1"
:placeholder="t('message.type_old_pass')"
/>
<input
ref="newPassInput"
:type="isShowPassword ? 'text' : 'password'"
class="form-control w-auto mb-1"
:placeholder="t('message.type_new_pass')"
/>
<input
ref="cnfPassInput"
:type="isShowPassword ? 'text' : 'password'"
class="form-control w-auto mb-1"
:placeholder="t('message.type_cof_pass')"
/>
<div>
<button class="btn" @click="changePassword">
{{ t("settings.set_new_password") }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<MyOffcanvas ref="mos" />
</template>
-11
View File
@@ -1,11 +0,0 @@
<script setup>
import useDropzone from '@/components/useDropzone.vue';
</script>
<template>
<useDropzone></useDropzone>
<!-- <useDropzoneBootstrap></useDropzoneBootstrap>
<useFilePond></useFilePond> -->
</template>
@@ -1,72 +0,0 @@
<template>
<div class="page-wrapper">
<!-- Page header -->
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<h2 class="page-title">Cards</h2>
</div>
</div>
</div>
</div>
<!-- Page body -->
<div class="page-body">
<div class="container-xl">
<div class="row row-cards">
<div class="col-md-6 col-lg-3">
<div class="card">
<!-- Photo -->
<div
class="img-responsive img-responsive-21x9 card-img-top"
style="
background-image: url(./static/photos/home-office-desk-with-macbook-iphone-calendar-watch-and-organizer.jpg);
"
></div>
<div class="card-body">
<h3 class="card-title">Card with top image</h3>
<p class="text-secondary card_text">
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
</p>
</div>
</div>
</div>
<div class="col-md-6 col-lg-3">
<div class="card">
<!-- Photo -->
<div
class="img-responsive img-responsive-21x9 card-img-top"
style="
background-image: url(./static/photos/home-office-desk-with-macbook-iphone-calendar-watch-and-organizer.jpg);
"
></div>
<div class="card-body">
<h3 class="card-title">Card with top image</h3>
<p class="text-secondary card_text">
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
Aperiam deleniti fugit incidunt, iste, itaque minima neque
pariatur perferendis sed suscipit velit vitae voluptatem. Lorem, ipsum dolor sit amet consectetur adipisicing elit. Illo optio et fuga omnis ipsa, odit repellendus iste doloremque est, nam eius quisquam perspiciatis deserunt. Quasi tempore velit architecto corporis voluptatibus!
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.card_text{
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2; /* 限制显示4行 */
overflow: hidden;
line-height: 1.5;
min-height: calc(1.5em * 2); /* 最小高度 */
}
</style>
+4 -11
View File
@@ -3,32 +3,25 @@ import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vueDevTools from "vite-plugin-vue-devtools";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), vueDevTools()],
plugins: [vue(), vueDevTools(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
build: {
outDir: "../../backend/dist", // 默认是 'dist',可以修改为你想要的目录名
assetsDir: "assets", // 静态资源目录(相对于 outDir)
outDir: "../../backend/dist",
assetsDir: "assets",
},
server: {
proxy: {
"/api": {
target: "http://127.0.0.1:8080",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, "/api"), // 如果需要重写路径
// 如果后端接口没有 /api 前缀,可以这样写:
// rewrite: (path) => path.replace(/^\/api/, '')
// 设置代理超时配置
// proxyTimeout: 30000, // 代理服务器等待目标服务器响应的超时时间(毫秒)
// timeout: 30000, // 整个请求的超时时间(毫秒)
// connectTimeout: 30000, // 连接超时(毫秒,某些版本支持)
},
},
},