@ysgroup/ui 0.1.0

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.
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@ysgroup/ui",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "dependencies": {
8
+ "@ysgroup/contracts": "0.1.1",
9
+ "@ysgroup/theme": "0.1.0"
10
+ },
11
+ "peerDependencies": {
12
+ "element-plus": ">=2.9",
13
+ "vue": ">=3.5"
14
+ },
15
+ "license": "MIT",
16
+ "files": [
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ }
23
+ }
@@ -0,0 +1,194 @@
1
+ <!-- YsCrudTable —— 只读表格 + 行"编辑"按钮呼出 YsFormDialog。
2
+ 规范:表格上不挂载任何行内编辑组件(体验与性能)。 -->
3
+ <script setup lang="ts">
4
+ import { computed, ref } from "vue";
5
+ import { ElMessageBox } from "element-plus";
6
+ import { FIELD_SPECS, ENUM_META } from "@ysgroup/contracts";
7
+ import { enumLabel } from "../tokens-runtime";
8
+ import YsFormDialog from "./YsFormDialog.vue";
9
+
10
+ interface FieldSpec {
11
+ key: string;
12
+ title: string;
13
+ widget: string;
14
+ enum: string | null;
15
+ sensitivity: string;
16
+ }
17
+
18
+ const COLUMN_WIDTHS: Record<string, number> = {
19
+ enabled: 54,
20
+ auto_trade: 72,
21
+ cname: 120,
22
+ account: 112,
23
+ type: 110,
24
+ trade_env: 72,
25
+ owner_id: 120,
26
+ team_id: 120,
27
+ broker_id: 120,
28
+ bank_card_id: 150,
29
+ fee_rate: 76,
30
+ commission_refund: 84,
31
+ positions_refund: 84,
32
+ interest: 72,
33
+ traded_br: 72,
34
+ traded_xt: 72,
35
+ cfmmc_username: 120,
36
+ futures_app_id: 120,
37
+ futures_auth_code: 130,
38
+ name: 140,
39
+ full_name: 200,
40
+ code: 110,
41
+ memo: 180,
42
+ phone: 120,
43
+ email: 180,
44
+ role: 90,
45
+ status: 80,
46
+ max_sessions: 80,
47
+ };
48
+
49
+ function columnWidth(field: FieldSpec): number {
50
+ return COLUMN_WIDTHS[field.key]
51
+ ?? (field.widget === "switch" ? 64 : field.widget === "enum" ? 100 : 140);
52
+ }
53
+
54
+ const props = defineProps<{
55
+ table: string;
56
+ rows: Record<string, unknown>[];
57
+ /** 表格展示的列(默认取契约 public 字段前若干个) */
58
+ columns?: string[];
59
+ /** 隐藏在编辑弹窗中的字段 */
60
+ hiddenInForm?: string[];
61
+ loading?: boolean;
62
+ readonly?: boolean;
63
+ createLabel?: string;
64
+ rowDisplay?: (row: Record<string, unknown>, field: string, value: unknown) => unknown;
65
+ saving?: boolean;
66
+ externalEditor?: boolean;
67
+ }>();
68
+
69
+ const emit = defineEmits<{
70
+ save: [payload: Record<string, unknown>, finish: (success: boolean) => void];
71
+ remove: [row: Record<string, unknown>];
72
+ create: [];
73
+ edit: [row: Record<string, unknown>];
74
+ }>();
75
+
76
+ const keyword = ref("");
77
+ const dialogOpen = ref(false);
78
+ const editing = ref<Record<string, unknown> | null>(null);
79
+
80
+ const allSpecs = computed<FieldSpec[]>(
81
+ () => (FIELD_SPECS as unknown as Record<string, FieldSpec[]>)[props.table] ?? [],
82
+ );
83
+
84
+ const displayColumns = computed<FieldSpec[]>(() => {
85
+ if (props.columns?.length) {
86
+ const map = new Map(allSpecs.value.map((f) => [f.key, f]));
87
+ return props.columns.map((k) => map.get(k)).filter(Boolean) as FieldSpec[];
88
+ }
89
+ // 默认:public 字段前 6 个(restricted/credential 不在列表直接展示)
90
+ return allSpecs.value.filter((f) => f.sensitivity === "public").slice(0, 6);
91
+ });
92
+
93
+ const filtered = computed(() => {
94
+ const kw = keyword.value.trim().toLowerCase();
95
+ if (!kw) return props.rows;
96
+ return props.rows.filter((r) =>
97
+ displayColumns.value.some((c) =>
98
+ String(r[c.key] ?? "").toLowerCase().includes(kw),
99
+ ),
100
+ );
101
+ });
102
+
103
+ function display(row: Record<string, unknown>, f: FieldSpec): string {
104
+ const v = props.rowDisplay ? props.rowDisplay(row, f.key, row[f.key]) : row[f.key];
105
+ if (f.widget === "switch") return v ? "是" : "否";
106
+ if (f.widget === "enum" && f.enum && ENUM_META[f.enum as keyof typeof ENUM_META]) {
107
+ return enumLabel(f.enum as keyof typeof ENUM_META, String(v ?? ""));
108
+ }
109
+ if (Array.isArray(v)) return v.join(", ");
110
+ return String(v ?? "");
111
+ }
112
+
113
+ function onCreate() {
114
+ if (props.externalEditor) {
115
+ emit("create");
116
+ return;
117
+ }
118
+ editing.value = null;
119
+ dialogOpen.value = true;
120
+ }
121
+ function onEdit(row: Record<string, unknown>) {
122
+ if (props.externalEditor) {
123
+ emit("edit", row);
124
+ return;
125
+ }
126
+ editing.value = { ...row };
127
+ dialogOpen.value = true;
128
+ }
129
+ async function onRemove(row: Record<string, unknown>) {
130
+ emit("remove", row);
131
+ }
132
+ function onSubmit(payload: Record<string, unknown>) {
133
+ emit("save", payload, (success) => {
134
+ if (success) dialogOpen.value = false;
135
+ });
136
+ }
137
+ </script>
138
+
139
+ <template>
140
+ <div class="ys-crud">
141
+ <div class="ys-crud__toolbar">
142
+ <el-input
143
+ v-model="keyword"
144
+ placeholder="搜索"
145
+ clearable
146
+ style="width: 200px"
147
+ />
148
+ <div class="ys-crud__spacer" />
149
+ <el-button v-if="!readonly" type="primary" @click="onCreate">{{ createLabel || "新建" }}</el-button>
150
+ </div>
151
+
152
+ <el-table :data="filtered" v-loading="loading" stripe border size="small" table-layout="fixed" row-key="_id">
153
+ <el-table-column
154
+ v-for="c in displayColumns"
155
+ :key="c.key"
156
+ :label="c.title"
157
+ :width="columnWidth(c)"
158
+ show-overflow-tooltip
159
+ >
160
+ <template #default="{ row }">{{ display(row, c) }}</template>
161
+ </el-table-column>
162
+ <el-table-column v-if="!readonly" label="操作" width="260">
163
+ <template #default="{ row }">
164
+ <el-button size="small" @click="onEdit(row)">编辑</el-button>
165
+ <slot name="row-actions" :row="row" />
166
+ <el-button size="small" type="danger" plain @click="onRemove(row)">
167
+ 删除
168
+ </el-button>
169
+ </template>
170
+ </el-table-column>
171
+ </el-table>
172
+
173
+ <YsFormDialog
174
+ v-if="!externalEditor"
175
+ v-model="dialogOpen"
176
+ :table="table"
177
+ :record="editing"
178
+ :hidden="hiddenInForm"
179
+ :saving="saving"
180
+ @submit="onSubmit"
181
+ />
182
+ </div>
183
+ </template>
184
+
185
+ <style scoped>
186
+ .ys-crud__toolbar {
187
+ display: flex;
188
+ align-items: center;
189
+ margin-bottom: 12px;
190
+ }
191
+ .ys-crud__spacer {
192
+ flex: 1;
193
+ }
194
+ </style>
@@ -0,0 +1,187 @@
1
+ <!-- YsFormDialog —— 由契约 FIELD_SPECS 自动生成的编辑弹窗。
2
+ 全平台统一"弹窗编辑"规范的落地组件:表格只读,编辑一律经此弹窗。 -->
3
+ <script setup lang="ts">
4
+ import { computed, ref, watch } from "vue";
5
+ import { ElMessage } from "element-plus";
6
+ import { FIELD_SPECS, ENUM_META } from "@ysgroup/contracts";
7
+ import { enumOptions } from "../tokens-runtime";
8
+
9
+ interface FieldSpec {
10
+ key: string;
11
+ title: string;
12
+ widget: string;
13
+ enum: string | null;
14
+ required: boolean;
15
+ sensitivity: string;
16
+ }
17
+
18
+ const props = defineProps<{
19
+ modelValue: boolean;
20
+ table: string;
21
+ record?: Record<string, unknown> | null;
22
+ title?: string;
23
+ /** 隐藏的字段(如外键由上下文决定,不在弹窗手填) */
24
+ hidden?: string[];
25
+ saving?: boolean;
26
+ }>();
27
+
28
+ const emit = defineEmits<{
29
+ "update:modelValue": [v: boolean];
30
+ submit: [payload: Record<string, unknown>];
31
+ }>();
32
+
33
+ const form = ref<Record<string, unknown>>({});
34
+ const invalidJson = ref<Set<string>>(new Set());
35
+
36
+ const specs = computed<FieldSpec[]>(() => {
37
+ const all = (FIELD_SPECS as unknown as Record<string, FieldSpec[]>)[props.table] ?? [];
38
+ const hidden = new Set(props.hidden ?? []);
39
+ // 凭据与密码哈希必须走专用安全操作,绝不进入通用编辑表单。
40
+ return all.filter(
41
+ (f) => f.sensitivity !== "credential" && f.key !== "password_hash" && !hidden.has(f.key),
42
+ );
43
+ });
44
+
45
+ const isEdit = computed(() => !!props.record?._id);
46
+ const dialogTitle = computed(
47
+ () => props.title ?? `${isEdit.value ? "编辑" : "新建"} ${props.table}`,
48
+ );
49
+
50
+ watch(
51
+ () => [props.modelValue, props.record],
52
+ ([open]) => {
53
+ if (!open) return;
54
+ invalidJson.value = new Set();
55
+ const base: Record<string, unknown> = {};
56
+ for (const f of specs.value) {
57
+ base[f.key] = props.record?.[f.key] ?? defaultFor(f);
58
+ }
59
+ if (props.record?._id) base._id = props.record._id;
60
+ form.value = base;
61
+ },
62
+ { immediate: true },
63
+ );
64
+
65
+ function defaultFor(f: FieldSpec): unknown {
66
+ if (f.widget === "switch") return false;
67
+ if (f.widget === "number") return 0;
68
+ if (f.widget === "tags" || f.widget === "enum-multi") return [];
69
+ if (f.widget === "json") return {};
70
+ return "";
71
+ }
72
+
73
+ function optionsOf(f: FieldSpec) {
74
+ return f.enum && ENUM_META[f.enum as keyof typeof ENUM_META]
75
+ ? enumOptions(f.enum as keyof typeof ENUM_META)
76
+ : [];
77
+ }
78
+
79
+ function jsonText(key: string) {
80
+ return computed({
81
+ get: () => JSON.stringify(form.value[key] ?? {}, null, 2),
82
+ set: (v: string) => {
83
+ try {
84
+ form.value[key] = JSON.parse(v);
85
+ invalidJson.value.delete(key);
86
+ invalidJson.value = new Set(invalidJson.value);
87
+ } catch {
88
+ invalidJson.value.add(key);
89
+ invalidJson.value = new Set(invalidJson.value);
90
+ }
91
+ },
92
+ });
93
+ }
94
+
95
+ function close() {
96
+ emit("update:modelValue", false);
97
+ }
98
+
99
+ function submit() {
100
+ const missing = specs.value.filter((f) => f.required && (
101
+ form.value[f.key] === "" || form.value[f.key] === null || form.value[f.key] === undefined
102
+ || (Array.isArray(form.value[f.key]) && !(form.value[f.key] as unknown[]).length)
103
+ ));
104
+ if (missing.length) {
105
+ ElMessage.warning(`请填写:${missing.map((f) => f.title).join("、")}`);
106
+ return;
107
+ }
108
+ if (invalidJson.value.size) {
109
+ const titles = specs.value.filter((f) => invalidJson.value.has(f.key)).map((f) => f.title);
110
+ ElMessage.warning(`JSON 格式错误:${titles.join("、")}`);
111
+ return;
112
+ }
113
+ emit("submit", { ...form.value });
114
+ }
115
+ </script>
116
+
117
+ <template>
118
+ <el-dialog
119
+ :model-value="modelValue"
120
+ :title="dialogTitle"
121
+ width="640px"
122
+ @update:model-value="close"
123
+ >
124
+ <el-form label-width="150px" label-position="right">
125
+ <el-form-item
126
+ v-for="f in specs"
127
+ :key="f.key"
128
+ :label="f.title"
129
+ :required="f.required"
130
+ >
131
+ <el-switch v-if="f.widget === 'switch'" v-model="form[f.key] as boolean" />
132
+ <el-input-number
133
+ v-else-if="f.widget === 'number'"
134
+ v-model="form[f.key] as number"
135
+ :controls="false"
136
+ style="width: 100%"
137
+ />
138
+ <el-select
139
+ v-else-if="f.widget === 'enum'"
140
+ v-model="form[f.key] as string"
141
+ style="width: 100%"
142
+ >
143
+ <el-option
144
+ v-for="o in optionsOf(f)"
145
+ :key="o.value"
146
+ :label="o.label"
147
+ :value="o.value"
148
+ />
149
+ </el-select>
150
+ <el-select
151
+ v-else-if="f.widget === 'enum-multi'"
152
+ v-model="form[f.key] as string[]"
153
+ multiple
154
+ style="width: 100%"
155
+ >
156
+ <el-option
157
+ v-for="o in optionsOf(f)"
158
+ :key="o.value"
159
+ :label="o.label"
160
+ :value="o.value"
161
+ />
162
+ </el-select>
163
+ <el-select
164
+ v-else-if="f.widget === 'tags'"
165
+ v-model="form[f.key] as string[]"
166
+ multiple
167
+ filterable
168
+ allow-create
169
+ default-first-option
170
+ :reserve-keyword="false"
171
+ style="width: 100%"
172
+ />
173
+ <el-input
174
+ v-else-if="f.widget === 'json'"
175
+ v-model="jsonText(f.key).value"
176
+ type="textarea"
177
+ :rows="3"
178
+ />
179
+ <el-input v-else v-model="form[f.key] as string" />
180
+ </el-form-item>
181
+ </el-form>
182
+ <template #footer>
183
+ <el-button @click="close">取消</el-button>
184
+ <el-button type="primary" :loading="saving" @click="submit">保存</el-button>
185
+ </template>
186
+ </el-dialog>
187
+ </template>
@@ -0,0 +1,108 @@
1
+ <!-- YsLoginPage —— 统一登录页。密码经 TLS 直传(不再前端 md5)。 -->
2
+ <script setup lang="ts">
3
+ import { ref } from "vue";
4
+
5
+ const props = defineProps<{
6
+ appTitle: string;
7
+ hint?: string;
8
+ logoUrl?: string;
9
+ error?: string;
10
+ otpEnabled?: boolean;
11
+ }>();
12
+ const emit = defineEmits<{ login: [username: string, password: string, otp: string] }>();
13
+
14
+ const username = ref("");
15
+ const password = ref("");
16
+ const otp = ref("");
17
+ const loading = ref(false);
18
+
19
+ async function submit() {
20
+ loading.value = true;
21
+ try {
22
+ emit("login", username.value, password.value, otp.value);
23
+ } finally {
24
+ loading.value = false;
25
+ }
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <div class="ys-login">
31
+ <div class="ys-login__card">
32
+ <img v-if="logoUrl" :src="logoUrl" class="ys-login__logo" alt="logo" />
33
+ <p v-if="hint" class="ys-login__hint">{{ hint }}</p>
34
+ <h2 v-else class="ys-login__title">{{ appTitle }}</h2>
35
+ <el-form @submit.prevent="submit">
36
+ <el-form-item>
37
+ <el-input v-model="username" placeholder="用户名" size="large" @keyup.enter="submit" />
38
+ </el-form-item>
39
+ <el-form-item>
40
+ <el-input
41
+ v-model="password"
42
+ type="password"
43
+ placeholder="密码"
44
+ size="large"
45
+ show-password
46
+ @keyup.enter="submit"
47
+ />
48
+ </el-form-item>
49
+ <el-form-item v-if="props.otpEnabled && username.trim().toLowerCase() === 'root'">
50
+ <el-input
51
+ v-model="otp"
52
+ inputmode="numeric"
53
+ autocomplete="one-time-code"
54
+ placeholder="动态验证码"
55
+ size="large"
56
+ @keyup.enter="submit"
57
+ />
58
+ </el-form-item>
59
+ <el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
60
+ <el-button
61
+ type="primary"
62
+ size="large"
63
+ :loading="loading"
64
+ style="width: 100%; margin-top: 8px"
65
+ @click="submit"
66
+ >
67
+ 登录
68
+ </el-button>
69
+ </el-form>
70
+ </div>
71
+ </div>
72
+ </template>
73
+
74
+ <style scoped>
75
+ .ys-login {
76
+ height: 100vh;
77
+ display: flex;
78
+ align-items: center;
79
+ justify-content: center;
80
+ background: var(--ys-color-bg);
81
+ }
82
+ .ys-login__card {
83
+ width: 360px;
84
+ padding: 40px 32px;
85
+ background: var(--ys-color-bg-elevated);
86
+ border: 1px solid var(--ys-color-border);
87
+ border-radius: var(--ys-radius-m);
88
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
89
+ text-align: center;
90
+ }
91
+ .ys-login__logo {
92
+ width: 68px;
93
+ height: 68px;
94
+ object-fit: contain;
95
+ margin-bottom: 16px;
96
+ }
97
+ .ys-login__title {
98
+ margin: 0 0 24px;
99
+ color: var(--ys-color-text-1);
100
+ font-weight: 600;
101
+ }
102
+ .ys-login__hint {
103
+ margin: 0 0 24px;
104
+ color: var(--ys-color-text-1);
105
+ font-size: 18px;
106
+ font-weight: 600;
107
+ }
108
+ </style>
@@ -0,0 +1,161 @@
1
+ <!-- YsPageLayout —— 统一布局骨架:顶栏(logo/app名/用户/明暗切换) + 侧栏 + 内容。 -->
2
+ <script setup lang="ts">
3
+ import { ref } from "vue";
4
+ import { applyTheme, currentMode, type ThemeMode } from "@ysgroup/theme";
5
+
6
+ defineProps<{
7
+ appTitle: string;
8
+ logoUrl?: string;
9
+ username?: string;
10
+ menus?: { key: string; title: string; icon?: string }[];
11
+ active?: string;
12
+ }>();
13
+
14
+ const emit = defineEmits<{ navigate: [key: string]; settings: []; logout: [] }>();
15
+
16
+ const mode = ref<ThemeMode>(currentMode());
17
+ function toggleTheme() {
18
+ const current = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
19
+ mode.value = current === "dark" ? "light" : "dark";
20
+ applyTheme(mode.value);
21
+ }
22
+
23
+ function handleUserCommand(command: string) {
24
+ if (command === "settings") emit("settings");
25
+ if (command === "logout") emit("logout");
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <div class="ys-layout">
31
+ <header class="ys-layout__top">
32
+ <div class="ys-layout__brand">
33
+ <img v-if="logoUrl" :src="logoUrl" class="ys-layout__logo" alt="logo" />
34
+ <span class="ys-layout__title">{{ appTitle }}</span>
35
+ </div>
36
+ <div class="ys-layout__spacer" />
37
+ <el-button
38
+ class="ys-layout__theme-toggle"
39
+ circle
40
+ aria-label="切换明暗模式"
41
+ @click="toggleTheme"
42
+ >
43
+ <span class="ys-layout__theme-icon">{{ mode === "dark" ? "☾" : "☀" }}</span>
44
+ </el-button>
45
+ <el-dropdown v-if="username" @command="handleUserCommand">
46
+ <span class="ys-layout__user">{{ username }}</span>
47
+ <template #dropdown>
48
+ <el-dropdown-menu>
49
+ <el-dropdown-item command="settings">设置中心</el-dropdown-item>
50
+ <el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
51
+ </el-dropdown-menu>
52
+ </template>
53
+ </el-dropdown>
54
+ </header>
55
+ <div class="ys-layout__body">
56
+ <aside v-if="menus?.length" class="ys-layout__side">
57
+ <div
58
+ v-for="m in menus"
59
+ :key="m.key"
60
+ class="ys-layout__menu"
61
+ :class="{ 'is-active': m.key === active }"
62
+ @click="emit('navigate', m.key)"
63
+ >
64
+ {{ m.title }}
65
+ </div>
66
+ </aside>
67
+ <main class="ys-layout__content">
68
+ <slot />
69
+ </main>
70
+ </div>
71
+ </div>
72
+ </template>
73
+
74
+ <style scoped>
75
+ .ys-layout {
76
+ height: 100vh;
77
+ display: flex;
78
+ flex-direction: column;
79
+ }
80
+ .ys-layout__top {
81
+ height: 52px;
82
+ display: flex;
83
+ align-items: center;
84
+ padding: 0 16px;
85
+ gap: 8px;
86
+ background: var(--ys-color-bg-elevated);
87
+ border-bottom: 1px solid var(--ys-color-border);
88
+ }
89
+ .ys-layout__brand {
90
+ display: flex;
91
+ align-items: center;
92
+ gap: 10px;
93
+ }
94
+ .ys-layout__logo {
95
+ width: 30px;
96
+ height: 26px;
97
+ object-fit: contain;
98
+ }
99
+ .ys-layout__title {
100
+ font-weight: 600;
101
+ color: var(--ys-color-text-1);
102
+ }
103
+ .ys-layout__spacer {
104
+ flex: 1;
105
+ }
106
+ .ys-layout__theme-toggle {
107
+ width: 28px;
108
+ height: 28px;
109
+ padding: 0;
110
+ border: 1px solid var(--ys-color-border);
111
+ background: var(--ys-color-bg-elevated);
112
+ color: var(--ys-color-text-2);
113
+ }
114
+ .ys-layout__theme-toggle:hover,
115
+ .ys-layout__theme-toggle:focus-visible {
116
+ border-color: var(--ys-color-primary);
117
+ background: var(--ys-color-bg-hover);
118
+ color: var(--ys-color-primary);
119
+ }
120
+ .ys-layout__theme-icon {
121
+ font-size: 16px;
122
+ line-height: 1;
123
+ }
124
+ .ys-layout__user {
125
+ cursor: pointer;
126
+ color: var(--ys-color-text-2);
127
+ padding: 0 8px;
128
+ }
129
+ .ys-layout__body {
130
+ flex: 1;
131
+ display: flex;
132
+ overflow: hidden;
133
+ }
134
+ .ys-layout__side {
135
+ width: 200px;
136
+ background: var(--ys-color-bg-elevated);
137
+ border-right: 1px solid var(--ys-color-border);
138
+ padding: 8px;
139
+ overflow-y: auto;
140
+ }
141
+ .ys-layout__menu {
142
+ padding: 9px 14px;
143
+ border-radius: var(--ys-radius-m);
144
+ cursor: pointer;
145
+ color: var(--ys-color-text-2);
146
+ font-size: 13px;
147
+ }
148
+ .ys-layout__menu:hover {
149
+ background: var(--ys-color-bg-hover);
150
+ }
151
+ .ys-layout__menu.is-active {
152
+ background: var(--ys-color-primary);
153
+ color: #fff;
154
+ }
155
+ .ys-layout__content {
156
+ flex: 1;
157
+ padding: 16px;
158
+ overflow: auto;
159
+ background: var(--ys-color-bg);
160
+ }
161
+ </style>
@@ -0,0 +1,102 @@
1
+ <!-- YsPortalGrid —— 门户应用九宫格。点击经一次性 code 单点登录跳转。 -->
2
+ <script setup lang="ts">
3
+ interface AppCard {
4
+ name: string;
5
+ title: string;
6
+ logo_url?: string;
7
+ kind: string;
8
+ status: string;
9
+ online: boolean;
10
+ port: number;
11
+ host?: string;
12
+ website_url?: string;
13
+ }
14
+
15
+ defineProps<{ apps: AppCard[] }>();
16
+ const emit = defineEmits<{ open: [app: AppCard] }>();
17
+ </script>
18
+
19
+ <template>
20
+ <div class="ys-portal">
21
+ <div
22
+ v-for="app in apps"
23
+ :key="app.name"
24
+ class="ys-portal__card"
25
+ :class="{ 'is-offline': app.kind === 'service' && !app.online, 'is-maint': app.status === 'maintenance' }"
26
+ @click="emit('open', app)"
27
+ >
28
+ <img v-if="app.logo_url" :src="app.logo_url" class="ys-portal__logo" alt="" />
29
+ <div v-else class="ys-portal__logo ys-portal__logo--ph">{{ app.title.slice(0, 1) }}</div>
30
+ <div class="ys-portal__name">{{ app.title || app.name }}</div>
31
+ <div class="ys-portal__meta">
32
+ <span v-if="app.kind === 'service'" class="ys-portal__dot" :class="app.online ? 'on' : 'off'" />
33
+ <span v-if="app.status === 'maintenance'">维护中</span>
34
+ <span v-else-if="app.kind === 'link'">外链</span>
35
+ <span v-else>{{ app.online ? "在线" : "离线" }}</span>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ </template>
40
+
41
+ <style scoped>
42
+ .ys-portal {
43
+ display: grid;
44
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
45
+ gap: 16px;
46
+ }
47
+ .ys-portal__card {
48
+ background: var(--ys-color-bg-elevated);
49
+ border: 1px solid var(--ys-color-border);
50
+ border-radius: var(--ys-radius-m);
51
+ padding: 20px 12px;
52
+ text-align: center;
53
+ cursor: pointer;
54
+ transition: transform 0.12s, box-shadow 0.12s;
55
+ }
56
+ .ys-portal__card:hover {
57
+ transform: translateY(-2px);
58
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
59
+ }
60
+ .ys-portal__card.is-offline {
61
+ opacity: 0.55;
62
+ }
63
+ .ys-portal__logo {
64
+ width: 48px;
65
+ height: 48px;
66
+ border-radius: var(--ys-radius-m);
67
+ object-fit: cover;
68
+ margin: 0 auto 10px;
69
+ }
70
+ .ys-portal__logo--ph {
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ background: var(--ys-color-primary);
75
+ color: #fff;
76
+ font-size: 22px;
77
+ font-weight: 600;
78
+ }
79
+ .ys-portal__name {
80
+ color: var(--ys-color-text-1);
81
+ font-size: 13px;
82
+ font-weight: 500;
83
+ }
84
+ .ys-portal__meta {
85
+ margin-top: 4px;
86
+ font-size: 11px;
87
+ color: var(--ys-color-text-3);
88
+ }
89
+ .ys-portal__dot {
90
+ display: inline-block;
91
+ width: 7px;
92
+ height: 7px;
93
+ border-radius: 50%;
94
+ margin-right: 4px;
95
+ }
96
+ .ys-portal__dot.on {
97
+ background: var(--ys-color-success);
98
+ }
99
+ .ys-portal__dot.off {
100
+ background: var(--ys-color-info);
101
+ }
102
+ </style>
@@ -0,0 +1,135 @@
1
+ <!-- YsSettingsCenterDialog —— 统一设置中心;Root 仅展示说明,不提供密码修改入口。 -->
2
+ <script setup lang="ts">
3
+ import { computed, reactive, ref, watch } from "vue";
4
+
5
+ const props = defineProps<{
6
+ modelValue: boolean;
7
+ role?: string;
8
+ username?: string;
9
+ }>();
10
+
11
+ const emit = defineEmits<{
12
+ "update:modelValue": [value: boolean];
13
+ changePassword: [currentPassword: string, newPassword: string];
14
+ }>();
15
+
16
+ const active = ref("security");
17
+ const form = reactive({ current: "", next: "", confirm: "" });
18
+ const error = ref("");
19
+ const submitting = ref(false);
20
+
21
+ const canChangePassword = computed(
22
+ () => props.role !== "Root" && props.username?.trim().toLowerCase() !== "root",
23
+ );
24
+
25
+ function reset() {
26
+ form.current = "";
27
+ form.next = "";
28
+ form.confirm = "";
29
+ error.value = "";
30
+ submitting.value = false;
31
+ }
32
+
33
+ watch(() => props.modelValue, (open) => {
34
+ if (!open) reset();
35
+ });
36
+
37
+ function close() {
38
+ if (submitting.value) return;
39
+ emit("update:modelValue", false);
40
+ }
41
+
42
+ async function submit() {
43
+ error.value = "";
44
+ if (!form.current || !form.next || !form.confirm) {
45
+ error.value = "请完整填写密码信息";
46
+ return;
47
+ }
48
+ if (form.next !== form.confirm) {
49
+ error.value = "两次新密码不一致";
50
+ return;
51
+ }
52
+ if (form.current === form.next) {
53
+ error.value = "新密码不能与当前密码相同";
54
+ return;
55
+ }
56
+ submitting.value = true;
57
+ emit("changePassword", form.current, form.next);
58
+ }
59
+
60
+ defineExpose({
61
+ finish(errorMessage = "") {
62
+ submitting.value = false;
63
+ error.value = errorMessage;
64
+ if (!errorMessage) close();
65
+ },
66
+ });
67
+ </script>
68
+
69
+ <template>
70
+ <el-dialog
71
+ :model-value="modelValue"
72
+ title="设置中心"
73
+ width="560px"
74
+ destroy-on-close
75
+ :close-on-click-modal="!submitting"
76
+ :close-on-press-escape="!submitting"
77
+ :show-close="!submitting"
78
+ @update:model-value="emit('update:modelValue', $event)"
79
+ >
80
+ <el-tabs v-model="active" tab-position="left" class="ys-settings">
81
+ <el-tab-pane label="账号安全" name="security">
82
+ <template v-if="canChangePassword">
83
+ <h3 class="ys-settings__title">修改登录密码</h3>
84
+ <el-alert
85
+ title="修改成功后,当前账号的所有登录会话都会退出,请使用新密码重新登录。"
86
+ type="info"
87
+ :closable="false"
88
+ show-icon
89
+ class="ys-settings__notice"
90
+ />
91
+ <el-form label-width="110px" @submit.prevent="submit">
92
+ <el-form-item label="当前密码" required>
93
+ <el-input v-model="form.current" type="password" show-password autocomplete="current-password" />
94
+ </el-form-item>
95
+ <el-form-item label="新密码" required>
96
+ <el-input v-model="form.next" type="password" show-password autocomplete="new-password" />
97
+ </el-form-item>
98
+ <el-form-item label="确认新密码" required>
99
+ <el-input v-model="form.confirm" type="password" show-password autocomplete="new-password" />
100
+ </el-form-item>
101
+ </el-form>
102
+ <el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
103
+ </template>
104
+ <el-empty v-else description="Root 登录密码由平台安全配置维护,不允许在设置中心修改" :image-size="72" />
105
+ </el-tab-pane>
106
+ <el-tab-pane v-if="role === 'Manager' && $slots.managerActions" label="前端管理" name="manager-actions">
107
+ <slot name="managerActions" />
108
+ </el-tab-pane>
109
+ </el-tabs>
110
+ <template #footer>
111
+ <el-button @click="close">关闭</el-button>
112
+ <el-button
113
+ v-if="canChangePassword && active === 'security'"
114
+ type="primary"
115
+ :loading="submitting"
116
+ @click="submit"
117
+ >
118
+ 修改并退出
119
+ </el-button>
120
+ </template>
121
+ </el-dialog>
122
+ </template>
123
+
124
+ <style scoped>
125
+ .ys-settings {
126
+ min-height: 300px;
127
+ }
128
+ .ys-settings__title {
129
+ margin: 0 0 14px;
130
+ color: var(--ys-color-text-1);
131
+ }
132
+ .ys-settings__notice {
133
+ margin-bottom: 18px;
134
+ }
135
+ </style>
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /* @ysgroup/ui —— 平台组件库(基于 Element Plus 封装,令牌来自 @ysgroup/theme)。 */
2
+
3
+ export { default as YsCrudTable } from "./components/YsCrudTable.vue";
4
+ export { default as YsFormDialog } from "./components/YsFormDialog.vue";
5
+ export { default as YsPageLayout } from "./components/YsPageLayout.vue";
6
+ export { default as YsLoginPage } from "./components/YsLoginPage.vue";
7
+ export { default as YsSettingsCenterDialog } from "./components/YsSettingsCenterDialog.vue";
8
+ export { default as YsPortalGrid } from "./components/YsPortalGrid.vue";
9
+ export { enumOptions, enumLabel, type EnumOption } from "./tokens-runtime";
10
+
11
+ export const YS_UI_VERSION = "0.1.0";
@@ -0,0 +1,22 @@
1
+ /* 运行时工具:明暗切换 + 契约枚举元数据访问(供组件消费)。 */
2
+ import { ENUM_META } from "@ysgroup/contracts";
3
+
4
+ export interface EnumOption {
5
+ value: string;
6
+ label: string;
7
+ sort: number;
8
+ }
9
+
10
+ /** 读取契约枚举的选项列表(含中文名与排序),驱动 YsEnumSelect / 表单。 */
11
+ export function enumOptions(enumName: keyof typeof ENUM_META): EnumOption[] {
12
+ const meta = ENUM_META[enumName] as Record<string, { name?: string; sort?: number }>;
13
+ if (!meta) return [];
14
+ return Object.entries(meta)
15
+ .map(([value, m]) => ({ value, label: m.name ?? value, sort: m.sort ?? 0 }))
16
+ .sort((a, b) => a.sort - b.sort);
17
+ }
18
+
19
+ export function enumLabel(enumName: keyof typeof ENUM_META, value: string): string {
20
+ const meta = ENUM_META[enumName] as Record<string, { name?: string }> | undefined;
21
+ return meta?.[value]?.name ?? value;
22
+ }