feat(studio-ds): P3 组件库——AppButton 令牌化(grad-primary/on-accent/focus环) + 新增 Icon(SVG)/Field/AppearanceSwitch
P3 of HJ-FE-DS-001 金样板。通用品牌组件,P4 接入 Feed/Create/Profile。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ecae9025a9
commit
655673eb99
@ -77,6 +77,11 @@ function onClick(ev: MouseEvent) {
|
||||
.app-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
/* —— 键盘聚焦环(无障碍;鼠标点击不触发,仅 :focus-visible)—— */
|
||||
.app-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* —— 尺寸 —— */
|
||||
.app-btn--normal {
|
||||
@ -105,9 +110,9 @@ function onClick(ev: MouseEvent) {
|
||||
背景 135° cyan→green 渐变 + 重字重 820 + 深色高对比字(#061014);
|
||||
仅 primary 用此渐变与字重,ghost/danger/plain 不受影响。 */
|
||||
.app-btn--primary {
|
||||
background: linear-gradient(135deg, var(--cyan), var(--green));
|
||||
color: #061014;
|
||||
font-weight: 820;
|
||||
background: var(--grad-primary);
|
||||
color: var(--on-accent);
|
||||
font-weight: var(--fw-cta);
|
||||
}
|
||||
/* ghost 透明描边青 */
|
||||
.app-btn--ghost {
|
||||
@ -118,7 +123,7 @@ function onClick(ev: MouseEvent) {
|
||||
/* danger 危险 */
|
||||
.app-btn--danger {
|
||||
background: var(--danger);
|
||||
color: #2a0608;
|
||||
color: var(--on-danger);
|
||||
}
|
||||
/* plain 次要面板 */
|
||||
.app-btn--plain {
|
||||
|
||||
104
game-studio/src/components/AppearanceSwitch.vue
Normal file
104
game-studio/src/components/AppearanceSwitch.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AppearanceSwitch —— 外观设置:主题(暗/浅/柔)+ 语言(中/英)分段切换。
|
||||
* 用于「我的/设置」入口。即时持久化 + 全局生效(useAppearance 单例 + i18n)。
|
||||
*/
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppearance, type ThemeMode, type LangCode } from '../composables/useAppearance'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { theme, lang, setTheme, setLang } = useAppearance()
|
||||
|
||||
// 主题三档(labelKey 走 i18n)
|
||||
const themes: { val: ThemeMode; labelKey: string }[] = [
|
||||
{ val: 'dark', labelKey: 'appearance.themeDark' },
|
||||
{ val: 'light', labelKey: 'appearance.themeLight' },
|
||||
{ val: 'dim', labelKey: 'appearance.themeDim' },
|
||||
]
|
||||
// 语言两档
|
||||
const langs: { val: LangCode; labelKey: string }[] = [
|
||||
{ val: 'zh-CN', labelKey: 'appearance.langZh' },
|
||||
{ val: 'en-US', labelKey: 'appearance.langEn' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="appearance">
|
||||
<div class="appearance__row">
|
||||
<span class="appearance__title"><Icon name="settings" :size="16" /> {{ t('appearance.theme') }}</span>
|
||||
<div class="seg" role="group" :aria-label="t('appearance.theme')">
|
||||
<button
|
||||
v-for="it in themes"
|
||||
:key="it.val"
|
||||
class="seg__btn"
|
||||
:class="{ 'seg__btn--on': theme === it.val }"
|
||||
type="button"
|
||||
@click="setTheme(it.val)"
|
||||
>
|
||||
{{ t(it.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="appearance__row">
|
||||
<span class="appearance__title"><Icon name="globe" :size="16" /> {{ t('appearance.language') }}</span>
|
||||
<div class="seg" role="group" :aria-label="t('appearance.language')">
|
||||
<button
|
||||
v-for="it in langs"
|
||||
:key="it.val"
|
||||
class="seg__btn"
|
||||
:class="{ 'seg__btn--on': lang === it.val }"
|
||||
type="button"
|
||||
@click="setLang(it.val)"
|
||||
>
|
||||
{{ t(it.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.appearance {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.appearance__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.appearance__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-body);
|
||||
color: var(--ink);
|
||||
font-weight: var(--fw-label);
|
||||
}
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 3px;
|
||||
background: color-mix(in srgb, var(--surface-2) 85%, transparent);
|
||||
}
|
||||
.seg__btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-weight: var(--fw-label);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: 0.18s ease;
|
||||
}
|
||||
.seg__btn--on {
|
||||
background: var(--grad-primary);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
</style>
|
||||
113
game-studio/src/components/Field.vue
Normal file
113
game-studio/src/components/Field.vue
Normal file
@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Field —— 通用表单字段(label + input/textarea + 错误/帮助文案 + 聚焦环)。
|
||||
* 统一替代散落 3 份手搓 .field 块(Detail/BizCreate/BizLeadDetail)。v-model 双向绑定。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 绑定值(v-model) */
|
||||
modelValue?: string
|
||||
/** 字段标签 */
|
||||
label?: string
|
||||
/** 控件类型 */
|
||||
type?: 'text' | 'textarea' | 'tel' | 'number'
|
||||
/** 占位符 */
|
||||
placeholder?: string
|
||||
/** 错误文案(非空时进入错误态,红框 + 红字) */
|
||||
error?: string
|
||||
/** 常驻帮助文案(无错误时显示) */
|
||||
helper?: string
|
||||
/** 必填标记 */
|
||||
required?: boolean
|
||||
/** textarea 行数 */
|
||||
rows?: number
|
||||
}>(),
|
||||
{ modelValue: '', type: 'text', rows: 4 },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
|
||||
|
||||
const isTextarea = computed(() => props.type === 'textarea')
|
||||
|
||||
function onInput(e: Event): void {
|
||||
emit('update:modelValue', (e.target as HTMLInputElement | HTMLTextAreaElement).value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="field" :class="{ 'field--error': !!error }">
|
||||
<label v-if="label" class="field__label">
|
||||
{{ label }}<span v-if="required" class="field__req" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-if="isTextarea"
|
||||
class="field__ctrl"
|
||||
:rows="rows"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@input="onInput"
|
||||
></textarea>
|
||||
<input
|
||||
v-else
|
||||
class="field__ctrl"
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@input="onInput"
|
||||
/>
|
||||
<span v-if="error" class="field__msg field__msg--error">{{ error }}</span>
|
||||
<span v-else-if="helper" class="field__msg">{{ helper }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.field__label {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
.field__req {
|
||||
color: var(--danger);
|
||||
margin-left: 3px;
|
||||
}
|
||||
.field__ctrl {
|
||||
width: 100%;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
background: color-mix(in srgb, var(--bg) 72%, transparent);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 11px 12px;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.field__ctrl::placeholder {
|
||||
color: color-mix(in srgb, var(--muted) 75%, transparent);
|
||||
}
|
||||
.field__ctrl:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--accent) 76%, transparent);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
textarea.field__ctrl {
|
||||
min-height: 96px;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
}
|
||||
.field--error .field__ctrl {
|
||||
border-color: var(--danger);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
}
|
||||
.field__msg {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
}
|
||||
.field__msg--error {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
55
game-studio/src/components/Icon.vue
Normal file
55
game-studio/src/components/Icon.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Icon —— 统一 SVG 图标(lucide 取向,1.75 描边,currentColor 跟随文字色)。
|
||||
* 替代散落的 emoji / Unicode 字形(满足设计体系 no-emoji-icons 红线)。
|
||||
* 用法:<Icon name="play" :size="20" />。新增图标在 PATHS 注册即可。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ name: string; size?: number }>(), { size: 20 })
|
||||
|
||||
// 图标内容(viewBox 0 0 24 24,stroke=currentColor)。值为内联 SVG 子元素片段。
|
||||
const PATHS: Record<string, string> = {
|
||||
feed: '<rect x="3" y="3" width="18" height="18" rx="3"/><path d="M3 9h18M9 9v12"/>',
|
||||
create: '<path d="M12 5v14M5 12h14"/>',
|
||||
message: '<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0"/>',
|
||||
me: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
|
||||
heart: '<path d="M20.8 5.6a5 5 0 0 0-7.1 0L12 7.3l-1.7-1.7a5 5 0 0 0-7.1 7.1L12 21l8.8-8.3a5 5 0 0 0 0-7.1z"/>',
|
||||
star: '<path d="m12 3 2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 18l-5.8 3.4 1.1-6.5L2.6 9.8l6.5-.9z"/>',
|
||||
share: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/>',
|
||||
play: '<path d="M7 4v16l13-8z"/>',
|
||||
back: '<path d="M15 18l-6-6 6-6"/>',
|
||||
chevron: '<path d="M9 6l6 6-6 6"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
spark: '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18"/>',
|
||||
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
||||
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z"/>',
|
||||
}
|
||||
|
||||
// 找不到的图标渲染空 svg(不报错)
|
||||
const inner = computed(() => PATHS[props.name] ?? '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- v-html 注入路径片段(现代 webview 对 svg innerHTML 在 SVG 命名空间解析) -->
|
||||
<svg
|
||||
class="icon"
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
v-html="inner"
|
||||
></svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.icon {
|
||||
display: block;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user