Compare commits

...
Author SHA1 Message Date
wenjianzhang ec10917272 Merge pull request #947 from go-admin-team/fix/915-scheduler-lease
fix🐛: make the job scheduler single-writer with a database lease
2026-09-22 22:57:21 +08:00
wenjianzhang b8af16baf0 Merge pull request #946 from go-admin-team/test/job-remove-goroutine-leak
test: fail when Remove parks a goroutine on an abandoned channel
2026-09-22 22:54:55 +08:00
wenjianzhang 92987cdee3 Merge pull request #943 from go-admin-team/feat/010-generator-vue3
feat: generate Vue 3 pages from the code generator (PRD 010)
2026-09-22 21:42:43 +08:00
zhangwenjian effc3a3e69 Merge branches 'feat/010-gen-plumbing' and 'feat/010-vue3-template' into integ/010-all 2026-09-19 21:21:25 +08:00
zhangwenjian 08f789737f fix🐛: escape the literal T in the datetime value-format
value-format="YYYY-MM-DDTHH:mm:ssZ" works today only because dayjs does
not currently give T a format-token meaning, so it passes through as a
literal character -- an accident of the current token table, not
something this string declares. Escaped it to YYYY-MM-DD[T]HH:mm:ssZ,
dayjs's own syntax for "this character, verbatim, not a token": produces
byte-for-byte the same output today (confirmed below) and stops
depending on T staying meaningless in a future dayjs version.

