20260721173401
This commit is contained in:
1 parent
fd161dd302
commit
bab8e2972f
32 files changed
+1207
-3171
No files matched your search
@@ -0,0 +1,104 @@
|
||||
# CODEBUDDY.md
|
||||
|
||||
This file provides guidance to CodeBuddy Code when working with code in this repository.
|
||||
|
||||
## Repository layout
|
||||
|
||||
This is a two-part application (FMS — 模块/数据管理平台):
|
||||
|
||||
- `fms-api/` — Spring Boot 4.1 (Java 21, Maven) backend. Serves on port **8088** with context-path **`/api`**.
|
||||
- `fms-vue/` — Vue 3 + Vite 8 (pnpm) frontend. Dev server on port **5180**, proxies `/api` → `http://127.0.0.1:8088`.
|
||||
|
||||
Supporting docs in the repo root (Chinese):
|
||||
- `开发规范.md` — binding development rules (read it; summarized below).
|
||||
- `新数据库结构.md` — the module-management table design (authoritative schema).
|
||||
- `旧数据库结构.md` — legacy schema, kept for reference only.
|
||||
- `模块管理实施计划.md` — phased plan for the module-management feature.
|
||||
- `fms-api/config/migrations/001_module_management.sql` — the only migration script.
|
||||
|
||||
## Commands
|
||||
|
||||
### Backend (`fms-api/`)
|
||||
```bash
|
||||
./mvnw clean package # build jar -> target/fms-api-0.0.1-SNAPSHOT.jar
|
||||
./mvnw test # run all tests (8 test classes under src/test)
|
||||
./mvnw test -Dtest=ClassName # run a single test class
|
||||
./mvnw test -Dtest=ClassName#methodName # run a single test method
|
||||
./mvnw spring-boot:run # run the dev server (or use start.cmd)
|
||||
java -jar target/fms-api-0.0.1-SNAPSHOT.jar # run built jar
|
||||
```
|
||||
JWT secret is read from env `FMS_JWT_SECRET` (defaults to a dev value in `application.yaml`).
|
||||
|
||||
### Frontend (`fms-vue/`)
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # vite dev server on :5180
|
||||
pnpm build # production build
|
||||
pnpm preview # preview built output
|
||||
pnpm fmt # format with oxfmt
|
||||
pnpm fmt:check # check formatting
|
||||
```
|
||||
There is **no lint or test script** in the frontend.
|
||||
|
||||
### Build/test discipline (from `开发规范.md`)
|
||||
Do **not** run build/test/start yourself after finishing a change. Only build, test, or start services when the user explicitly asks. When delivering, state that build/test were not executed.
|
||||
|
||||
## Backend architecture
|
||||
|
||||
The defining characteristic of this backend is that it has **almost no business-specific endpoints**. Almost all data access goes through a small set of generic endpoints in `DataController`, implemented by `DataService` / `DataSaveService`. There are no entity/DAO/mapper layers — `utils/DbUtils` does raw JDBC against SQL Server.
|
||||
|
||||
Generic data endpoints (full path includes the `/api` context-path):
|
||||
- `POST /api/data/loaddata` — load rows from a `view_name` with optional search/order (`DataService.loadData`).
|
||||
- `POST /api/data/page` — paginated load (`page_no`, `page_size`, `view_name`, `order_by`).
|
||||
- `POST /api/data/saveobjt` — batch upsert across multiple tables in one transaction (`DataSaveService.save`). Request body is a JSON array of `{ table, key_field, inserts[], updates[], deletes[] }`; the whole request rolls back if any table fails.
|
||||
- `GET /api/data/nextid` — Snowflake ID from `utils/snowflake/IdGenerator`.
|
||||
- `POST /api/data/loaddatabysql` — runs an arbitrary SQL string. **Avoid this**; it exists but new feature work should prefer the structured endpoints.
|
||||
- `POST /api/auth/login` — `AuthController` / `AuthService`.
|
||||
|
||||
Key rules when working on the backend:
|
||||
- Prefer the generic endpoints for query/paging/save/ID-generation. Do **not** modify their URL, parameters, response shape, or logic, and do **not** add new business-specific endpoints, unless you first explain why the generic API cannot do it and get user confirmation.
|
||||
- `b_id` values that are `bigint` snowflake IDs must be serialized to the frontend as **strings**.
|
||||
|
||||
### Multi-organization database routing
|
||||
Connections are per-organization SQL Server pools (Druid). The active org is selected at request time:
|
||||
- `config/dbconfigs/<ORG_ID>.properties` holds `url`/`username`/`password`/`driver` for one org (e.g. `g3hd.properties` for org `G3HD`). **These files are git-ignored** and must not be committed. The `config-dir` is set by `fms.database.config-dir` in `application.yaml` (default `./config/dbconfigs`).
|
||||
- `database/OrgDatabaseConfigLoader` loads a properties file by org id; `OrgDataSourceManager` caches one `DruidDataSource` per org.
|
||||
- `config/JwtAuthFilter` parses the JWT, then `database/OrgContext` holds the current `orgId` so `DbUtils` targets the right datasource (`OrgRoutingDataSource`).
|
||||
- New features operate on the `g3hd.properties` database (`fms`); do not touch the legacy database.
|
||||
|
||||
### Auth / JWT
|
||||
- `config/JwtAuthFilter` (skips `/auth/login`) validates `Authorization: Bearer`, checks `ActiveSessionRegistry` (single-device enforcement), and sets `OrgContext.orgId`.
|
||||
- `utils/JwtUtils` (jjwt) puts `orgid` / `sessionId` claims. `config/AuthProperties` reads `fms.auth.jwt-secret` / `fms.auth.jwt-expiration`.
|
||||
- Login response returns `id`, `account`, `name`; the JWT session identifier stays the account.
|
||||
|
||||
## Frontend architecture
|
||||
|
||||
Vue 3 `<script setup>` SFCs, Pinia for state, **antdv-next** components (auto-imported via `@antdv-next/auto-import-resolver` in `vite.config.js` — do not manually import antdv-next components that the resolver handles). There is **no vue-i18n**; i18n is custom and DB-driven.
|
||||
|
||||
### API service layer
|
||||
`src/services/api.js` (built on axios wrapper `src/services/http.js`, baseURL `/api`) exposes:
|
||||
- `loadDataApi` → `POST /data/loaddata`
|
||||
- `pageDataApi` → `POST /data/page`
|
||||
- `saveObjectApi` → `POST /data/saveobjt`
|
||||
- `nextIdApi` → `GET /data/nextid`
|
||||
- `loadDataBySqlApi` → `POST /data/loaddatabysql` (avoid)
|
||||
- `loginApi` → `POST /auth/login`
|
||||
|
||||
Feature code should call these, or wrap them in a dedicated service like `src/services/moduleManagement.js` (e.g. `loadModuleConfiguration`, `nextIds`, `saveChanges`). Do not introduce new backend URLs from the frontend — reuse the generic endpoints.
|
||||
|
||||
### Routing & permissions
|
||||
- `src/router/index.js` registers view components via **static explicit `import()`**, not a dynamic whitelist. Each route carries `meta.moduleCode` (e.g. `M_MODULE`), plus `menuKey`, `title`, `requiresAuth`.
|
||||
- A `beforeEach` guard checks auth, then `permissionStore.canModule(to.meta.moduleCode)`. `src/stores/permissions.js` loads `b_user_module` / `b_user_power` / `b_module_power` and gates page entry and button enable/disable. Currently permission enforcement is **frontend-only**; backend interception is not yet implemented.
|
||||
- `src/layouts/components/AppSidebar.vue` still uses a hardcoded `fallbackMenuItems` array. It should fall back to this static menu when the generic-interface module load fails.
|
||||
|
||||
### State & i18n
|
||||
- Pinia stores: `stores/auth.js` (token/user, persisted), `stores/app.js` (locale, sidebar collapse, visited tabs), `stores/permissions.js` (module/power grants).
|
||||
- i18n: `app` store holds `locale` (default `zh-CN`); translation text comes from the DB `b_i18n` table (loaded via `loadModuleConfiguration`), not vue-i18n.
|
||||
|
||||
### Module management feature
|
||||
`src/views/module-management/` holds `ModuleManagementView.vue` plus `components/` panels (`ModuleTreePanel`, `ModuleBasicInfoPanel`, `ModuleFieldDefinitionPanel`, `ModulePowerPanel`, `ModuleTranslationsPanel`). Demo data lives in `src/data/moduleDemo.js` — the real page must not depend on it. The table design (six tables: `b_module`, `b_module_field`, `b_module_power`, `b_user_module`, `b_user_power`, `b_i18n`) is in `新数据库结构.md`; follow that schema exactly. `b_module.b_id` is the module code (e.g. `M_BIZ`), immutable after creation; other `b_id`s are snowflake bigints returned as strings.
|
||||
|
||||
## Cross-cutting conventions
|
||||
- Reuse existing components and antdv-next; extract shared logic into components/composables/services only when it genuinely reduces duplication. Keep new CSS scoped to the component.
|
||||
- Changes should stay scoped to the current task — no unrelated refactors or formatting. Confirm impact before touching generic endpoints, shared components, or global styles. Preserve existing functionality and the user's uncommitted work.
|
||||
- Boolean columns are `int` `0/1`; `b_xh` is display order (smaller = first). Table/column names must be backend-validated identifiers — never concatenated into SQL.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"experimentalDecorators": true,
|
||||
"lib": ["esnext", "dom", "dom.iterable"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "build"]
|
||||
}
|
||||
@@ -12,11 +12,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.24.0",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"antdv-next": "^1.4.2",
|
||||
"axios": "^1.18.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^4.0.2",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"stk-table-vue": "^1.0.0",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
@@ -25,6 +27,7 @@
|
||||
"@antdv-next/tailwind": "^1.1.1",
|
||||
"@tailwindcss/vite": "4.3.2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"less": "^4.7.0",
|
||||
"oxfmt": "^0.58.0",
|
||||
"sass": "^1.101.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
|
||||
Generated
+228
-20
@@ -11,6 +11,9 @@ importers:
|
||||
'@lucide/vue':
|
||||
specifier: ^1.24.0
|
||||
version: 1.24.0(vue@3.5.39)
|
||||
'@vueuse/core':
|
||||
specifier: ^14.3.0
|
||||
version: 14.3.0(vue@3.5.39)
|
||||
antdv-next:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(vue@3.5.39)
|
||||
@@ -26,25 +29,31 @@ importers:
|
||||
pinia-plugin-persistedstate:
|
||||
specifier: ^4.7.1
|
||||
version: 4.7.1(pinia@4.0.2(@vue/devtools-api@8.1.5)(vue@3.5.39))
|
||||
stk-table-vue:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
vue:
|
||||
specifier: ^3.5.39
|
||||
version: 3.5.39
|
||||
vue-router:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(@vue/compiler-sfc@3.5.39)(pinia@4.0.2(@vue/devtools-api@8.1.5)(vue@3.5.39))(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
|
||||
version: 5.2.0(@vue/compiler-sfc@3.5.39)(pinia@4.0.2(@vue/devtools-api@8.1.5)(vue@3.5.39))(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
|
||||
devDependencies:
|
||||
'@antdv-next/auto-import-resolver':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(unplugin-vue-components@32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39))
|
||||
version: 1.1.0(unplugin-vue-components@32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39))
|
||||
'@antdv-next/tailwind':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(tailwindcss@4.3.2)
|
||||
'@tailwindcss/vite':
|
||||
specifier: 4.3.2
|
||||
version: 4.3.2(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))
|
||||
version: 4.3.2(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.7
|
||||
version: 6.0.7(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
|
||||
version: 6.0.7(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
|
||||
less:
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0
|
||||
oxfmt:
|
||||
specifier: ^0.58.0
|
||||
version: 0.58.0
|
||||
@@ -59,10 +68,10 @@ importers:
|
||||
version: 21.0.0(@vueuse/core@14.3.0(vue@3.5.39))
|
||||
unplugin-vue-components:
|
||||
specifier: ^32.1.0
|
||||
version: 32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
|
||||
version: 32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
|
||||
vite:
|
||||
specifier: ^8.1.1
|
||||
version: 8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)
|
||||
version: 8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -936,12 +945,32 @@ packages:
|
||||
confbox@0.2.4:
|
||||
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
|
||||
|
||||
copy-anything@3.0.5:
|
||||
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
|
||||
engines: {node: '>=12.13'}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
dayjs@1.11.21:
|
||||
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
|
||||
|
||||
debug@2.6.9:
|
||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@3.2.7:
|
||||
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -974,6 +1003,10 @@ packages:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
errno@0.1.8:
|
||||
resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==}
|
||||
hasBin: true
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1070,6 +1103,14 @@ packages:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
immutable@5.1.9:
|
||||
resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==}
|
||||
|
||||
@@ -1081,6 +1122,10 @@ packages:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-what@4.1.16:
|
||||
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
|
||||
engines: {node: '>=12.13'}
|
||||
|
||||
jiti@2.7.0:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
@@ -1098,6 +1143,11 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
less@4.7.0:
|
||||
resolution: {integrity: sha512-i7dAlT3+boO0mMh1G4cex0vz1lLAScmBbikm1VEDNv+cy0ore1CUo2UtX8m3N9QLE5WYDr4ISbiCRzHNGyFkrA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -1176,6 +1226,9 @@ packages:
|
||||
resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -1183,6 +1236,10 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
make-dir@5.1.0:
|
||||
resolution: {integrity: sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1195,9 +1252,17 @@ packages:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime@1.6.0:
|
||||
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
mlly@1.8.2:
|
||||
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
||||
|
||||
ms@2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -1209,6 +1274,16 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
needle@2.9.1:
|
||||
resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==}
|
||||
engines: {node: '>= 4.4.x'}
|
||||
hasBin: true
|
||||
|
||||
needle@3.5.0:
|
||||
resolution: {integrity: sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==}
|
||||
engines: {node: '>= 4.4.x'}
|
||||
hasBin: true
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
@@ -1235,6 +1310,10 @@ packages:
|
||||
vite-plus:
|
||||
optional: true
|
||||
|
||||
parse-node-version@1.0.1:
|
||||
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
@@ -1282,10 +1361,16 @@ packages:
|
||||
resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
probe-image-size@7.3.0:
|
||||
resolution: {integrity: sha512-7CaDeBwiAbh6ohXsvLbAZhO7wzsZAmaevfxe39qvCwRh8LyaZfDlBGGLU1CCTgrTLtCOdwBBhjOrIHaIIimHfQ==}
|
||||
|
||||
proxy-from-env@2.1.0:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
prr@1.0.1:
|
||||
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
|
||||
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
@@ -1301,11 +1386,18 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
sass@1.101.0:
|
||||
resolution: {integrity: sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
hasBin: true
|
||||
|
||||
sax@1.6.0:
|
||||
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
|
||||
scroll-into-view-if-needed@3.1.0:
|
||||
resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==}
|
||||
|
||||
@@ -1316,6 +1408,16 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
source-map@0.6.1:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stk-table-vue@1.0.0:
|
||||
resolution: {integrity: sha512-K6YvwicGfQPeKImIc4MBMD3Hw2E45vrxmKX103fPCdgeT8PEq1Da643IeMWW9dFz1VD9XV/hMUJXow/EZYW1sw==}
|
||||
|
||||
stream-parser@0.3.1:
|
||||
resolution: {integrity: sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==}
|
||||
|
||||
strip-literal@3.1.0:
|
||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||
|
||||
@@ -1509,9 +1611,9 @@ snapshots:
|
||||
|
||||
'@ant-design/icons-svg@4.5.0': {}
|
||||
|
||||
'@antdv-next/auto-import-resolver@1.1.0(unplugin-vue-components@32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39))':
|
||||
'@antdv-next/auto-import-resolver@1.1.0(unplugin-vue-components@32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39))':
|
||||
dependencies:
|
||||
unplugin-vue-components: 32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
|
||||
unplugin-vue-components: 32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)
|
||||
|
||||
'@antdv-next/cssinjs@1.0.6(vue@3.5.39)':
|
||||
dependencies:
|
||||
@@ -1852,12 +1954,12 @@ snapshots:
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.2
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.3.2
|
||||
|
||||
'@tailwindcss/vite@4.3.2(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))':
|
||||
'@tailwindcss/vite@4.3.2(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.3.2
|
||||
'@tailwindcss/oxide': 4.3.2
|
||||
tailwindcss: 4.3.2
|
||||
vite: 8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)
|
||||
vite: 8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0)
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
dependencies:
|
||||
@@ -2112,10 +2214,10 @@ snapshots:
|
||||
'@v-c/util': 1.0.21(vue@3.5.39)
|
||||
vue: 3.5.39
|
||||
|
||||
'@vitejs/plugin-vue@6.0.7(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)':
|
||||
'@vitejs/plugin-vue@6.0.7(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39)':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)
|
||||
vite: 8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0)
|
||||
vue: 3.5.39
|
||||
|
||||
'@vue-macros/common@3.1.3(vue@3.5.39)':
|
||||
@@ -2315,10 +2417,24 @@ snapshots:
|
||||
|
||||
confbox@0.2.4: {}
|
||||
|
||||
copy-anything@3.0.5:
|
||||
dependencies:
|
||||
is-what: 4.1.16
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
dayjs@1.11.21: {}
|
||||
|
||||
debug@2.6.9:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
optional: true
|
||||
|
||||
debug@3.2.7:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -2342,6 +2458,11 @@ snapshots:
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
errno@0.1.8:
|
||||
dependencies:
|
||||
prr: 1.0.1
|
||||
optional: true
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
@@ -2429,6 +2550,16 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
optional: true
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
optional: true
|
||||
|
||||
immutable@5.1.9: {}
|
||||
|
||||
is-extglob@2.1.1:
|
||||
@@ -2439,6 +2570,8 @@ snapshots:
|
||||
is-extglob: 2.1.1
|
||||
optional: true
|
||||
|
||||
is-what@4.1.16: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
@@ -2447,6 +2580,21 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
less@4.7.0:
|
||||
dependencies:
|
||||
copy-anything: 3.0.5
|
||||
parse-node-version: 1.0.1
|
||||
optionalDependencies:
|
||||
errno: 0.1.8
|
||||
graceful-fs: 4.2.11
|
||||
make-dir: 5.1.0
|
||||
mime: 1.6.0
|
||||
needle: 3.5.0
|
||||
probe-image-size: 7.3.0
|
||||
source-map: 0.6.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
@@ -2502,6 +2650,9 @@ snapshots:
|
||||
pkg-types: 2.3.1
|
||||
quansync: 0.2.11
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
optional: true
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
dependencies:
|
||||
magic-string: 0.30.21
|
||||
@@ -2510,6 +2661,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
make-dir@5.1.0:
|
||||
optional: true
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
@@ -2518,6 +2672,9 @@ snapshots:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mime@1.6.0:
|
||||
optional: true
|
||||
|
||||
mlly@1.8.2:
|
||||
dependencies:
|
||||
acorn: 8.17.0
|
||||
@@ -2525,12 +2682,30 @@ snapshots:
|
||||
pkg-types: 1.3.1
|
||||
ufo: 1.6.4
|
||||
|
||||
ms@2.0.0:
|
||||
optional: true
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
nanoid@3.3.16: {}
|
||||
|
||||
needle@2.9.1:
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
iconv-lite: 0.4.24
|
||||
sax: 1.6.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
needle@3.5.0:
|
||||
dependencies:
|
||||
iconv-lite: 0.6.3
|
||||
sax: 1.6.0
|
||||
optional: true
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
optional: true
|
||||
|
||||
@@ -2564,6 +2739,8 @@ snapshots:
|
||||
'@oxfmt/binding-win32-ia32-msvc': 0.58.0
|
||||
'@oxfmt/binding-win32-x64-msvc': 0.58.0
|
||||
|
||||
parse-node-version@1.0.1: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
perfect-debounce@2.1.0: {}
|
||||
@@ -2602,8 +2779,20 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
probe-image-size@7.3.0:
|
||||
dependencies:
|
||||
lodash.merge: 4.6.2
|
||||
needle: 2.9.1
|
||||
stream-parser: 0.3.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
prr@1.0.1:
|
||||
optional: true
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
@@ -2631,6 +2820,9 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.1.5
|
||||
'@rolldown/binding-win32-x64-msvc': 1.1.5
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
optional: true
|
||||
|
||||
sass@1.101.0:
|
||||
dependencies:
|
||||
chokidar: 5.0.0
|
||||
@@ -2639,6 +2831,9 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@parcel/watcher': 2.5.6
|
||||
|
||||
sax@1.6.0:
|
||||
optional: true
|
||||
|
||||
scroll-into-view-if-needed@3.1.0:
|
||||
dependencies:
|
||||
compute-scroll-into-view: 3.1.1
|
||||
@@ -2647,6 +2842,18 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map@0.6.1:
|
||||
optional: true
|
||||
|
||||
stk-table-vue@1.0.0: {}
|
||||
|
||||
stream-parser@0.3.1:
|
||||
dependencies:
|
||||
debug: 2.6.9
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
strip-literal@3.1.0:
|
||||
dependencies:
|
||||
js-tokens: 9.0.1
|
||||
@@ -2704,7 +2911,7 @@ snapshots:
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.5
|
||||
|
||||
unplugin-vue-components@32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39):
|
||||
unplugin-vue-components@32.1.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39):
|
||||
dependencies:
|
||||
chokidar: 5.0.0
|
||||
local-pkg: 1.2.1
|
||||
@@ -2713,7 +2920,7 @@ snapshots:
|
||||
obug: 2.1.3
|
||||
picomatch: 4.0.5
|
||||
tinyglobby: 0.2.17
|
||||
unplugin: 3.3.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))
|
||||
unplugin: 3.3.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))
|
||||
unplugin-utils: 0.3.2
|
||||
vue: 3.5.39
|
||||
transitivePeerDependencies:
|
||||
@@ -2734,16 +2941,16 @@ snapshots:
|
||||
picomatch: 4.0.5
|
||||
webpack-virtual-modules: 0.6.2
|
||||
|
||||
unplugin@3.3.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)):
|
||||
unplugin@3.3.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
picomatch: 4.0.5
|
||||
webpack-virtual-modules: 0.6.2
|
||||
optionalDependencies:
|
||||
rolldown: 1.1.5
|
||||
vite: 8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)
|
||||
vite: 8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0)
|
||||
|
||||
vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0):
|
||||
vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.5
|
||||
@@ -2753,10 +2960,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
less: 4.7.0
|
||||
sass: 1.101.0
|
||||
yaml: 2.9.0
|
||||
|
||||
vue-router@5.2.0(@vue/compiler-sfc@3.5.39)(pinia@4.0.2(@vue/devtools-api@8.1.5)(vue@3.5.39))(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39):
|
||||
vue-router@5.2.0(@vue/compiler-sfc@3.5.39)(pinia@4.0.2(@vue/devtools-api@8.1.5)(vue@3.5.39))(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.39):
|
||||
dependencies:
|
||||
'@babel/generator': 8.0.0
|
||||
'@vue-macros/common': 3.1.3(vue@3.5.39)
|
||||
@@ -2773,14 +2981,14 @@ snapshots:
|
||||
picomatch: 4.0.5
|
||||
scule: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
unplugin: 3.3.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))
|
||||
unplugin: 3.3.0(rolldown@1.1.5)(vite@8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0))
|
||||
unplugin-utils: 0.3.2
|
||||
vue: 3.5.39
|
||||
yaml: 2.9.0
|
||||
optionalDependencies:
|
||||
'@vue/compiler-sfc': 3.5.39
|
||||
pinia: 4.0.2(@vue/devtools-api@8.1.5)(vue@3.5.39)
|
||||
vite: 8.1.4(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)
|
||||
vite: 8.1.4(jiti@2.7.0)(less@4.7.0)(sass@1.101.0)(yaml@2.9.0)
|
||||
transitivePeerDependencies:
|
||||
- '@farmfe/core'
|
||||
- '@rspack/core'
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
bordered: { type: Boolean, default: true },
|
||||
scroll: { type: Object, default: () => ({}) },
|
||||
scrollX: { type: [Boolean, Number, String], default: "max-content" },
|
||||
});
|
||||
|
||||
const tableAreaRef = ref(null);
|
||||
const bodyHeight = ref(240);
|
||||
const mergedScroll = computed(() => ({
|
||||
...props.scroll,
|
||||
x: props.scrollX,
|
||||
y: bodyHeight.value,
|
||||
}));
|
||||
|
||||
let resizeObserver;
|
||||
let mutationObserver;
|
||||
let frameId = 0;
|
||||
const observedElements = new Set();
|
||||
|
||||
function getOuterHeight(element) {
|
||||
if (!element) return 0;
|
||||
|
||||
const style = getComputedStyle(element);
|
||||
const marginTop = Number.parseFloat(style.marginTop) || 0;
|
||||
const marginBottom = Number.parseFloat(style.marginBottom) || 0;
|
||||
|
||||
return element.getBoundingClientRect().height + marginTop + marginBottom;
|
||||
}
|
||||
|
||||
function clipsOverflowY(element) {
|
||||
const { overflow, overflowY } = getComputedStyle(element);
|
||||
return [overflow, overflowY].some((value) => /^(auto|clip|hidden|scroll)$/.test(value));
|
||||
}
|
||||
|
||||
function getAvailableHeight(area) {
|
||||
const areaTop = area.getBoundingClientRect().top;
|
||||
let boundaryBottom = Number.POSITIVE_INFINITY;
|
||||
let ancestor = area.parentElement;
|
||||
|
||||
while (ancestor && ancestor !== document.documentElement) {
|
||||
if (clipsOverflowY(ancestor)) {
|
||||
const rect = ancestor.getBoundingClientRect();
|
||||
boundaryBottom = Math.min(boundaryBottom, rect.top + ancestor.clientTop + ancestor.clientHeight);
|
||||
}
|
||||
ancestor = ancestor.parentElement;
|
||||
}
|
||||
|
||||
if (Number.isFinite(boundaryBottom) && boundaryBottom > areaTop) {
|
||||
return Math.floor(boundaryBottom - areaTop);
|
||||
}
|
||||
return area.clientHeight;
|
||||
}
|
||||
|
||||
function updateBodyHeight() {
|
||||
const area = tableAreaRef.value;
|
||||
if (!area?.clientHeight) return;
|
||||
|
||||
const headerHeight = getOuterHeight(
|
||||
area.querySelector(".ant-table-header") || area.querySelector(".ant-table-thead"),
|
||||
);
|
||||
const paginationHeight = getOuterHeight(area.querySelector(".ant-table-pagination"));
|
||||
const titleHeight = getOuterHeight(area.querySelector(".ant-table-title"));
|
||||
const footerHeight = getOuterHeight(area.querySelector(".ant-table-footer"));
|
||||
|
||||
const nextHeight = Math.max(
|
||||
1,
|
||||
getAvailableHeight(area) - headerHeight - paginationHeight - titleHeight - footerHeight,
|
||||
);
|
||||
if (nextHeight !== bodyHeight.value) bodyHeight.value = nextHeight;
|
||||
}
|
||||
|
||||
function scheduleHeightUpdate() {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = requestAnimationFrame(updateBodyHeight);
|
||||
}
|
||||
|
||||
function observe(element) {
|
||||
if (!element || observedElements.has(element)) return;
|
||||
resizeObserver?.observe(element);
|
||||
observedElements.add(element);
|
||||
}
|
||||
|
||||
function syncObservedElements() {
|
||||
const area = tableAreaRef.value;
|
||||
if (!area) return;
|
||||
|
||||
let ancestor = area.parentElement;
|
||||
while (ancestor && ancestor !== document.documentElement) {
|
||||
if (ancestor === area.parentElement || clipsOverflowY(ancestor)) observe(ancestor);
|
||||
ancestor = ancestor.parentElement;
|
||||
}
|
||||
observe(area.querySelector(".ant-table-header"));
|
||||
observe(area.querySelector(".ant-table-pagination"));
|
||||
observe(area.querySelector(".ant-table-title"));
|
||||
observe(area.querySelector(".ant-table-footer"));
|
||||
scheduleHeightUpdate();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
resizeObserver = new ResizeObserver(scheduleHeightUpdate);
|
||||
mutationObserver = new MutationObserver(() => {
|
||||
nextTick(syncObservedElements);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
syncObservedElements();
|
||||
mutationObserver.observe(tableAreaRef.value, { childList: true, subtree: true });
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(frameId);
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
observedElements.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="tableAreaRef"
|
||||
class="auto-height-table"
|
||||
:style="{ '--auto-table-body-height': bodyHeight + 'px' }"
|
||||
>
|
||||
<a-table v-bind="$attrs" :bordered="bordered" :scroll="mergedScroll" size="small">
|
||||
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
|
||||
<slot :name="name" v-bind="slotProps || {}" />
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auto-height-table {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-table-wrapper),
|
||||
:deep(.ant-spin-nested-loading),
|
||||
:deep(.ant-spin-container),
|
||||
:deep(.ant-table),
|
||||
:deep(.ant-table-container) {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-table-wrapper) {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-spin-nested-loading),
|
||||
:deep(.ant-spin-container),
|
||||
:deep(.ant-table),
|
||||
:deep(.ant-table-container) {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-table-header),
|
||||
:deep(.ant-table-pagination) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
:deep(.ant-table-body) {
|
||||
height: var(--auto-table-body-height);
|
||||
min-height: 0;
|
||||
max-height: var(--auto-table-body-height) !important;
|
||||
flex: 0 0 var(--auto-table-body-height);
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* scroll.y 模式下 antdv 不会渲染 body 底部外边框;且直接加在滚动容器 .ant-table-body
|
||||
上时,边框会被内部滚动内容的最后一行盖住、或被祖先 overflow:hidden 裁掉,表现为
|
||||
“看不到/被遮挡”。所以把底边框挂在不滚动的 .ant-table-container 上,并强制 border-box
|
||||
让边框计入盒子高度,避免溢出裁切。 */
|
||||
::deep(.ant-table-container),
|
||||
::deep(.ant-table-body) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
::deep(.ant-table-bordered .ant-table-container) {
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary, #f0f0f0);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useLookupOptions } from "./select/useLookupOptions";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: "" },
|
||||
labelKey: { type: String, default: "b_name" },
|
||||
valueKey: { type: String, default: "b_id" },
|
||||
viewName: { type: String, required: true },
|
||||
searchCondition: { type: String, default: "" },
|
||||
searchColumns: { type: Array, default: () => [] },
|
||||
orderBy: { type: String, default: "" },
|
||||
pageSize: { type: Number, default: 20 },
|
||||
searchDelay: { type: Number, default: 300 },
|
||||
allowClear: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "change"]);
|
||||
const lookup = useLookupOptions(props);
|
||||
|
||||
const options = computed(() =>
|
||||
lookup.rows.value.map((row) => ({
|
||||
value: String(row[props.labelKey] ?? ""),
|
||||
label: String(row[props.labelKey] ?? ""),
|
||||
key: String(row[props.valueKey] ?? row[props.labelKey] ?? ""),
|
||||
record: row,
|
||||
})),
|
||||
);
|
||||
|
||||
function onOpenChange(open) {
|
||||
if (open) lookup.open();
|
||||
else lookup.resetAfterClose();
|
||||
}
|
||||
|
||||
function onChange(value) {
|
||||
emit("update:modelValue", String(value ?? ""));
|
||||
if (!value) emit("change", null);
|
||||
}
|
||||
|
||||
function onSelect(_value, option) {
|
||||
emit("change", option.record ?? null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-auto-complete
|
||||
v-bind="$attrs"
|
||||
class="fms-auto-complete"
|
||||
:value="modelValue"
|
||||
:options="options"
|
||||
:show-search="{ onSearch: lookup.search }"
|
||||
:not-found-content="lookup.loading.value ? '加载中...' : '暂无数据'"
|
||||
:allow-clear="allowClear"
|
||||
@change="onChange"
|
||||
@focus="lookup.open"
|
||||
@open-change="onOpenChange"
|
||||
@popup-scroll="lookup.onPopupScroll"
|
||||
@select="onSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fms-auto-complete {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: "" },
|
||||
disabled: { type: Boolean, default: false },
|
||||
readonly: { type: Boolean, default: false },
|
||||
allowClear: { type: Boolean, default: true },
|
||||
maxlength: { type: Number, default: undefined },
|
||||
popupWidth: { type: [Number, String], default: 420 },
|
||||
popupHeight: { type: [Number, String], default: 180 },
|
||||
placement: { type: String, default: "bottomCenter" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "change", "focus", "blur", "openChange"]);
|
||||
|
||||
const open = ref(false);
|
||||
const inputRef = ref(null);
|
||||
const textareaRef = ref(null);
|
||||
const editorRef = ref(null);
|
||||
const triggerWidth = ref(0);
|
||||
|
||||
const popoverPlacement = computed(() =>
|
||||
props.placement === "bottomCenter" ? "bottom" : props.placement,
|
||||
);
|
||||
const popupWidth = computed(() =>
|
||||
typeof props.popupWidth === "number" ? `${props.popupWidth}px` : props.popupWidth,
|
||||
);
|
||||
const popupHeight = computed(() =>
|
||||
typeof props.popupHeight === "number" ? `${props.popupHeight}px` : props.popupHeight,
|
||||
);
|
||||
const popupStyles = computed(() => ({
|
||||
container: {
|
||||
width: popupWidth.value,
|
||||
minWidth: triggerWidth.value ? `${triggerWidth.value}px` : undefined,
|
||||
maxWidth: "calc(100vw - 24px)",
|
||||
padding: "8px",
|
||||
},
|
||||
}));
|
||||
|
||||
function updateValue(value) {
|
||||
emit("update:modelValue", value ?? "");
|
||||
}
|
||||
|
||||
function onInputChange(event) {
|
||||
emit("change", event);
|
||||
}
|
||||
|
||||
async function showEditor() {
|
||||
if (props.disabled || open.value) return;
|
||||
|
||||
const inputElement = inputRef.value?.$el ?? inputRef.value;
|
||||
triggerWidth.value = inputElement?.getBoundingClientRect?.().width ?? 0;
|
||||
open.value = true;
|
||||
emit("openChange", true);
|
||||
await nextTick();
|
||||
textareaRef.value?.focus?.({ cursor: "end" });
|
||||
}
|
||||
|
||||
function onInputFocus(event) {
|
||||
emit("focus", event);
|
||||
showEditor();
|
||||
}
|
||||
|
||||
function onOpenChange(value) {
|
||||
if (value && props.disabled) return;
|
||||
open.value = value;
|
||||
emit("openChange", value);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
if (!open.value) return;
|
||||
open.value = false;
|
||||
emit("openChange", false);
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(event) {
|
||||
if (!open.value) return;
|
||||
|
||||
const inputElement = inputRef.value?.$el ?? inputRef.value;
|
||||
const path = event.composedPath?.() ?? [];
|
||||
if (path.includes(inputElement) || path.includes(editorRef.value)) return;
|
||||
closeEditor();
|
||||
}
|
||||
|
||||
function onTextareaKeydown(event) {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeEditor();
|
||||
}
|
||||
}
|
||||
|
||||
function onInputBlur(event) {
|
||||
if (!open.value) emit("blur", event);
|
||||
}
|
||||
|
||||
function onTextareaBlur(event) {
|
||||
emit("blur", event);
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("pointerdown", onDocumentPointerDown, true));
|
||||
onBeforeUnmount(() => document.removeEventListener("pointerdown", onDocumentPointerDown, true));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-popover
|
||||
:open="open"
|
||||
:trigger="[]"
|
||||
:placement="popoverPlacement"
|
||||
:arrow="false"
|
||||
:styles="popupStyles"
|
||||
@open-change="onOpenChange"
|
||||
>
|
||||
<template #content>
|
||||
<div ref="editorRef" class="fms-memo-editor" @mousedown.stop @click.stop>
|
||||
<a-textarea
|
||||
ref="textareaRef"
|
||||
:value="modelValue"
|
||||
:maxlength="maxlength"
|
||||
:placeholder="$attrs.placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:style="{ height: popupHeight, resize: 'both' }"
|
||||
@update:value="updateValue"
|
||||
@change="onInputChange"
|
||||
@keydown="onTextareaKeydown"
|
||||
@blur="onTextareaBlur"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-input
|
||||
ref="inputRef"
|
||||
v-bind="$attrs"
|
||||
class="fms-memo-input"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:allow-clear="allowClear"
|
||||
:maxlength="maxlength"
|
||||
@update:value="updateValue"
|
||||
@change="onInputChange"
|
||||
@focus="onInputFocus"
|
||||
@blur="onInputBlur"
|
||||
@click.stop="showEditor"
|
||||
@keydown.stop
|
||||
>
|
||||
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
|
||||
<slot :name="name" v-bind="slotProps || {}" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-popover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fms-memo-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fms-memo-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fms-memo-editor :deep(textarea) {
|
||||
min-width: 180px;
|
||||
min-height: 96px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useLookupOptions } from "./select/useLookupOptions";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: null, default: "" },
|
||||
label: { type: String, default: "" },
|
||||
labelKey: { type: String, default: "b_name" },
|
||||
valueKey: { type: String, default: "b_id" },
|
||||
viewName: { type: String, required: true },
|
||||
searchCondition: { type: String, default: "" },
|
||||
searchColumns: { type: Array, default: () => [] },
|
||||
orderBy: { type: String, default: "" },
|
||||
pageSize: { type: Number, default: 20 },
|
||||
searchDelay: { type: Number, default: 300 },
|
||||
allowClear: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "update:label", "change"]);
|
||||
const lookup = useLookupOptions(props);
|
||||
|
||||
const options = computed(() => {
|
||||
const items = lookup.rows.value.map((row) => ({
|
||||
value: row[props.valueKey],
|
||||
label: String(row[props.labelKey] ?? ""),
|
||||
record: row,
|
||||
}));
|
||||
|
||||
if (
|
||||
props.modelValue !== "" &&
|
||||
props.modelValue != null &&
|
||||
props.label &&
|
||||
!items.some((item) => item.value === props.modelValue)
|
||||
) {
|
||||
items.unshift({ value: props.modelValue, label: props.label, record: null });
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
function onOpenChange(open) {
|
||||
if (open) lookup.open();
|
||||
else lookup.resetAfterClose();
|
||||
}
|
||||
|
||||
function onSelect(value, option) {
|
||||
emit("update:modelValue", value);
|
||||
emit("update:label", String(option.label ?? ""));
|
||||
emit("change", option.record ?? null);
|
||||
}
|
||||
|
||||
function onChange(value) {
|
||||
if (value !== undefined && value !== null) return;
|
||||
emit("update:modelValue", "");
|
||||
emit("update:label", "");
|
||||
emit("change", null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-select
|
||||
v-bind="$attrs"
|
||||
class="fms-select"
|
||||
:value="modelValue === '' ? undefined : modelValue"
|
||||
:options="options"
|
||||
:loading="lookup.loading.value"
|
||||
:filter-option="false"
|
||||
:allow-clear="allowClear"
|
||||
show-search
|
||||
@search="lookup.search"
|
||||
@open-change="onOpenChange"
|
||||
@popup-scroll="lookup.onPopupScroll"
|
||||
@select="onSelect"
|
||||
@change="onChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fms-select {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, ref, useSlots } from "vue";
|
||||
import { useRafFn, useResizeObserver } from "@vueuse/core";
|
||||
import { StkTable } from "stk-table-vue";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
defineProps({
|
||||
virtual: { type: Boolean, default: true },
|
||||
virtualX: { type: Boolean, default: true },
|
||||
colResizable: { type: Boolean, default: true },
|
||||
bordered: { type: [Boolean, String], default: true },
|
||||
noDataFull: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
const tableRef = ref(null);
|
||||
|
||||
const { resume: refreshLayout } = useRafFn(
|
||||
async () => {
|
||||
await nextTick();
|
||||
tableRef.value?.initVirtualScroll?.();
|
||||
await nextTick();
|
||||
},
|
||||
{ immediate: false, once: true },
|
||||
);
|
||||
|
||||
useResizeObserver(
|
||||
() => tableRef.value?.$el,
|
||||
([entry]) => {
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0 && height > 0) refreshLayout();
|
||||
},
|
||||
);
|
||||
|
||||
// Forward every slot except `empty`; the `empty` slot is handled below so the
|
||||
// default Result fallback only renders when the parent does not provide one.
|
||||
const forwardSlots = computed(() => {
|
||||
const result = {};
|
||||
for (const name in slots) {
|
||||
if (name !== "empty") result[name] = slots[name];
|
||||
}
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StkTable
|
||||
ref="tableRef"
|
||||
class="fms-table"
|
||||
v-bind="$attrs"
|
||||
:auto-resize="false"
|
||||
:virtual="virtual"
|
||||
:virtual-x="virtualX"
|
||||
:col-resizable="colResizable"
|
||||
:bordered="bordered"
|
||||
:no-data-full="noDataFull"
|
||||
>
|
||||
<template v-for="(slot, name) in forwardSlots" :key="name" #[name]="slotProps">
|
||||
<slot :name="name" v-bind="slotProps || {}" />
|
||||
</template>
|
||||
<template v-if="slots.empty" #empty>
|
||||
<slot name="empty" />
|
||||
</template>
|
||||
<template v-else #empty>
|
||||
<a-result status="info" title="暂无数据" sub-title="当前没有可显示的内容" />
|
||||
</template>
|
||||
</StkTable>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fms-table {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
defineProps({
|
||||
blockNode: { type: Boolean, default: true },
|
||||
virtual: { type: Boolean, default: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-tree v-bind="$attrs" :block-node="blockNode" :virtual="virtual">
|
||||
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
|
||||
<slot :name="name" v-bind="slotProps || {}" />
|
||||
</template>
|
||||
</a-tree>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useTimeoutFn } from "@vueuse/core";
|
||||
import { pageDataApi } from "@/services/api";
|
||||
|
||||
function escapeSearchText(value) {
|
||||
return String(value).replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function combineConditions(baseCondition, searchCondition) {
|
||||
if (!baseCondition) return searchCondition;
|
||||
if (!searchCondition) return baseCondition;
|
||||
return `(${baseCondition}) AND (${searchCondition})`;
|
||||
}
|
||||
|
||||
export function useLookupOptions(props) {
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const pageNo = ref(0);
|
||||
const total = ref(0);
|
||||
const query = ref("");
|
||||
|
||||
const searchColumns = computed(() => [
|
||||
...new Set([props.labelKey, props.valueKey, ...props.searchColumns].filter(Boolean)),
|
||||
]);
|
||||
|
||||
let requestId = 0;
|
||||
|
||||
async function load(searchText = query.value, append = false) {
|
||||
if (!props.viewName) return;
|
||||
|
||||
const currentRequestId = ++requestId;
|
||||
const requestedPage = append ? pageNo.value + 1 : 1;
|
||||
const normalizedSearchText = String(searchText ?? "").trim();
|
||||
const searchFilter = normalizedSearchText
|
||||
? `${props.labelKey} LIKE '%${escapeSearchText(normalizedSearchText)}%'`
|
||||
: "";
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await pageDataApi({
|
||||
viewName: props.viewName,
|
||||
orderBy: props.orderBy || props.labelKey,
|
||||
searchColumns: searchColumns.value,
|
||||
searchCondition: combineConditions(props.searchCondition, searchFilter),
|
||||
pageNo: requestedPage,
|
||||
pageSize: props.pageSize,
|
||||
});
|
||||
|
||||
if (currentRequestId !== requestId) return;
|
||||
|
||||
const result = response.data ?? {};
|
||||
const nextRows = Array.isArray(result.rows) ? result.rows : [];
|
||||
rows.value = append ? [...rows.value, ...nextRows] : nextRows;
|
||||
pageNo.value = Number(result.page_no) || requestedPage;
|
||||
total.value = Number(result.total ?? result.row_count) || 0;
|
||||
query.value = normalizedSearchText;
|
||||
} catch {
|
||||
if (currentRequestId === requestId && !append) {
|
||||
rows.value = [];
|
||||
pageNo.value = 0;
|
||||
total.value = 0;
|
||||
}
|
||||
} finally {
|
||||
if (currentRequestId === requestId) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const { start: startSearchTimer, stop: stopSearchTimer } = useTimeoutFn(
|
||||
() => load(query.value),
|
||||
() => props.searchDelay,
|
||||
{ immediate: false },
|
||||
);
|
||||
const { start: startResetTimer, stop: stopResetTimer } = useTimeoutFn(reset, 200, {
|
||||
immediate: false,
|
||||
});
|
||||
|
||||
function search(value) {
|
||||
stopResetTimer();
|
||||
query.value = String(value ?? "");
|
||||
startSearchTimer();
|
||||
}
|
||||
|
||||
function open() {
|
||||
stopResetTimer();
|
||||
if (!rows.value.length && !loading.value) load(query.value);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
stopSearchTimer();
|
||||
stopResetTimer();
|
||||
requestId += 1;
|
||||
rows.value = [];
|
||||
loading.value = false;
|
||||
pageNo.value = 0;
|
||||
total.value = 0;
|
||||
query.value = "";
|
||||
}
|
||||
|
||||
function resetAfterClose() {
|
||||
startResetTimer();
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (loading.value || rows.value.length >= total.value) return;
|
||||
load(query.value, true);
|
||||
}
|
||||
|
||||
function onPopupScroll(event) {
|
||||
const target = event.currentTarget || event.target;
|
||||
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 24) loadMore();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.viewName,
|
||||
props.labelKey,
|
||||
props.valueKey,
|
||||
props.orderBy,
|
||||
props.searchCondition,
|
||||
props.searchColumns,
|
||||
],
|
||||
reset,
|
||||
);
|
||||
|
||||
onBeforeUnmount(reset);
|
||||
|
||||
return { rows, loading, query, load, search, open, resetAfterClose, onPopupScroll };
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Settings,
|
||||
Wallet,
|
||||
} from "@lucide/vue";
|
||||
import { useAppStore } from "../../stores/app";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -255,6 +255,7 @@ function updateOpenKeys(keys) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--fms-border);
|
||||
will-change: width;
|
||||
|
||||
:deep(.ant-layout-sider-children) {
|
||||
display: flex;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ChevronLeft, ChevronRight, X } from "@lucide/vue";
|
||||
import { theme } from "antdv-next";
|
||||
import { useAppStore } from "../../stores/app";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { computed, h, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Bell, ChevronDown, Languages, LogOut, Settings, UserRound } from "@lucide/vue";
|
||||
import { useAppStore } from "../../stores/app";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -5,6 +5,7 @@ import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./styles/tailwind.css";
|
||||
import "./styles/main.scss";
|
||||
import 'stk-table-vue/lib/style.css';
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
@@ -2,8 +2,8 @@ import { createRouter, createWebHistory } from "vue-router";
|
||||
import NProgress from "nprogress";
|
||||
import { message } from "antdv-next";
|
||||
import "nprogress/nprogress.css";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { usePermissionStore } from "../stores/permissions";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { usePermissionStore } from "@/stores/permissions";
|
||||
|
||||
NProgress.configure({ showSpinner: false });
|
||||
|
||||
@@ -16,7 +16,7 @@ function getRedirectPath(value) {
|
||||
const routes = [
|
||||
{
|
||||
path: "/",
|
||||
component: () => import("../layouts/DefaultLayout.vue"),
|
||||
component: () => import("@/layouts/DefaultLayout.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
@@ -26,117 +26,13 @@ const routes = [
|
||||
{
|
||||
path: "dashboard",
|
||||
name: "dashboard",
|
||||
component: () => import("../views/DashboardView.vue"),
|
||||
component: () => import("@/views/DashboardView.vue"),
|
||||
meta: { title: "工作台", menuKey: "/dashboard" },
|
||||
},
|
||||
{
|
||||
path: "business/orders",
|
||||
name: "business-orders",
|
||||
component: () => import("../views/DataListView.vue"),
|
||||
meta: {
|
||||
title: "业务单据",
|
||||
section: "业务管理",
|
||||
menuKey: "/business/orders",
|
||||
source: "v_bs_business",
|
||||
orderBy: "b_id DESC",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "business/orders/:id",
|
||||
name: "business-order-detail",
|
||||
component: () => import("../views/DetailView.vue"),
|
||||
meta: {
|
||||
title: "业务单据详情",
|
||||
listTitle: "业务单据",
|
||||
section: "业务管理",
|
||||
menuKey: "/business/orders",
|
||||
listPath: "/business/orders",
|
||||
source: "v_bs_business",
|
||||
detail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "fees/receivable",
|
||||
name: "fees-receivable",
|
||||
component: () => import("../views/DataListView.vue"),
|
||||
meta: {
|
||||
title: "应收费用",
|
||||
section: "费用管理",
|
||||
menuKey: "/fees/receivable",
|
||||
source: "v_fee_receivable",
|
||||
orderBy: "b_id DESC",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "fees/receivable/:id",
|
||||
name: "fees-receivable-detail",
|
||||
component: () => import("../views/DetailView.vue"),
|
||||
meta: {
|
||||
title: "应收费用详情",
|
||||
listTitle: "应收费用",
|
||||
section: "费用管理",
|
||||
menuKey: "/fees/receivable",
|
||||
listPath: "/fees/receivable",
|
||||
source: "v_fee_receivable",
|
||||
detail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "reports/operations",
|
||||
name: "reports-operations",
|
||||
component: () => import("../views/DataListView.vue"),
|
||||
meta: {
|
||||
title: "运营报表",
|
||||
section: "报表中心",
|
||||
menuKey: "/reports/operations",
|
||||
source: "v_report_operations",
|
||||
orderBy: "b_id DESC",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "reports/operations/:id",
|
||||
name: "reports-operations-detail",
|
||||
component: () => import("../views/DetailView.vue"),
|
||||
meta: {
|
||||
title: "运营报表详情",
|
||||
listTitle: "运营报表",
|
||||
section: "报表中心",
|
||||
menuKey: "/reports/operations",
|
||||
listPath: "/reports/operations",
|
||||
source: "v_report_operations",
|
||||
detail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "system/users",
|
||||
name: "system-users",
|
||||
component: () => import("../views/DataListView.vue"),
|
||||
meta: {
|
||||
title: "用户管理",
|
||||
section: "系统设置",
|
||||
menuKey: "/system/users",
|
||||
source: "b_user",
|
||||
orderBy: "b_id ASC",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "system/users/:id",
|
||||
name: "system-users-detail",
|
||||
component: () => import("../views/DetailView.vue"),
|
||||
meta: {
|
||||
title: "用户管理详情",
|
||||
listTitle: "用户管理",
|
||||
section: "系统设置",
|
||||
menuKey: "/system/users",
|
||||
listPath: "/system/users",
|
||||
source: "b_user",
|
||||
detail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "system/modules",
|
||||
name: "system-modules",
|
||||
component: () => import("../views/module-management/ModuleManagementView.vue"),
|
||||
component: () => import("@/views/module-management/ModuleManagementView.vue"),
|
||||
meta: {
|
||||
title: "模块管理",
|
||||
section: "系统管理",
|
||||
@@ -149,7 +45,7 @@ const routes = [
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: () => import("../views/LoginView.vue"),
|
||||
component: () => import("@/views/LoginView.vue"),
|
||||
meta: { title: "登录" },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const AUTH_ERROR_CODE = 401;
|
||||
const SESSION_REPLACED_CODE = 40101;
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { loadDataApi, nextIdApi, saveObjectApi } from "./api";
|
||||
|
||||
export const MODULE_ID = "M_MODULE";
|
||||
export const LOCALES = [
|
||||
{ value: "zh-CN", label: "简体中文" },
|
||||
{ value: "en-US", label: "English" },
|
||||
{ value: "zh-TW", label: "繁體中文" },
|
||||
];
|
||||
|
||||
export async function loadModuleConfiguration() {
|
||||
const [modules, fields, powers, translations, userModules, userPowers] = await Promise.all([
|
||||
loadDataApi("b_module", null, "b_xh ASC"),
|
||||
loadDataApi("b_module_field", null, "b_xh ASC"),
|
||||
loadDataApi("b_module_power", null, "b_xh ASC"),
|
||||
loadDataApi("b_i18n", null, "b_key ASC"),
|
||||
loadDataApi("b_user_module", null, "b_id ASC"),
|
||||
loadDataApi("b_user_power", null, "b_id ASC"),
|
||||
]);
|
||||
return {
|
||||
modules: modules.data,
|
||||
fields: fields.data,
|
||||
powers: powers.data,
|
||||
translations: translations.data,
|
||||
userModules: userModules.data,
|
||||
userPowers: userPowers.data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function nextIds(count) {
|
||||
const ids = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const response = await nextIdApi();
|
||||
ids.push(String(response.data));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function tableRequest(table, changes, keyField = "b_id") {
|
||||
return {
|
||||
table,
|
||||
key_field: keyField,
|
||||
inserts: changes.inserts || [],
|
||||
updates: changes.updates || [],
|
||||
deletes: changes.deletes || [],
|
||||
};
|
||||
}
|
||||
|
||||
export function saveChanges(requests) {
|
||||
return saveObjectApi(
|
||||
requests.filter(
|
||||
(request) => request.inserts.length || request.updates.length || request.deletes.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
const AUTH_STORAGE_KEY = "fms-auth";
|
||||
|
||||
try {
|
||||
// Remove sessions persisted by earlier versions; authentication is session-only now.
|
||||
localStorage.removeItem("fms-auth");
|
||||
// Retain a session created before persistence moved from sessionStorage.
|
||||
const session = sessionStorage.getItem(AUTH_STORAGE_KEY);
|
||||
if (session && !localStorage.getItem(AUTH_STORAGE_KEY)) {
|
||||
localStorage.setItem(AUTH_STORAGE_KEY, session);
|
||||
}
|
||||
} catch {
|
||||
// Storage can be unavailable in restricted browser contexts.
|
||||
}
|
||||
@@ -39,8 +44,8 @@ export const useAuthStore = defineStore(
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: "fms-auth",
|
||||
storage: sessionStorage,
|
||||
key: AUTH_STORAGE_KEY,
|
||||
storage: localStorage,
|
||||
pick: ["token", "orgId", "user"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { loadDataApi } from "../services/api";
|
||||
import { loadDataApi } from "@/services/api";
|
||||
import { useAuthStore } from "./auth";
|
||||
|
||||
export const usePermissionStore = defineStore("permissions", () => {
|
||||
|
||||
@@ -1,51 +1,100 @@
|
||||
<script setup lang="js">
|
||||
import AutoHeightTable from "../components/AutoHeightTable.vue";
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import FmsMemoInput from "@/components/FmsMemoInput.vue";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Name (all screens)",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
},
|
||||
{
|
||||
title: "Age (medium screen or bigger)",
|
||||
dataIndex: "age",
|
||||
key: "age",
|
||||
responsive: ["md"],
|
||||
},
|
||||
{
|
||||
title: "Address (large screen or bigger)",
|
||||
dataIndex: "address",
|
||||
key: "address",
|
||||
responsive: ["lg"],
|
||||
},
|
||||
];
|
||||
|
||||
const dataSource = Array.from({ length: 30 }, (_, i) => ({
|
||||
key: String(i + 1),
|
||||
name: "John Brown",
|
||||
age: 32,
|
||||
address: "New York No. 1 Lake Park",
|
||||
}));
|
||||
const memo = ref("");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard h-full min-h-0 flex flex-col gap-3 overflow-hidden p-4">
|
||||
<div class="toolbar flex flex-none items-center justify-between">
|
||||
<span class="text-base font-semibold">Dashboard</span>
|
||||
<a-space>
|
||||
<a-button type="primary">新增</a-button>
|
||||
<a-button>刷新</a-button>
|
||||
<a-button>导出</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<main class="memo-demo">
|
||||
<header class="memo-demo-header">
|
||||
<h1>Memo 输入框</h1>
|
||||
</header>
|
||||
|
||||
<AutoHeightTable :columns="columns" :data-source="dataSource">
|
||||
<template #bodyCell="{ column, text }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<a>{{ text }}</a>
|
||||
</template>
|
||||
</template>
|
||||
</AutoHeightTable>
|
||||
</div>
|
||||
<section class="memo-demo-content" aria-label="FmsMemoInput 测试">
|
||||
<label for="memo-test">备注</label>
|
||||
<FmsMemoInput
|
||||
id="memo-test"
|
||||
v-model="memo"
|
||||
placeholder="点击输入备注"
|
||||
/>
|
||||
|
||||
<div class="memo-value">
|
||||
<span>当前值</span>
|
||||
<pre>{{ memo || "--" }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.memo-demo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
color: var(--fms-text);
|
||||
background: var(--fms-surface);
|
||||
}
|
||||
|
||||
.memo-demo-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
.memo-demo-header h1 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.memo-demo-content {
|
||||
display: grid;
|
||||
width: min(680px, 100%);
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.memo-demo-content > label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.memo-value {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-column: 2;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.memo-value > span {
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.memo-value pre {
|
||||
min-height: 72px;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: 4px;
|
||||
background: var(--fms-bg);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.memo-demo-content {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.memo-value {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,632 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Ellipsis, ListFilter, Plus, Search } from "@lucide/vue";
|
||||
import { loadDataApi, pageDataApi } from "../services/api";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const sourceRows = ref([]);
|
||||
const rows = ref([]);
|
||||
const total = ref(0);
|
||||
const filters = reactive({ field: "name", keyword: "" });
|
||||
const pagination = reactive({ current: 1, pageSize: 20 });
|
||||
const tableAreaRef = ref(null);
|
||||
const tableScrollY = ref(240);
|
||||
const advancedOpen = ref(false);
|
||||
const appliedFilterCount = ref(0);
|
||||
let resizeObserver;
|
||||
let loadSequence = 0;
|
||||
|
||||
const columns = [
|
||||
{ title: "编号", dataIndex: "id", key: "id", width: 160 },
|
||||
{ title: "名称", dataIndex: "name", key: "name", width: 240 },
|
||||
{ title: "状态", dataIndex: "state", key: "state", width: 100 },
|
||||
{ title: "更新时间", dataIndex: "updated_at", key: "updated_at", width: 160 },
|
||||
];
|
||||
const fieldOptions = [
|
||||
{ label: "名称", value: "name" },
|
||||
{ label: "编号", value: "id" },
|
||||
{ label: "状态", value: "state" },
|
||||
];
|
||||
const businessTypeOptions = ["海运", "空运", "陆运", "铁路"].map((value) => ({
|
||||
label: value,
|
||||
value,
|
||||
}));
|
||||
const statusOptions = ["草稿", "处理中", "已完成", "已取消"].map((value) => ({
|
||||
label: value,
|
||||
value,
|
||||
}));
|
||||
const ownerOptions = ["张三", "李四", "王五"].map((value) => ({ label: value, value }));
|
||||
const departmentOptions = ["销售部", "操作部", "财务部"].map((value) => ({
|
||||
label: value,
|
||||
value,
|
||||
}));
|
||||
const settlementOptions = ["未结算", "部分结算", "已结算"].map((value) => ({
|
||||
label: value,
|
||||
value,
|
||||
}));
|
||||
const currencyOptions = ["CNY", "USD", "EUR"].map((value) => ({ label: value, value }));
|
||||
const advancedFilters = reactive(createEmptyAdvancedFilters());
|
||||
const draftFilterCount = computed(
|
||||
() => Object.values(advancedFilters).filter(isFilterValueFilled).length,
|
||||
);
|
||||
const hasActiveFilters = computed(
|
||||
() => Boolean(filters.keyword.trim()) || appliedFilterCount.value > 0,
|
||||
);
|
||||
|
||||
function createEmptyAdvancedFilters() {
|
||||
return {
|
||||
documentNo: "",
|
||||
customerName: "",
|
||||
businessType: undefined,
|
||||
status: undefined,
|
||||
businessDate: [],
|
||||
owner: undefined,
|
||||
department: undefined,
|
||||
settlementStatus: undefined,
|
||||
currency: undefined,
|
||||
amountMin: null,
|
||||
amountMax: null,
|
||||
creator: "",
|
||||
createdDate: [],
|
||||
remark: "",
|
||||
};
|
||||
}
|
||||
|
||||
function isFilterValueFilled(value) {
|
||||
if (Array.isArray(value)) return value.some(isFilterValueFilled);
|
||||
return value !== undefined && value !== null && value !== "";
|
||||
}
|
||||
|
||||
function getRowValue(row, candidates) {
|
||||
const keys = Object.keys(row);
|
||||
for (const candidate of candidates) {
|
||||
const matchedKey = keys.find((key) => key.toLowerCase() === candidate.toLowerCase());
|
||||
if (matchedKey !== undefined && row[matchedKey] != null) return row[matchedKey];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeRow(row, index) {
|
||||
const id = getRowValue(row, ["id", "b_id", "user_id", "userid"]);
|
||||
const name = getRowValue(row, [
|
||||
"name",
|
||||
"b_name",
|
||||
"customer_name",
|
||||
"v_customer_company",
|
||||
"b_ywno",
|
||||
"title",
|
||||
]);
|
||||
const state = getRowValue(row, ["state", "b_state", "status", "b_canuse"]);
|
||||
const updatedAt = getRowValue(row, [
|
||||
"updated_at",
|
||||
"b_updatedatetime",
|
||||
"update_time",
|
||||
"updated_time",
|
||||
"b_updated_at",
|
||||
]);
|
||||
|
||||
return {
|
||||
...row,
|
||||
id: id == null ? "" : String(id),
|
||||
name: name == null ? "" : String(name),
|
||||
state: state == null ? "" : String(state),
|
||||
updated_at: updatedAt == null ? "" : String(updatedAt),
|
||||
__rowKey: `${String(route.meta.source)}-${String(id ?? index)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function includesValue(row, candidates, expected) {
|
||||
if (!isFilterValueFilled(expected)) return true;
|
||||
const actual = getRowValue(row, candidates);
|
||||
return String(actual ?? "")
|
||||
.toLowerCase()
|
||||
.includes(String(expected).trim().toLowerCase());
|
||||
}
|
||||
|
||||
function isInDateRange(row, candidates, range) {
|
||||
if (!Array.isArray(range) || !range.some(Boolean)) return true;
|
||||
const actual = String(getRowValue(row, candidates) ?? "").slice(0, 10);
|
||||
if (!actual) return false;
|
||||
const start = range[0]?.format?.("YYYY-MM-DD");
|
||||
const end = range[1]?.format?.("YYYY-MM-DD");
|
||||
return (!start || actual >= start) && (!end || actual <= end);
|
||||
}
|
||||
|
||||
function matchesAdvancedFilters(row) {
|
||||
const amount = Number(
|
||||
String(getRowValue(row, ["amount", "total_amount", "b_amount"]) ?? "").replaceAll(",", ""),
|
||||
);
|
||||
|
||||
return (
|
||||
includesValue(row, ["id", "b_id", "document_no"], advancedFilters.documentNo) &&
|
||||
includesValue(
|
||||
row,
|
||||
["customer", "customer_name", "name", "b_name"],
|
||||
advancedFilters.customerName,
|
||||
) &&
|
||||
includesValue(row, ["business_type", "businessType", "b_type"], advancedFilters.businessType) &&
|
||||
includesValue(row, ["state", "b_state", "status"], advancedFilters.status) &&
|
||||
isInDateRange(row, ["business_date", "b_date"], advancedFilters.businessDate) &&
|
||||
includesValue(row, ["owner", "owner_name", "sales_name"], advancedFilters.owner) &&
|
||||
includesValue(row, ["department", "department_name"], advancedFilters.department) &&
|
||||
includesValue(
|
||||
row,
|
||||
["settlement_status", "settlementStatus"],
|
||||
advancedFilters.settlementStatus,
|
||||
) &&
|
||||
includesValue(row, ["currency", "currency_code"], advancedFilters.currency) &&
|
||||
(!isFilterValueFilled(advancedFilters.amountMin) ||
|
||||
(Number.isFinite(amount) && amount >= advancedFilters.amountMin)) &&
|
||||
(!isFilterValueFilled(advancedFilters.amountMax) ||
|
||||
(Number.isFinite(amount) && amount <= advancedFilters.amountMax)) &&
|
||||
includesValue(row, ["creator", "created_by", "creator_name"], advancedFilters.creator) &&
|
||||
isInDateRange(
|
||||
row,
|
||||
["created_at", "create_time", "created_time"],
|
||||
advancedFilters.createdDate,
|
||||
) &&
|
||||
includesValue(row, ["remark", "remarks", "memo"], advancedFilters.remark)
|
||||
);
|
||||
}
|
||||
|
||||
function applyLocalFilters() {
|
||||
const keyword = filters.keyword.trim().toLowerCase();
|
||||
const filteredRows = sourceRows.value.filter(
|
||||
(row) =>
|
||||
(!keyword ||
|
||||
String(row[filters.field] ?? "")
|
||||
.toLowerCase()
|
||||
.includes(keyword)) &&
|
||||
matchesAdvancedFilters(row),
|
||||
);
|
||||
const pageCount = Math.max(Math.ceil(filteredRows.length / pagination.pageSize), 1);
|
||||
if (pagination.current > pageCount) pagination.current = pageCount;
|
||||
const start = (pagination.current - 1) * pagination.pageSize;
|
||||
rows.value = filteredRows.slice(start, start + pagination.pageSize);
|
||||
total.value = filteredRows.length;
|
||||
}
|
||||
|
||||
function searchData() {
|
||||
pagination.current = 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const currentSequence = ++loadSequence;
|
||||
const viewName = String(route.meta.source || "");
|
||||
if (!viewName) return;
|
||||
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
|
||||
try {
|
||||
const response = hasActiveFilters.value
|
||||
? await loadDataApi(viewName)
|
||||
: await pageDataApi({
|
||||
viewName,
|
||||
orderBy: String(route.meta.orderBy || "b_id DESC"),
|
||||
pageNo: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
if (currentSequence !== loadSequence) return;
|
||||
|
||||
if (hasActiveFilters.value) {
|
||||
sourceRows.value = (Array.isArray(response.data) ? response.data : []).map(normalizeRow);
|
||||
applyLocalFilters();
|
||||
} else {
|
||||
const result = response.data ?? {};
|
||||
sourceRows.value = [];
|
||||
rows.value = (Array.isArray(result.rows) ? result.rows : []).map(normalizeRow);
|
||||
total.value = Number(result.total) || 0;
|
||||
pagination.current = Number(result.page_no) || pagination.current;
|
||||
pagination.pageSize = Number(result.page_size) || pagination.pageSize;
|
||||
}
|
||||
} catch (error) {
|
||||
if (currentSequence !== loadSequence) return;
|
||||
sourceRows.value = [];
|
||||
rows.value = [];
|
||||
total.value = 0;
|
||||
loadError.value = error.message || "数据加载失败";
|
||||
} finally {
|
||||
if (currentSequence === loadSequence) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(record) {
|
||||
router.push(`${route.path}/${encodeURIComponent(record.id)}`);
|
||||
}
|
||||
|
||||
function changePage(page, pageSize) {
|
||||
pagination.current = page;
|
||||
pagination.pageSize = pageSize;
|
||||
if (hasActiveFilters.value) {
|
||||
applyLocalFilters();
|
||||
} else {
|
||||
loadData();
|
||||
}
|
||||
}
|
||||
|
||||
function resetAdvancedFilters() {
|
||||
Object.assign(advancedFilters, createEmptyAdvancedFilters());
|
||||
}
|
||||
|
||||
function applyAdvancedFilters() {
|
||||
pagination.current = 1;
|
||||
appliedFilterCount.value = draftFilterCount.value;
|
||||
advancedOpen.value = false;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function updateTableHeight() {
|
||||
const tableArea = tableAreaRef.value;
|
||||
if (!tableArea) return;
|
||||
|
||||
const headerHeight =
|
||||
tableArea.querySelector(".ant-table-thead")?.getBoundingClientRect().height ?? 39;
|
||||
tableScrollY.value = Math.max(Math.floor(tableArea.clientHeight - headerHeight), 80);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resizeObserver = new ResizeObserver(updateTableHeight);
|
||||
if (tableAreaRef.value) resizeObserver.observe(tableAreaRef.value);
|
||||
nextTick(updateTableHeight);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => resizeObserver?.disconnect());
|
||||
|
||||
watch(
|
||||
() => route.meta.source,
|
||||
() => {
|
||||
pagination.current = 1;
|
||||
filters.keyword = "";
|
||||
resetAdvancedFilters();
|
||||
appliedFilterCount.value = 0;
|
||||
loadData();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="data-page">
|
||||
<section class="filter-bar" aria-label="查询条件">
|
||||
<a-button type="primary" class="toolbar-button">
|
||||
<template #icon><Plus /></template>
|
||||
新增
|
||||
</a-button>
|
||||
|
||||
<div class="filter-fields">
|
||||
<a-space-compact class="search-compact">
|
||||
<a-select
|
||||
v-model:value="filters.field"
|
||||
class="keyword-select"
|
||||
:options="fieldOptions"
|
||||
aria-label="查询字段"
|
||||
/>
|
||||
<a-input-search
|
||||
id="data-keyword"
|
||||
v-model:value="filters.keyword"
|
||||
class="keyword-search"
|
||||
allow-clear
|
||||
enter-button="搜索"
|
||||
@search="searchData"
|
||||
/>
|
||||
</a-space-compact>
|
||||
|
||||
<a-tooltip title="高级查询" placement="bottom">
|
||||
<a-badge :count="appliedFilterCount" size="small">
|
||||
<a-button
|
||||
shape="circle"
|
||||
class="round-action"
|
||||
aria-label="高级查询"
|
||||
@click="advancedOpen = true"
|
||||
>
|
||||
<template #icon><ListFilter /></template>
|
||||
</a-button>
|
||||
</a-badge>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="更多" placement="bottom">
|
||||
<a-button shape="circle" class="round-action" aria-label="更多">
|
||||
<template #icon><Ellipsis /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="table-panel">
|
||||
<a-alert v-if="loadError" class="load-alert" type="error" show-icon :message="loadError">
|
||||
<template #action><a-button size="small" @click="loadData">重试</a-button></template>
|
||||
</a-alert>
|
||||
<div
|
||||
ref="tableAreaRef"
|
||||
class="table-area"
|
||||
:style="{ '--table-body-height': `${tableScrollY}px` }"
|
||||
>
|
||||
<a-table
|
||||
row-key="__rowKey"
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:loading="loading"
|
||||
bordered
|
||||
size="small"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 'max-content', y: tableScrollY }"
|
||||
>
|
||||
<template #bodyCell="{ column, text, record }">
|
||||
<a-button
|
||||
v-if="column.key === 'id' && record.id"
|
||||
type="link"
|
||||
class="table-id-link"
|
||||
@click="openDetail(record)"
|
||||
>
|
||||
{{ text }}
|
||||
</a-button>
|
||||
<template v-else>{{ text }}</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<footer class="table-footer">
|
||||
<a-pagination
|
||||
size="small"
|
||||
:current="pagination.current"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
@change="changePage"
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<a-drawer
|
||||
v-model:open="advancedOpen"
|
||||
root-class="advanced-filter-drawer"
|
||||
title="高级查询"
|
||||
placement="left"
|
||||
:size="560"
|
||||
:styles="{
|
||||
body: { padding: '12px 16px' },
|
||||
footer: { padding: '8px 16px' },
|
||||
}"
|
||||
>
|
||||
<a-form layout="vertical" class="advanced-filter-form">
|
||||
<a-form-item label="单据编号">
|
||||
<a-input v-model:value="advancedFilters.documentNo" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item label="客户名称">
|
||||
<a-input v-model:value="advancedFilters.customerName" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item label="业务类型">
|
||||
<a-select
|
||||
v-model:value="advancedFilters.businessType"
|
||||
allow-clear
|
||||
:options="businessTypeOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="业务状态">
|
||||
<a-select v-model:value="advancedFilters.status" allow-clear :options="statusOptions" />
|
||||
</a-form-item>
|
||||
<a-form-item label="业务日期" class="filter-span-full">
|
||||
<a-range-picker
|
||||
v-model:value="advancedFilters.businessDate"
|
||||
:allow-empty="[true, true]"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="负责人">
|
||||
<a-select v-model:value="advancedFilters.owner" allow-clear :options="ownerOptions" />
|
||||
</a-form-item>
|
||||
<a-form-item label="所属部门">
|
||||
<a-select
|
||||
v-model:value="advancedFilters.department"
|
||||
allow-clear
|
||||
:options="departmentOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="结算状态">
|
||||
<a-select
|
||||
v-model:value="advancedFilters.settlementStatus"
|
||||
allow-clear
|
||||
:options="settlementOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="币种">
|
||||
<a-select
|
||||
v-model:value="advancedFilters.currency"
|
||||
allow-clear
|
||||
:options="currencyOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="金额下限">
|
||||
<a-input-number v-model:value="advancedFilters.amountMin" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="金额上限">
|
||||
<a-input-number v-model:value="advancedFilters.amountMax" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="创建人">
|
||||
<a-input v-model:value="advancedFilters.creator" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item label="创建日期">
|
||||
<a-range-picker v-model:value="advancedFilters.createdDate" :allow-empty="[true, true]" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注关键词" class="filter-span-full">
|
||||
<a-input v-model:value="advancedFilters.remark" allow-clear />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<a-button @click="resetAdvancedFilters">重置</a-button>
|
||||
<a-button type="primary" @click="applyAdvancedFilters">
|
||||
<template #icon><Search /></template>
|
||||
查询
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.data-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--fms-surface);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.filter-fields {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.search-compact {
|
||||
align-items: center;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.keyword-select {
|
||||
width: 82px;
|
||||
}
|
||||
|
||||
.keyword-search {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.round-action {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 50% !important;
|
||||
|
||||
:deep(.lucide) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.load-alert {
|
||||
flex: none;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.table-area {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
height: 1px;
|
||||
background: var(--fms-border);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:deep(.ant-table-wrapper) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-table-body) {
|
||||
height: var(--table-body-height);
|
||||
min-height: var(--table-body-height);
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:nth-child(even) > td) {
|
||||
background: var(--fms-bg);
|
||||
}
|
||||
|
||||
.table-id-link {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
display: flex;
|
||||
flex: none;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.advanced-filter-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 12px;
|
||||
row-gap: 0;
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
min-width: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-label) {
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
:deep(.ant-select),
|
||||
:deep(.ant-picker),
|
||||
:deep(.ant-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-span-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:global(.advanced-filter-drawer .ant-drawer-content-wrapper) {
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,179 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ArrowLeft } from "@lucide/vue";
|
||||
import { loadDataApi } from "../services/api";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const remark = ref("");
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const sourceRecord = ref(null);
|
||||
let loadSequence = 0;
|
||||
|
||||
const recordId = computed(() => String(route.params.id ?? ""));
|
||||
const detailTitle = computed(() => route.meta.listTitle || route.meta.title || "详情");
|
||||
|
||||
function getRowValue(row, candidates) {
|
||||
if (!row) return undefined;
|
||||
const keys = Object.keys(row);
|
||||
for (const candidate of candidates) {
|
||||
const matchedKey = keys.find((key) => key.toLowerCase() === candidate.toLowerCase());
|
||||
if (matchedKey !== undefined && row[matchedKey] != null) return row[matchedKey];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = computed(() => {
|
||||
const row = sourceRecord.value;
|
||||
return {
|
||||
id: recordId.value,
|
||||
name: getRowValue(row, ["name", "b_name", "v_customer_company", "b_ywno", "title"]) ?? "--",
|
||||
state: getRowValue(row, ["state", "b_state", "status", "b_canuse"]) ?? "--",
|
||||
businessType: getRowValue(row, ["business_type", "b_ywtype", "b_type"]) ?? "--",
|
||||
owner: getRowValue(row, ["owner", "owner_name", "v_sales_name", "b_owner_id"]) ?? "--",
|
||||
customer: getRowValue(row, ["customer", "customer_name", "v_customer_company"]) ?? "--",
|
||||
amount: getRowValue(row, ["amount", "total_amount", "b_amount"]) ?? "--",
|
||||
currency: getRowValue(row, ["currency", "currency_code", "b_currency"]) ?? "",
|
||||
updated_at:
|
||||
getRowValue(row, ["updated_at", "b_updatedatetime", "update_time", "updated_time"]) ?? "--",
|
||||
};
|
||||
});
|
||||
|
||||
function escapeSqlString(value) {
|
||||
return String(value).replaceAll("'", "''");
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
const currentSequence = ++loadSequence;
|
||||
const source = String(route.meta.source || "");
|
||||
if (!source || !recordId.value) {
|
||||
sourceRecord.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
sourceRecord.value = null;
|
||||
try {
|
||||
const response = await loadDataApi(source, `b_id = N'${escapeSqlString(recordId.value)}'`);
|
||||
if (currentSequence !== loadSequence) return;
|
||||
sourceRecord.value = Array.isArray(response.data) ? (response.data[0] ?? null) : null;
|
||||
if (!sourceRecord.value) loadError.value = "未找到对应记录";
|
||||
} catch (error) {
|
||||
if (currentSequence !== loadSequence) return;
|
||||
loadError.value = error.message || "详情加载失败";
|
||||
} finally {
|
||||
if (currentSequence === loadSequence) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
router.push(String(route.meta.listPath || "/dashboard"));
|
||||
}
|
||||
|
||||
watch([() => route.meta.source, recordId], loadDetail, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<header class="detail-toolbar">
|
||||
<a-button type="text" size="small" @click="backToList">
|
||||
<template #icon><ArrowLeft /></template>
|
||||
返回列表
|
||||
</a-button>
|
||||
<div class="detail-heading">
|
||||
<strong>{{ detailTitle }}</strong>
|
||||
<span>{{ recordId }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="detail-content">
|
||||
<a-spin :spinning="loading">
|
||||
<a-alert v-if="loadError" type="error" show-icon :message="loadError">
|
||||
<template #action><a-button size="small" @click="loadDetail">重试</a-button></template>
|
||||
</a-alert>
|
||||
|
||||
<a-descriptions v-else bordered size="small" :column="3">
|
||||
<a-descriptions-item label="编号">{{ recordId }}</a-descriptions-item>
|
||||
<a-descriptions-item label="名称">{{ record.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">{{ record.state }}</a-descriptions-item>
|
||||
<a-descriptions-item label="客户">{{ record.customer }}</a-descriptions-item>
|
||||
<a-descriptions-item label="业务类型">{{ record.businessType }}</a-descriptions-item>
|
||||
<a-descriptions-item label="负责人">{{ record.owner }}</a-descriptions-item>
|
||||
<a-descriptions-item label="金额">
|
||||
{{ record.amount }} {{ record.currency }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="更新时间">{{ record.updated_at }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
|
||||
<section class="detail-remark">
|
||||
<div class="detail-remark__label">备注</div>
|
||||
<a-textarea v-model:value="remark" :rows="3" />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--fms-surface);
|
||||
}
|
||||
|
||||
.detail-toolbar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 38px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.detail-toolbar :deep(.lucide) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.detail-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
color: var(--fms-text);
|
||||
font-size: 13px;
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.detail-remark {
|
||||
margin-top: 12px;
|
||||
|
||||
&__label {
|
||||
margin-bottom: 5px;
|
||||
color: var(--fms-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,8 +3,8 @@ import { onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Building2, LockKeyhole, LogIn, MapPin, Ship, Truck, UserRound } from "@lucide/vue";
|
||||
import { App } from "antdv-next";
|
||||
import { loginApi } from "../services/api";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { loginApi } from "@/services/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const LOGIN_IDENTITY_KEY = "fms-login-identity";
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,15 +2,9 @@
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { App } from "antdv-next";
|
||||
import { Database, FileText, Languages, RotateCcw, Save, ShieldCheck, Trash2 } from "@lucide/vue";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { usePermissionStore } from "../../stores/permissions";
|
||||
import {
|
||||
LOCALES,
|
||||
loadModuleConfiguration,
|
||||
nextIds,
|
||||
saveChanges,
|
||||
tableRequest,
|
||||
} from "../../services/moduleManagement";
|
||||
import { loadDataApi, nextIdApi, saveObjectApi } from "@/services/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { usePermissionStore } from "@/stores/permissions";
|
||||
import ModuleBasicInfoPanel from "./components/ModuleBasicInfoPanel.vue";
|
||||
import ModuleFieldDefinitionPanel from "./components/ModuleFieldDefinitionPanel.vue";
|
||||
import ModulePowerPanel from "./components/ModulePowerPanel.vue";
|
||||
@@ -18,6 +12,7 @@ import ModuleTranslationsPanel from "./components/ModuleTranslationsPanel.vue";
|
||||
import ModuleTreePanel from "./components/ModuleTreePanel.vue";
|
||||
|
||||
const { message } = App.useApp();
|
||||
const LOCALES = ["zh-CN", "en-US", "zh-TW"];
|
||||
const authStore = useAuthStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
const loading = ref(false);
|
||||
@@ -25,7 +20,6 @@ const saving = ref(false);
|
||||
const selectedKey = ref("");
|
||||
const activeTab = ref("basic");
|
||||
const isNew = ref(false);
|
||||
const customI18nKeys = ref([]);
|
||||
const deleteConfirmOpen = ref(false);
|
||||
const pendingDeleteKey = ref("");
|
||||
const original = ref("");
|
||||
@@ -81,7 +75,6 @@ function rowKeys() {
|
||||
draft.module?.b_i18n,
|
||||
...draft.fields.map((item) => item.b_i18n),
|
||||
...draft.powers.map((item) => item.b_i18n),
|
||||
...customI18nKeys.value,
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -92,7 +85,6 @@ function clearDraft() {
|
||||
draft.fields = [];
|
||||
draft.powers = [];
|
||||
draft.translations = {};
|
||||
customI18nKeys.value = [];
|
||||
original.value = "";
|
||||
activeTab.value = "basic";
|
||||
}
|
||||
@@ -106,7 +98,6 @@ function selectModule(code) {
|
||||
draft.module = clone(module);
|
||||
draft.fields = clone(data.fields.filter((item) => item.b_module_id === code));
|
||||
draft.powers = clone(data.powers.filter((item) => item.b_module_id === code));
|
||||
customI18nKeys.value = [];
|
||||
const keys = new Set(rowKeys());
|
||||
|
||||
draft.translations = {};
|
||||
@@ -124,7 +115,22 @@ function selectModule(code) {
|
||||
async function loadAll(preferredKey = selectedKey.value) {
|
||||
loading.value = true;
|
||||
try {
|
||||
Object.assign(data, await loadModuleConfiguration());
|
||||
const [modules, fields, powers, translations, userModules, userPowers] = await Promise.all([
|
||||
loadDataApi("b_module", null, "b_xh ASC"),
|
||||
loadDataApi("b_module_field", null, "b_xh ASC"),
|
||||
loadDataApi("b_module_power", null, "b_xh ASC"),
|
||||
loadDataApi("b_i18n", null, "b_key ASC"),
|
||||
loadDataApi("b_user_module", null, "b_id ASC"),
|
||||
loadDataApi("b_user_power", null, "b_id ASC"),
|
||||
]);
|
||||
Object.assign(data, {
|
||||
modules: modules.data,
|
||||
fields: fields.data,
|
||||
powers: powers.data,
|
||||
translations: translations.data,
|
||||
userModules: userModules.data,
|
||||
userPowers: userPowers.data,
|
||||
});
|
||||
const target = data.modules.some((item) => item.b_id === preferredKey) ? preferredKey : "";
|
||||
|
||||
if (target) {
|
||||
@@ -179,7 +185,6 @@ function addModule(kind, contextKey) {
|
||||
draft.translations = {
|
||||
["module." + id]: { "zh-CN": draft.module.b_name },
|
||||
};
|
||||
customI18nKeys.value = [];
|
||||
selectedKey.value = id;
|
||||
isNew.value = true;
|
||||
original.value = "";
|
||||
@@ -212,52 +217,6 @@ function updateModuleId(value) {
|
||||
});
|
||||
}
|
||||
|
||||
async function addField() {
|
||||
if (!draft.module) return;
|
||||
|
||||
const [id] = await nextIds(1);
|
||||
const index = draft.fields.length + 1;
|
||||
const fieldCode = "b_field" + index;
|
||||
const i18nKey = "field." + draft.module.b_id + "." + fieldCode;
|
||||
draft.fields.push({
|
||||
b_id: id,
|
||||
b_module_id: draft.module.b_id,
|
||||
b_field: fieldCode,
|
||||
b_name: "新字段",
|
||||
b_i18n: i18nKey,
|
||||
b_type: "string",
|
||||
b_canlist: 1,
|
||||
b_canform: 1,
|
||||
b_must: 0,
|
||||
b_canquery: 0,
|
||||
b_readonly: 0,
|
||||
b_width: 120,
|
||||
b_value: null,
|
||||
b_xh: index * 10,
|
||||
b_canuse: 1,
|
||||
});
|
||||
draft.translations[i18nKey] = { "zh-CN": "新字段" };
|
||||
}
|
||||
|
||||
async function addPower() {
|
||||
if (!draft.module) return;
|
||||
|
||||
const [id] = await nextIds(1);
|
||||
const index = draft.powers.length + 1;
|
||||
const code = "action" + index;
|
||||
const i18nKey = "power." + draft.module.b_id + "." + code;
|
||||
draft.powers.push({
|
||||
b_id: id,
|
||||
b_module_id: draft.module.b_id,
|
||||
b_code: code,
|
||||
b_name: "新操作",
|
||||
b_i18n: i18nKey,
|
||||
b_xh: index * 10,
|
||||
b_canuse: 1,
|
||||
});
|
||||
draft.translations[i18nKey] = { "zh-CN": "新操作" };
|
||||
}
|
||||
|
||||
function normalizeModuleType() {
|
||||
if (draft.module.b_type === "directory") return;
|
||||
draft.module.b_type = draft.module.b_route?.trim() ? "page" : "data";
|
||||
@@ -330,18 +289,22 @@ async function translationChanges(previousKeys) {
|
||||
|
||||
for (const key of currentKeys) {
|
||||
for (const locale of LOCALES) {
|
||||
const value = draft.translations[key]?.[locale.value]?.trim();
|
||||
const value = draft.translations[key]?.[locale]?.trim();
|
||||
if (!value) continue;
|
||||
const old = oldRows.find((item) => item.b_key === key && item.b_locale === locale.value);
|
||||
const old = oldRows.find((item) => item.b_key === key && item.b_locale === locale);
|
||||
if (old) {
|
||||
desired.push({ ...old, b_value: value });
|
||||
} else {
|
||||
missing.push({ key, locale: locale.value, value });
|
||||
missing.push({ key, locale, value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = await nextIds(missing.length);
|
||||
const ids = [];
|
||||
for (let index = 0; index < missing.length; index += 1) {
|
||||
const response = await nextIdApi();
|
||||
ids.push(String(response.data));
|
||||
}
|
||||
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
|
||||
missing.forEach((item, index) => {
|
||||
desired.push({
|
||||
@@ -400,19 +363,33 @@ async function saveModule() {
|
||||
const requests = [];
|
||||
|
||||
if (assignedPowerDeletes.length) {
|
||||
requests.push(tableRequest("b_user_power", { deletes: assignedPowerDeletes }));
|
||||
requests.push({
|
||||
table: "b_user_power",
|
||||
key_field: "b_id",
|
||||
inserts: [],
|
||||
updates: [],
|
||||
deletes: assignedPowerDeletes,
|
||||
});
|
||||
}
|
||||
|
||||
const i18nChanges = await translationChanges(previousKeys);
|
||||
requests.push(
|
||||
tableRequest("b_module", {
|
||||
{
|
||||
table: "b_module",
|
||||
key_field: "b_id",
|
||||
inserts: isNew.value ? [draft.module] : [],
|
||||
updates: isNew.value ? [] : [draft.module],
|
||||
}),
|
||||
tableRequest("b_module_field", fieldChanges),
|
||||
tableRequest("b_module_power", powerChanges),
|
||||
tableRequest("b_i18n", await translationChanges(previousKeys)),
|
||||
deletes: [],
|
||||
},
|
||||
{ table: "b_module_field", key_field: "b_id", ...fieldChanges },
|
||||
{ table: "b_module_power", key_field: "b_id", ...powerChanges },
|
||||
{ table: "b_i18n", key_field: "b_id", ...i18nChanges },
|
||||
);
|
||||
await saveObjectApi(
|
||||
requests.filter(
|
||||
(request) => request.inserts.length || request.updates.length || request.deletes.length,
|
||||
),
|
||||
);
|
||||
await saveChanges(requests);
|
||||
|
||||
const savedKey = draft.module.b_id;
|
||||
message.success("模块配置已保存");
|
||||
@@ -457,26 +434,61 @@ async function deleteModule(moduleId = draft.module?.b_id) {
|
||||
].filter(Boolean),
|
||||
);
|
||||
|
||||
await saveChanges([
|
||||
tableRequest("b_user_power", {
|
||||
const requests = [
|
||||
{
|
||||
table: "b_user_power",
|
||||
key_field: "b_id",
|
||||
inserts: [],
|
||||
updates: [],
|
||||
deletes: data.userPowers
|
||||
.filter((item) => powerIds.has(String(item.b_power_id)))
|
||||
.map((item) => ({ b_id: item.b_id })),
|
||||
}),
|
||||
tableRequest("b_user_module", {
|
||||
},
|
||||
{
|
||||
table: "b_user_module",
|
||||
key_field: "b_id",
|
||||
inserts: [],
|
||||
updates: [],
|
||||
deletes: data.userModules
|
||||
.filter((item) => item.b_module_id === moduleId)
|
||||
.map((item) => ({ b_id: item.b_id })),
|
||||
}),
|
||||
tableRequest("b_module_power", { deletes: powers.map((item) => ({ b_id: item.b_id })) }),
|
||||
tableRequest("b_module_field", { deletes: fields.map((item) => ({ b_id: item.b_id })) }),
|
||||
tableRequest("b_i18n", {
|
||||
},
|
||||
{
|
||||
table: "b_module_power",
|
||||
key_field: "b_id",
|
||||
inserts: [],
|
||||
updates: [],
|
||||
deletes: powers.map((item) => ({ b_id: item.b_id })),
|
||||
},
|
||||
{
|
||||
table: "b_module_field",
|
||||
key_field: "b_id",
|
||||
inserts: [],
|
||||
updates: [],
|
||||
deletes: fields.map((item) => ({ b_id: item.b_id })),
|
||||
},
|
||||
{
|
||||
table: "b_i18n",
|
||||
key_field: "b_id",
|
||||
inserts: [],
|
||||
updates: [],
|
||||
deletes: data.translations
|
||||
.filter((item) => keys.has(item.b_key))
|
||||
.map((item) => ({ b_id: item.b_id })),
|
||||
}),
|
||||
tableRequest("b_module", { deletes: [{ b_id: moduleId }] }),
|
||||
]);
|
||||
},
|
||||
{
|
||||
table: "b_module",
|
||||
key_field: "b_id",
|
||||
inserts: [],
|
||||
updates: [],
|
||||
deletes: [{ b_id: moduleId }],
|
||||
},
|
||||
];
|
||||
await saveObjectApi(
|
||||
requests.filter(
|
||||
(request) => request.inserts.length || request.updates.length || request.deletes.length,
|
||||
),
|
||||
);
|
||||
|
||||
message.success("模块已删除");
|
||||
deleteConfirmOpen.value = false;
|
||||
@@ -612,12 +624,7 @@ onMounted(async () => {
|
||||
<Database :size="15" />字段定义 <a-badge :count="draft.fields.length" />
|
||||
</span>
|
||||
</template>
|
||||
<ModuleFieldDefinitionPanel
|
||||
v-model:fields="draft.fields"
|
||||
v-model:translations="draft.translations"
|
||||
:can-save="canSave"
|
||||
@add="addField"
|
||||
/>
|
||||
<ModuleFieldDefinitionPanel v-model:fields="draft.fields" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="powers">
|
||||
<template #tab>
|
||||
@@ -625,22 +632,24 @@ onMounted(async () => {
|
||||
<ShieldCheck :size="15" />操作权限 <a-badge :count="draft.powers.length" />
|
||||
</span>
|
||||
</template>
|
||||
<ModulePowerPanel v-model:powers="draft.powers" :can-save="canSave" @add="addPower" />
|
||||
<ModulePowerPanel v-model:powers="draft.powers" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="i18n">
|
||||
<template #tab>
|
||||
<span class="tab-label"><Languages :size="15" />多语言</span>
|
||||
</template>
|
||||
<ModuleTranslationsPanel
|
||||
v-model:custom-keys="customI18nKeys"
|
||||
v-model:translations="draft.translations"
|
||||
:can-save="canSave"
|
||||
:keys="translationKeys"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
<a-empty v-else description="请选择模块,或右键树节点创建模块和页面" />
|
||||
<a-empty
|
||||
v-else
|
||||
class="workspace-empty"
|
||||
description="请选择模块,或右键树节点创建模块和页面"
|
||||
/>
|
||||
</a-spin>
|
||||
</main>
|
||||
|
||||
@@ -749,6 +758,16 @@ h1 {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-empty {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-spin-nested-loading),
|
||||
:deep(.ant-spin-container) {
|
||||
height: 100%;
|
||||
|
||||
+19
-223
@@ -1,190 +1,30 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { Languages, Plus, Trash2 } from "@lucide/vue";
|
||||
import { LOCALES } from "../../../services/moduleManagement";
|
||||
import AutoHeightTable from "../../../components/AutoHeightTable.vue";
|
||||
import FmsTable from "@/components/FmsTable.vue";
|
||||
|
||||
defineProps({
|
||||
canSave: Boolean,
|
||||
});
|
||||
const emit = defineEmits(["add"]);
|
||||
const fields = defineModel("fields", { type: Array, default: () => [] });
|
||||
const translations = defineModel("translations", { type: Object, default: () => ({}) });
|
||||
|
||||
const editingFieldId = ref("");
|
||||
const selectedFieldKeys = ref([]);
|
||||
const columns = [
|
||||
{ title: "字段", key: "b_field", width: 150 },
|
||||
{ title: "名称", key: "b_name", width: 150 },
|
||||
{ title: "类型", key: "b_type", width: 120 },
|
||||
{ title: "列表", key: "b_canlist", width: 64, align: "center" },
|
||||
{ title: "表单", key: "b_canform", width: 64, align: "center" },
|
||||
{ title: "必填", key: "b_must", width: 64, align: "center" },
|
||||
{ title: "查询", key: "b_canquery", width: 64, align: "center" },
|
||||
{ title: "只读", key: "b_readonly", width: 64, align: "center" },
|
||||
{ title: "宽度", key: "b_width", width: 90 },
|
||||
{ title: "顺序", key: "b_xh", width: 80 },
|
||||
{ title: "操作", key: "operation", width: 58, fixed: "right" },
|
||||
{ dataIndex: "b_field", title: "字段", width: 150 },
|
||||
{ dataIndex: "b_name", title: "名称", width: 160 },
|
||||
{ dataIndex: "b_type", title: "类型", width: 120 },
|
||||
{ dataIndex: "b_canlist", title: "列表", width: 64, align: "center", headerAlign: "center" },
|
||||
{ dataIndex: "b_canform", title: "表单", width: 64, align: "center", headerAlign: "center" },
|
||||
{ dataIndex: "b_must", title: "必填", width: 64, align: "center", headerAlign: "center" },
|
||||
{ dataIndex: "b_canquery", title: "查询", width: 64, align: "center", headerAlign: "center" },
|
||||
{ dataIndex: "b_readonly", title: "只读", width: 64, align: "center", headerAlign: "center" },
|
||||
{ dataIndex: "b_width", title: "宽度", width: 90 },
|
||||
{ dataIndex: "b_xh", title: "顺序", width: 80 },
|
||||
];
|
||||
const fieldTypes = [
|
||||
["string", "单行文本"],
|
||||
["text", "多行文本"],
|
||||
["number", "数字"],
|
||||
["select", "选择"],
|
||||
["date", "日期"],
|
||||
["boolean", "布尔"],
|
||||
].map(([value, label]) => ({ value, label }));
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedFieldKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedFieldKeys.value = keys;
|
||||
},
|
||||
columnWidth: 40,
|
||||
}));
|
||||
|
||||
function translationEntry(key) {
|
||||
translations.value[key] ||= {};
|
||||
return translations.value[key];
|
||||
}
|
||||
|
||||
function setFieldTranslation(record, locale, value) {
|
||||
translationEntry(record.b_i18n)[locale] = value;
|
||||
if (locale === "zh-CN") record.b_name = value;
|
||||
}
|
||||
|
||||
function fieldTranslationValue(record, locale) {
|
||||
return translationEntry(record.b_i18n)[locale] ?? (locale === "zh-CN" ? record.b_name : "");
|
||||
}
|
||||
|
||||
function fieldTypeLabel(value) {
|
||||
return fieldTypes.find((item) => item.value === value)?.label || value;
|
||||
}
|
||||
|
||||
function setBoolean(record, field, checked) {
|
||||
record[field] = checked ? 1 : 0;
|
||||
if (field === "b_canform" && !checked) record.b_must = 0;
|
||||
}
|
||||
|
||||
function fieldRow(record) {
|
||||
return {
|
||||
onClick: () => {
|
||||
editingFieldId.value = String(record.b_id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function leaveEdit(event) {
|
||||
if (!event.target.closest(".ant-table-row")) editingFieldId.value = "";
|
||||
}
|
||||
|
||||
function removeField(record) {
|
||||
fields.value = fields.value.filter((item) => String(item.b_id) !== String(record.b_id));
|
||||
selectedFieldKeys.value = selectedFieldKeys.value.filter(
|
||||
(key) => String(key) !== String(record.b_id),
|
||||
);
|
||||
}
|
||||
|
||||
function deleteSelectedFields() {
|
||||
const ids = new Set(selectedFieldKeys.value.map(String));
|
||||
fields.value = fields.value.filter((item) => !ids.has(String(item.b_id)));
|
||||
selectedFieldKeys.value = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="table-pane" @click="leaveEdit">
|
||||
<div class="table-toolbar">
|
||||
<a-button type="primary" ghost :disabled="!canSave" @click="emit('add')">
|
||||
<Plus :size="15" />新增字段
|
||||
</a-button>
|
||||
<a-popconfirm title="确定删除选中的字段吗?" @confirm="deleteSelectedFields">
|
||||
<a-button danger :disabled="!canSave || !selectedFieldKeys.length">
|
||||
<Trash2 :size="15" />批量删除 ({{ selectedFieldKeys.length }})
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
|
||||
<AutoHeightTable
|
||||
<section class="table-pane">
|
||||
<FmsTable
|
||||
class="sub-table"
|
||||
row-key="b_id"
|
||||
:columns="columns"
|
||||
:data-source="fields"
|
||||
:pagination="false"
|
||||
:row-class-name="(record) => (String(record.b_id) === editingFieldId ? 'editing-row' : '')"
|
||||
:row-selection="rowSelection"
|
||||
row-key="b_id"
|
||||
@row="fieldRow"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<a-tooltip v-if="column.key === 'operation'" title="删除字段">
|
||||
<a-button type="text" danger @click.stop="removeField(record)">
|
||||
<Trash2 :size="15" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<template v-else-if="String(record.b_id) === editingFieldId">
|
||||
<a-input v-if="column.key === 'b_field'" v-model:value="record.b_field" />
|
||||
<div v-else-if="column.key === 'b_name'" class="field-name-editor">
|
||||
<a-input
|
||||
:value="record.b_name"
|
||||
@update:value="setFieldTranslation(record, 'zh-CN', $event)"
|
||||
/>
|
||||
<a-popover trigger="click" placement="bottomRight">
|
||||
<template #content>
|
||||
<a-space direction="vertical" size="small" style="width: 260px">
|
||||
<div v-for="locale in LOCALES" :key="locale.value">
|
||||
<div class="locale-label">{{ locale.label }}</div>
|
||||
<a-input
|
||||
:value="fieldTranslationValue(record, locale.value)"
|
||||
@update:value="setFieldTranslation(record, locale.value, $event)"
|
||||
/>
|
||||
</div>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-button type="text" class="i18n-button" @click.stop>
|
||||
<Languages :size="15" />
|
||||
</a-button>
|
||||
</a-popover>
|
||||
</div>
|
||||
<a-select
|
||||
v-else-if="column.key === 'b_type'"
|
||||
v-model:value="record.b_type"
|
||||
:options="fieldTypes"
|
||||
/>
|
||||
<a-switch
|
||||
v-else-if="
|
||||
['b_canlist', 'b_canform', 'b_must', 'b_canquery', 'b_readonly'].includes(column.key)
|
||||
"
|
||||
size="small"
|
||||
:checked="record[column.key] === 1"
|
||||
:disabled="column.key === 'b_must' && record.b_canform === 0"
|
||||
@change="setBoolean(record, column.key, $event)"
|
||||
/>
|
||||
<a-input-number
|
||||
v-else-if="column.key === 'b_width'"
|
||||
v-model:value="record.b_width"
|
||||
:min="40"
|
||||
/>
|
||||
<a-input-number v-else-if="column.key === 'b_xh'" v-model:value="record.b_xh" :min="0" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span v-if="column.key === 'b_field'" class="cell-code">{{ record.b_field }}</span>
|
||||
<span v-else-if="column.key === 'b_name'" class="cell-name">
|
||||
{{ record.b_name }}<Languages v-if="record.b_i18n" :size="13" />
|
||||
</span>
|
||||
<span v-else-if="column.key === 'b_type'">{{ fieldTypeLabel(record.b_type) }}</span>
|
||||
<a-switch
|
||||
v-else-if="
|
||||
['b_canlist', 'b_canform', 'b_must', 'b_canquery', 'b_readonly'].includes(column.key)
|
||||
"
|
||||
size="small"
|
||||
:checked="record[column.key] === 1"
|
||||
disabled
|
||||
/>
|
||||
<span v-else-if="column.key === 'b_width'">{{ record.b_width || "-" }}</span>
|
||||
<span v-else-if="column.key === 'b_xh'">{{ record.b_xh }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</AutoHeightTable>
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -197,52 +37,8 @@ function deleteSelectedFields() {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.field-name-editor,
|
||||
.cell-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field-name-editor .ant-input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.i18n-button {
|
||||
flex: 0 0 auto;
|
||||
padding-inline: 5px;
|
||||
}
|
||||
|
||||
.cell-name svg {
|
||||
flex: 0 0 auto;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.cell-code {
|
||||
color: #475569;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.editing-row > td) {
|
||||
background: #f0f7ff !important;
|
||||
}
|
||||
|
||||
:deep(.ant-table-row) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.ant-table-cell .ant-input-number),
|
||||
:deep(.ant-table-cell .ant-select) {
|
||||
width: 100%;
|
||||
.sub-table {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,120 +1,25 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { Plus, Trash2 } from "@lucide/vue";
|
||||
import AutoHeightTable from "../../../components/AutoHeightTable.vue";
|
||||
import FmsTable from "@/components/FmsTable.vue";
|
||||
|
||||
defineProps({
|
||||
canSave: Boolean,
|
||||
});
|
||||
const emit = defineEmits(["add"]);
|
||||
const powers = defineModel("powers", { type: Array, default: () => [] });
|
||||
|
||||
const editingPowerId = ref("");
|
||||
const selectedPowerKeys = ref([]);
|
||||
const columns = [
|
||||
{ title: "权限代码", key: "b_code", width: 180 },
|
||||
{ title: "名称", key: "b_name", width: 200 },
|
||||
{ title: "多语言键", key: "b_i18n" },
|
||||
{ title: "启用", key: "b_canuse", width: 74, align: "center" },
|
||||
{ title: "顺序", key: "b_xh", width: 90 },
|
||||
{ title: "操作", key: "operation", width: 58 },
|
||||
{ dataIndex: "b_code", title: "权限代码", width: 180 },
|
||||
{ dataIndex: "b_name", title: "名称", width: 200 },
|
||||
{ dataIndex: "b_i18n", title: "多语言键", width: 200 },
|
||||
{ dataIndex: "b_canuse", title: "启用", width: 74, align: "center", headerAlign: "center" },
|
||||
{ dataIndex: "b_xh", title: "顺序", width: 90 },
|
||||
];
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedPowerKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedPowerKeys.value = keys;
|
||||
},
|
||||
columnWidth: 40,
|
||||
}));
|
||||
|
||||
function setBoolean(record, field, checked) {
|
||||
record[field] = checked ? 1 : 0;
|
||||
}
|
||||
|
||||
function powerRow(record) {
|
||||
return {
|
||||
onClick: () => {
|
||||
editingPowerId.value = String(record.b_id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function leaveEdit(event) {
|
||||
if (!event.target.closest(".ant-table-row")) editingPowerId.value = "";
|
||||
}
|
||||
|
||||
function removePower(record) {
|
||||
powers.value = powers.value.filter((item) => String(item.b_id) !== String(record.b_id));
|
||||
selectedPowerKeys.value = selectedPowerKeys.value.filter(
|
||||
(key) => String(key) !== String(record.b_id),
|
||||
);
|
||||
}
|
||||
|
||||
function deleteSelectedPowers() {
|
||||
const ids = new Set(selectedPowerKeys.value.map(String));
|
||||
powers.value = powers.value.filter((item) => !ids.has(String(item.b_id)));
|
||||
selectedPowerKeys.value = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="table-pane" @click="leaveEdit">
|
||||
<div class="table-toolbar">
|
||||
<a-button type="primary" ghost :disabled="!canSave" @click="emit('add')">
|
||||
<Plus :size="15" />新增权限
|
||||
</a-button>
|
||||
<a-popconfirm title="确定删除选中的权限吗?" @confirm="deleteSelectedPowers">
|
||||
<a-button danger :disabled="!canSave || !selectedPowerKeys.length">
|
||||
<Trash2 :size="15" />批量删除 ({{ selectedPowerKeys.length }})
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
|
||||
<AutoHeightTable
|
||||
<section class="table-pane">
|
||||
<FmsTable
|
||||
class="sub-table"
|
||||
row-key="b_id"
|
||||
:columns="columns"
|
||||
:data-source="powers"
|
||||
:pagination="false"
|
||||
:row-class-name="(record) => (String(record.b_id) === editingPowerId ? 'editing-row' : '')"
|
||||
:row-selection="rowSelection"
|
||||
row-key="b_id"
|
||||
@row="powerRow"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<a-tooltip v-if="column.key === 'operation'" title="删除权限">
|
||||
<a-button type="text" danger @click.stop="removePower(record)">
|
||||
<Trash2 :size="15" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<template v-else-if="String(record.b_id) === editingPowerId">
|
||||
<a-input v-if="column.key === 'b_code'" v-model:value="record.b_code" />
|
||||
<a-input v-else-if="column.key === 'b_name'" v-model:value="record.b_name" />
|
||||
<a-input v-else-if="column.key === 'b_i18n'" v-model:value="record.b_i18n" />
|
||||
<a-switch
|
||||
v-else-if="column.key === 'b_canuse'"
|
||||
size="small"
|
||||
:checked="record.b_canuse === 1"
|
||||
@change="setBoolean(record, 'b_canuse', $event)"
|
||||
/>
|
||||
<a-input-number v-else-if="column.key === 'b_xh'" v-model:value="record.b_xh" :min="0" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span v-if="column.key === 'b_code'" class="cell-code">{{ record.b_code }}</span>
|
||||
<span v-else-if="column.key === 'b_name'">{{ record.b_name }}</span>
|
||||
<span v-else-if="column.key === 'b_i18n'" class="cell-code">
|
||||
{{ record.b_i18n || "-" }}
|
||||
</span>
|
||||
<a-switch
|
||||
v-else-if="column.key === 'b_canuse'"
|
||||
size="small"
|
||||
:checked="record.b_canuse === 1"
|
||||
disabled
|
||||
/>
|
||||
<span v-else-if="column.key === 'b_xh'">{{ record.b_xh }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</AutoHeightTable>
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -127,30 +32,8 @@ function deleteSelectedPowers() {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cell-code {
|
||||
color: #475569;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.editing-row > td) {
|
||||
background: #f0f7ff !important;
|
||||
}
|
||||
|
||||
:deep(.ant-table-row) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.ant-table-cell .ant-input-number) {
|
||||
width: 100%;
|
||||
.sub-table {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
+29
-112
@@ -1,112 +1,43 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { Plus, Trash2 } from "@lucide/vue";
|
||||
import { App } from "antdv-next";
|
||||
import { LOCALES } from "../../../services/moduleManagement";
|
||||
import AutoHeightTable from "../../../components/AutoHeightTable.vue";
|
||||
import { computed } from "vue";
|
||||
import FmsTable from "@/components/FmsTable.vue";
|
||||
|
||||
const LOCALES = [
|
||||
{ value: "zh-CN", label: "简体中文" },
|
||||
{ value: "en-US", label: "English" },
|
||||
{ value: "zh-TW", label: "繁體中文" },
|
||||
];
|
||||
|
||||
const props = defineProps({
|
||||
canSave: Boolean,
|
||||
keys: { type: Array, default: () => [] },
|
||||
});
|
||||
const customKeys = defineModel("customKeys", { type: Array, default: () => [] });
|
||||
const translations = defineModel("translations", { type: Object, default: () => ({}) });
|
||||
const { message } = App.useApp();
|
||||
|
||||
const newI18nKey = ref("");
|
||||
const showI18nInput = ref(false);
|
||||
const columns = [
|
||||
{ title: "多语言键", key: "key", width: 280, fixed: "left" },
|
||||
...LOCALES.map((locale) => ({
|
||||
title: locale.label,
|
||||
key: locale.value,
|
||||
width: 220,
|
||||
})),
|
||||
{ title: "操作", key: "operation", width: 58, fixed: "right" },
|
||||
];
|
||||
const rows = computed(() =>
|
||||
props.keys.map((key) => ({
|
||||
key,
|
||||
isCustom: customKeys.value.includes(key),
|
||||
})),
|
||||
props.keys.map((key) => {
|
||||
const entry = translations.value[key] || {};
|
||||
const row = { key };
|
||||
LOCALES.forEach((locale) => {
|
||||
row[locale.value] = entry[locale.value];
|
||||
});
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
|
||||
function translationEntry(key) {
|
||||
translations.value[key] ||= {};
|
||||
return translations.value[key];
|
||||
}
|
||||
|
||||
function setTranslation(key, locale, value) {
|
||||
translationEntry(key)[locale] = value;
|
||||
}
|
||||
|
||||
function addCustomKey() {
|
||||
const key = newI18nKey.value.trim();
|
||||
if (!key) return;
|
||||
if (props.keys.includes(key)) {
|
||||
message.warning("多语言键已存在");
|
||||
return;
|
||||
}
|
||||
|
||||
customKeys.value = [...customKeys.value, key];
|
||||
translationEntry(key);
|
||||
newI18nKey.value = "";
|
||||
showI18nInput.value = false;
|
||||
}
|
||||
|
||||
function removeCustomKey(key) {
|
||||
customKeys.value = customKeys.value.filter((item) => item !== key);
|
||||
delete translations.value[key];
|
||||
}
|
||||
const columns = [
|
||||
{ dataIndex: "key", title: "多语言键", width: 280, fixed: "left" },
|
||||
...LOCALES.map((locale) => ({ dataIndex: locale.value, title: locale.label, width: 220 })),
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="translation-pane">
|
||||
<div class="table-toolbar">
|
||||
<template v-if="showI18nInput">
|
||||
<a-input
|
||||
v-model:value="newI18nKey"
|
||||
placeholder="输入多语言键名,回车确认"
|
||||
size="small"
|
||||
class="new-key-input"
|
||||
@press-enter="addCustomKey"
|
||||
/>
|
||||
<a-button
|
||||
type="primary"
|
||||
ghost
|
||||
size="small"
|
||||
:disabled="!newI18nKey.trim()"
|
||||
@click="addCustomKey"
|
||||
>
|
||||
<Plus :size="15" />添加
|
||||
</a-button>
|
||||
<a-button size="small" @click="showI18nInput = false">取消</a-button>
|
||||
</template>
|
||||
<a-button v-else type="primary" ghost :disabled="!canSave" @click="showI18nInput = true">
|
||||
<Plus :size="15" />新增多语言键
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<AutoHeightTable :columns="columns" :data-source="rows" :pagination="false" row-key="key">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<code v-if="column.key === 'key'" class="translation-key">{{ record.key }}</code>
|
||||
<a-input
|
||||
v-else-if="LOCALES.some((locale) => locale.value === column.key)"
|
||||
:value="translationEntry(record.key)[column.key]"
|
||||
size="small"
|
||||
:placeholder="column.key === 'zh-CN' ? '必填' : '可选'"
|
||||
@update:value="setTranslation(record.key, column.key, $event)"
|
||||
/>
|
||||
<a-tooltip v-else-if="column.key === 'operation' && record.isCustom" title="删除多语言键">
|
||||
<a-button type="text" danger @click="removeCustomKey(record.key)">
|
||||
<Trash2 :size="15" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template #emptyText>
|
||||
<a-empty description="当前模块没有多语言键" />
|
||||
</template>
|
||||
</AutoHeightTable>
|
||||
<FmsTable
|
||||
class="sub-table"
|
||||
row-key="key"
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -119,22 +50,8 @@ function removeCustomKey(key) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.new-key-input {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.translation-key {
|
||||
color: #374151;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
.sub-table {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { RefreshCw, Search } from "@lucide/vue";
|
||||
import FmsTree from "@/components/FmsTree.vue";
|
||||
|
||||
const props = defineProps({
|
||||
modules: { type: Array, required: true },
|
||||
@@ -35,11 +36,15 @@ const treeData = computed(() => {
|
||||
const build = (parent) =>
|
||||
(children.get(parent) || [])
|
||||
.filter((item) => !visible || visible.has(item.b_id))
|
||||
.map((item) => ({
|
||||
key: item.b_id,
|
||||
title: item.b_name,
|
||||
children: build(item.b_id),
|
||||
}));
|
||||
.map((item) => {
|
||||
const nodeChildren = build(item.b_id);
|
||||
return {
|
||||
key: item.b_id,
|
||||
title: item.b_name,
|
||||
children: nodeChildren,
|
||||
isLeaf: nodeChildren.length === 0,
|
||||
};
|
||||
});
|
||||
|
||||
return build("__root__");
|
||||
});
|
||||
@@ -94,16 +99,15 @@ const contextMenu = computed(() => {
|
||||
</a-tooltip>
|
||||
</header>
|
||||
|
||||
<a-input v-model:value="searchText" allow-clear size="small" placeholder="搜索名称或编码">
|
||||
<a-input v-model:value="searchText" allow-clear placeholder="搜索名称或编码">
|
||||
<template #prefix><Search :size="15" /></template>
|
||||
</a-input>
|
||||
|
||||
<a-spin :spinning="loading" class="tree-spin">
|
||||
<a-dropdown :menu="contextMenu" :trigger="['contextmenu']">
|
||||
<a-tree
|
||||
<FmsTree
|
||||
block-node
|
||||
default-expand-all
|
||||
show-line
|
||||
:tree-data="treeData"
|
||||
:selected-keys="selectedKey ? [selectedKey] : []"
|
||||
@right-click="onRightClick"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
@@ -6,6 +7,11 @@ import Components from "unplugin-vue-components/vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5180,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- `/data/nextid`
|
||||
- 不随意修改通用接口的地址、参数、返回结构和处理逻辑。
|
||||
- 不随意新增业务专用接口。
|
||||
- 不新增 services 这类接口或者文件。
|
||||
- 如果现有通用接口确实无法完成需求,必须先说明原因、拟新增或修改的内容及影响范围,并取得用户确认后再实施。
|
||||
|
||||
## 三、前端实现
|
||||
@@ -25,6 +26,7 @@
|
||||
- 优先复用现有布局、样式变量和组件能力,以尽量少的 CSS 完成功能。
|
||||
- 不使用 CSS 重复实现组件已有的尺寸、状态、交互和响应式能力。
|
||||
- 新增 CSS 应限制在当前组件作用域内,避免影响其他页面。
|
||||
- 不需要兼容移动端,按桌面端实现即可。
|
||||
|
||||
## 四、变更范围
|
||||
|
||||
|
||||
Reference in new issue
Block a user