m-dock.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import {
  2. ChangeDetectionStrategy,
  3. Component,
  4. ElementRef,
  5. computed,
  6. input,
  7. output,
  8. viewChild,
  9. } from '@angular/core';
  10. /**
  11. * 底部输入坞(Demo `.dock`)
  12. *
  13. * Demo 形态:圆角卡片 + 自适应高度 textarea + 工具行(附件 / 目标 / 发送)。
  14. * 与桌面 `composer` 的区别:桌面版工具行在上、输入在下;移动端输入在上、
  15. * 工具行在下(拇指可达区)。事件语义与桌面一致(draftChange / send / attach)。
  16. *
  17. * 安全区:坞底由页面负责加 `--mob-safe-bottom`。
  18. */
  19. @Component({
  20. selector: 'app-m-dock',
  21. changeDetection: ChangeDetectionStrategy.OnPush,
  22. template: `
  23. <div class="m-dock">
  24. <textarea
  25. rows="1"
  26. [value]="draft()"
  27. [placeholder]="placeholder()"
  28. [attr.aria-label]="placeholder()"
  29. [disabled]="disabled()"
  30. (input)="onInput($event)"
  31. (keydown.enter)="onEnter($event)"
  32. ></textarea>
  33. <div class="m-dock-tools">
  34. <button type="button" class="m-tool" aria-label="添加附件" [disabled]="disabled() || uploading()" (click)="pickFile()">
  35. <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
  36. </button>
  37. <input #fileRef type="file" multiple hidden (change)="onFile($event)" />
  38. @if (target()) {
  39. <span class="m-target">
  40. <i class="fa-solid fa-robot" aria-hidden="true"></i>
  41. {{ target() }}
  42. </span>
  43. }
  44. <button
  45. type="button"
  46. class="m-send"
  47. [class.ready]="hasContent()"
  48. [disabled]="disabled() || !hasContent()"
  49. [attr.aria-label]="sending() ? '发送中' : '发送'"
  50. (click)="submit()"
  51. >
  52. <i [class]="sending() ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-arrow-up'" aria-hidden="true"></i>
  53. </button>
  54. </div>
  55. @if (uploading()) {
  56. <div class="m-dock-progress" role="progressbar" [attr.aria-valuenow]="pct()" aria-valuemin="0" aria-valuemax="100">
  57. <i [style.width.%]="pct()"></i>
  58. </div>
  59. }
  60. </div>
  61. `,
  62. styles: [
  63. `
  64. :host {
  65. display: block;
  66. }
  67. .m-dock {
  68. margin: 0 var(--sp-4);
  69. padding: var(--sp-3) var(--sp-4);
  70. border: 1px solid var(--edge);
  71. border-radius: var(--r-lg);
  72. background: var(--surface);
  73. box-shadow: var(--shadow-pop);
  74. box-sizing: border-box;
  75. }
  76. textarea {
  77. display: block;
  78. width: 100%;
  79. min-height: 44px;
  80. max-height: 120px;
  81. padding: var(--sp-2) 0;
  82. border: 0;
  83. outline: 0;
  84. resize: none;
  85. background: transparent;
  86. color: var(--ink);
  87. font: inherit;
  88. font-size: var(--fs-17);
  89. line-height: 1.5;
  90. }
  91. .m-dock-tools {
  92. display: flex;
  93. align-items: center;
  94. gap: var(--sp-3);
  95. color: var(--muted);
  96. }
  97. .m-tool {
  98. display: grid;
  99. place-items: center;
  100. width: 36px;
  101. height: 36px;
  102. border: 0;
  103. border-radius: var(--r-tag);
  104. background: none;
  105. color: var(--muted);
  106. font-size: var(--fs-17);
  107. cursor: pointer;
  108. }
  109. .m-target {
  110. display: inline-flex;
  111. align-items: center;
  112. gap: var(--sp-1);
  113. min-width: 0;
  114. overflow: hidden;
  115. text-overflow: ellipsis;
  116. white-space: nowrap;
  117. color: var(--muted);
  118. font-size: var(--fs-13);
  119. }
  120. .m-send {
  121. margin-left: auto;
  122. display: grid;
  123. place-items: center;
  124. flex: none;
  125. width: 36px;
  126. height: 36px;
  127. border: 0;
  128. border-radius: var(--r-tag);
  129. background: var(--primary-subtle);
  130. color: var(--muted);
  131. font-size: var(--fs-15);
  132. cursor: pointer;
  133. transition:
  134. background var(--motion-fast) var(--ease-standard),
  135. color var(--motion-fast) var(--ease-standard);
  136. }
  137. .m-send.ready {
  138. background: var(--primary);
  139. color: var(--primary-content);
  140. }
  141. .m-send:disabled {
  142. cursor: not-allowed;
  143. }
  144. .m-dock-progress {
  145. height: 3px;
  146. margin-top: var(--sp-2);
  147. border-radius: var(--r-tag);
  148. background: var(--surface-active);
  149. overflow: hidden;
  150. }
  151. .m-dock-progress i {
  152. display: block;
  153. height: 100%;
  154. background: var(--primary);
  155. transition: width var(--motion-detail) var(--ease-standard);
  156. }
  157. `,
  158. ],
  159. })
  160. export class MDockComponent {
  161. readonly draft = input('');
  162. readonly placeholder = input('发消息…');
  163. /** 右侧显示的目标(数字生命名称);空则不显示 */
  164. readonly target = input('');
  165. readonly disabled = input(false);
  166. readonly sending = input(false);
  167. readonly uploading = input(false);
  168. readonly pct = input(0);
  169. readonly draftChange = output<string>();
  170. readonly send = output<string>();
  171. readonly attach = output<FileList>();
  172. /** 是否已有待发附件(由页面传入;纯附件也可发送) */
  173. readonly hasAttachments = input(false);
  174. protected readonly fileRef = viewChild.required<ElementRef<HTMLInputElement>>('fileRef');
  175. /** 文本非空 **或** 有待发附件即可发送 */
  176. protected readonly hasContent = computed(
  177. () => (this.draft() ?? '').trim().length > 0 || this.hasAttachments(),
  178. );
  179. protected onInput(e: Event): void {
  180. const el = e.target as HTMLTextAreaElement;
  181. this.draftChange.emit(el.value);
  182. el.style.height = 'auto';
  183. el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
  184. }
  185. /** 回车发送,Shift+Enter 换行 */
  186. protected onEnter(e: Event): void {
  187. const ke = e as KeyboardEvent;
  188. if (ke.shiftKey) return;
  189. ke.preventDefault();
  190. this.submit();
  191. }
  192. protected submit(): void {
  193. if (this.disabled() || this.sending() || !this.hasContent()) return;
  194. this.send.emit(this.draft().trim());
  195. }
  196. /** 打开系统文件选择器 */
  197. protected pickFile(): void {
  198. this.fileRef().nativeElement.click();
  199. }
  200. protected onFile(e: Event): void {
  201. const input = e.target as HTMLInputElement;
  202. if (input.files && input.files.length > 0) this.attach.emit(input.files);
  203. input.value = '';
  204. }
  205. }