agentworkshop 0.7.11 → 0.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/CXutoGrn.js +14 -0
  3. package/.output/public/_nuxt/{BLzCac9K2.js → CcwJ2mrD2.js} +1 -1
  4. package/.output/public/_nuxt/{CGx-mtWf.js → D8xY-Yjc.js} +1 -1
  5. package/.output/public/_nuxt/DGVAuScD.js +1 -0
  6. package/.output/public/_nuxt/DVoWWlUW.js +1 -0
  7. package/.output/public/_nuxt/DaE5saZg.js +1 -0
  8. package/.output/public/_nuxt/Dsdy4Ua-.js +1 -0
  9. package/.output/public/_nuxt/{B8WV7GzS.js → E88a-JCG.js} +1 -1
  10. package/.output/public/_nuxt/bAFueD6a.js +1 -0
  11. package/.output/public/_nuxt/builds/latest.json +1 -1
  12. package/.output/public/_nuxt/builds/meta/bf43874b-06d3-4e20-a84d-e5b0d09e8a27.json +1 -0
  13. package/.output/public/_nuxt/dcw.rpqpOYQ8.css +1 -0
  14. package/.output/public/_nuxt/entry.Bv_GCmPP.css +1 -0
  15. package/.output/public/_nuxt/logs.BarkrvUp.css +1 -0
  16. package/.output/public/_nuxt/pages.BkE-AfIb.css +1 -0
  17. package/.output/public/_nuxt/permissions.DTRtZeTw.css +1 -0
  18. package/.output/public/_nuxt/plugins.BcSEwwGb.css +1 -0
  19. package/.output/public/_nuxt/tokens.BVXU1jOK.css +1 -0
  20. package/.output/public/_nuxt/workshop.CbymC2LY.css +1 -0
  21. package/.output/server/chunks/_/nitro.mjs +610 -609
  22. package/.output/server/chunks/_/user.schema.mjs +1 -1
  23. package/.output/server/chunks/_/user.schema.mjs.map +1 -1
  24. package/.output/server/chunks/build/{dcw-DqjHrl5Z.mjs → dcw-B0dtiHFG.mjs} +43 -43
  25. package/.output/server/chunks/build/dcw-B0dtiHFG.mjs.map +1 -0
  26. package/.output/server/chunks/build/{default-YcUHc1Ev.mjs → default-DdV642BY.mjs} +23 -2
  27. package/.output/server/chunks/build/{default-YcUHc1Ev.mjs.map → default-DdV642BY.mjs.map} +1 -1
  28. package/.output/server/chunks/build/{logs-Co854R4y.mjs → logs-UWXeNKy_.mjs} +34 -34
  29. package/.output/server/chunks/build/{logs-Co854R4y.mjs.map → logs-UWXeNKy_.mjs.map} +1 -1
  30. package/.output/server/chunks/build/{pages-DPC1Vp6w.mjs → pages-BmRUbGUi.mjs} +28 -18
  31. package/.output/server/chunks/build/pages-BmRUbGUi.mjs.map +1 -0
  32. package/.output/server/chunks/build/{permissions-Eu9e6hfZ.mjs → permissions-CxEgXic0.mjs} +12 -12
  33. package/.output/server/chunks/build/{permissions-Eu9e6hfZ.mjs.map → permissions-CxEgXic0.mjs.map} +1 -1
  34. package/.output/server/chunks/build/{plugins-D84mvK9G.mjs → plugins-wLIKHcIE.mjs} +17 -17
  35. package/.output/server/chunks/build/plugins-wLIKHcIE.mjs.map +1 -0
  36. package/.output/server/chunks/build/{tokens-CZpnEDVf.mjs → tokens-Dxnuw96_.mjs} +44 -75
  37. package/.output/server/chunks/build/tokens-Dxnuw96_.mjs.map +1 -0
  38. package/.output/server/chunks/build/{workshop-Crsai2Y9.mjs → workshop-CTcYDq2i.mjs} +21 -16
  39. package/.output/server/chunks/build/{workshop-Crsai2Y9.mjs.map → workshop-CTcYDq2i.mjs.map} +1 -1
  40. package/.output/server/chunks/routes/renderer.mjs +1 -1
  41. package/.output/server/chunks/virtual/entry.mjs +14 -12
  42. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  43. package/.output/server/package.json +1 -1
  44. package/app/assets/css/main.css +161 -0
  45. package/app/components/AwChart.vue +12 -2
  46. package/app/composables/useRouteMeta.ts +8 -1
  47. package/app/pages/dcw/index.vue +1079 -1079
  48. package/app/pages/index.vue +7 -2
  49. package/app/pages/logs/index.vue +623 -621
  50. package/app/pages/permissions/index.vue +330 -327
  51. package/app/pages/plugins/index.vue +6 -3
  52. package/app/pages/tokens.vue +11 -13
  53. package/app/pages/workshop/index.vue +3 -1
  54. package/i18n/locales/en.ts +1944 -1943
  55. package/i18n/locales/zh-CN.ts +1943 -1942
  56. package/package.json +1 -1
  57. package/scripts/_dbg-kicker-check.mjs +11 -0
  58. package/scripts/_dbg-perms-e2e.mjs +3 -3
  59. package/scripts/_dbg-plugins-e2e.mjs +1 -1
  60. package/scripts/_dbg-swatch-check.mjs +10 -0
  61. package/scripts/_dbg-ui-shots.mjs +28 -0
  62. package/server/data/dcw-rollback.json +6 -6
  63. package/server/data/dcws.json +345 -345
  64. package/server/repositories/user.repository.ts +4 -3
  65. package/server/schemas/user.schema.ts +2 -2
  66. package/.output/public/_nuxt/B5CYgFAD.js +0 -14
  67. package/.output/public/_nuxt/Bckshc9V.js +0 -1
  68. package/.output/public/_nuxt/D7jiode2.js +0 -1
  69. package/.output/public/_nuxt/DEaY_uvR.js +0 -1
  70. package/.output/public/_nuxt/builds/meta/88479a5b-ce6a-400f-9737-1cc9f54b1fe5.json +0 -1
  71. package/.output/public/_nuxt/d_Sz9KGm.js +0 -1
  72. package/.output/public/_nuxt/dcw.DcDjwvqv.css +0 -1
  73. package/.output/public/_nuxt/entry.B03HBJGC.css +0 -1
  74. package/.output/public/_nuxt/logs.BO-BSyjm.css +0 -1
  75. package/.output/public/_nuxt/pages.CytEOfUs.css +0 -1
  76. package/.output/public/_nuxt/permissions.CwZo6t1V.css +0 -1
  77. package/.output/public/_nuxt/plugins.Clsky_kl.css +0 -1
  78. package/.output/public/_nuxt/tJuVvBSl.js +0 -1
  79. package/.output/public/_nuxt/tokens.BPLe9ioQ.css +0 -1
  80. package/.output/public/_nuxt/workshop.BIdoGfoC.css +0 -1
  81. package/.output/server/chunks/build/dcw-DqjHrl5Z.mjs.map +0 -1
  82. package/.output/server/chunks/build/pages-DPC1Vp6w.mjs.map +0 -1
  83. package/.output/server/chunks/build/plugins-D84mvK9G.mjs.map +0 -1
  84. package/.output/server/chunks/build/tokens-CZpnEDVf.mjs.map +0 -1