Re-verified both directions against the escaped string, and did so
against the real el-date-picker component this time rather than dayjs
alone: mounted element-plus's actual ElDatePicker with
value-format="YYYY-MM-DD[T]HH:mm:ssZ" (copied from a real rendering of
this fixed template, not retyped) and confirmed it renders a real,
non-blank date -- not "Invalid Date" -- when its modelValue is set to
either shape Go's encoding/json actually sends (a numeric offset or a
literal Z for UTC), and that both render identically since they are the
same instant. Submission was re-checked through the same dayjs call the
component itself makes to format a picked value. A fourth check formats
the same instant with both the old and the escaped string and asserts
they are equal, so this suite would have caught the difference if the
escape had changed anything instead of just hardening it.
2026-09-19 21:21:01 +08:00
zhangwenjian f57bf5d61d fix🐛: query-less table's Query type broke FK dropdown fetches (P0)
3625ce8 fixed the no-empty-object-type lint error by switching a
zero-IsQuery table's {ClassName}Query to `Record<string, never>`, the
same default useTable.ts's own `TQuery extends object = Record<string,
never>` uses. That default is safe there only because useTable.ts's one
internal `TQuery & PageQuery` goes through an `as` cast rather than a
structural check. Code that builds the object literal directly does not
get that protection - and vue.go.template's foreign-key dropdown fetch
does exactly that: `list{FkClass}({ pageIndex: 1, pageSize: 100 })`.

Record<string, never> is a mapped type over every string key, each
mapped to never, so `Record<string, never> & PageQuery` does not leave
PageQuery's own properties alone: pageIndex becomes `never & number`,
i.e. never, and no value can be passed for it - a straight type error,
not a lint warning, so the previous fix's pnpm lint pass did not catch
it. Trips on any query-less table referenced by a foreign key - a plain
lookup/dict table used as a dropdown source, an ordinary shape, not a
rare one.

Switched to Record<never, never>: a mapped type over the empty key
set, which behaves as the empty object type `{}` under intersection
(PageQuery's properties come through unchanged) while still satisfying
`TQuery extends object` and not tripping no-empty-object-type (it is a
generic instantiation, not a literal `{}` type annotation) - confirmed
all three separately before touching the template.

Verified with node 24.11.0: generated a real zero-IsQuery table, copied
its .ts into go-admin-ui alongside a throwaway file reproducing
vue.go.template's exact FK call site
(`await list{Class}({ pageIndex: 1, pageSize: 100 })`), and ran both
eslint and vue-tsc --noEmit - the type-check step lint alone cannot
cover, which is what let this through the first time. Confirmed red
first (swapped the generated file's Record<never, never> back to
Record<string, never>): vue-tsc reported the exact "Property 'pageIndex'
is incompatible with index signature" error. Restored the fix - both
clean.
2026-09-19 21:20:41 +08:00
zhangwenjian 143dbf19a2 Merge branches 'feat/010-gen-plumbing' and 'feat/010-vue3-template' into integ/010-all 2026-09-19 20:51:39 +08:00
zhangwenjian f6bd306d6d fix🐛: send datetime fields as RFC3339, not space-separated local time
Both date-pickers -- the search filter and the insert/edit form -- used
value-format="YYYY-MM-DD HH:mm:ss", which formats a picked instant as
e.g. "2026-09-19 12:30:00": no T separator, no offset. dto.go.template
declares every datetime column's InsertReq/UpdateReq field as
time.Time with a plain `json:"..."` tag (R6 leaves that file alone, so
there is no time_format tag to reach for instead), and encoding/json's
default (Un)MarshalJSON for time.Time only accepts RFC3339. The
generated form would submit new and edited datetime values in a shape
Go's JSON decoder cannot parse -- a runtime failure on every create and
update, with nothing in `pnpm type-check` or `pnpm lint` positioned to
see it: both check the request is well-typed TypeScript, not that the
string it produces is a string Go can read.

Changed both to value-format="YYYY-MM-DDTHH:mm:ssZ" -- dayjs's Z token
renders the picker's own local offset, which is what a zero-nanosecond
time.Time (anything without a database column storing sub-second
precision) round-trips to on either side of the wire; confirmed
separately against Go's actual json.Marshal/Unmarshal, not assumed from
the RFC.

The search filter needed the same fix, not just the form: GetPageReq
binds a `time.Time` query field via `form:"..."` (dto.go.template),
and gin's own default for an untagged time.Time binding is also
RFC3339 -- the same failure mode on the query side, one call the
report didn't name but the same root cause reaches.

This is a runtime behaviour change no compiler catches, so it was
verified as one: a Go program exercising encoding/json directly (not
assumed from reading the RFC) confirmed a zero-nanosecond time.Time
marshals to plain RFC3339 with no fractional seconds, and unmarshals
correctly from both a numeric offset and a literal Z. Separately, a
Node script loaded go-admin-ui's own installed dayjs 1.11.21 with the
customParseFormat plugin -- the same plugin element-plus's date-picker
extends dayjs with -- and called the same parseDate path date-picker
panel.mjs uses (time-picker/src/utils.ts, no strict flag passed, so
lenient parsing): formatting with this value-format produced a valid
submission string, and parsing either an offset or a literal-Z string
back with the same format produced a valid, correctly-valued date --
covering create, edit prefill, and update in the two directions that
matter (browser to Go, Go to browser) without needing a live backend.
2026-09-19 20:50:13 +08:00
zhangwenjian 3beb00143a fix🐛: give FK and dict options separate variable names
A column configured with both FkTableName and DictType -- and reaching
a branch of each, e.g. required + IsQuery=1 + IsInsert=1 with
HtmlType=radio -- had both blocks declare `const {JsonField}Options`:
the dict branch from useDict, the FK branch from ref(). TS2451,
Cannot redeclare block-scoped variable, and the page does not compile.

This is reachable precisely because FkTableName and DictType do not
exclude each other consistently: search, list and the form's select
branch check FK first and fall back to dict, but the form's radio
branch never looks at FK at all -- it was already established (the
$dictUsed/$fkUsed audit two commits back) that a radio column's dict
options are used regardless of whatever FkTableName says. A column
that is both radio and query-or-insert-select can legitimately need
both sources at once, under one shared name.

Renamed to {JsonField}DictOptions and {JsonField}FkOptions and updated
every consuming branch to the name that matches what it was already
branching on: FK branches (search select, form select, the list
column's Label function) read FkOptions; dict branches (search select,
form select, form radio, the list column's dictLabel call) read
DictOptions. Mechanical rename, no new conditions -- each site already
knew which source it wanted from its own if/else-if.

Verified with team-lead's exact repro (FkTableName + DictType +
IsQuery=1 + IsInsert=1 + HtmlType=radio) rendered through the real
template.Execute and checked against a throwaway go-admin-ui worktree
(deleted afterwards): TS2451 fired twice before this change, zero
after -- and the rendered file confirms the search select actually
reads kindFkOptions (FK wins search's priority) while the insert radio
reads kindDictOptions (radio never checks FK), so both sources are
live, not just declared. Re-ran every fixture from every previous
round alongside it; all stayed green. pnpm type-check and pnpm lint
both zero error, on Node 24.11.0.
2026-09-19 20:49:41 +08:00
zhangwenjian 7a52a50964 fix🐛: dedupe FK imports by target table and filter by actual use
Two columns pointing at the same foreign table -- owner and approver
both selecting from the same users table, say -- each triggered their
own `import { listX } from ...` / `import type { X } from ...` line,
which is a duplicate ES module import once both fire: TS2300. Nothing
here ever asked whether a target table had already been imported by an
earlier column, because nothing tracked target tables at all -- only
source columns, and "one FK-configured column" was never the same
thing as "one distinct target table".

The same import was also gated on the column having FkTableName set,
not on $fkUsed -- the condition the previous fix already applies to the
const declarations that read the import. A column carrying FK metadata
but reaching no query, list or insert-select branch imported a module
nothing in the file references.

text/template has no set to check membership in, so the dedup is a
nested range: a column only imports its target if no earlier,
equally-used column already claimed the same FkTableNameClass. $fkUsed
is recomputed for both the outer and the inner column rather than
factored out, since text/template has no way to carry a per-column
value computed in one range into a second, later range over the same
data.

Verified with a fixture carrying three columns pointing at the same
target table -- one read only from search, one only from the list, one
from neither -- rendered through the real template.Execute and checked
against a throwaway go-admin-ui worktree (deleted afterwards): before
this change, TS2300 fired six times (the function and the type, three
times over); after, exactly one import of each, and the unused third
column contributes neither. Re-ran the previous rounds' fixtures
alongside it; all stayed green. pnpm type-check and pnpm lint both zero
error, on Node 24.11.0.
2026-09-19 20:48:58 +08:00
zhangwenjian 630e13686c fix🐛: gate optional imports on where they're actually used, not on raw metadata
Every $hasX flag controlling an optional import matched "this column
carries the metadata" rather than "some rendered branch actually reads
it" -- necessary but not sufficient, since FkTableName/DictType lose to
each other by priority (FK wins search, list and the form's select
branch; the form's radio branch never checks FK at all) and a column
can carry either while being neither queryable, listed, nor an
insertable select/radio.

$hasDatetime was the reachable case integration testing found:
sys_tables.go assigns HtmlType "datetime" to any timestamp/datetime
column on import regardless of IsList, because GetList's audit-column
exclusion is a separate, later step editTable.vue never surfaces
created_at/updated_at through anyway. Nearly every real table has both,
so nearly every table imported DateCell without using it.

$hasFk and $hasDict had the identical shape one level down: the
per-column ref/onMounted/useDict declarations were gated on "this
column has FkTableName/DictType", not on whether the column reaches a
branch that reads the resulting Options ref -- an FK column used only
via search (no IsList, no insert-select) still declared a Label
function nothing calls, and a dict column used only in an insert radio
(no IsQuery, no IsList) still would have, had the two flags controlling
its import stayed as wide as the per-column check they were meant to
gate.

Rewrote both to a shared $dictUsed/$fkUsed condition, matching each
consuming branch's own guard term for term, and split the FK block's
Label function under its own IsList check -- Options can be needed for
search or the form's select without List ever being true. $hasDictList
already had this shape from the previous fix and needed no change.

Verified with two new fixtures, rendered through the real
template.Execute and checked against a throwaway go-admin-ui worktree
(deleted afterwards) with hand-written API-module stubs: a bare table
carrying only the standard created_at/updated_at pair -- confirmed red
on DateCell before this change, green after -- and a table exercising
every optional import through a path distinct from the ones the two
earlier verification rounds covered (a dict column read only from an
insert radio, an FK column read only from search, and a business
datetime column that IS listed, so DateCell still has to import when
the real thing needs it). Re-ran the three fixtures from the previous
two rounds alongside these two; all five stayed green. pnpm type-check
and pnpm lint both zero error, on Node 24.11.0.
2026-09-19 20:40:29 +08:00
zhangwenjian 05661e2f3e fix🐛: jsonField format check relaxed to any legal identifier
jsonFieldPattern copied businessName's rule (^[a-z][A-Za-z]+$: at least
two letters, no digits) on the theory that jsonField should tighten to
the same identifier shape. That does not hold: businessName is typed
by a person on genInfoForm.vue, so a strict pattern is a reasonable
guardrail on human input. jsonField is computed by the importer from
the column name (sys_tables.go's namelist/JsonField loop) - nobody
types it, so the same pattern only rejected names the importer
legitimately produces: a single-letter column ("x") or one whose last
segment ends in a digit ("address2", "a1") both collapse to a single
camelCase word with nothing left to re-capitalize, and both failed the
old check.

The blast radius is wider than "this one column can't be edited":
validateAndSanitizeColumns runs over every column on every Update, so
a table that merely contains one such column could not save any
config change at all, including edits with nothing to do with that
column.

Relaxed to ^[a-z][A-Za-z0-9]*$ - any legal JS/TS identifier starting
with a lowercase letter. Still rejects what has to be rejected: empty,
whitespace/punctuation, and leading-digit names, since those cannot be
unquoted object keys in the generated interface/lang file at all.
Uniqueness and the expression-content check on defaultValue are
unchanged - defaultValue is genuinely user-typed (F6's config page),
so tightening it was the right call to begin with; this was the only
place a human-input rule had been copied onto machine-generated data.

Verified against the real import path, not hand-typed jsonField values:
built a table with columns id/x/address2/a1 in a fake information_schema,
ran it through the real SysTable.Insert, confirmed the importer computes
exactly jsonField x/address2/a1, then submitted an update through the
real SysTable.Update changing only tableComment (nothing about those
columns). Confirmed red first - 500, "jsonField 格式不合法:\"x\"" - a
change unrelated to any of the three columns was rejected solely because
they existed on the table. Restored the fix - 200, "修改成功".
2026-09-19 20:37:01 +08:00
zhangwenjian 3625ce851b fix🐛: empty Query interface for zero-IsQuery tables trips no-empty-object-type
A plain display table with no search form (zero columns marked
IsQuery=1) is a normal shape, not an edge case - but ts.go.template's
Query interface only had a body when the range over .Columns found a
match, so it rendered `export interface {ClassName}Query {}`, which
@typescript-eslint/no-empty-object-type flags and pnpm lint fails on.

Falls back to `export type {ClassName}Query = Record<string, never>`
when no column qualifies - the same answer go-admin-ui's own
useTable.ts already gives this shape (`TQuery extends object =
Record<string, never>`), so it intersects with PageQuery the same way
an empty interface would have and callers do not need to special-case
a query-less table.

Audited the rest of this template for the same "zero of some optional
feature" shape while in here, since this is the third time a query-less/
select-less/whatever-less table has slipped through (dictLabel, DateCell,
this one):

  - a zero-column table: cannot occur - a table without at least a
    primary key column cannot exist to be imported from information_schema
    in the first place, so .Columns is never empty here.
  - a table with only its primary key column: the row interface still
    has one property (the pk); not empty, no lint issue.
  - a table where every column has IsInsert=0: does not affect this
    template - add{Class}/update{Class} both take the full row interface
    unconditionally (every column, not just IsInsert ones), so there is
    no column-count-dependent shape to go empty here.

Verified with node 24.11.0 (not the machine default): generated a
zero-IsQuery table and a with-IsQuery table, copied both into
go-admin-ui and ran eslint + vue-tsc --noEmit. Confirmed red first -
`export interface VerifyNoQueryQuery {}` failed eslint with exactly the
no-empty-object-type error. Restored the fix - clean on both, plus a
throwaway call site instantiating useTable<VerifyNoQuery,
VerifyNoQueryQuery> to prove the type satisfies useTable's `TQuery
extends object` constraint, not just that it parses in isolation.
2026-09-19 20:36:24 +08:00
zhangwenjian 8b312bed1d Merge branches 'feat/010-gen-plumbing' and 'feat/010-vue3-template' into integ/010-all 2026-09-19 15:22:03 +08:00
zhangwenjian 30bcb57f41 fix🐛: put the rules computed's comma at the end of the previous line
The rules block joined entries with a $first-flag comma the same way
defaultQuery and defaultModel do, but on its own line rather than all
on one -- so the comma for every entry but the first sat at the start
of its line instead of the end of the one before it. @stylistic/comma-style
requires the opposite, and pnpm lint fails on any table with two or
more required insert fields (one comma is enough to trip it; a table
with 0-1 required fields never renders a second entry to get it wrong).
Reproduced first: rendering a four-required-field fixture reported
three comma-style errors, matching what integration testing found on
qa010_widget and qa010_natural.

Fixed by moving the separator so it trails the previous entry instead
of leading the next one: the else branch now emits ",\n  " -- comma,
then the newline and indent -- rather than "\n  , ", so the comma lands
on the line that already has content instead of opening a fresh one.

Also fixed while reproducing, found by the same fixture: dictLabel was
imported whenever any column had a DictType, but it is only called from
the list column's dictionary branch. A table using a dictionary solely
in its insert form (no such column in the list) imported dictLabel and
never called it, tripping no-unused-vars. $hasDict now only gates
useDict; a new $hasDictList gates dictLabel specifically.

Verified against three fixtures via a throwaway go-admin-ui worktree
(deleted afterwards) with hand-written API-module stubs standing in for
F5: the regression fixture (four required fields, confirmed red before
the fix, green after), and the two fixtures from the original F4
verification round (full branch coverage, and the all-flags-off edge
case), all of which stayed green. pnpm type-check and pnpm lint both
ran clean with zero errors, on Node 24.11.0 (this machine's default
node is 20.19.0; the project's engines field wants >=22).
2026-09-19 15:21:30 +08:00
zhangwenjian 5bb211afcd fix🐛: Preview now sets tab.MLTBName before rendering, matching NOActionsGen
MLTBName (table_name with underscores turned to dashes, e.g. "user_profile"
-> "user-profile") is a gorm:"-" field - table.Get never fills it in, the
caller has to. NOActionsGen has done so since it existed; Preview never
did, so every template's import path that reads it
(from '@/api/{PackageName}/{MLTBName}' in vue.go.template, present in
both the pre-Vue3 template and F4's rewrite) rendered with the module
segment missing - "from '@/api/admin/'" - in the preview dialog only.
The real generated file was always correct.

Pre-existing, not introduced by this PRD, but worth fixing now: F3/F9
added two more Preview panes (the language packs) on top of an assumption
- that Preview's output stands in for what NOActionsGen actually writes -
that was never true for this field. Checked the rest of gen.go for the
same "only set on the write path" shape; MLTBName is the only one -
every other field Preview's templates read comes straight off the
sys_tables/sys_columns rows table.Get already loads.

Verified with a throwaway harness driving Gen.Preview through a real gin
Context and sqlite-backed db, parsing the JSON response and reading back
template/vue.go.template's import line. Confirmed red first (temporarily
removed the added line): "from '@/api/verify010/'". Restored it: "from
'@/api/verify010/verify-widget'".
2026-09-19 15:18:29 +08:00
zhangwenjian d102c4b7c1 Merge branch 'feat/010-vue3-template' into integ/010-all 2026-09-19 14:17:14 +08:00
zhangwenjian 545453c93e feat: infer column width from column_type before rendering (PRD 010 R2)
vue.go.template (F4) only had a flat fallback for an unconfigured
colWidth: 110 for datetime, 120 for everything else. R2 asks for a
precise per-type inference instead - varchar(n) tiered by n, tinyint(1)
narrower than a general integer, text/blob wide - sized so a typical
table's list columns land inside go-admin-ui's ~580px text-column
budget. text/template cannot parse "varchar(255)" itself, so this has
to run in gen.go before the template executes, not in the template.

Judgment is columnType, not goType, same as F1/F2's earlier reversal
(API契约.md §1.1): sys_tables.go:323-338 gives every non-primary-key
int/tinyint/bigint/decimal column goType "string", so goType alone
cannot tell a boolean flag from a bigint from a name column.

InferColumnWidth(columnType string) int is exported and pure per
测试用例.md §2.5's own request, so it can be pinned with an exact
input/output table rather than only asserting "the rendered page
happens not to overflow" - see column_width_test.go, including the
tinyint(1)-vs-tinyint(4) and the varchar tier-boundary cases.
applyInferredColumnWidths only touches a column already at the 0
sentinel, wired into both Preview and NOActionsGen ahead of the
template execute calls; a column already configured (by the user or F6)
is left untouched, also pinned by a test.

Verified against the real NOActionsGen call path (not just the unit
test): generated a table mixing every branch (tinyint(1), datetime,
decimal, varchar at three tiers, text, and one pre-configured column)
and printed the resulting tab.Columns[i].ColWidth after generation -
every value matched InferColumnWidth's own table, and the
pre-configured column's 333 was left untouched.
2026-09-19 14:16:37 +08:00
zhangwenjian c3d2a5952b fix🐛: getVerifyXxx's pk parameter type follows GoType, not always number
ts.go.template hardcoded the pk parameter as `: number`. vue.go.template
(F4, landed after this file) derives $pkType per table - "number" unless
the pk column's GoType is "string" (natural keys do exist; sys_tables.go
gives a pk column GoType "string" whenever its ColumnType is not
int-shaped), then types useForm on that same $pkType. A string-pk table
therefore generated a page calling get{Class}(id: string) against an api
module whose get{Class} only accepted number - a TS compile error, not
a runtime bug, and exactly the kind of gap each side's own template-only
verification could not see (F4 was checked against a hand-written .ts
stub before this template existed; this template was checked against a
numeric-pk fixture only).

Copies vue.go.template's $pkType derivation verbatim so both templates
agree by construction rather than by convention.

Verified: generated both a numeric-pk and a string-pk table's api module,
copied both into go-admin-ui and ran vue-tsc --noEmit - clean. Added a
throwaway call-site file exercising getVerifyStrPk with a string and (via
@ts-expect-error) confirming a number argument is now rejected, so the
type is actually enforced rather than having quietly widened to any.
2026-09-19 14:12:01 +08:00
zhangwenjian d65b21baf6 feat: field-level validation for sys_tables.go:357's Update (PRD 010 F10)
That bind-and-save path had zero field-level validation (API契约.md
§1.2/§2.1, decision D6), which left four ways to silently corrupt the
generator's own metadata or the language packs it writes:

  - jsonField had no format rule at all, unlike tableName/className/
    businessName, which all have a pattern check. A jsonField with a
    dot, a leading digit, or anything not matching lower camelCase
    still saved, and would land verbatim as a key segment in
    gen/{PackageName}/{BusinessName}.ts.
  - jsonField had no per-table uniqueness check. Two columns retitled
    to the same jsonField overwrite each other's generated i18n key
    with no warning.
  - businessName had no cross-table uniqueness check within a
    packageName. Two tables sharing one write the same language pack
    path and the second's generation silently clobbers the first's.
  - defaultValue is spliced into the generated defaultModel() as a
    literal, never evaluated (API契约.md §2.1) - so content shaped
    like a function call or block does not do what it looks like it
    does, and previously saved without complaint either way.

colWidth is handled differently on purpose: API契约.md §2.1 says an
out-of-range value should fall back to the inferred width rather than
be rejected, so it is reset to the 0 sentinel in place instead of
failing the request - same outcome as if it had never been set.

G10 (colliding with the built-in admin/* i18n namespace) is not one of
the four: D9 already moved generated keys into their own gen/
namespace, so that collision no longer exists.

All four checks and colWidth's sanitize-in-place path are covered by
sys_tables_validate_test.go. Confirmed red first: swapped in a no-op
stand-in for both validators and reran - 13 sub-tests that should now
be rejected or sanitized passed straight through instead (jsonField
format x5, jsonField uniqueness x1, colWidth range x2, defaultValue
expression x4, businessName uniqueness x1). Restored the real
implementation and reran - all green.
2026-09-19 14:08:05 +08:00
zhangwenjian 0f31feae6f feat: rewrite the generator's Vue template for Vue 3 (PRD 010 F4)
template/v4/vue.go.template produced Vue 2 syntax -- slot-scope, .sync,
.native -- all removed outright in Vue 3, so every generated page
failed to render (PRD 010 G1). Rewritten from scratch to match
go-admin-ui's reference page (src/views/demo/product/index.vue):
PageContainer + ProTable + useTable/useForm/useRemove,
<script setup lang="ts">.

Four constraints from the phase-3 review, all load-bearing:

- An HtmlType this template doesn't recognise (the old "file" branch,
  or anything future work adds) renders as a plain input rather than
  nothing. sys_tables.go still assigns "datetime" automatically on
  import, and a row can carry "file" from before F7 disabled it in the
  UI, so "unknown" is a real, reachable state, not a hypothetical one.
- Every column width is min-width, never width -- a rigid width
  repeats G5's overflow bug on any table with enough columns. colWidth
  (F1) backs it when set, a flat per-column-kind number otherwise.
- Not one Chinese character anywhere in the output, comments included:
  D10's CJK scan is a bare regex with no exception for "it's only a
  comment". Every label goes through
  $t('gen.{PackageName}.{BusinessName}.{JsonField}'), read from the
  language pack F9 wired into src/lang/{locale}/gen/.
- defineOptions carries the ClassName + "Manage" suffix gen.go writes
  into sys_menu.menu_name (R4) -- the old template's bare ClassName
  was a latent mismatch masked by loadView()'s runtime rewrite (G7).

Verified by rendering two fixtures through the real template.Execute --
one exercising every optional import (dictionary, foreign key,
datetime, required-field rules, search filters) and one with none of
them, a string primary key, and a select/radio with neither a
dictionary nor a foreign key configured, plus an html_type this enum
has never had. Both scan clean for CJK, and both pass pnpm type-check
and pnpm lint with zero errors against a throwaway go-admin-ui
worktree (deleted afterwards) seeded with hand-written API-module
stubs standing in for F5, which lands in template/v4/js.go.template
separately.
2026-09-19 14:04:29 +08:00
zhangwenjian 0494a27d6c fix🐛: SysColumns.Update can now clear colWidth/defaultValue back to 0/""
Updates(&e) uses GORM's struct form, which skips zero-value fields -
but 0/"" is exactly the sentinel PRD 010 F1/F2 chose for "unconfigured"
(数据库变更.md §1.1). A caller resetting colWidth or defaultValue back
to that sentinel was therefore silently ignored: the row kept its old
value, the API reported success, and reopening the edit form showed the
stale number/string again.

Not fixed by switching to Select("*") - that would also start writing
this struct's other zero-valued fields (Sort, the Pk/Required/... bools),
none of which are part of this change. A second, map-form Updates
scoped to just these two columns writes the sentinel without touching
anything else; map-form Updates does not skip zero values.

TestSysColumnsUpdateClearsSentinelFields pins the regression: seed
150/"active", update to 0/"", read back and assert it stuck. Confirmed
red before this change (got 150/"active" back) and green after.
2026-09-19 13:59:27 +08:00
zhangwenjian e2b6ddb290 feat: generate per-table zh-CN/en-US language packs (PRD 010 F3/F9)
Add lang-zh.go.template and lang-en.go.template, one key per column
keyed by JsonField, valued from ColumnComment (R3) with a fallback to
JsonField when ColumnComment is blank (G11) - both locales resolve to
the same zh-CN text today since D3 rules out machine translation and
go-admin-ui's parity test rejects blank values.

NOActionsGen writes both files to lang/{locale}/gen/{PackageName}/
{BusinessName}.ts, two levels deep so go-admin-ui's gen-namespace.ts
glob (./*/*.ts under each locale's gen/) picks them up; nesting under
PackageName (rather than a flat gen/{BusinessName}.ts) avoids two
tables in different packages silently overwriting each other's
translations, since BusinessName has no uniqueness check.

Output has to be single-quoted with no trailing comma to satisfy
go-admin-ui's eslint config, which text/template's builtin `printf "%q"`
does not produce, hence the small parseGenTemplate/singleQuote helper
shared by the two templates.

Verified end to end: generated a real table's output (including a
blank-ColumnComment column, to exercise the G11 fallback), copied the
two language packs into go-admin-ui and ran vue-tsc --noEmit and eslint
against them - both clean, comma-dangle/quotes included.
2026-09-19 13:56:34 +08:00
zhangwenjian 28eba92077 feat: generate a typed .ts API module instead of .js (PRD 010 F5)
Rewrite template/v4/js.go.template (renamed to ts.go.template) to emit
TypeScript matching go-admin-ui's src/api/demo/product.ts: an interface
per row and per query params, typed CRUD functions, and a del{Class}
that takes Id[] like useRemove expects. GoType maps to number for
int/int64/float32/float64 and string otherwise, since go-admin-ui's
request() types the {code,data,msg} envelope rather than the payload.

Preview's response map key changes from template/js.go.template to
template/api.ts.template so its Tab label matches the new content;
NOActionsGen writes the file with a .ts extension.

Verified end to end: generated a real table's output, copied it into
go-admin-ui and ran vue-tsc --noEmit and eslint against it - both clean.
2026-09-19 13:55:56 +08:00
zhangwenjian aa7a92664d feat: add sys_columns.col_width and default_value (PRD 010 F1/F2)
Back the code generator's Vue 3 template migration: col_width lets R2's
column-width inference be overridden per field, and default_value lets
R1/A6's "unconfigured rows still generate a usable page" guarantee hold
for generated forms. Both use a sentinel default (0 / "") rather than
NULL so "unconfigured" has exactly one representation - see
docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1.

The migration and the model change land together: AddColumn reads the
column definition off tools.SysColumns's own gorm tag, so splitting them
across commits would leave one of them failing to compile.
2026-09-19 13:33:42 +08:00
zhangwenjian 1cd39b80b3 test: fail when Remove parks a goroutine on an abandoned channel
#924 buffered the channel Remove returns, so the goroutine it starts can
finish when the caller has already timed out and walked away. That half of
the patch was not covered: reverting the buffer on its own left the whole
suite green, while the timeout assertion it shipped with stayed red only for
the error-return half.

This test abandons twenty channels and then requires every goroutine to be
gone. It waits for the scheduler to empty first, so a goroutine that has not
yet reached its send cannot make the test pass while proving nothing.

Without the buffer: 20 of 20 parked on `chan send` at jobbase.go:216.
2026-09-16 08:27:24 +08:00
15 changed files with 1334 additions and 520 deletions
+97
View File
@@ -0,0 +1,97 @@
package jobs
import (
"runtime"
"strings"
"testing"
"time"
"github.com/robfig/cron/v3"
)
// The frame the leaked goroutines park in. Remove starts it, and it is the
// only goroutine in this package that sends on a channel the caller may have
// walked away from.
const removeSenderFrame = "go-admin/app/jobs.Remove.func1"
// Remove hands the caller a channel it is free to abandon: RemoveJob stops
// waiting after a second and returns a timeout error. The send therefore has
// to complete with nobody receiving, or every stop that times out parks a
// goroutine on it for the life of the process.
//
// The order matters. Counting parked goroutines straight after calling Remove
// would pass while proving nothing, because the goroutine may not have reached
// the send yet. So the entries are waited out first: an empty scheduler means
// every goroutine is at or past its send, and only then is a survivor a leak.
func TestRemoveLetsItsGoroutineFinishWithNobodyReceiving(t *testing.T) {
const jobs = 20
c := cron.New()
ids := make([]cron.EntryID, 0, jobs)
for i := 0; i < jobs; i++ {
id, err := c.AddFunc("@every 1h", func() {})
if err != nil {
t.Fatalf("AddFunc: %v", err)
}
ids = append(ids, id)
}
for _, id := range ids {
// The returned channel is dropped on purpose: this is what a caller
// that has already timed out leaves behind.
_ = Remove(c, int(id))
}
if err := waitFor(3*time.Second, func() bool { return len(c.Entries()) == 0 }); err != nil {
t.Fatalf("the scheduler still holds %d entries, so the goroutines never reached their send "+
"and this test cannot show anything", len(c.Entries()))
}
if err := waitFor(3*time.Second, func() bool { return parkedInRemove() == 0 }); err != nil {
t.Errorf("%d of %d goroutines are still parked sending on an abandoned channel:\n%s",
parkedInRemove(), jobs, oneParkedStack())
}
}
func waitFor(d time.Duration, done func() bool) error {
deadline := time.Now().Add(d)
for {
if done() {
return nil
}
if time.Now().After(deadline) {
return errTimeout
}
time.Sleep(10 * time.Millisecond)
}
}
var errTimeout = timeoutError{}
type timeoutError struct{}
func (timeoutError) Error() string { return "timed out" }
func parkedInRemove() int {
return strings.Count(goroutineDump(), removeSenderFrame)
}
func oneParkedStack() string {
for _, block := range strings.Split(goroutineDump(), "\n\n") {
if strings.Contains(block, removeSenderFrame) {
return block
}
}
return "(none)"
}
func goroutineDump() string {
buf := make([]byte, 1<<20)
for {
n := runtime.Stack(buf, true)
if n < len(buf) {
return string(buf[:n])
}
buf = make([]byte, 2*len(buf))
}
}
+126
View File
@@ -0,0 +1,126 @@
package tools
import (
"regexp"
"strconv"
"strings"
"go-admin/app/other/models/tools"
)
// columnLengthPattern pulls the first parenthesized integer out of a MySQL
// COLUMN_TYPE string - the "(255)" in "varchar(255)", the "(10" in
// "decimal(10,2)". Works regardless of trailing modifiers such as
// "unsigned" or a charset clause, since it only looks for the first digits
// after the first '('.
var columnLengthPattern = regexp.MustCompile(`\((\d+)`)
// InferColumnWidth backs R2's fallback path: when a column's colWidth is
// left at its 0 sentinel (unconfigured), this reads sys_columns.column_type
// - MySQL's information_schema.COLUMNS.COLUMN_TYPE, which carries length,
// e.g. "varchar(255)", "int(11)", "decimal(10,2)", "tinyint(1)" - and
// returns a px width sized to fit inside go-admin-ui's ~580px text-column
// budget for a 1280px viewport (its AGENTS.md "列宽" section).
//
// The judgment has to be columnType, not goType: sys_tables.go:323-338
// gives every non-primary-key int/tinyint/bigint/decimal column goType
// "string" (a bare substring match on "int" that also catches "tinyint"/
// "bigint", intentional at import time but useless for telling a boolean
// flag from a bigint), so goType alone cannot distinguish a switch column
// from a price column from a name column. This is the same judgment call
// API契约.md §1.1 made, reversing the PRD's original "GoType" reading of R2.
// GoType is not consulted anywhere in this function, including for
// datetime/timestamp columns - those are matched on columnType too.
//
// Exported and pure (string in, int out) so QA can pin an exact input/output
// table against it directly (测试用例.md §2.5's own recommendation), rather
// than only being able to assert "the rendered page happens not to overflow".
func InferColumnWidth(columnType string) int {
ct := strings.ToLower(strings.TrimSpace(columnType))
switch {
case strings.HasPrefix(ct, "tinyint(1)"):
// MySQL's own shape for a boolean/status flag - a tag or a switch,
// not text, so it wants less room than a general numeric column.
return 70
case strings.Contains(ct, "datetime"), strings.Contains(ct, "timestamp"),
strings.Contains(ct, "date"), strings.Contains(ct, "time"):
return 110
case strings.HasPrefix(ct, "tinyint"), strings.HasPrefix(ct, "smallint"),
strings.HasPrefix(ct, "mediumint"), strings.HasPrefix(ct, "int"),
strings.HasPrefix(ct, "bigint"), strings.HasPrefix(ct, "decimal"),
strings.HasPrefix(ct, "float"), strings.HasPrefix(ct, "double"):
// API契约.md §1.1: "decimal/bigint/int 类给数字型窄宽度" groups these
// together rather than sizing each individually - none of them need
// more than a handful of digits' worth of width.
return 90
case strings.HasPrefix(ct, "varchar"), strings.HasPrefix(ct, "char"):
return varcharWidth(columnLength(ct))
case strings.Contains(ct, "text"), strings.Contains(ct, "blob"):
// longtext/mediumtext/text/blob: no declared length to size against,
// and content here is free-form, so this errs wide rather than
// guessing a number the actual content will not respect.
return 260
default:
// Unrecognized column_type (an enum, a json column, a driver this
// codebase does not special-case, ...). Matches the flat fallback
// vue.go.template already used for every non-datetime column before
// this function existed, so a type this does not recognize is no
// worse off than the old blanket default.
return 120
}
}
// varcharWidth tiers a char/varchar column by its declared length. The
// tiers are deliberately coarse - R2 only asks for "common tables land in
// the 580px budget", not pixel-perfect sizing per character.
func varcharWidth(n int) int {
switch {
case n <= 0:
// Length did not parse (unexpected shape) - mid tier, not the
// narrowest, since an un-lengthed varchar is unlikely to be a
// short code column.
return 150
case n <= 10:
return 90
case n <= 20:
return 110
case n <= 50:
return 150
case n <= 100:
return 200
default:
return 240
}
}
// columnLength extracts the first parenthesized integer, or 0 if the type
// string does not have one (already-lowercased input expected).
func columnLength(columnType string) int {
m := columnLengthPattern.FindStringSubmatch(columnType)
if m == nil {
return 0
}
n, err := strconv.Atoi(m[1])
if err != nil {
return 0
}
return n
}
// applyInferredColumnWidths fills in InferColumnWidth's result for every
// column still at the 0 "unconfigured" sentinel, in place, before the
// template that reads .ColWidth runs. A column the user (or F6's config
// page) already gave an explicit width is left untouched.
func applyInferredColumnWidths(columns []tools.SysColumns) {
for i := range columns {
if columns[i].ColWidth == 0 {
columns[i].ColWidth = InferColumnWidth(columns[i].ColumnType)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package tools
import (
"testing"
"go-admin/app/other/models/tools"
)
// Input/output pins for InferColumnWidth, per 测试用例.md §2.5's own
// recommendation ("QA 才能在阶段 4 补一张精确的输入→输出对照表断言, 而不是只测
// 结果凑巧没溢出这种弱结论") - this is that table, kept next to the function
// it pins rather than only living in a later QA-owned suite.
func TestInferColumnWidth(t *testing.T) {
cases := []struct {
name string
columnType string
want int
}{
{"boolean/status flag", "tinyint(1)", 70},
{"boolean flag, case-insensitive", "TINYINT(1)", 70},
{"datetime", "datetime", 110},
{"timestamp", "timestamp", 110},
{"date only", "date", 110},
{"time only", "time", 110},
{"plain tinyint (not the (1) boolean shape)", "tinyint(4)", 90},
{"smallint", "smallint(6)", 90},
{"mediumint", "mediumint(9)", 90},
{"int", "int(11)", 90},
{"bigint", "bigint(20)", 90},
{"decimal", "decimal(10,2)", 90},
{"float", "float", 90},
{"double", "double", 90},
{"varchar short code", "varchar(8)", 90},
{"varchar at the 10 boundary", "varchar(10)", 90},
{"varchar just past the 10 boundary", "varchar(11)", 110},
{"varchar at the 20 boundary", "varchar(20)", 110},
{"varchar mid length", "varchar(32)", 150},
{"varchar at the 50 boundary", "varchar(50)", 150},
{"varchar just past the 50 boundary", "varchar(51)", 200},
{"varchar(255), the common default", "varchar(255)", 240},
{"char, fixed-width", "char(2)", 90},
{"varchar with no parsed length", "varchar", 150},
{"text, no length to size against", "text", 260},
{"longtext", "longtext", 260},
{"mediumtext", "mediumtext", 260},
{"blob", "blob", 260},
{"unrecognized type falls back to the old flat default", "json", 120},
{"empty column_type falls back to the old flat default", "", 120},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := InferColumnWidth(tc.columnType); got != tc.want {
t.Errorf("InferColumnWidth(%q) = %d, want %d", tc.columnType, got, tc.want)
}
})
}
}
func TestApplyInferredColumnWidths(t *testing.T) {
columns := []tools.SysColumns{
{JsonField: "name", ColumnType: "varchar(64)", ColWidth: 0},
{JsonField: "price", ColumnType: "decimal(10,2)", ColWidth: 300}, // already configured
}
applyInferredColumnWidths(columns)
if columns[0].ColWidth == 0 {
t.Error("unconfigured column: want an inferred non-zero width, still 0")
}
if want := InferColumnWidth("varchar(64)"); columns[0].ColWidth != want {
t.Errorf("unconfigured column: want %d (InferColumnWidth's own answer), got %d", want, columns[0].ColWidth)
}
if columns[1].ColWidth != 300 {
t.Errorf("already-configured column: want the user's 300 left untouched, got %d", columns[1].ColWidth)
}
}
+100 -6
View File
@@ -22,6 +22,29 @@ type Gen struct {
api.Api
}
// genLangFuncs backs the lang-zh/lang-en templates (PRD 010 F3/F9). The
// generated files are TypeScript, and go-admin-ui's eslint config requires
// single-quoted strings with no trailing comma (@stylistic/quotes,
// @stylistic/comma-dangle: never) - text/template's builtin `printf "%q"`
// only produces Go/JSON-style double-quoted output, so this supplies a
// single-quote equivalent instead of leaning on the builtin.
var genLangFuncs = template.FuncMap{
"singleQuote": func(s string) string {
r := strings.NewReplacer(`\`, `\\`, `'`, `\'`, "\n", `\n`, "\r", `\r`)
return "'" + r.Replace(s) + "'"
},
}
// parseGenTemplate is template.ParseFiles plus genLangFuncs, for the two
// language-pack templates. template.New's name must match the file's base
// name - ParseFiles reuses the template already registered under that name
// instead of creating an unnamed second one, which is what makes Execute
// find the parsed content afterwards.
func parseGenTemplate(path string) (*template.Template, error) {
base := path[strings.LastIndex(path, "/")+1:]
return template.New(base).Funcs(genLangFuncs).ParseFiles(path)
}
func (e Gen) Preview(c *gin.Context) {
e.Context = c
log := e.GetLogger()
@@ -45,10 +68,10 @@ func (e Gen) Preview(c *gin.Context) {
e.Error(500, err, fmt.Sprintf("api模版读取失败!错误详情:%s", err.Error()))
return
}
t3, err := template.ParseFiles("template/v4/js.go.template")
t3, err := template.ParseFiles("template/v4/ts.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("js模版读取失败!错误详情:%s", err.Error()))
e.Error(500, err, fmt.Sprintf("ts模版读取失败!错误详情:%s", err.Error()))
return
}
t4, err := template.ParseFiles("template/v4/vue.go.template")
@@ -75,6 +98,22 @@ func (e Gen) Preview(c *gin.Context) {
e.Error(500, err, fmt.Sprintf("service模版读取失败!错误详情:%s", err.Error()))
return
}
// t8/t9 back F3/F9 (PRD 010): one language pack per locale, nested under
// gen/{PackageName}/{BusinessName}.ts by NOActionsGen below so go-admin-ui's
// gen-namespace.ts glob (`./*/*.ts` under each locale's gen/) picks them up.
// See docs-prd/010-代码生成器前端模板迁移Vue3/API契约.md §2.3.
t8, err := parseGenTemplate("template/v4/lang-zh.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("zh语言包模版读取失败!错误详情:%s", err.Error()))
return
}
t9, err := parseGenTemplate("template/v4/lang-en.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("en语言包模版读取失败!错误详情:%s", err.Error()))
return
}
db, err := pkg.GetOrm(c)
if err != nil {
@@ -84,6 +123,17 @@ func (e Gen) Preview(c *gin.Context) {
}
tab, _ := table.Get(db, false)
// MLTBName (table_name with underscores turned to dashes) is a gorm:"-"
// field - table.Get never fills it in, so every template that reads it
// (the .vue/.ts import paths, e.g. "@/api/{PackageName}/{MLTBName}")
// silently rendered it empty here. NOActionsGen has set this since it
// existed (see below); Preview never did, which is why the two paths
// are not interchangeable stand-ins for each other and should not be
// assumed to be.
tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1)
// R2: infer a width for any column the config page left at colWidth's 0
// sentinel, before vue.go.template reads .ColWidth - see column_width.go.
applyInferredColumnWidths(tab.Columns)
var b1 bytes.Buffer
err = t1.Execute(&b1, tab)
var b2 bytes.Buffer
@@ -98,15 +148,21 @@ func (e Gen) Preview(c *gin.Context) {
err = t6.Execute(&b6, tab)
var b7 bytes.Buffer
err = t7.Execute(&b7, tab)
var b8 bytes.Buffer
err = t8.Execute(&b8, tab)
var b9 bytes.Buffer
err = t9.Execute(&b9, tab)
mp := make(map[string]interface{})
mp["template/model.go.template"] = b1.String()
mp["template/api.go.template"] = b2.String()
mp["template/js.go.template"] = b3.String()
mp["template/api.ts.template"] = b3.String()
mp["template/vue.go.template"] = b4.String()
mp["template/router.go.template"] = b5.String()
mp["template/dto.go.template"] = b6.String()
mp["template/service.go.template"] = b7.String()
mp["template/lang-zh.go.template"] = b8.String()
mp["template/lang-en.go.template"] = b9.String()
e.OK(mp, "")
}
@@ -165,6 +221,8 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
e.Context = c
log := e.GetLogger()
tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1)
// R2: see the matching call and comment in Preview above.
applyInferredColumnWidths(tab.Columns)
basePath := "template/v4/"
routerFile := basePath + "no_actions/router_check_role.go.template"
@@ -191,10 +249,10 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
e.Error(500, err, fmt.Sprintf("路由模版失败!错误详情:%s", err.Error()))
return
}
t4, err := template.ParseFiles(basePath + "js.go.template")
t4, err := template.ParseFiles(basePath + "ts.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("js模版解析失败!错误详情:%s", err.Error()))
e.Error(500, err, fmt.Sprintf("ts模版解析失败!错误详情:%s", err.Error()))
return
}
t5, err := template.ParseFiles(basePath + "vue.go.template")
@@ -215,6 +273,19 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
e.Error(500, err, fmt.Sprintf("service模版失败!错误详情:%s", err.Error()))
return
}
// t8/t9 back F3/F9 (PRD 010): see the matching comment in Preview above.
t8, err := parseGenTemplate(basePath + "lang-zh.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("zh语言包模版解析失败!错误详情:%s", err.Error()))
return
}
t9, err := parseGenTemplate(basePath + "lang-en.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("en语言包模版解析失败!错误详情:%s", err.Error()))
return
}
_ = pkg.PathCreate("./app/" + tab.PackageName + "/apis/")
_ = pkg.PathCreate("./app/" + tab.PackageName + "/models/")
@@ -227,6 +298,23 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
e.Error(500, err, fmt.Sprintf("views目录创建失败!错误详情:%s", err.Error()))
return
}
// gen/{PackageName}/ nests under each locale so go-admin-ui's
// gen-namespace.ts (`./*/*.ts` glob, one level under gen/) picks the file
// up - a flat gen/{BusinessName}.ts would let two tables in different
// packages silently overwrite each other's translations, since
// BusinessName only has a pattern check, no uniqueness check.
err = pkg.PathCreate(config.GenConfig.FrontPath + "/lang/zh-CN/gen/" + tab.PackageName + "/")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("zh语言包目录创建失败!错误详情:%s", err.Error()))
return
}
err = pkg.PathCreate(config.GenConfig.FrontPath + "/lang/en-US/gen/" + tab.PackageName + "/")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("en语言包目录创建失败!错误详情:%s", err.Error()))
return
}
var b1 bytes.Buffer
err = t1.Execute(&b1, tab)
@@ -242,13 +330,19 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
err = t6.Execute(&b6, tab)
var b7 bytes.Buffer
err = t7.Execute(&b7, tab)
var b8 bytes.Buffer
err = t8.Execute(&b8, tab)
var b9 bytes.Buffer
err = t9.Execute(&b9, tab)
pkg.FileCreate(b1, "./app/"+tab.PackageName+"/models/"+tab.TBName+".go")
pkg.FileCreate(b2, "./app/"+tab.PackageName+"/apis/"+tab.TBName+".go")
pkg.FileCreate(b3, "./app/"+tab.PackageName+"/router/"+tab.TBName+".go")
pkg.FileCreate(b4, config.GenConfig.FrontPath+"/api/"+tab.PackageName+"/"+tab.MLTBName+".js")
pkg.FileCreate(b4, config.GenConfig.FrontPath+"/api/"+tab.PackageName+"/"+tab.MLTBName+".ts")
pkg.FileCreate(b5, config.GenConfig.FrontPath+"/views/"+tab.PackageName+"/"+tab.MLTBName+"/index.vue")
pkg.FileCreate(b6, "./app/"+tab.PackageName+"/service/dto/"+tab.TBName+".go")
pkg.FileCreate(b7, "./app/"+tab.PackageName+"/service/"+tab.TBName+".go")
pkg.FileCreate(b8, config.GenConfig.FrontPath+"/lang/zh-CN/gen/"+tab.PackageName+"/"+tab.BusinessName+".ts")
pkg.FileCreate(b9, config.GenConfig.FrontPath+"/lang/en-US/gen/"+tab.PackageName+"/"+tab.BusinessName+".ts")
}
+15
View File
@@ -368,6 +368,21 @@ func (e SysTable) Update(c *gin.Context) {
return
}
// PRD 010 F10: this bind-and-save path has no field-level validation of
// its own (API契约.md §1.2/§2.1, D6) - see sys_tables_validate.go for
// what each check guards and why colWidth is sanitized in place rather
// than rejected.
if err = validateAndSanitizeColumns(data.Columns); err != nil {
log.Errorf("validate columns error, %s", err.Error())
e.Error(500, err, err.Error())
return
}
if err = validateBusinessNameUnique(db, data.PackageName, data.BusinessName, data.TableId); err != nil {
log.Errorf("validate businessName error, %s", err.Error())
e.Error(500, err, err.Error())
return
}
data.UpdateBy = 0
result, err := data.Update(db)
if err != nil {
+112
View File
@@ -0,0 +1,112 @@
package tools
import (
"fmt"
"regexp"
"strings"
"gorm.io/gorm"
"go-admin/app/other/models/tools"
)
// jsonFieldPattern accepts any legal JS/TS identifier that starts with a
// lowercase letter - not businessName's rule.
//
// This used to be businessName's own pattern (^[a-z][A-Za-z]+$, requiring at
// least two letters and no digits), copied over on the theory that jsonField
// "should tighten to the same identifier shape". That theory does not hold:
// businessName is typed by a person on genInfoForm.vue, so a strict pattern
// is a reasonable guardrail on human input. jsonField is computed by the
// importer from the column name (sys_tables.go's namelist/JsonField loop) -
// nobody types it, so the same pattern only rejects names the importer
// legitimately produces. A one-letter column ("x") or a column ending in a
// digit ("address2", "a1") both import to a single camelCase word with no
// separators to re-capitalize, and both used to fail this check - meaning a
// table that merely contained such a column could never save any config
// again, unrelated columns included, since this check runs over every
// column on every Update.
//
// What still has to be rejected is a jsonField that cannot be a raw object
// key at all: empty, containing whitespace/punctuation, or leading with a
// digit (`2faEnabled: 1` is not valid JS - identifiers cannot start with a
// digit, and this is what lands as the property name in gen.go's generated
// interface / lang file, both unquoted). Hence still anchoring on a
// lowercase letter first, but no longer requiring a second character or
// forbidding digits after it.
var jsonFieldPattern = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`)
// colWidthMin/colWidthMax are API契约.md §2.1's suggested range for colWidth.
const (
colWidthMin = 40
colWidthMax = 800
)
// expressionMarkers flags the "meant to be evaluated" shapes API契约.md §2.1
// says defaultValue must not carry: it is spliced into the generated
// defaultModel() as a literal and never evaluated, so anything that looks
// like a function call or a block is rejected outright rather than
// generating code that silently does nothing.
var expressionMarkers = []string{"(", ")", "{", "}", "`", ";", "=>"}
// validateAndSanitizeColumns enforces PRD 010 F10 on the columns carried by
// a table update (sys_tables.go:357's Update handler, the one bind-and-save
// path with no field-level validation at all - see API契约.md §1.2/§2.1,
// decision D6).
//
// jsonField and defaultValue problems reject the request outright: letting
// either through would corrupt the generated i18n file silently (a
// duplicate or malformed jsonField becomes a duplicate or invalid key in
// gen/{PackageName}/{BusinessName}.ts, see the lang-zh/lang-en templates).
// An out-of-range colWidth does not reject - §2.1 says it "falls back to
// the inferred value", so this resets it to the 0 sentinel in place and lets
// R2's inference take over, the same as if the field had never been set.
func validateAndSanitizeColumns(columns []tools.SysColumns) error {
seen := make(map[string]bool, len(columns))
for i := range columns {
col := &columns[i]
if !jsonFieldPattern.MatchString(col.JsonField) {
return fmt.Errorf("jsonField 格式不合法:%q,须以小写字母开头且只能包含英文字母", col.JsonField)
}
if seen[col.JsonField] {
return fmt.Errorf("jsonField 在同一张表内重复:%q", col.JsonField)
}
seen[col.JsonField] = true
if col.ColWidth != 0 && (col.ColWidth < colWidthMin || col.ColWidth > colWidthMax) {
col.ColWidth = 0
}
for _, marker := range expressionMarkers {
if strings.Contains(col.DefaultValue, marker) {
return fmt.Errorf("defaultValue 不允许包含表达式或函数调用内容:%q", col.DefaultValue)
}
}
}
return nil
}
// validateBusinessNameUnique enforces PRD 010 F10's other half: two tables
// sharing (packageName, businessName) write the same generated language
// pack path, gen/{PackageName}/{BusinessName}.ts (see gen.go's
// NOActionsGen), so the second one silently overwrites the first's
// translations. tableID excludes the row being saved, so a table updating
// its own unchanged name does not trip the check on itself.
//
// G10's other concern - colliding with the built-in admin/* i18n namespace -
// does not apply here anymore: D9 moved generated keys to their own gen/
// namespace, so this only has to guard generated tables against each other.
func validateBusinessNameUnique(db *gorm.DB, packageName, businessName string, tableID int) error {
var count int64
err := db.Table("sys_tables").
Where("package_name = ? AND business_name = ? AND table_id != ?", packageName, businessName, tableID).
Count(&count).Error
if err != nil {
return err
}
if count > 0 {
return fmt.Errorf("packageName=%q 下 businessName=%q 已被其它表使用", packageName, businessName)
}
return nil
}
@@ -0,0 +1,157 @@
package tools
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"go-admin/app/other/models/tools"
)
func TestValidateAndSanitizeColumns_JsonFieldFormat(t *testing.T) {
cases := []struct {
name string
jsonField string
wantErr bool
}{
{"lower camelCase", "userName", false},
{"two-letter lowercase", "id", false},
// The importer's own output (sys_tables.go's namelist/JsonField
// loop), not made up: a single-letter column ("x"), and a column
// whose last name segment ends in a digit ("address2", "a1") both
// produce a jsonField with no separator left to re-capitalize.
// These three used to be rejected - the whole point of this fix.
{"single letter, real importer output for a column named x", "x", false},
{"letters then a trailing digit, real importer output for address2", "address2", false},
{"two letters then a digit, real importer output for a1", "a1", false},
{"leading underscore rejected", "_id", true},
{"leading digit rejected (not a legal identifier start)", "1name", true},
{"snake_case rejected (importer never emits an underscore)", "user_name", true},
{"dot rejected, would break the gen/{pkg}/{biz}.ts key path", "user.name", true},
{"empty rejected", "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: tc.jsonField}})
if tc.wantErr && err == nil {
t.Errorf("jsonField %q: want error, got nil", tc.jsonField)
}
if !tc.wantErr && err != nil {
t.Errorf("jsonField %q: want no error, got %v", tc.jsonField, err)
}
})
}
}
func TestValidateAndSanitizeColumns_JsonFieldUniqueWithinTable(t *testing.T) {
err := validateAndSanitizeColumns([]tools.SysColumns{
{JsonField: "name"},
{JsonField: "name"},
})
if err == nil {
t.Fatal("want error for a jsonField repeated in the same table, got nil")
}
}
func TestValidateAndSanitizeColumns_ColWidthOutOfRangeIsSanitizedNotRejected(t *testing.T) {
cases := []struct {
name string
width int
want int
}{
{"zero (unconfigured) is left alone", 0, 0},
{"in range is left alone", 150, 150},
{"lower bound is left alone", colWidthMin, colWidthMin},
{"upper bound is left alone", colWidthMax, colWidthMax},
{"too small falls back to the sentinel", colWidthMin - 1, 0},
{"too large falls back to the sentinel", colWidthMax + 1, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cols := []tools.SysColumns{{JsonField: "name", ColWidth: tc.width}}
if err := validateAndSanitizeColumns(cols); err != nil {
t.Fatalf("colWidth %d: want no error (out-of-range sanitizes, it does not reject), got %v", tc.width, err)
}
if cols[0].ColWidth != tc.want {
t.Errorf("colWidth %d: want sanitized to %d, got %d", tc.width, tc.want, cols[0].ColWidth)
}
})
}
}
func TestValidateAndSanitizeColumns_DefaultValueExpressionRejected(t *testing.T) {
cases := []struct {
name string
defaultValue string
wantErr bool
}{
{"plain literal", "0", false},
{"plain string literal", "active", false},
{"empty (unconfigured)", "", false},
{"function call rejected", "Date.now()", true},
{"template literal rejected", "`x`", true},
{"arrow function rejected", "() => 1", true},
{"statement separator rejected", "1; drop", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: "name", DefaultValue: tc.defaultValue}})
if tc.wantErr && err == nil {
t.Errorf("defaultValue %q: want error, got nil", tc.defaultValue)
}
if !tc.wantErr && err != nil {
t.Errorf("defaultValue %q: want no error, got %v", tc.defaultValue, err)
}
})
}
}
func newBusinessNameTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(new(tools.SysTables)); err != nil {
t.Fatalf("migrate sys_tables: %v", err)
}
return db
}
func TestValidateBusinessNameUnique(t *testing.T) {
db := newBusinessNameTestDB(t)
existing := tools.SysTables{TBName: "sys_widget", PackageName: "biz", BusinessName: "widget"}
if err := db.Table("sys_tables").Create(&existing).Error; err != nil {
t.Fatalf("seed: %v", err)
}
t.Run("same package, same businessName, different table: rejected", func(t *testing.T) {
other := tools.SysTables{TBName: "sys_widget_copy", PackageName: "biz", BusinessName: "widget"}
if err := db.Table("sys_tables").Create(&other).Error; err != nil {
t.Fatalf("seed second row: %v", err)
}
// Unscoped: a plain Delete only soft-deletes (SysTables carries
// common.ModelTime), which would leave this row's businessName
// looking taken for the next subtest - production's own delete path
// (SysTables.BatchDelete) hard-deletes for the same reason.
defer db.Table("sys_tables").Unscoped().Delete(&other)
if err := validateBusinessNameUnique(db, "biz", "widget", other.TableId); err == nil {
t.Error("want error for a businessName already used by another table in the same package, got nil")
}
})
t.Run("different package, same businessName: allowed", func(t *testing.T) {
if err := validateBusinessNameUnique(db, "other-pkg", "widget", 0); err != nil {
t.Errorf("want no error across different packages, got %v", err)
}
})
t.Run("a table checking against its own current name: allowed", func(t *testing.T) {
if err := validateBusinessNameUnique(db, "biz", "widget", existing.TableId); err != nil {
t.Errorf("want no error when the only match is the row being saved itself, got %v", err)
}
})
}
+31
View File
@@ -45,6 +45,19 @@ type SysColumns struct {
CreateBy int `gorm:"column:create_by;size:20;" json:"createBy"`
UpdateBy int `gorm:"column:update_By;size:20;" json:"updateBy"`
// ColWidth and DefaultValue back PRD 010 F1/F2 (代码生成器前端模板迁移 Vue 3).
// Both use a sentinel default (0 / "") rather than NULL - see
// docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1: a non-pointer
// int/string field can never read NULL back out, and NULL would give
// "unconfigured" two representations instead of one. Callers test
// ColWidth == 0 / DefaultValue == "" to detect "not configured".
//
// ColWidth deliberately has no gorm size tag: this codebase's "size:N"
// convention on numeric fields maps to a narrow SQL integer type (see
// column_width_test.go), and col_width needs to hold values up to 800.
ColWidth int `gorm:"column:col_width;not null;default:0;comment:table column width in px, 0 = not configured" json:"colWidth"`
DefaultValue string `gorm:"column:default_value;size:255;not null;default:'';comment:form field default value, empty = not configured" json:"defaultValue"`
common.ModelTime
}
@@ -97,5 +110,23 @@ func (e *SysColumns) Update(tx *gorm.DB) (update SysColumns, err error) {
return
}
// Updates(&e) above skips zero-value fields (GORM's struct-form Updates
// always does), but ColWidth/DefaultValue's own "unconfigured" sentinel
// is 0/"" (see the field comments on SysColumns) - so clearing either one
// back to its sentinel is indistinguishable, to a struct-form Updates,
// from "the caller didn't touch this field" and silently does not get
// written. A map-form Updates does not skip zero values, so it is used
// here for just these two columns rather than widening this to
// Select("*") (which would also start writing every other zero-valued
// field on this struct - Sort, the Pk/Required/... bools - and that is a
// pre-existing gap in this method affecting fields outside PRD 010's
// scope, not fixed here).
if err = tx.Table("sys_columns").Model(&update).Updates(map[string]interface{}{
"col_width": e.ColWidth,
"default_value": e.DefaultValue,
}).Error; err != nil {
return
}
return
}
@@ -0,0 +1,46 @@
package tools
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// GORM's Updates(struct) skips zero-value fields, and PRD 010 F1/F2 chose 0 /
// "" as the sentinel for "unconfigured" (docs-prd/010-代码生成器前端模板迁移Vue3/
// 数据库变更.md §1.1). Put those together and Update can set ColWidth/
// DefaultValue but never clear them back to the sentinel: the struct-form
// Updates call silently drops the very values this feature needs to write.
func TestSysColumnsUpdateClearsSentinelFields(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(new(SysColumns)); err != nil {
t.Fatalf("migrate sys_columns: %v", err)
}
col := SysColumns{TableId: 1, ColumnName: "status", ColWidth: 150, DefaultValue: "active"}
if _, err := col.Create(db); err != nil {
t.Fatalf("create: %v", err)
}
// Reset back to the sentinel - the UI action for "go back to inferred
// width / no default", not merely "never configured".
update := SysColumns{ColumnId: col.ColumnId, ColWidth: 0, DefaultValue: ""}
if _, err := update.Update(db); err != nil {
t.Fatalf("update: %v", err)
}
var got SysColumns
if err := db.Table("sys_columns").First(&got, col.ColumnId).Error; err != nil {
t.Fatalf("read back: %v", err)
}
if got.ColWidth != 0 {
t.Errorf("colWidth: want 0 (cleared), got %d - Update() did not write the sentinel back", got.ColWidth)
}
if got.DefaultValue != "" {
t.Errorf("defaultValue: want \"\" (cleared), got %q - Update() did not write the sentinel back", got.DefaultValue)
}
}
@@ -0,0 +1,56 @@
package version
import (
"runtime"
"gorm.io/gorm"
"go-admin/app/other/models/tools"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
// Add sys_columns.col_width and sys_columns.default_value for PRD 010 F1/F2
// (代码生成器前端模板迁移 Vue 3).
//
// col_width backs R2's column-width inference fallback and default_value
// backs R1/A6's "unconfigured rows still generate a usable page" guarantee -
// see docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1 for why both
// defaults are sentinels (0 / "") rather than NULL: a non-pointer Go int/
// string field can never read NULL back out, and NULL would give
// "unconfigured" two representations instead of one.
//
// Ordered after 1786700003000, so this reads tools.SysColumns (the runtime
// model sys_columns's Update/GetPage/GetSysTablesInfo actually query through)
// rather than cmd/migrate/migration/models, matching every migration in this
// directory since sys_columns was converted - see
// 1786700004000_generator_tables_marker.go and schema_coverage_test.go's
// TestPostConversionMigrationsAvoidFrozenSeedModels.
//
// Hard prerequisite: tools.SysColumns must already declare ColWidth and
// DefaultValue (with the gorm tags in the doc above) by the time this file
// is compiled - AddColumn reads the column definition off the struct's own
// tag, not off anything in this file. Landing this migration without that
// model change first makes HasColumn/AddColumn silently do nothing (the
// field lookup fails and AddColumn returns an error naming the missing
// field), which fails loudly rather than silently - see the "no such field"
// error - so this is caught at migrate time, not left for a report later.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700010000GenColumnLayoutFields)
}
func _1786700010000GenColumnLayoutFields(db *gorm.DB, version string) error {
m := db.Migrator()
if !m.HasColumn(&tools.SysColumns{}, "ColWidth") {
if err := m.AddColumn(&tools.SysColumns{}, "ColWidth"); err != nil {
return err
}
}
if !m.HasColumn(&tools.SysColumns{}, "DefaultValue") {
if err := m.AddColumn(&tools.SysColumns{}, "DefaultValue"); err != nil {
return err
}
}
return db.Create(&common.Migration{Version: version}).Error
}
-47
View File
@@ -1,47 +0,0 @@
import request from '@/utils/request'
// 查询{{.ClassName}}列表
export function list{{.ClassName}}(query) {
return request({
url: '/api/v1/{{.ModuleName}}',
method: 'get',
params: query
})
}
// 查询{{.ClassName}}详细
export function get{{.ClassName}} ({{.PkJsonField}}) {
return request({
url: '/api/v1/{{.ModuleName}}/' + {{.PkJsonField}},
method: 'get'
})
}
// 新增{{.ClassName}}
export function add{{.ClassName}}(data) {
return request({
url: '/api/v1/{{.ModuleName}}',
method: 'post',
data: data
})
}
// 修改{{.ClassName}}
export function update{{.ClassName}}(data) {
return request({
url: '/api/v1/{{.ModuleName}}/'+data.{{.PkJsonField}},
method: 'put',
data: data
})
}
// 删除{{.ClassName}}
export function del{{.ClassName}}(data) {
return request({
url: '/api/v1/{{.ModuleName}}',
method: 'delete',
data: data
})
}
+6
View File
@@ -0,0 +1,6 @@
export default {
{{- range $i, $col := .Columns}}
{{- if $i}},{{end}}
{{$col.JsonField}}: {{if $col.ColumnComment}}{{singleQuote $col.ColumnComment}}{{else}}{{singleQuote $col.JsonField}}{{end}}
{{- end}}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
{{- range $i, $col := .Columns}}
{{- if $i}},{{end}}
{{$col.JsonField}}: {{if $col.ColumnComment}}{{singleQuote $col.ColumnComment}}{{else}}{{singleQuote $col.JsonField}}{{end}}
{{- end}}
}
+125
View File
@@ -0,0 +1,125 @@
{{- /*
$pkType: the primary key's TS type for get{ClassName}'s parameter.
Defaults to "number" - true for every column but string primary keys
(natural keys), which do exist (sys_tables.go:323-338 gives a primary
key column GoType "string" whenever its ColumnType is not int-shaped).
Matches vue.go.template's own $pkType derivation exactly (F4) - useForm
there is typed on the same column, and a mismatch between the two is a
TS compile error at the call site, not a runtime bug.
*/ -}}
{{- $pkType := "number" -}}
{{- $hasQuery := false -}}
{{- range .Columns -}}
{{- if and .Pk (eq .GoType "string") }}{{$pkType = "string"}}{{end -}}
{{- if eq .IsQuery "1" }}{{$hasQuery = true}}{{end -}}
{{- end -}}
import request from '@/utils/request'
import type { ApiResponse, PageQuery, PageResult, Id } from '@/types/api'
export interface {{.ClassName}} {
{{- range .Columns}}
{{.JsonField}}?: {{if eq .GoType "int" -}}
number
{{- else if eq .GoType "int64" -}}
number
{{- else if eq .GoType "float32" -}}
number
{{- else if eq .GoType "float64" -}}
number
{{- else -}}
string
{{- end}}
{{- end}}
}
{{if $hasQuery -}}
export interface {{.ClassName}}Query {
{{- range .Columns}}
{{- if eq .IsQuery "1"}}
{{.JsonField}}?: {{if eq .GoType "int" -}}
number
{{- else if eq .GoType "int64" -}}
number
{{- else if eq .GoType "float32" -}}
number
{{- else if eq .GoType "float64" -}}
number
{{- else -}}
string
{{- end}}
{{- end}}
{{- end}}
}
{{- else -}}
{{- /*
No column is marked IsQuery - a plain display table with no search form
is a normal shape, not an edge case, so this still has to produce a type
useTable<Row, Query>/list{ClassName}(query: Query & PageQuery) can use.
`export interface {ClassName}Query {}` is what naturally falls out of the
range above finding nothing to iterate, but an empty interface trips
@typescript-eslint/no-empty-object-type and fails pnpm lint.
Record<string, never> (this file's first attempt, and the type
useTable.ts's own `TQuery extends object = Record<string, never>` default
uses) looks like the obvious match but is wrong here: it is a mapped type
over *every* string key, each mapped to never, so intersecting it with
PageQuery does not leave PageQuery alone - `pageIndex` becomes
`never & number`, i.e. never, and no value can be passed for it at all.
useTable.ts itself never hits this because its one internal use of
`TQuery & PageQuery` goes through an `as` cast rather than a structural
check (composables/useTable.ts ~line 160); code that builds the object
literal directly - such as a foreign-key column's
`list{FkClass}({ pageIndex: 1, pageSize: 100 })` call in vue.go.template -
is not casting anything and hits the real error, only when the referenced
table happens to have no query columns of its own (a plain lookup/dict
table used as a dropdown source, not a rare shape).
Record<never, never> is the type with the same intent - "no query
columns" - but the mapped-type domain is `never`, so it has no keys at
all rather than "every key, mapped to never": it behaves as the empty
object type `{}` under intersection, leaving PageQuery's own pageIndex/
pageSize untouched, and confirmed separately not to trip
no-empty-object-type either (it is a generic instantiation, not a
literal `{}` type annotation).
*/ -}}
export type {{.ClassName}}Query = Record<never, never>
{{- end}}
export function list{{.ClassName}}(query: {{.ClassName}}Query & PageQuery) {
return request<ApiResponse<PageResult<{{.ClassName}}>>>({
url: '/api/v1/{{.ModuleName}}',
method: 'get',
params: query
})
}
export function get{{.ClassName}}({{.PkJsonField}}: {{$pkType}}) {
return request<ApiResponse<{{.ClassName}}>>({
url: '/api/v1/{{.ModuleName}}/' + {{.PkJsonField}},
method: 'get'
})
}
export function add{{.ClassName}}(data: {{.ClassName}}) {
return request<ApiResponse<{{.ClassName}}>>({
url: '/api/v1/{{.ModuleName}}',
method: 'post',
data
})
}
export function update{{.ClassName}}(data: {{.ClassName}}) {
return request<ApiResponse<{{.ClassName}}>>({
url: '/api/v1/{{.ModuleName}}/' + data.{{.PkJsonField}},
method: 'put',
data
})
}
export function del{{.ClassName}}(ids: Id[]) {
return request<ApiResponse<null>>({
url: '/api/v1/{{.ModuleName}}',
method: 'delete',
data: { ids: ids.map(Number) }
})
}
+377 -467
View File
@@ -1,479 +1,389 @@
{{$tableComment:=.TableComment}}
{{- /*
Vue 3 + Element Plus + TypeScript list page (PRD 010, F4).
Shape matches go-admin-ui/src/views/demo/product/index.vue, the reference
page AGENTS.md names: PageContainer + ProTable + useTable/useForm/useRemove,
<script setup lang="ts">. The old template produced slot-scope/.sync/.native
syntax that Vue 3 removed outright (PRD 010 G1) -- this is not a patch on
that file, it is a different template for a different framework version.
Every label goes through $t('gen.{PackageName}.{BusinessName}.{JsonField}'),
never a literal ColumnComment -- see src/lang/{locale}/gen/index.ts (F9) for
how that namespace is loaded. This is also why the file must not contain a
literal CJK character anywhere, comments included: D10's acceptance check is
a bare regex scan of the rendered output with no exception for "but this one
is a comment", so a Chinese aside here would fail the same test a stray
placeholder="{{"{{"}}.ColumnComment{{"}}"}}" would.
HtmlType has seven stored values (PRD 010 G8) and only four render here on
purpose: checkbox and datetime became selectable in the F7 front-end change
(editTable.vue), so they get a branch; file stays disabled there, but a row
imported or edited before that change can still carry "file" or any other
value this template does not know -- the final branch below renders those,
and anything else future work introduces, as a plain input rather than
emitting nothing (PRD 010 phase-3 constraint #1: a silently empty field is
worse than a plain one).
*/ -}}
{{- $package := .PackageName -}}
{{- $business := .BusinessName -}}
{{- /*
Whether any column needs a given import, computed once by walking .Columns
rather than at each usage site -- text/template has no way to ask "did the
loop below already import this", so the alternative is repeating the same
import line once per matching column. "$var = value" (not ":=") reassigns an
outer-scope variable from inside a range -- a text/template feature since
Go 1.11, needed here because a range body cannot otherwise leave a mark on
anything outside itself.
Each condition below must match, term for term, the condition guarding the
markup or script that actually consumes the import -- not just "this column
has a DictType/FkTableName", which is necessary but not sufficient. A column
can carry dictionary or foreign-key metadata that no rendered branch reads:
FkTableName/DictType lose to each other by priority (FK wins search, list
and the form's select branch; the form's radio branch never looks at FK at
all), and a column can carry either one while being neither queryable nor
listed nor an insertable select/radio -- created_at/updated_at are exactly
this: sys_tables.go assigns HtmlType "datetime" to any timestamp/datetime
column on import whether or not it ever reaches IsList, because GetList's
audit-column exclusion is a separate, later step. Get a term here wrong in
either direction and either an import goes unused (no-unused-vars) or a real
usage silently loses its import (a ReferenceError this template cannot see
coming, since Vue components are the last stage that runs).
*/ -}}
{{- $hasDict := false -}}
{{- $hasDictList := false -}}
{{- $hasFk := false -}}
{{- $hasDatetime := false -}}
{{- $hasRules := false -}}
{{- $hasQuery := false -}}
{{- $pkType := "number" -}}
{{- range .Columns -}}
{{- $dictUsed := and (ne .DictType "") (or (and (eq .IsQuery "1") (eq .FkTableName "")) (and (eq .IsList "1") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "select") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "radio"))) -}}
{{- $fkUsed := and (ne .FkTableName "") (or (eq .IsQuery "1") (eq .IsList "1") (and (eq .IsInsert "1") (eq .HtmlType "select"))) -}}
{{- if $dictUsed }}{{$hasDict = true}}{{end -}}
{{- if and (eq .IsList "1") (eq .FkTableName "") (ne .DictType "") }}{{$hasDictList = true}}{{end -}}
{{- if $fkUsed }}{{$hasFk = true}}{{end -}}
{{- if and (eq .IsList "1") (eq .FkTableName "") (eq .DictType "") (eq .HtmlType "datetime") }}{{$hasDatetime = true}}{{end -}}
{{- if eq .IsQuery "1" }}{{$hasQuery = true}}{{end -}}
{{- if and (eq .IsInsert "1") (eq .IsRequired "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy") }}{{$hasRules = true}}{{end -}}
{{- if and .Pk (eq .GoType "string") }}{{$pkType = "string"}}{{end -}}
{{- end -}}
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<el-form ref="queryForm" :model="queryParams" :inline="true" label-width="68px">
{{range .Columns}}
{{- $x := .IsQuery -}}
{{- if (eq $x "1") -}}
<el-form-item label="{{.ColumnComment}}" prop="{{.JsonField}}">
{{- if ne .FkTableName "" -}}
<el-select v-model="queryParams.{{.JsonField}}"
placeholder="请选择" clearable size="small" {{if eq .IsEdit "false" -}} :disabled="isEdit" {{- end }}>
<el-option
v-for="dict in {{.JsonField}}Options"
:key="dict.key"
:label="dict.value"
:value="dict.key"
/>
</el-select>
{{- else -}}
{{if eq .DictType "" -}}
<el-input v-model="queryParams.{{.JsonField}}" placeholder="请输入{{.ColumnComment}}" clearable
size="small" @keyup.enter.native="handleQuery"/>
{{- else -}}
<el-select v-model="queryParams.{{.JsonField}}"
placeholder="{{$tableComment}}{{.ColumnComment}}" clearable size="small">
<el-option
v-for="dict in {{.JsonField}}Options"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
{{- end}}
{{- end}}
</el-form-item>
{{end}}
{{- end }}
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<PageContainer>
<ProTable :table="table" selection row-key="{{.PkJsonField}}">
{{- if $hasQuery}}
<template #search>
{{- range .Columns}}
{{- if eq .IsQuery "1"}}
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
<el-form-item :label="$t('{{$key}}')">
{{- if ne .FkTableName ""}}
<el-select v-model="table.query.{{.JsonField}}" clearable :placeholder="$t('common.selectPlaceholder')">
<el-option
v-for="item in {{.JsonField}}FkOptions"
:key="item.{{.FkLabelId}}"
:label="item.{{.FkLabelName}}"
:value="item.{{.FkLabelId}}"
/>
</el-select>
{{- else if ne .DictType ""}}
<el-select v-model="table.query.{{.JsonField}}" clearable :placeholder="$t('common.selectPlaceholder')">
<el-option
v-for="dict in {{.JsonField}}DictOptions"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
{{- else if eq .HtmlType "datetime"}}
<el-date-picker
v-model="table.query.{{.JsonField}}"
type="datetime"
value-format="YYYY-MM-DD[T]HH:mm:ssZ"
clearable
/>
{{- else}}
<el-input v-model="table.query.{{.JsonField}}" clearable />
{{- end}}
</el-form-item>
{{- end}}
{{- end}}
</template>
{{- end}}
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:add']"
type="primary"
icon="el-icon-plus"
size="mini"
@click="handleAdd"
>新增
</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:edit']"
type="success"
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
>修改
</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']"
type="danger"
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
>删除
</el-button>
</el-col>
</el-row>
<template #toolbar>
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:add']" type="primary" @click="form.openCreate()">
{{ "{{" }} $t('common.add') {{ "}}" }}
</el-button>
<el-button
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']"
type="danger"
plain
:disabled="table.multiple"
@click="remove(table.selectedIds)"
>
{{ "{{" }} $t('common.delete') {{ "}}" }}
</el-button>
</template>
{{- range .Columns}}
{{- if eq .IsList "1"}}
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
{{- if ne .FkTableName ""}}
<el-table v-loading="loading" :data="{{.BusinessName}}List" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center"/>
{{- range .Columns -}}
{{- $x := .IsList -}}
{{- if (eq $x "1") }}
{{- if ne .FkTableName "" -}}
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}" :formatter="{{.JsonField}}Format" width="100">
<template slot-scope="scope">
{{ "{{" }} {{.JsonField}}Format(scope.row) {{"}}"}}
</template>
</el-table-column>
<el-table-column :label="$t('{{$key}}')" min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}120{{end}}" show-overflow-tooltip>
<template #default="{ row }">{{ "{{" }} {{.JsonField}}Label(row.{{.JsonField}}) {{ "}}" }}</template>
</el-table-column>
{{- else if ne .DictType ""}}
{{- else -}}
{{- if ne .DictType "" -}}
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}"
:formatter="{{.JsonField}}Format" width="100">
<template slot-scope="scope">
{{ "{{" }} {{.JsonField}}Format(scope.row) {{"}}"}}
</template>
</el-table-column>
<el-table-column :label="$t('{{$key}}')" min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}120{{end}}">
<template #default="{ row }">{{ "{{" }} dictLabel({{.JsonField}}DictOptions, row.{{.JsonField}}) {{ "}}" }}</template>
</el-table-column>
{{- else if eq .HtmlType "datetime"}}
{{- end -}}
{{- if eq .DictType "" -}}
{{- if eq .HtmlType "datetime" -}}
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}"
:show-overflow-tooltip="true">
<template slot-scope="scope">
<span>{{ "{{" }} parseTime(scope.row.{{.JsonField}}) {{"}}"}}</span>
</template>
</el-table-column>
{{- else -}}
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}"
:show-overflow-tooltip="true"/>
{{- end -}}
{{- end -}}
{{- end -}}
{{- end }}
{{- end }}
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
slot="reference"
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:edit']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
>修改
</el-button>
<el-popconfirm
class="delete-popconfirm"
title="确认要删除吗?"
confirm-button-text="删除"
@confirm="handleDelete(scope.row)"
>
<el-button
slot="reference"
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']"
size="mini"
type="text"
icon="el-icon-delete"
>删除
</el-button>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-table-column :label="$t('{{$key}}')" min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}110{{end}}">
<template #default="{ row }"><DateCell :value="row.{{.JsonField}}" /></template>
</el-table-column>
{{- else}}
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageIndex"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<el-table-column
:label="$t('{{$key}}')"
prop="{{.JsonField}}"
min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}120{{end}}"
show-overflow-tooltip
/>
{{- end}}
{{- end}}
{{- end}}
<!-- 添加或修改对话框 -->
<el-dialog :title="title" :visible.sync="open" width="500px">
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
{{ range .Columns }}
{{- $x := .IsInsert -}}
{{- if (eq $x "1") -}}
{{- if (.Pk) }}
{{- else if eq .GoField "CreatedAt" -}}
{{- else if eq .GoField "UpdatedAt" -}}
{{- else if eq .GoField "DeletedAt" -}}
{{- else if eq .GoField "UpdateBy" -}}
{{- else if eq .GoField "CreateBy" -}}
{{- else }}
<el-form-item label="{{.ColumnComment}}" prop="{{.JsonField}}">
{{ if eq "input" .HtmlType -}}
<el-input v-model{{if eq .GoType "int64" -}}.number{{- end}}="form.{{.JsonField}}" placeholder="{{.ColumnComment}}"
{{if eq .IsEdit "false" -}}:disabled="isEdit" {{- end}}/>
{{- else if eq "select" .HtmlType -}}
{{- if ne .FkTableName "" -}}
<el-select v-model="form.{{.JsonField}}"
placeholder="请选择" {{if eq .IsEdit "false" -}} :disabled="isEdit" {{- end }}>
<el-option
v-for="dict in {{.JsonField}}Options"
:key="dict.key"
:label="dict.value"
:value="dict.key"
/>
</el-select>
{{- else -}}
<el-select v-model="form.{{.JsonField}}"
placeholder="请选择" {{if eq .IsEdit "false" -}} :disabled="isEdit" {{- end }}>
<el-option
v-for="dict in {{.JsonField}}Options"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
{{- end -}}
{{- else if eq "radio" .HtmlType -}}
<el-radio-group v-model="form.{{.JsonField}}">
<el-radio
v-for="dict in {{.JsonField}}Options"
:key="dict.value"
:label="dict.value"
>{{"{{"}} dict.label {{"}}"}}</el-radio>
</el-radio-group>
{{- else if eq "file" .HtmlType -}}
<el-input
v-model="form.{{.JsonField}}"
placeholder="图片"
/>
<el-button type="primary" @click="fileShow{{.GoField}}">选择文件</el-button>
{{- else if eq "datetime" .HtmlType -}}
<el-date-picker
v-model="form.{{.JsonField}}"
type="datetime"
placeholder="选择日期">
</el-date-picker>
{{- else if eq "textarea" .HtmlType -}}
<el-input
v-model="form.{{.JsonField}}"
type="textarea"
:rows="2"
placeholder="请输入内容">
</el-input>
{{- end }}
</el-form-item>
{{- end }}
{{- end }}
{{- end }}
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
</el-card>
</template>
</BasicLayout>
<template #actions="{ row }">
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:edit']" link type="primary" @click="form.openEdit(row)">
{{ "{{" }} $t('common.edit') {{ "}}" }}
</el-button>
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']" link type="danger" @click="remove(row.{{.PkJsonField}})">
{{ "{{" }} $t('common.delete') {{ "}}" }}
</el-button>
</template>
</ProTable>
<el-dialog
v-model="form.visible"
:title="form.title"
width="500px"
:close-on-click-modal="false"
@closed="form.reset"
>
<el-form
:ref="form.bindFormRef"
v-loading="form.loading"
:model="form.model"
{{- if $hasRules}}
:rules="form.rules"
{{- end}}
label-width="100px"
>
{{- range .Columns}}
{{- if and (eq .IsInsert "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy")}}
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
<el-form-item :label="$t('{{$key}}')" prop="{{.JsonField}}">
{{- if eq .HtmlType "select"}}
{{- if ne .FkTableName ""}}
<el-select v-model="form.model.{{.JsonField}}" :placeholder="$t('common.selectPlaceholder')">
<el-option
v-for="item in {{.JsonField}}FkOptions"
:key="item.{{.FkLabelId}}"
:label="item.{{.FkLabelName}}"
:value="item.{{.FkLabelId}}"
/>
</el-select>
{{- else if ne .DictType ""}}
<el-select v-model="form.model.{{.JsonField}}" :placeholder="$t('common.selectPlaceholder')">
<el-option
v-for="dict in {{.JsonField}}DictOptions"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
{{- else}}
<el-input v-model="form.model.{{.JsonField}}" />
{{- end}}
{{- else if eq .HtmlType "radio"}}
{{- if ne .DictType ""}}
<el-radio-group v-model="form.model.{{.JsonField}}">
<el-radio v-for="dict in {{.JsonField}}DictOptions" :key="dict.value" :value="dict.value">
{{ "{{" }} dict.label {{ "}}" }}
</el-radio>
</el-radio-group>
{{- else}}
<el-input v-model="form.model.{{.JsonField}}" />
{{- end}}
{{- else if eq .HtmlType "checkbox"}}
<el-checkbox v-model="form.model.{{.JsonField}}" true-value="1" false-value="0" />
{{- else if eq .HtmlType "datetime"}}
<el-date-picker
v-model="form.model.{{.JsonField}}"
type="datetime"
value-format="YYYY-MM-DD[T]HH:mm:ssZ"
/>
{{- else if eq .HtmlType "textarea"}}
<el-input v-model="form.model.{{.JsonField}}" type="textarea" :rows="2" />
{{- else}}
<el-input v-model="form.model.{{.JsonField}}" />
{{- end}}
</el-form-item>
{{- end}}
{{- end}}
</el-form>
<template #footer>
<el-button @click="form.close">{{ "{{" }} $t('common.dialogCancel') {{ "}}" }}</el-button>
<el-button type="primary" :loading="form.submitting" @click="form.submit">
{{ "{{" }} $t('common.dialogConfirm') {{ "}}" }}
</el-button>
</template>
</el-dialog>
</PageContainer>
</template>
<script>
import {add{{.ClassName}}, del{{.ClassName}}, get{{.ClassName}}, list{{.ClassName}}, update{{.ClassName}}} from '@/api/{{ .PackageName}}/{{ .MLTBName}}'
{{ $package:=.PackageName }}
{{range .Columns}}
{{- if ne .FkTableName "" -}}
import {list{{.FkTableNameClass}} } from '@/api/{{ $package }}/{{ .FkTableNamePackage}}'
{{ end -}}
{{- end -}}
<script setup lang="ts">
{{- if $hasRules}}
import { computed } from 'vue'
{{- end}}
{{- if $hasFk}}
import { ref, onMounted } from 'vue'
{{- end}}
{{- if $hasRules}}
import { useI18n } from 'vue-i18n'
import type { FormRules } from 'element-plus'
{{- end}}
import PageContainer from '@/components/PageContainer/index.vue'
import ProTable from '@/components/ProTable/index.vue'
{{- if $hasDatetime}}
import DateCell from '@/components/DateCell/index.vue'
{{- end}}
{{- if $hasDict}}
{{- if $hasDictList}}
import { useTable, useForm, useRemove, useDict, dictLabel } from '@/composables'
{{- else}}
import { useTable, useForm, useRemove, useDict } from '@/composables'
{{- end}}
{{- else}}
import { useTable, useForm, useRemove } from '@/composables'
{{- end}}
import {
add{{.ClassName}}, del{{.ClassName}}, get{{.ClassName}}, list{{.ClassName}}, update{{.ClassName}}
} from '@/api/{{.PackageName}}/{{.MLTBName}}'
import type { {{.ClassName}}, {{.ClassName}}Query } from '@/api/{{.PackageName}}/{{.MLTBName}}'
{{- /*
Two columns pointing at the same foreign table must not import it twice --
"one FK-configured column" was never the same thing as "one distinct target
table", and gen.go has no concept of a table's FK targets being unique.
text/template has no set to check membership in, so the dedup is a nested
range: a column only imports its target if no earlier, equally-used column
already claimed the same FkTableNameClass. $fkUsed is repeated here (it also
guards the const declarations above) because a column with FkTableName set
but reaching none of them -- unqueried, unlisted, not an insert select --
has nothing that would use the import either.
*/ -}}
{{- range $i, $col := .Columns}}
{{- $fkUsed := and (ne $col.FkTableName "") (or (eq $col.IsQuery "1") (eq $col.IsList "1") (and (eq $col.IsInsert "1") (eq $col.HtmlType "select"))) -}}
{{- if $fkUsed}}
{{- $alreadyImported := false -}}
{{- range $j, $prior := $.Columns}}
{{- if lt $j $i}}
{{- $priorUsed := and (ne $prior.FkTableName "") (or (eq $prior.IsQuery "1") (eq $prior.IsList "1") (and (eq $prior.IsInsert "1") (eq $prior.HtmlType "select"))) -}}
{{- if and $priorUsed (eq $prior.FkTableNameClass $col.FkTableNameClass) }}{{$alreadyImported = true}}{{end -}}
{{- end}}
{{- end}}
{{- if not $alreadyImported}}
import { list{{$col.FkTableNameClass}} } from '@/api/{{$package}}/{{$col.FkTableNamePackage}}'
import type { {{$col.FkTableNameClass}} } from '@/api/{{$package}}/{{$col.FkTableNamePackage}}'
{{- end}}
{{- end}}
{{- end}}
{{- /*
Manage suffix, not just ClassName: this must match gen.go's
Cmenu.MenuName = tab.ClassName + "Manage" byte for byte (PRD 010 R4), or
keep-alive's include list -- built from menu_name -- never matches this
component's name and the page never caches. The old template wrote
name: '{ClassName}' with no suffix; the mismatch went unnoticed because
stores/permission.ts's loadView() rewrites the rendered component's name to
menu_name at runtime regardless of what defineOptions said (PRD 010 G7).
That fallback stays in place after this change -- it is not this template's
to remove -- but the value declared here should be right regardless of it.
*/}}
export default {
name: '{{.ClassName}}',
components: {
},
data() {
return {
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 总条数
total: 0,
// 弹出层标题
title: '',
// 是否显示弹出层
open: false,
isEdit: false,
// 类型数据字典
typeOptions: [],
{{.BusinessName}}List: [],
{{range .Columns}}
{{- if ne .DictType "" -}}
{{.JsonField}}Options: [],
{{- end -}}
{{- end }}
// 关系表类型
{{range .Columns}}
{{- if ne .FkTableName "" -}}
{{.JsonField}}Options :[],
{{ end -}}
{{- end }}
// 查询参数
queryParams: {
pageIndex: 1,
pageSize: 10,
{{ range .Columns }}
{{- if (.IsQuery) -}}
{{.JsonField}}:undefined,
{{ end -}}
{{- end }}
},
// 表单参数
form: {
},
// 表单校验
rules: {
{{- range .Columns -}}
{{- $x := .IsQuery -}}
{{- if (eq $x "1") -}}
{{.JsonField}}: [ {required: true, message: '{{.ColumnComment}}不能为空', trigger: 'blur'} ],
{{ end }}
{{- end -}}
}
}
},
created() {
this.getList()
{{range .Columns}}
{{- if ne .DictType "" -}}
this.getDicts('{{.DictType}}').then(response => {
this.{{.JsonField}}Options = response.data
})
{{ end -}}
{{- if ne .FkTableName "" -}}
this.get{{.FkTableNameClass}}Items()
{{ end -}}
{{- end -}}
},
methods: {
/** 查询参数列表 */
getList() {
this.loading = true
list{{.ClassName}}(this.addDateRange(this.queryParams, this.dateRange)).then(response => {
this.{{.BusinessName}}List = response.data.list
this.total = response.data.count
this.loading = false
}
)
},
// 取消按钮
cancel() {
this.open = false
this.reset()
},
// 表单重置
reset() {
this.form = {
{{ range .Columns}}
{{- $x := .IsInsert -}}
{{- if (eq $x "1") -}}
{{- if eq .GoField "CreatedAt" -}}
{{- else if eq .GoField "UpdatedAt" -}}
{{- else if eq .GoField "DeletedAt" -}}
{{- else if eq .GoField "UpdateBy" -}}
{{- else if eq .GoField "CreateBy" -}}
{{- else }}
{{.JsonField}}: undefined,
{{- end }}
{{- end -}}
{{- end }}
}
this.resetForm('form')
},
getImgList: function() {
this.form[this.fileIndex] = this.$refs['fileChoose'].resultList[0].fullUrl
},
fileClose: function() {
this.fileOpen = false
},
{{range .Columns}}
{{- if ne .DictType "" -}}
{{.JsonField}}Format(row) {
return this.selectDictLabel(this.{{.JsonField}}Options, row.{{.JsonField}})
},
{{ end -}}
{{- if ne .FkTableName "" -}}
{{.JsonField}}Format(row) {
return this.selectItemsLabel(this.{{.JsonField}}Options, row.{{.JsonField}})
},
{{ end -}}
{{- end -}}
// 关系
{{range .Columns}}
{{- if ne .FkTableName "" -}}
get{{.FkTableNameClass}}Items() {
this.getItems(list{{.FkTableNameClass}}, undefined).then(res => {
this.{{.JsonField}}Options = this.setItems(res, '{{.FkLabelId}}', '{{.FkLabelName}}')
})
},
{{ end -}}
{{- end -}}
// 文件
{{range .Columns}}
{{- if eq .HtmlType "file" -}}
fileShow{{.GoField}}: function() {
this.fileOpen = true
this.fileIndex = '{{.JsonField}}'
},
{{ end -}}
{{- end -}}
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageIndex = 1
this.getList()
},
/** 重置按钮操作 */
resetQuery() {
this.dateRange = []
this.resetForm('queryForm')
this.handleQuery()
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
this.open = true
this.title = '添加{{.TableComment}}'
this.isEdit = false
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.{{.PkJsonField}})
this.single = selection.length !== 1
this.multiple = !selection.length
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset()
const {{.PkJsonField}} =
row.{{.PkJsonField}} || this.ids
get{{.ClassName}}({{.PkJsonField}}).then(response => {
this.form = response.data
this.open = true
this.title = '修改{{.TableComment}}'
this.isEdit = true
})
},
/** 提交按钮 */
submitForm: function () {
this.$refs['form'].validate(valid => {
if (valid) {
if (this.form.{{.PkJsonField}} !== undefined) {
update{{.ClassName}}(this.form).then(response => {
if (response.code === 200) {
this.msgSuccess(response.msg)
this.open = false
this.getList()
} else {
this.msgError(response.msg)
}
})
} else {
add{{.ClassName}}(this.form).then(response => {
if (response.code === 200) {
this.msgSuccess(response.msg)
this.open = false
this.getList()
} else {
this.msgError(response.msg)
}
})
}
}
})
},
/** 删除按钮操作 */
handleDelete(row) {
var Ids = (row.{{.PkJsonField}} && [row.{{.PkJsonField}}]) || this.ids
defineOptions({ name: '{{.ClassName}}Manage' })
{{- range .Columns}}
{{- $dictUsed := and (ne .DictType "") (or (and (eq .IsQuery "1") (eq .FkTableName "")) (and (eq .IsList "1") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "select") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "radio"))) -}}
{{- $fkUsed := and (ne .FkTableName "") (or (eq .IsQuery "1") (eq .IsList "1") (and (eq .IsInsert "1") (eq .HtmlType "select"))) -}}
{{- if $dictUsed}}
this.$confirm('是否确认删除编号为"' + Ids + '"的数据项?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(function () {
return del{{.ClassName}}( { 'ids': Ids })
}).then((response) => {
if (response.code === 200) {
this.msgSuccess(response.msg)
this.open = false
this.getList()
} else {
this.msgError(response.msg)
}
}).catch(function () {
})
}
}
}
const { {{.DictType}}: {{.JsonField}}DictOptions } = useDict('{{.DictType}}')
{{- end}}
{{- if $fkUsed}}
const {{.JsonField}}FkOptions = ref<{{.FkTableNameClass}}[]>([])
onMounted(async() => {
const res = await list{{.FkTableNameClass}}({ pageIndex: 1, pageSize: 100 })
{{.JsonField}}FkOptions.value = res.data?.list ?? []
})
{{- if eq .IsList "1"}}
const {{.JsonField}}Label = (value: unknown) =>
{{.JsonField}}FkOptions.value.find(item => item.{{.FkLabelId}} === value)?.{{.FkLabelName}} ?? value
{{- end}}
{{- end}}
{{- end}}
{{- /*
Every object literal below is built on one line, joined with ", " through a
$first flag rather than one field per line with a trailing comma after each:
comma-dangle is "never" (no comma before the closing brace) and comma-style
is "last" (a comma may not open a line), and text/template has no arithmetic
to compute "is this the last matching column" up front -- knowing that would
be what a one-field-per-line, trailing-comma-free rendering needs instead.
*/}}
const table = useTable<{{.ClassName}}, {{.ClassName}}Query>({
api: list{{.ClassName}},
idKey: '{{.PkJsonField}}'
{{- if $hasQuery}},
defaultQuery: () => ({{"{"}} {{$qFirst := true}}{{range .Columns}}{{if eq .IsQuery "1"}}{{if $qFirst}}{{$qFirst = false}}{{else}}, {{end}}{{.JsonField}}: undefined{{end}}{{end}} {{"}"}})
{{- end}}
})
{{- if $hasRules}}
const { t } = useI18n()
{{- /*
Built from the same field-label key rather than a dedicated
gen.{pkg}.{biz}.rules.{field} key: R3 derives one key per field from
PackageName+BusinessName+JsonField, and a second, validation-only key per
required field would double the language pack's surface for a message that
reads fine as the field name alone in the space Element Plus renders it --
directly under the labelled field it failed to validate.
*/}}
const rules = computed<FormRules>(() => ({ {{$rFirst := true}}
{{- range .Columns}}
{{- if and (eq .IsInsert "1") (eq .IsRequired "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy")}}
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
{{- if $rFirst}}{{$rFirst = false}}{{else}},
{{end}}{{.JsonField}}: [{ required: true, message: t('{{$key}}'), trigger: '{{if or (eq .HtmlType "select") (eq .HtmlType "radio") (eq .HtmlType "datetime") (eq .HtmlType "checkbox")}}change{{else}}blur{{end}}' }]
{{- end}}
{{- end}}
}))
{{- end}}
const form = useForm<{{.ClassName}}, {{$pkType}}>({
defaultModel: () => ({{"{"}} {{.PkJsonField}}: undefined{{range .Columns}}{{if and (eq .IsInsert "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy")}}, {{.JsonField}}: {{if eq .DefaultValue ""}}undefined{{else if eq .GoType "int"}}{{.DefaultValue}}{{else}}'{{js .DefaultValue}}'{{end}}{{end}}{{end}} {{"}"}}),
idKey: '{{.PkJsonField}}',
{{- if $hasRules}}
rules,
{{- end}}
api: { get: get{{.ClassName}}, add: add{{.ClassName}}, update: update{{.ClassName}} },
onSuccess: () => table.getList()
})
const { remove } = useRemove({
api: del{{.ClassName}},
onSuccess: () => table.getList()
})
</script>