@@ -1,327 +1,330 @@
1
- <script setup lang="ts">
2
- /**
3
- * 权限管理(admin 专属)—— 用户 × 产线授权矩阵。
4
- * 三态:无权(none,后端不返回该产线数据)/ 仅查看(readonly,数采只读)/ 可操控(operate,数采+数控全量)。
5
- * 用户详情抽屉含:基本信息、该用户创建的 Channel、产线授权矩阵(逐线三态选择,批量保存)。
6
- */
7
- import { computed, onMounted, reactive, ref } from 'vue'
8
- import { message } from 'ant-design-vue'
9
- import { useUserStore } from '~/stores/workshop/user'
10
-
11
- const { t } = useI18n()
12
- const userStore = useUserStore()
13
-
14
- interface LineView { id: string, name: string }
15
- interface GrantRow { lineId: string, mode: string, grantedBy: string | null, grantedAt: string }
16
- interface UserRow {
17
- id: string
18
- name: string
19
- email: string
20
- role: string
21
- status: string
22
- createdAt: string
23
- channels: Array<{ id: string, name: string, createdAt: string }>
24
- grants: GrantRow[]
25
- }
26
-
27
- const lines = ref<LineView[]>([])
28
- const users = ref<UserRow[]>([])
29
- const loading = ref(false)
30
- const keyword = ref('')
31
- const drawerOpen = ref(false)
32
- const current = ref<UserRow | null>(null)
33
- // lineId → 'none' | 'readonly' | 'operate'
34
- const draft = reactive<Record<string, string>>({})
35
- const saving = ref(false)
36
-
37
- function authHeaders(): Record<string, string> {
38
- const token = (userStore as { token?: string }).token
39
- return token ? { authorization: `Bearer ${token}` } : {}
40
- }
41
-
42
- async function load() {
43
- loading.value = true
44
- try {
45
- const d = await $fetch<{ code: number, data?: { lines: LineView[], users: UserRow[] } }>('/api/workshop/permissions', { headers: authHeaders() })
46
- if (d.code !== 0 || !d.data) throw new Error('load fail')
47
- lines.value = d.data.lines ?? []
48
- users.value = d.data.users ?? []
49
- }
50
- catch (err) {
51
- message.error(apiErrorMessage(err, t('permissions.loadFail')))
52
- }
53
- finally {
54
- loading.value = false
55
- }
56
- }
57
-
58
- const filteredUsers = computed(() => {
59
- const kw = keyword.value.trim().toLowerCase()
60
- if (!kw) return users.value
61
- return users.value.filter(u => `${u.name} ${u.email}`.toLowerCase().includes(kw))
62
- })
63
-
64
- function openDetail(u: UserRow) {
65
- current.value = u
66
- draftClear(u)
67
- drawerOpen.value = true
68
- }
69
-
70
- function draftClear(u: UserRow) {
71
- for (const k of Object.keys(draft)) Reflect.deleteProperty(draft, k)
72
- for (const l of lines.value) {
73
- const g = u.grants.find(x => x.lineId === l.id)
74
- draft[l.id] = (g?.mode === 'readonly' || g?.mode === 'operate') ? g.mode : 'none'
75
- }
76
- }
77
-
78
- const dirtyCount = computed(() => {
79
- if (!current.value) return 0
80
- let n = 0
81
- for (const l of lines.value) {
82
- const g = current.value.grants.find(x => x.lineId === l.id)
83
- const cur = (g?.mode === 'readonly' || g?.mode === 'operate') ? g.mode : 'none'
84
- if ((draft[l.id] ?? 'none') !== cur) n++
85
- }
86
- return n
87
- })
88
-
89
- async function save() {
90
- if (!current.value) return
91
- saving.value = true
92
- try {
93
- const grants = lines.value
94
- .filter(l => (draft[l.id] ?? 'none') !== 'none')
95
- .map(l => ({ lineId: l.id, mode: draft[l.id] }))
96
- const revoked = lines.value
97
- .filter(l => (draft[l.id] ?? 'none') === 'none' && current.value!.grants.some(g => g.lineId === l.id))
98
- .map(l => ({ lineId: l.id, mode: null }))
99
- const d = await $fetch<{ code: number, data?: { grants: GrantRow[] } }>('/api/workshop/permissions', {
100
- method: 'PUT',
101
- headers: authHeaders(),
102
- body: { userId: current.value.id, grants: [...grants, ...revoked] },
103
- })
104
- if (d.code !== 0 || !d.data) throw new Error('save fail')
105
- current.value.grants = d.data.grants
106
- const u = users.value.find(x => x.id === current.value?.id)
107
- if (u) u.grants = d.data.grants
108
- message.success(t('permissions.saved', { n: grants.length + revoked.length }))
109
- }
110
- catch (err) {
111
- message.error(apiErrorMessage(err, t('permissions.saveFail')))
112
- }
113
- finally {
114
- saving.value = false
115
- }
116
- }
117
-
118
- function modeTag(mode: string): string {
119
- return mode === 'operate' ? t('permissions.operate') : mode === 'readonly' ? t('permissions.readonly') : t('permissions.none')
120
- }
121
-
122
- // admin 专属页:非 admin 直接回仪表盘(后端 API 亦有 admin 拦截)
123
- watch(() => userStore.isLoggedIn, (ok) => {
124
- if (ok && !userStore.isAdmin) navigateTo('/')
125
- }, { immediate: true })
126
-
127
- onMounted(load)
128
- </script>
129
-
130
- <template>
131
- <div class="perm-page">
132
- <header class="pg-head">
133
- <div>
134
- <h1 class="pg-title">
135
- {{ $t('permissions.title') }}
136
- </h1>
137
- <p class="pg-sub">
138
- {{ $t('permissions.subtitle') }}
139
- </p>
140
- </div>
141
- <a-input-search
142
- v-model:value="keyword"
143
- :placeholder="$t('permissions.search')"
144
- style="max-width: 260px"
145
- allow-clear
146
- />
147
- </header>
148
-
149
- <a-table
150
- :data-source="filteredUsers"
151
- :loading="loading"
152
- row-key="id"
153
- size="middle"
154
- :pagination="{ pageSize: 12, showSizeChanger: false }"
155
- >
156
- <a-table-column
157
- :title="$t('permissions.colUser')"
158
- data-index="name"
159
- >
160
- <template #default="{ record }">
161
- <a @click.prevent="openDetail(record)">{{ record.name }}</a>
162
- </template>
163
- </a-table-column>
164
- <a-table-column
165
- :title="$t('permissions.colEmail')"
166
- data-index="email"
167
- />
168
- <a-table-column
169
- :title="$t('permissions.colRole')"
170
- data-index="role"
171
- >
172
- <template #default="{ record }">
173
- <a-tag :color="record.role === 'admin' ? 'green' : record.role === 'editor' ? 'blue' : 'default'">
174
- {{ record.role }}
175
- </a-tag>
176
- </template>
177
- </a-table-column>
178
- <a-table-column
179
- :title="$t('permissions.colStatus')"
180
- data-index="status"
181
- >
182
- <template #default="{ record }">
183
- <a-tag :color="record.status === 'active' ? 'cyan' : 'red'">
184
- {{ record.status === 'active' ? $t('permissions.active') : record.status }}
185
- </a-tag>
186
- </template>
187
- </a-table-column>
188
- <a-table-column
189
- :title="$t('permissions.colChannels')"
190
- data-index="channels"
191
- >
192
- <template #default="{ record }">
193
- {{ record.channels.length }}
194
- </template>
195
- </a-table-column>
196
- <a-table-column
197
- :title="$t('permissions.colGrants')"
198
- data-index="grants"
199
- >
200
- <template #default="{ record }">
201
- <a-tag
202
- v-for="g in record.grants"
203
- :key="g.lineId"
204
- style="margin-bottom: 2px"
205
- >
206
- {{ (lines.find(l => l.id === g.lineId)?.name ?? g.lineId).slice(0, 14) }} · {{ modeTag(g.mode) }}
207
- </a-tag>
208
- <span
209
- v-if="!record.grants.length"
210
- class="dim"
211
- >—</span>
212
- </template>
213
- </a-table-column>
214
- <a-table-column
215
- :title="$t('permissions.colOps')"
216
- :width="90"
217
- >
218
- <template #default="{ record }">
219
- <a-button
220
- size="small"
221
- @click="openDetail(record)"
222
- >
223
- {{ $t('permissions.manage') }}
224
- </a-button>
225
- </template>
226
- </a-table-column>
227
- </a-table>
228
-
229
- <a-drawer
230
- v-model:open="drawerOpen"
231
- :title="current ? `${current.name} · ${$t('permissions.detail')}` : ''"
232
- width="560"
233
- >
234
- <template v-if="current">
235
- <h4 class="sec">
236
- {{ $t('permissions.secProfile') }}
237
- </h4>
238
- <dl class="detail">
239
- <dt>email</dt><dd>{{ current.email }}</dd>
240
- <dt>role</dt><dd>{{ current.role }}</dd>
241
- <dt>status</dt><dd>{{ current.status }}</dd>
242
- <dt>created</dt><dd>{{ current.createdAt }}</dd>
243
- </dl>
244
-
245
- <h4 class="sec">
246
- {{ $t('permissions.secChannels') }}({{ current.channels.length }})
247
- </h4>
248
- <div
249
- v-if="current.channels.length"
250
- class="ch-list"
251
- >
252
- <code
253
- v-for="c in current.channels"
254
- :key="c.id"
255
- >{{ c.name || c.id.slice(0, 8) }}</code>
256
- </div>
257
- <p
258
- v-else
259
- class="dim"
260
- >
261
- {{ $t('permissions.noChannels') }}
262
- </p>
263
-
264
- <h4 class="sec">
265
- {{ $t('permissions.secMatrix') }}
266
- <a-button
267
- type="primary"
268
- size="small"
269
- :loading="saving"
270
- :disabled="dirtyCount === 0"
271
- style="margin-left: 12px"
272
- @click="save"
273
- >
274
- {{ $t('permissions.save', { n: dirtyCount }) }}
275
- </a-button>
276
- </h4>
277
- <p class="hint">
278
- {{ $t('permissions.matrixHint') }}
279
- </p>
280
- <div class="matrix">
281
- <div
282
- v-for="l in lines"
283
- :key="l.id"
284
- class="row"
285
- >
286
- <span
287
- class="line-name"
288
- :title="l.id"
289
- >{{ l.name }}</span>
290
- <a-radio-group
291
- v-model:value="draft[l.id]"
292
- size="small"
293
- >
294
- <a-radio-button value="none">
295
- {{ $t('permissions.none') }}
296
- </a-radio-button>
297
- <a-radio-button value="readonly">
298
- {{ $t('permissions.readonly') }}
299
- </a-radio-button>
300
- <a-radio-button value="operate">
301
- {{ $t('permissions.operate') }}
302
- </a-radio-button>
303
- </a-radio-group>
304
- </div>
305
- </div>
306
- </template>
307
- </a-drawer>
308
- </div>
309
- </template>
310
-
311
- <style scoped lang="css">
312
- .perm-page { max-width: 1180px; margin: 0 auto; padding: 24px 20px 48px; }
313
- .pg-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
314
- .pg-title { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: .5px; }
315
- .pg-sub { margin: 4px 0 0; opacity: .6; font-size: 12px; }
316
- .dim { opacity: .45; }
317
- .sec { margin: 20px 0 8px; font-size: 13px; font-weight: 700; }
318
- .detail dt { margin-top: 8px; font-size: 11px; opacity: .5; }
319
- .detail dd { margin: 2px 0 0; font-family: ui-monospace, monospace; font-size: 12px; }
320
- .ch-list { display: flex; flex-wrap: wrap; gap: 6px; }
321
- .ch-list code { padding: 2px 8px; border: 1px solid rgba(128, 152, 199, .3); border-radius: 6px; font-size: 11px; }
322
- .hint { font-size: 11px; opacity: .55; margin: 4px 0 10px; }
323
- .matrix { display: flex; flex-direction: column; gap: 8px; }
324
- .matrix .row { display: flex; align-items: center; justify-content: space-between; gap: 12px;
325
- padding: 8px 12px; border: 1px solid var(--aw-border, rgba(128, 152, 199, .25)); border-radius: 10px; }
326
- .line-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
327
- </style>
1
+ <script setup lang="ts">
2
+ /**
3
+ * 权限管理(admin 专属)—— 用户 × 产线授权矩阵。
4
+ * 三态:无权(none,后端不返回该产线数据)/ 仅查看(readonly,数采只读)/ 可操控(operate,数采+数控全量)。
5
+ * 用户详情抽屉含:基本信息、该用户创建的 Channel、产线授权矩阵(逐线三态选择,批量保存)。
6
+ */
7
+ import { computed, onMounted, reactive, ref } from 'vue'
8
+ import { message } from 'ant-design-vue'
9
+ import { useUserStore } from '~/stores/workshop/user'
10
+
11
+ const { t } = useI18n()
12
+ const userStore = useUserStore()
13
+
14
+ interface LineView { id: string, name: string }
15
+ interface GrantRow { lineId: string, mode: string, grantedBy: string | null, grantedAt: string }
16
+ interface UserRow {
17
+ id: string
18
+ name: string
19
+ email: string
20
+ role: string
21
+ status: string
22
+ createdAt: string
23
+ channels: Array<{ id: string, name: string, createdAt: string }>
24
+ grants: GrantRow[]
25
+ }
26
+
27
+ const lines = ref<LineView[]>([])
28
+ const users = ref<UserRow[]>([])
29
+ const loading = ref(false)
30
+ const keyword = ref('')
31
+ const drawerOpen = ref(false)
32
+ const current = ref<UserRow | null>(null)
33
+ // lineId → 'none' | 'readonly' | 'operate'
34
+ const draft = reactive<Record<string, string>>({})
35
+ const saving = ref(false)
36
+
37
+ function authHeaders(): Record<string, string> {
38
+ const token = (userStore as { token?: string }).token
39
+ return token ? { authorization: `Bearer ${token}` } : {}
40
+ }
41
+
42
+ async function load() {
43
+ loading.value = true
44
+ try {
45
+ const d = await $fetch<{ code: number, data?: { lines: LineView[], users: UserRow[] } }>('/api/workshop/permissions', { headers: authHeaders() })
46
+ if (d.code !== 0 || !d.data) throw new Error('load fail')
47
+ lines.value = d.data.lines ?? []
48
+ users.value = d.data.users ?? []
49
+ }
50
+ catch (err) {
51
+ message.error(apiErrorMessage(err, t('permissions.loadFail')))
52
+ }
53
+ finally {
54
+ loading.value = false
55
+ }
56
+ }
57
+
58
+ const filteredUsers = computed(() => {
59
+ const kw = keyword.value.trim().toLowerCase()
60
+ if (!kw) return users.value
61
+ return users.value.filter(u => `${u.name} ${u.email}`.toLowerCase().includes(kw))
62
+ })
63
+
64
+ function openDetail(u: UserRow) {
65
+ current.value = u
66
+ draftClear(u)
67
+ drawerOpen.value = true
68
+ }
69
+
70
+ function draftClear(u: UserRow) {
71
+ for (const k of Object.keys(draft)) Reflect.deleteProperty(draft, k)
72
+ for (const l of lines.value) {
73
+ const g = u.grants.find(x => x.lineId === l.id)
74
+ draft[l.id] = (g?.mode === 'readonly' || g?.mode === 'operate') ? g.mode : 'none'
75
+ }
76
+ }
77
+
78
+ const dirtyCount = computed(() => {
79
+ if (!current.value) return 0
80
+ let n = 0
81
+ for (const l of lines.value) {
82
+ const g = current.value.grants.find(x => x.lineId === l.id)
83
+ const cur = (g?.mode === 'readonly' || g?.mode === 'operate') ? g.mode : 'none'
84
+ if ((draft[l.id] ?? 'none') !== cur) n++
85
+ }
86
+ return n
87
+ })
88
+
89
+ async function save() {
90
+ if (!current.value) return
91
+ saving.value = true
92
+ try {
93
+ const grants = lines.value
94
+ .filter(l => (draft[l.id] ?? 'none') !== 'none')
95
+ .map(l => ({ lineId: l.id, mode: draft[l.id] }))
96
+ const revoked = lines.value
97
+ .filter(l => (draft[l.id] ?? 'none') === 'none' && current.value!.grants.some(g => g.lineId === l.id))
98
+ .map(l => ({ lineId: l.id, mode: null }))
99
+ const d = await $fetch<{ code: number, data?: { grants: GrantRow[] } }>('/api/workshop/permissions', {
100
+ method: 'PUT',
101
+ headers: authHeaders(),
102
+ body: { userId: current.value.id, grants: [...grants, ...revoked] },
103
+ })
104
+ if (d.code !== 0 || !d.data) throw new Error('save fail')
105
+ current.value.grants = d.data.grants
106
+ const u = users.value.find(x => x.id === current.value?.id)
107
+ if (u) u.grants = d.data.grants
108
+ message.success(t('permissions.saved', { n: grants.length + revoked.length }))
109
+ }
110
+ catch (err) {
111
+ message.error(apiErrorMessage(err, t('permissions.saveFail')))
112
+ }
113
+ finally {
114
+ saving.value = false
115
+ }
116
+ }
117
+
118
+ function modeTag(mode: string): string {
119
+ return mode === 'operate' ? t('permissions.operate') : mode === 'readonly' ? t('permissions.readonly') : t('permissions.none')
120
+ }
121
+
122
+ // admin 专属页:非 admin 直接回仪表盘(后端 API 亦有 admin 拦截)
123
+ watch(() => userStore.isLoggedIn, (ok) => {
124
+ if (ok && !userStore.isAdmin) navigateTo('/')
125
+ }, { immediate: true })
126
+
127
+ onMounted(load)
128
+ </script>
129
+
130
+ <template>
131
+ <div class="perm-page">
132
+ <header class="pg-head">
133
+ <div>
134
+ <p class="aw-kicker">
135
+ agentworkshop / permissions
136
+ </p>
137
+ <h1 class="pg-title">
138
+ {{ $t('permissions.title') }}
139
+ </h1>
140
+ <p class="pg-sub">
141
+ {{ $t('permissions.subtitle') }}
142
+ </p>
143
+ </div>
144
+ <a-input-search
145
+ v-model:value="keyword"
146
+ :placeholder="$t('permissions.search')"
147
+ style="max-width: 260px"
148
+ allow-clear
149
+ />
150
+ </header>
151
+
152
+ <a-table
153
+ :data-source="filteredUsers"
154
+ :loading="loading"
155
+ row-key="id"
156
+ size="middle"
157
+ :pagination="{ pageSize: 12, showSizeChanger: false }"
158
+ >
159
+ <a-table-column
160
+ :title="$t('permissions.colUser')"
161
+ data-index="name"
162
+ >
163
+ <template #default="{ record }">
164
+ <a @click.prevent="openDetail(record)">{{ record.name }}</a>
165
+ </template>
166
+ </a-table-column>
167
+ <a-table-column
168
+ :title="$t('permissions.colEmail')"
169
+ data-index="email"
170
+ />
171
+ <a-table-column
172
+ :title="$t('permissions.colRole')"
173
+ data-index="role"
174
+ >
175
+ <template #default="{ record }">
176
+ <a-tag :color="record.role === 'admin' ? 'green' : record.role === 'editor' ? 'blue' : 'default'">
177
+ {{ record.role }}
178
+ </a-tag>
179
+ </template>
180
+ </a-table-column>
181
+ <a-table-column
182
+ :title="$t('permissions.colStatus')"
183
+ data-index="status"
184
+ >
185
+ <template #default="{ record }">
186
+ <a-tag :color="record.status === 'active' ? 'cyan' : 'red'">
187
+ {{ record.status === 'active' ? $t('permissions.active') : record.status }}
188
+ </a-tag>
189
+ </template>
190
+ </a-table-column>
191
+ <a-table-column
192
+ :title="$t('permissions.colChannels')"
193
+ data-index="channels"
194
+ >
195
+ <template #default="{ record }">
196
+ {{ record.channels.length }}
197
+ </template>
198
+ </a-table-column>
199
+ <a-table-column
200
+ :title="$t('permissions.colGrants')"
201
+ data-index="grants"
202
+ >
203
+ <template #default="{ record }">
204
+ <a-tag
205
+ v-for="g in record.grants"
206
+ :key="g.lineId"
207
+ style="margin-bottom: 2px"
208
+ >
209
+ {{ (lines.find(l => l.id === g.lineId)?.name ?? g.lineId).slice(0, 14) }} · {{ modeTag(g.mode) }}
210
+ </a-tag>
211
+ <span
212
+ v-if="!record.grants.length"
213
+ class="dim"
214
+ >—</span>
215
+ </template>
216
+ </a-table-column>
217
+ <a-table-column
218
+ :title="$t('permissions.colOps')"
219
+ :width="90"
220
+ >
221
+ <template #default="{ record }">
222
+ <a-button
223
+ size="small"
224
+ @click="openDetail(record)"
225
+ >
226
+ {{ $t('permissions.manage') }}
227
+ </a-button>
228
+ </template>
229
+ </a-table-column>
230
+ </a-table>
231
+
232
+ <a-drawer
233
+ v-model:open="drawerOpen"
234
+ :title="current ? `${current.name} · ${$t('permissions.detail')}` : ''"
235
+ width="560"
236
+ >
237
+ <template v-if="current">
238
+ <h4 class="sec">
239
+ {{ $t('permissions.secProfile') }}
240
+ </h4>
241
+ <dl class="detail">
242
+ <dt>email</dt><dd>{{ current.email }}</dd>
243
+ <dt>role</dt><dd>{{ current.role }}</dd>
244
+ <dt>status</dt><dd>{{ current.status }}</dd>
245
+ <dt>created</dt><dd>{{ current.createdAt }}</dd>
246
+ </dl>
247
+
248
+ <h4 class="sec">
249
+ {{ $t('permissions.secChannels') }}({{ current.channels.length }})
250
+ </h4>
251
+ <div
252
+ v-if="current.channels.length"
253
+ class="ch-list"
254
+ >
255
+ <code
256
+ v-for="c in current.channels"
257
+ :key="c.id"
258
+ >{{ c.name || c.id.slice(0, 8) }}</code>
259
+ </div>
260
+ <p
261
+ v-else
262
+ class="dim"
263
+ >
264
+ {{ $t('permissions.noChannels') }}
265
+ </p>
266
+
267
+ <h4 class="sec">
268
+ {{ $t('permissions.secMatrix') }}
269
+ <a-button
270
+ type="primary"
271
+ size="small"
272
+ :loading="saving"
273
+ :disabled="dirtyCount === 0"
274
+ style="margin-left: 12px"
275
+ @click="save"
276
+ >
277
+ {{ $t('permissions.save', { n: dirtyCount }) }}
278
+ </a-button>
279
+ </h4>
280
+ <p class="hint">
281
+ {{ $t('permissions.matrixHint') }}
282
+ </p>
283
+ <div class="matrix">
284
+ <div
285
+ v-for="l in lines"
286
+ :key="l.id"
287
+ class="row"
288
+ >
289
+ <span
290
+ class="line-name"
291
+ :title="l.id"
292
+ >{{ l.name }}</span>
293
+ <a-radio-group
294
+ v-model:value="draft[l.id]"
295
+ size="small"
296
+ >
297
+ <a-radio-button value="none">
298
+ {{ $t('permissions.none') }}
299
+ </a-radio-button>
300
+ <a-radio-button value="readonly">
301
+ {{ $t('permissions.readonly') }}
302
+ </a-radio-button>
303
+ <a-radio-button value="operate">
304
+ {{ $t('permissions.operate') }}
305
+ </a-radio-button>
306
+ </a-radio-group>
307
+ </div>
308
+ </div>
309
+ </template>
310
+ </a-drawer>
311
+ </div>
312
+ </template>
313
+
314
+ <style scoped lang="css">
315
+ .perm-page { max-width: 1180px; margin: 0 auto; padding: 24px 20px 48px; }
316
+ .pg-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
317
+ .pg-title { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: .5px; }
318
+ .pg-sub { margin: 4px 0 0; opacity: .6; font-size: 12px; }
319
+ .dim { opacity: .45; }
320
+ .sec { margin: 20px 0 8px; font-size: 13px; font-weight: 700; }
321
+ .detail dt { margin-top: 8px; font-size: 11px; opacity: .5; }
322
+ .detail dd { margin: 2px 0 0; font-family: ui-monospace, monospace; font-size: 12px; }
323
+ .ch-list { display: flex; flex-wrap: wrap; gap: 6px; }
324
+ .ch-list code { padding: 2px 8px; border: 1px solid rgba(128, 152, 199, .3); border-radius: 6px; font-size: 11px; }
325
+ .hint { font-size: 11px; opacity: .55; margin: 4px 0 10px; }
326
+ .matrix { display: flex; flex-direction: column; gap: 8px; }
327
+ .matrix .row { display: flex; align-items: center; justify-content: space-between; gap: 12px;
328
+ padding: 8px 12px; border: 1px solid var(--aw-border, rgba(128, 152, 199, .25)); border-radius: 10px; }
329
+ .line-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
330
+ </style>